feat(bedrock): Ochre runtime MVP — Converse over self-host + Anthropic-direct (#520)

* feat(bedrock): Ochre runtime MVP — Converse over self-host + Anthropic-direct

Add the AWS-Bedrock-compatible gateway services (bedrock, bedrock-runtime)
following the EKS REST-JSON pattern: ListFoundationModels / GetFoundationModel
over a static connectivity-tiered catalog, and non-streaming Converse routed
per modelId to one of two provider backends — self-hosted vLLM (OpenAI wire)
and Anthropic-direct (Anthropic Messages wire).

Per-account provider API keys are a master-key-encrypted JetStream KV secret
with an optional platform default; the advertised catalog gates external
models on a resolvable credential. Self-host endpoints are pinned via config
(OCHRE_VLLM_ENDPOINTS) behind an EndpointResolver seam.

Adds gateway/bedrock/ (catalog, provider router, Anthropic + vLLM adapters,
credential store), awserrors REST-JSON codes, and the bedrock E2E suite
(list/get light; Converse gated on live backends). Daemon/NATS control plane
and dynamic endpoint lifecycle are deferred to Phase 3.

* test(bedrock): cover dispatch, router, credentials KV + endpoint config

Add unit coverage for the Ochre Phase 1 diff: Bedrock_Request /
BedrockRuntime_Request end-to-end dispatch, provider router, JSON
response/error writers, upstream-status mapping, the credential-store KV
round-trip, the Anthropic bound-provider path, and parseBedrockEndpoints.
Lifts PR diff coverage above the 60% gate.
This commit is contained in:
Josh Siviour
2026-07-13 21:40:34 +10:00
committed by GitHub
parent 1e1d2f3c6d
commit cee73671ff
28 changed files with 2383 additions and 41 deletions
+4 -3
View File
@@ -1,4 +1,4 @@
PKG ?= ./cert/... ./lb/... ./eks/... ./ecs/... ./iam/... ./ecr/... ./multinode/... ./baremetal/... ./reboot/... ./tofu/...
PKG ?= ./cert/... ./lb/... ./eks/... ./ecs/... ./iam/... ./ecr/... ./bedrock/... ./multinode/... ./baremetal/... ./reboot/... ./tofu/...
ARTIFACT_DIR ?= /tmp/spinifex-e2e-artifacts
TIMEOUT ?= 30m
MODE ?= single
@@ -11,9 +11,9 @@ SUITE_TAG ?= $(notdir $(patsubst %/...,%,$(firstword $(PKG))))
# named suite into BIN_DIR/<suite>.test so CI can ship binaries (not source +
# go toolchain) to the remote VM.
BIN_DIR ?= $(CURDIR)/_bin
SUITES_BUILD ?= cert lb eks ecs single iam ecr gpu natuplink
SUITES_BUILD ?= cert lb eks ecs single iam ecr bedrock gpu natuplink
.PHONY: e2e e2e-cert e2e-lb e2e-eks e2e-ecs e2e-single e2e-iam e2e-ecr e2e-multinode e2e-bm e2e-reboot e2e-tofu e2e-gpu e2e-natuplink e2e-all clean e2e-build
.PHONY: e2e e2e-cert e2e-lb e2e-eks e2e-ecs e2e-single e2e-iam e2e-ecr e2e-bedrock e2e-multinode e2e-bm e2e-reboot e2e-tofu e2e-gpu e2e-natuplink e2e-all clean e2e-build
ifeq ($(GOTESTSUM),1)
E2E_CMD = gotestsum \
@@ -39,6 +39,7 @@ e2e-ecs: ; $(MAKE) e2e PKG=./ecs/...
e2e-single: ; $(MAKE) e2e PKG=./single/...
e2e-iam: ; $(MAKE) e2e PKG=./iam/...
e2e-ecr: ; $(MAKE) e2e PKG=./ecr/...
e2e-bedrock: ; $(MAKE) e2e PKG=./bedrock/...
e2e-multinode: ; $(MAKE) e2e PKG=./multinode/... MODE=multinode
e2e-bm: ; $(MAKE) e2e PKG=./baremetal/... MODE=baremetal
e2e-reboot: ; $(MAKE) e2e PKG=./reboot/...
+130
View File
@@ -0,0 +1,130 @@
//go:build e2e
package bedrock
import (
"os"
"testing"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/bedrock"
"github.com/aws/aws-sdk-go/service/bedrockruntime"
"github.com/mulgadc/spinifex/tests/e2e/harness"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// Catalog model IDs mirror the static Phase-1 catalog in gateway_bedrock.
const (
modelSelfHostLlama = "meta.llama3-70b-instruct-v1:0"
modelAnthropic = "anthropic.claude-3-5-sonnet-20240620-v1:0"
modelBogus = "does.not-exist-v1:0"
)
// TestListFoundationModels confirms the self-host model is always advertised;
// the Anthropic model is only asserted on when present, since its advertisement
// depends on cluster credential configuration.
func TestListFoundationModels(t *testing.T) {
f := requireBedrockFixture(t)
out, err := f.AWS.Bedrock.ListFoundationModels(&bedrock.ListFoundationModelsInput{})
require.NoError(t, err, "list-foundation-models")
ids := make(map[string]*bedrock.FoundationModelSummary, len(out.ModelSummaries))
for _, m := range out.ModelSummaries {
ids[aws.StringValue(m.ModelId)] = m
}
assert.Contains(t, ids, modelSelfHostLlama, "self-host model must always be advertised")
if anthropic, ok := ids[modelAnthropic]; ok {
assert.Equal(t, "Anthropic", aws.StringValue(anthropic.ProviderName),
"anthropic model advertised with unexpected provider name")
}
}
// TestGetFoundationModel round-trips a known model and asserts
// ResourceNotFoundException for an unknown one.
func TestGetFoundationModel(t *testing.T) {
f := requireBedrockFixture(t)
t.Run("known model", func(t *testing.T) {
out, err := f.AWS.Bedrock.GetFoundationModel(&bedrock.GetFoundationModelInput{
ModelIdentifier: aws.String(modelSelfHostLlama),
})
require.NoError(t, err, "get-foundation-model %s", modelSelfHostLlama)
require.NotNil(t, out.ModelDetails, "empty ModelDetails")
assert.Equal(t, modelSelfHostLlama, aws.StringValue(out.ModelDetails.ModelId))
})
t.Run("unknown model", func(t *testing.T) {
harness.ExpectError(t, "ResourceNotFoundException", func() error {
_, e := f.AWS.Bedrock.GetFoundationModel(&bedrock.GetFoundationModelInput{
ModelIdentifier: aws.String(modelBogus),
})
return e
})
})
}
// TestConverseSelfHost exercises the real vLLM Converse path. Gated behind
// OCHRE_E2E_SELFHOST=1 — it needs a GPU-backed vLLM endpoint configured on the
// gateway via OCHRE_VLLM_ENDPOINTS at boot.
func TestConverseSelfHost(t *testing.T) {
if os.Getenv("OCHRE_E2E_SELFHOST") != "1" {
t.Skip("OCHRE_E2E_SELFHOST!=1; skipping real self-host Converse")
}
f := requireBedrockFixture(t)
out, err := f.AWS.BedrockRuntime.Converse(&bedrockruntime.ConverseInput{
ModelId: aws.String(modelSelfHostLlama),
Messages: []*bedrockruntime.Message{{
Role: aws.String(bedrockruntime.ConversationRoleUser),
Content: []*bedrockruntime.ContentBlock{{Text: aws.String("Say hello in one word.")}},
}},
InferenceConfig: &bedrockruntime.InferenceConfiguration{MaxTokens: aws.Int64(64)},
})
require.NoError(t, err, "converse self-host")
require.NotNil(t, out.Output, "nil output")
require.NotNil(t, out.Output.Message, "nil output message")
assert.NotEmpty(t, converseText(out.Output.Message), "empty assistant text")
require.NotNil(t, out.Usage, "nil usage")
assert.Greater(t, aws.Int64Value(out.Usage.OutputTokens), int64(0), "OutputTokens must be > 0")
}
// TestConverseAnthropic exercises the real Anthropic-direct Converse path.
// Gated behind OCHRE_E2E_ANTHROPIC=1 — it needs a live Anthropic API key
// configured on the cluster (OCHRE_ANTHROPIC_API_KEY or a per-account KV entry).
func TestConverseAnthropic(t *testing.T) {
if os.Getenv("OCHRE_E2E_ANTHROPIC") != "1" {
t.Skip("OCHRE_E2E_ANTHROPIC!=1; skipping real Anthropic Converse")
}
f := requireBedrockFixture(t)
out, err := f.AWS.BedrockRuntime.Converse(&bedrockruntime.ConverseInput{
ModelId: aws.String(modelAnthropic),
Messages: []*bedrockruntime.Message{{
Role: aws.String(bedrockruntime.ConversationRoleUser),
Content: []*bedrockruntime.ContentBlock{{Text: aws.String("Say hello in one word.")}},
}},
InferenceConfig: &bedrockruntime.InferenceConfiguration{MaxTokens: aws.Int64(64)},
})
require.NoError(t, err, "converse anthropic")
require.NotNil(t, out.Output, "nil output")
require.NotNil(t, out.Output.Message, "nil output message")
assert.NotEmpty(t, converseText(out.Output.Message), "empty assistant text")
require.NotNil(t, out.Usage, "nil usage")
assert.Greater(t, aws.Int64Value(out.Usage.InputTokens), int64(0), "InputTokens must be > 0")
assert.Greater(t, aws.Int64Value(out.Usage.OutputTokens), int64(0), "OutputTokens must be > 0")
}
// converseText concatenates the text content blocks of a Converse output message.
func converseText(msg *bedrockruntime.Message) string {
var text string
for _, c := range msg.Content {
if c != nil && c.Text != nil {
text += *c.Text
}
}
return text
}
+61
View File
@@ -0,0 +1,61 @@
//go:build e2e
// Package bedrock is the Ochre (AWS-Bedrock) E2E suite. Hermetic API-shape and
// provider-translation parity (Anthropic Messages / vLLM OpenAI wire formats)
// is covered by the gateway_bedrock unit tests; this suite exercises
// ListFoundationModels/GetFoundationModel against any running cluster and
// gates real Converse calls on backend availability (self-host GPU or a live
// Anthropic key), so CI parity holds without requiring either to be present.
package bedrock
import (
"os"
"sync"
"testing"
"github.com/mulgadc/spinifex/tests/e2e/harness"
)
// Package-scoped singleton fixture, built lazily by requireBedrockFixture and
// torn down by TestMain after the run.
var (
pkgFixOnce sync.Once
pkgFix *Fixture
pkgFixErr error
)
// Fixture carries per-process state shared across every Test* in this package.
type Fixture struct {
Env *harness.Env
AWS *harness.AWSClient
Account string
}
func TestMain(m *testing.M) {
os.Exit(m.Run())
}
// requireBedrockFixture returns the package-scoped Fixture, building it on
// first call. Skips the calling test if SPINIFEX_E2E is unset.
func requireBedrockFixture(t *testing.T) *Fixture {
t.Helper()
pkgFixOnce.Do(func() {
if os.Getenv("SPINIFEX_E2E") == "" {
return
}
env := harness.LoadEnv(t)
awsCli := harness.NewAWSClient(t, env)
pkgFix = &Fixture{
Env: env,
AWS: awsCli,
Account: harness.IAMAccountID(t, awsCli),
}
})
if pkgFixErr != nil {
t.Fatalf("bedrock fixture init failed: %v", pkgFixErr)
}
if pkgFix == nil {
t.Skip("bedrock fixture unavailable (SPINIFEX_E2E unset)")
}
return pkgFix
}
+24 -18
View File
@@ -14,6 +14,8 @@ import (
"github.com/aws/aws-sdk-go/aws/credentials"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/acm"
"github.com/aws/aws-sdk-go/service/bedrock"
"github.com/aws/aws-sdk-go/service/bedrockruntime"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/aws/aws-sdk-go/service/ec2/ec2iface"
"github.com/aws/aws-sdk-go/service/ecr"
@@ -30,15 +32,17 @@ type AWSClient struct {
// EC2 is wrapped so RunInstances auto-serialises against cluster capacity
// (see capacityRetryEC2). EC2Conf is the underlying SDK client shared by
// both; kept for call sites that read or mutate its Config.
EC2 ec2iface.EC2API
EC2Conf *ec2.EC2
ELBv2 *elbv2.ELBV2
IAM *iam.IAM
STS *sts.STS
EKS *eks.EKS
ACM *acm.ACM
ECR *ecr.ECR
ECS *ecs.ECS
EC2 ec2iface.EC2API
EC2Conf *ec2.EC2
ELBv2 *elbv2.ELBV2
IAM *iam.IAM
STS *sts.STS
EKS *eks.EKS
ACM *acm.ACM
ECR *ecr.ECR
ECS *ecs.ECS
Bedrock *bedrock.Bedrock
BedrockRuntime *bedrockruntime.BedrockRuntime
}
// NewAWSClient builds clients pointed at the spinifex gateway using the
@@ -128,15 +132,17 @@ func newAWSClient(t *testing.T, env *Env, accessKey, secretKey, sessionToken str
rawEC2 := ec2.New(sess)
return &AWSClient{
EC2: &capacityRetryEC2{EC2API: rawEC2},
EC2Conf: rawEC2,
ELBv2: elbv2.New(sess),
IAM: iam.New(sess),
STS: sts.New(sess),
EKS: eks.New(sess),
ACM: acm.New(sess),
ECR: ecr.New(sess),
ECS: ecs.New(sess),
EC2: &capacityRetryEC2{EC2API: rawEC2},
EC2Conf: rawEC2,
ELBv2: elbv2.New(sess),
IAM: iam.New(sess),
STS: sts.New(sess),
EKS: eks.New(sess),
ACM: acm.New(sess),
ECR: ecr.New(sess),
ECS: ecs.New(sess),
Bedrock: bedrock.New(sess),
BedrockRuntime: bedrockruntime.New(sess),
}
}