mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-22 04:07:04 +00:00
261d5d505e
* 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
200 lines
7.7 KiB
Go
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
|
|
}
|