diff --git a/management/internals/modules/agentnetwork/modeldiscovery/discovery_test.go b/management/internals/modules/agentnetwork/modeldiscovery/discovery_test.go index 6cb75c18b..133bd5148 100644 --- a/management/internals/modules/agentnetwork/modeldiscovery/discovery_test.go +++ b/management/internals/modules/agentnetwork/modeldiscovery/discovery_test.go @@ -494,3 +494,39 @@ func TestRegionFromUpstream(t *testing.T) { }) } } + +// bedrockGeoListing carries profiles from geographies the original prefix list +// did not name. Every one reduces to a catalog key, so every one must arrive +// priced — an unstripped geography is what made a real account's listing come +// back almost entirely at zero. +const bedrockGeoListing = `{"inferenceProfileSummaries":[ + {"inferenceProfileId":"jp.anthropic.claude-sonnet-5-20260514-v1:0", + "inferenceProfileName":"JP Anthropic Claude Sonnet 5","status":"ACTIVE","type":"SYSTEM_DEFINED"}, + {"inferenceProfileId":"au.anthropic.claude-haiku-4-5-20251001-v1:0", + "inferenceProfileName":"AU Anthropic Claude Haiku 4.5","status":"ACTIVE","type":"SYSTEM_DEFINED"}, + {"inferenceProfileId":"us-gov.anthropic.claude-sonnet-5-20260514-v1:0", + "inferenceProfileName":"GovCloud Anthropic Claude Sonnet 5","status":"ACTIVE","type":"SYSTEM_DEFINED"} +]}` + +func TestBedrockProfilesFromAnyGeographyArrivePriced(t *testing.T) { + cl, _ := newStubClient(http.StatusOK, bedrockGeoListing) + + models, err := cl.Fetch(context.Background(), Request{ + CatalogID: "bedrock_api", + UpstreamURL: "https://bedrock-runtime.eu-central-1.amazonaws.com", + APIKey: "aws-token", + }) + require.NoError(t, err) + require.Len(t, models, 3) + + for _, m := range models { + assert.True(t, m.PricingKnown, "%s must resolve to a catalog rate", m.ID) + assert.Greater(t, m.InputPer1k, 0.0, "input rate for %s", m.ID) + assert.Greater(t, m.OutputPer1k, 0.0, "output rate for %s", m.ID) + assert.Greater(t, m.CacheReadPer1k, 0.0, "cache-read rate for %s", m.ID) + } + + // The wire id is preserved whatever the pricing key reduced to: it is the + // only form that works at invoke time. + assert.Equal(t, "jp.anthropic.claude-sonnet-5-20260514-v1:0", models[0].ID) +} diff --git a/management/internals/modules/agentnetwork/synthesizer_pricing_test.go b/management/internals/modules/agentnetwork/synthesizer_pricing_test.go index 83961878a..e82f2ef05 100644 --- a/management/internals/modules/agentnetwork/synthesizer_pricing_test.go +++ b/management/internals/modules/agentnetwork/synthesizer_pricing_test.go @@ -103,3 +103,37 @@ func TestBuildCostMeterConfig_OrphanAndGatewayProviders(t *testing.T) { assert.NotContains(t, cfg.Pricing.Providers, "prov-litellm", "empty-models gateway needs no per-record entry") assert.NotEmpty(t, cfg.Pricing.Defaults["openai"], "defaults still ship so the gateway's catalog-model traffic is priced") } + +// TestBuildCostMeterConfig_BedrockGeographyOutsideTheOriginalFour is the +// accounting half of the geography bug. The docs tell operators to register a +// Bedrock id exactly as AWS issues it, region prefix included, and the cost +// meter keys its table by the normalized form. While the geography was matched +// against a list of four, a profile issued anywhere else kept its prefix, +// missed the catalog entry it was meant to inherit from, and billed with a +// zero entry underneath the operator's own rates — so every cache bucket +// metered free and a model priced only by catalog defaults metered at nothing +// at all. +func TestBuildCostMeterConfig_BedrockGeographyOutsideTheOriginalFour(t *testing.T) { + for _, geo := range []string{"jp", "au", "ca", "sa", "us-gov"} { + t.Run(geo, func(t *testing.T) { + bedrock := &types.Provider{ + ID: "prov-bedrock", + ProviderID: "bedrock_api", + Enabled: true, + Models: []types.ProviderModel{ + {ID: geo + ".anthropic.claude-sonnet-5-20260514-v1:0", InputPer1k: 0.003, OutputPer1k: 0.015}, + }, + } + raw, err := buildCostMeterConfigJSON([]*types.Provider{bedrock}, map[string][]string{"prov-bedrock": {"grp"}}) + require.NoError(t, err) + cfg := decodeCostMeterConfig(t, raw) + + e, ok := cfg.Pricing.Providers["prov-bedrock"]["anthropic.claude-sonnet-5"] + require.True(t, ok, "a %s profile must key by the same normalized id the parser emits", geo) + assert.InDelta(t, 0.0003, e.CacheReadPer1k, 1e-9, + "cache read must be inherited from the bedrock default entry, not left at zero") + assert.InDelta(t, 0.00375, e.CacheCreationPer1k, 1e-9, + "cache creation must be inherited from the bedrock default entry, not left at zero") + }) + } +} diff --git a/shared/llm/model.go b/shared/llm/model.go index 4fb631520..904c3eb2d 100644 --- a/shared/llm/model.go +++ b/shared/llm/model.go @@ -10,9 +10,59 @@ import ( "strings" ) -// bedrockRegionPrefixes are the cross-region inference-profile prefixes that -// front a Bedrock model id (e.g. "eu.anthropic.claude-..."). -var bedrockRegionPrefixes = []string{"us.", "eu.", "apac.", "global."} +// bedrockVendorNamespaces are the vendor segments a Bedrock model id is +// published under. They identify the geography in front of a cross-region +// inference profile without enumerating geographies: in +// "eu.anthropic.claude-...", what makes "eu" a geography is that "anthropic" +// follows it. +// +// Listing geographies instead is what this replaced, and it aged badly — the +// list held us, eu, apac and global, so every profile issued under jp, au, ca, +// sa or us-gov carried its prefix into the pricing key, matched no catalog +// entry, and reported the model unpriced. +// +// A vendor missing from this map fails safe: its id keeps the geography, which +// is exactly the behaviour of the list this replaced. Over-stripping is the +// dangerous direction, because the result also decides which route may claim a +// model. +var bedrockVendorNamespaces = map[string]struct{}{ + "ai21": {}, + "amazon": {}, + "anthropic": {}, + "cohere": {}, + "deepseek": {}, + "luma": {}, + "meta": {}, + "mistral": {}, + "openai": {}, + "qwen": {}, + "stability": {}, + "twelvelabs": {}, + "writer": {}, +} + +// stripBedrockGeography removes the cross-region inference-profile geography +// from a Bedrock model id, leaving the "." form the catalog and +// the pricing table key on. +// +// A leading segment counts as a geography only when a known vendor follows it. +// "amazon.nova-pro" is a vendor and a model, not a geography and a model, and +// cutting its first segment would strip the vendor away. +func stripBedrockGeography(modelID string) string { + dot := strings.IndexByte(modelID, '.') + if dot <= 0 { + return modelID + } + rest := modelID[dot+1:] + vendor, _, found := strings.Cut(rest, ".") + if !found { + return modelID + } + if _, ok := bedrockVendorNamespaces[vendor]; !ok { + return modelID + } + return rest +} // bedrockVersionSuffix matches the trailing "-vN[:N]" or "-YYYYMMDD-vN[:N]" // version/throughput suffix of a Bedrock model id. @@ -37,12 +87,7 @@ func NormalizeBedrockModel(modelID string) string { m = m[i+1:] } } - for _, p := range bedrockRegionPrefixes { - if strings.HasPrefix(m, p) { - m = m[len(p):] - break - } - } + m = stripBedrockGeography(m) return bedrockVersionSuffix.ReplaceAllString(m, "") } diff --git a/shared/llm/model_test.go b/shared/llm/model_test.go index 5ce2ff497..de3320895 100644 --- a/shared/llm/model_test.go +++ b/shared/llm/model_test.go @@ -60,3 +60,37 @@ func TestNormalizeAnthropicModel(t *testing.T) { require.Equal(t, want, NormalizeAnthropicModel(in), "normalize %q", in) } } + +// TestNormalizeBedrockModel_GeographiesBeyondTheOriginalFour covers the bug +// that made this vendor-anchored: the geography used to be matched against a +// list of four, so a profile issued anywhere else kept its prefix, missed the +// catalog key it was supposed to match, and reported the model unpriced. +func TestNormalizeBedrockModel_GeographiesBeyondTheOriginalFour(t *testing.T) { + for _, geo := range []string{"us", "eu", "apac", "global", "jp", "au", "ca", "sa", "us-gov", "il", "mx"} { + t.Run(geo, func(t *testing.T) { + got := NormalizeBedrockModel(geo + ".anthropic.claude-sonnet-5-20260514-v1:0") + require.Equal(t, "anthropic.claude-sonnet-5", got, + "a cross-region profile must reduce to the catalog key whatever geography issued it") + }) + } +} + +// TestNormalizeBedrockModel_KeepsAVendorItCannotMistakeForAGeography pins the +// direction that must never break: a plain "." id has no +// geography, and cutting its first segment would strip the vendor away and +// hand the id to whichever route claims the bare model name. +func TestNormalizeBedrockModel_KeepsAVendorItCannotMistakeForAGeography(t *testing.T) { + cases := map[string]string{ + "amazon.nova-pro-v1:0": "amazon.nova-pro", + "anthropic.claude-sonnet-5-v1:0": "anthropic.claude-sonnet-5", + "meta.llama3-3-70b-instruct-v1:0": "meta.llama3-3-70b-instruct", + "cohere.command-r-plus-v1:0": "cohere.command-r-plus", + "eu.unknownvendor.some-model-v1:0": "eu.unknownvendor.some-model", + "Qwen/Qwen2.5-0.5B-Instruct": "Qwen/Qwen2.5-0.5B-Instruct", + } + for in, want := range cases { + t.Run(in, func(t *testing.T) { + require.Equal(t, want, NormalizeBedrockModel(in)) + }) + } +}