Files
spinifex/tests/e2e/bedrock/bedrock_test.go
T
Josh Siviour 261d5d505e feat: Ochre catalog serving spec and a servable self-host model (#672)
* feat: in-tree serving specs + KV weights override for ochre catalog

Extend catalogEntry with MinVRAMMiB, InstanceType and VLLMArgs so a
self-host model's serving requirements ship with the catalog rather
than living only in an operator's head. Add WeightsStore, a
deployment-scoped (not account-scoped) JetStream KV override in a new
bedrock-weights bucket that resolves a model's staged weights snapshot
ID, following the same shape as the existing CredentialStore. It is
plumbed into tieredCatalog and GetFoundationModel through a
package-level resolver (SetWeightsResolver/currentWeightsResolver),
since both are called from gateway/bedrock.go's fixed-arity route
table, which is out of scope here.

A self-host model with no resolvable weights snapshot is not
servable and must not be advertised: tieredCatalog filters it out and
GetFoundationModel returns ResourceNotFoundException for it.

Replace the meta.llama3-70b-instruct-v1:0 self-host entry, which
needs roughly 140 GB of VRAM in fp16 and has never actually run, with
meta.llama3-2-1b-instruct-v1:0, a real AWS-published Bedrock model
ID. At bf16 its 1.24B params are roughly 2.5 GiB of weights, well
inside wattle's single RTX A1000 (8188 MiB); MinVRAMMiB is set to
4096 to budget headroom for CUDA context, activations and KV cache on
top of that. This is an estimate, not a measurement off wattle -
.7.5's launcher should correct it against a real nvidia-smi reading
once the model is actually running there.

lookupCatalogEntry, used by the untiered invoke/converse routing
path, is left unchanged and still ignores weights resolvability;
gating that path is out of scope for this bead.

* fix: separate weights resolve errors from not-found in ochre catalog

An internal fault (e.g. a JetStream outage) reaching the KV weights
resolver was being collapsed into the same "not servable" verdict as
a clean not-found: tieredCatalog would silently unadvertise every
self-host model, and GetFoundationModel would return
ResourceNotFoundException for a model that does exist but whose
resolver is temporarily broken. Both call sites now distinguish the
two: a resolve error is logged and surfaced as its own error (mapped
to ServerInternal at the AWS error boundary), and only a clean
not-resolvable result yields ResourceNotFoundException.

tieredCatalog's signature changes to return an error alongside the
entries so ListFoundationModels can propagate a resolve fault instead
of silently returning a thinned-out list.

Also raises the self-host entry's gpu-memory-utilization from 0.5 to
0.6 (roughly 4913 MiB of vLLM's own pool) and MinVRAMMiB from 4096 to
5120 to match: at 0.5, weights (~2.5 GiB) left under 1.5 GiB for KV
cache and activations at max-model-len=8192, likely too tight for any
real concurrency. The two numbers are meant to agree: MinVRAMMiB is
the admission-gate floor, gpu-memory-utilization is what tells vLLM
to actually claim that much on an 8188 MiB device.

Comments trimmed to the repo's 3-line limit and stripped of plan/bead
references; the VRAM reasoning and model-choice rationale live in the
commit body and the plan doc instead.

Threading the weights resolver through as an explicit parameter
(mirroring CredentialResolver) rather than a package-level global was
considered per review. Doing so needs edits to gateway/bedrock.go
(bedrockRouteHandler's signature, both route closures, the
Bedrock_Request call site, a new bedrockWeightsResolver() method) and
gateway/gateway.go (a new BedrockWeights field on GatewayConfig).
Both are outside gateway/bedrock/ and are explicitly out of scope for
this change, owned by a parallel workstream already modifying the
same route table. The global resolver stays, now with the
error-handling fix above.

* fix: stop e2e GetFoundationModel regressing on the new weights gate

TestGetFoundationModel's "known model" subtest asserted on
meta.llama3-2-1b-instruct-v1:0 unconditionally, but that entry is now
gated on a weights snapshot staged in the bedrock-weights KV bucket.
On any real deployment that bucket starts empty, so the subtest would
fail with ResourceNotFoundException outside of OCHRE_E2E_SELFHOST.

The "known model" assertion now targets the Anthropic entry, which
resolves unconditionally and keeps testing what the subtest was
actually there for: a known model round-trips, an unknown one is
ResourceNotFoundException. The self-host assertion moves to a new
TestGetFoundationModelSelfHost, gated behind OCHRE_E2E_SELFHOST=1
alongside TestConverseSelfHost, where weights are expected staged.

tests/integration/bedrock_test.go's TestListFoundationModels already
wires a weights-resolver stub before asserting the self-host entry is
present, so it does not regress the same way.

* test: correct a stale assertion message on the ochre catalog weights gate
2026-08-03 12:34:07 +10:00

200 lines
7.7 KiB
Go

//go:build e2e
package bedrock
import (
"encoding/json"
"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-2-1b-instruct-v1:0"
modelAnthropic = "anthropic.claude-3-5-sonnet-20240620-v1:0"
modelBogus = "does.not-exist-v1:0"
)
// TestGetFoundationModel round-trips a known model and asserts
// ResourceNotFoundException for an unknown one. Uses the Anthropic entry: it
// resolves unconditionally, unlike the self-host entry, which is gated on a
// weights snapshot being staged — see TestGetFoundationModelSelfHost.
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(modelAnthropic),
})
require.NoError(t, err, "get-foundation-model %s", modelAnthropic)
require.NotNil(t, out.ModelDetails, "empty ModelDetails")
assert.Equal(t, modelAnthropic, 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
})
})
}
// TestGetFoundationModelSelfHost describes the self-host model directly.
// Gated behind OCHRE_E2E_SELFHOST=1: besides a GPU-backed vLLM endpoint, it
// needs the model's weights snapshot staged in the bedrock-weights KV bucket.
func TestGetFoundationModelSelfHost(t *testing.T) {
if os.Getenv("OCHRE_E2E_SELFHOST") != "1" {
t.Skip("OCHRE_E2E_SELFHOST!=1; skipping self-host GetFoundationModel")
}
f := requireBedrockFixture(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))
}
// 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")
}
// TestInvokeModelSelfHost exercises the real vLLM InvokeModel path with the
// Bedrock-native Llama request/response shape. Gated behind
// OCHRE_E2E_SELFHOST=1 — it needs a GPU-backed vLLM endpoint configured on the
// gateway via OCHRE_VLLM_ENDPOINTS at boot.
func TestInvokeModelSelfHost(t *testing.T) {
if os.Getenv("OCHRE_E2E_SELFHOST") != "1" {
t.Skip("OCHRE_E2E_SELFHOST!=1; skipping real self-host InvokeModel")
}
f := requireBedrockFixture(t)
reqBody, err := json.Marshal(map[string]any{
"prompt": "Say hello in one word.",
"max_gen_len": 64,
})
require.NoError(t, err, "marshal invoke-model request")
out, err := f.AWS.BedrockRuntime.InvokeModel(&bedrockruntime.InvokeModelInput{
ModelId: aws.String(modelSelfHostLlama),
Body: reqBody,
ContentType: aws.String("application/json"),
})
require.NoError(t, err, "invoke-model self-host")
assert.Equal(t, "application/json", aws.StringValue(out.ContentType))
var respBody struct {
Generation string `json:"generation"`
GenerationTokenCount int `json:"generation_token_count"`
}
require.NoError(t, json.Unmarshal(out.Body, &respBody), "unmarshal invoke-model response")
assert.NotEmpty(t, respBody.Generation, "empty generation")
assert.Greater(t, respBody.GenerationTokenCount, 0, "GenerationTokenCount must be > 0")
}
// TestInvokeModelAnthropic exercises the real Anthropic-direct InvokeModel
// path with the Bedrock-native Claude Messages request/response shape. 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 TestInvokeModelAnthropic(t *testing.T) {
if os.Getenv("OCHRE_E2E_ANTHROPIC") != "1" {
t.Skip("OCHRE_E2E_ANTHROPIC!=1; skipping real Anthropic InvokeModel")
}
f := requireBedrockFixture(t)
reqBody, err := json.Marshal(map[string]any{
"anthropic_version": "bedrock-2023-05-31",
"max_tokens": 64,
"messages": []map[string]any{
{"role": "user", "content": "Say hello in one word."},
},
})
require.NoError(t, err, "marshal invoke-model request")
out, err := f.AWS.BedrockRuntime.InvokeModel(&bedrockruntime.InvokeModelInput{
ModelId: aws.String(modelAnthropic),
Body: reqBody,
ContentType: aws.String("application/json"),
})
require.NoError(t, err, "invoke-model anthropic")
assert.Equal(t, "application/json", aws.StringValue(out.ContentType))
var respBody struct {
Content []struct {
Type string `json:"type"`
Text string `json:"text"`
} `json:"content"`
}
require.NoError(t, json.Unmarshal(out.Body, &respBody), "unmarshal invoke-model response")
require.NotEmpty(t, respBody.Content, "empty content")
assert.NotEmpty(t, respBody.Content[0].Text, "empty assistant text")
}
// 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
}