From 119b70c664b714cbacc56af9d718568dedefab95 Mon Sep 17 00:00:00 2001 From: mlsmaycon Date: Tue, 11 Aug 2026 02:50:43 +0000 Subject: [PATCH] [proxy] Match dated Anthropic model ids against their undated form shared/llm normalizes Bedrock and Vertex model ids so both sides of the routing and pricing contract compare equal, but nothing did the same for a first-party Anthropic id. A client pinning "claude-sonnet-4-5-20250929" against a record registered as "claude-sonnet-4-5" denied as not-routable, and where a catch-all route carried it through, the price lookup missed and the request recorded no cost. Add NormalizeAnthropicModel beside the existing two and consult it after an exact match fails in the router's claim check, the pricing table, and the per-record price map. Exact matches still win, so an operator who registers two dated releases of the same family keeps them distinct. --- proxy/internal/llm/model.go | 8 ++++++ proxy/internal/llm/pricing/pricing.go | 16 ++++++++++-- proxy/internal/llm/pricing/pricing_test.go | 19 ++++++++++++++ .../builtin/cost_meter/middleware.go | 18 ++++++++++++- .../builtin/llm_router/middleware.go | 6 +++++ .../builtin/llm_router/middleware_test.go | 25 +++++++++++++++++++ shared/llm/model.go | 16 ++++++++++++ shared/llm/model_test.go | 18 +++++++++++++ 8 files changed, 123 insertions(+), 3 deletions(-) diff --git a/proxy/internal/llm/model.go b/proxy/internal/llm/model.go index 76ccfeccf..2e056a57a 100644 --- a/proxy/internal/llm/model.go +++ b/proxy/internal/llm/model.go @@ -13,6 +13,14 @@ func NormalizeBedrockModel(modelID string) string { return sharedllm.NormalizeBedrockModel(modelID) } +// NormalizeAnthropicModel strips the trailing "-YYYYMMDD" release-date suffix +// from an Anthropic model id so a dated id a client pins matches the undated +// one the operator registered. Thin delegate to shared/llm for the same +// contract reason as the two below. +func NormalizeAnthropicModel(modelID string) string { + return sharedllm.NormalizeAnthropicModel(modelID) +} + // NormalizeVertexModel strips the "@version" suffix from a Vertex AI model id // so it matches the catalog/pricing key. Thin delegate to shared/llm, kept // beside NormalizeBedrockModel for the same contract reason. diff --git a/proxy/internal/llm/pricing/pricing.go b/proxy/internal/llm/pricing/pricing.go index ce6e636cf..52cedb60e 100644 --- a/proxy/internal/llm/pricing/pricing.go +++ b/proxy/internal/llm/pricing/pricing.go @@ -10,6 +10,8 @@ package pricing import ( "fmt" "math" + + sharedllm "github.com/netbirdio/netbird/shared/llm" ) // Entry is a single model's input and output pricing, expressed in USD per @@ -92,7 +94,10 @@ func NewTable(raw map[string]map[string]EntryJSON) (*Table, error) { return &Table{entries: entries}, nil } -// Lookup returns the entry for the given provider surface and model. +// Lookup returns the entry for the given provider surface and model. A +// dated Anthropic id falls back to its undated form, so a client pinning +// "claude-sonnet-4-5-20250929" bills at the registered "claude-sonnet-4-5" +// rate instead of recording no cost at all. func (t *Table) Lookup(provider, model string) (Entry, bool) { if t == nil { return Entry{}, false @@ -101,7 +106,14 @@ func (t *Table) Lookup(provider, model string) (Entry, bool) { if !ok { return Entry{}, false } - e, ok := byModel[model] + if e, found := byModel[model]; found { + return e, true + } + undated := sharedllm.NormalizeAnthropicModel(model) + if undated == model { + return Entry{}, false + } + e, ok := byModel[undated] return e, ok } diff --git a/proxy/internal/llm/pricing/pricing_test.go b/proxy/internal/llm/pricing/pricing_test.go index b946faa7f..e7d339f06 100644 --- a/proxy/internal/llm/pricing/pricing_test.go +++ b/proxy/internal/llm/pricing/pricing_test.go @@ -175,3 +175,22 @@ func TestNewTable_NilAndEmpty(t *testing.T) { require.NoError(t, err) assert.Empty(t, entries, "nil in, empty (never-matching) map out for the per-record map") } + +// TestLookup_DatedAnthropicIDFallsBackToUndated covers a client pinning a +// release date on a model priced under its undated id. Without the +// fallback the request records no cost at all. +func TestLookup_DatedAnthropicIDFallsBackToUndated(t *testing.T) { + table, err := NewTable(map[string]map[string]EntryJSON{ + "anthropic": { + "claude-sonnet-4-5": {InputPer1K: 0.003, OutputPer1K: 0.015}, + }, + }) + require.NoError(t, err, "table must build from a valid defaults map") + + entry, ok := table.Lookup("anthropic", "claude-sonnet-4-5-20250929") + require.True(t, ok, "a dated id must resolve to the undated entry") + assert.InDelta(t, 0.003, entry.InputPer1K, 1e-9, "dated id must bill at the registered rate") + + _, ok = table.Lookup("anthropic", "claude-sonnet-9-9-20250929") + assert.False(t, ok, "an unknown family must stay unpriced") +} diff --git a/proxy/internal/middleware/builtin/cost_meter/middleware.go b/proxy/internal/middleware/builtin/cost_meter/middleware.go index 2ce706cda..8e2e0590c 100644 --- a/proxy/internal/middleware/builtin/cost_meter/middleware.go +++ b/proxy/internal/middleware/builtin/cost_meter/middleware.go @@ -11,6 +11,7 @@ import ( "fmt" "strconv" + "github.com/netbirdio/netbird/proxy/internal/llm" "github.com/netbirdio/netbird/proxy/internal/llm/pricing" "github.com/netbirdio/netbird/proxy/internal/middleware" ) @@ -175,13 +176,28 @@ func (m *Middleware) Invoke(_ context.Context, in *middleware.Input) (*middlewar // Anthropic route still bills its cache buckets additively. func (m *Middleware) lookupCosts(md []middleware.KV, surface, model string, inTokens, outTokens, cachedTokens, cacheCreationTokens int64) (pricing.Costs, bool) { if recordID := lookupKV(md, middleware.KeyLLMResolvedProviderID); recordID != "" { - if entry, ok := m.perRecord[recordID][model]; ok { + if entry, ok := perRecordEntry(m.perRecord[recordID], model); ok { return pricing.EntryCosts(entry, surface, inTokens, outTokens, cachedTokens, cacheCreationTokens), true } } return m.defaults.Costs(surface, model, inTokens, outTokens, cachedTokens, cacheCreationTokens) } +// perRecordEntry resolves the operator's stored price for a model on one +// provider record, falling back to the undated form of a dated Anthropic id +// so a client that pins a release date still bills at the registered rate. +func perRecordEntry(byModel map[string]pricing.Entry, model string) (pricing.Entry, bool) { + if entry, ok := byModel[model]; ok { + return entry, true + } + undated := llm.NormalizeAnthropicModel(model) + if undated == model { + return pricing.Entry{}, false + } + entry, ok := byModel[undated] + return entry, ok +} + // usd renders a cost as the fixed-precision string every cost.usd_* key // carries, so the per-bucket values and the aggregates round identically. // diff --git a/proxy/internal/middleware/builtin/llm_router/middleware.go b/proxy/internal/middleware/builtin/llm_router/middleware.go index d19a4d941..384f3c50b 100644 --- a/proxy/internal/middleware/builtin/llm_router/middleware.go +++ b/proxy/internal/middleware/builtin/llm_router/middleware.go @@ -567,6 +567,12 @@ func routeClaimsModel(route ProviderRoute, model string) bool { if route.Bedrock && llm.NormalizeBedrockModel(candidate) == model { return true } + // A client may pin a dated Anthropic id ("claude-sonnet-4-5-20250929") + // where the operator registered the undated one. Exact matches above + // win, so two dated releases stay distinct when both are registered. + if llm.NormalizeAnthropicModel(candidate) == llm.NormalizeAnthropicModel(model) { + return true + } } return false } diff --git a/proxy/internal/middleware/builtin/llm_router/middleware_test.go b/proxy/internal/middleware/builtin/llm_router/middleware_test.go index 754174254..a63028427 100644 --- a/proxy/internal/middleware/builtin/llm_router/middleware_test.go +++ b/proxy/internal/middleware/builtin/llm_router/middleware_test.go @@ -880,3 +880,28 @@ func TestRouter_EmptyModelsClaimsAnyModel(t *testing.T) { resolved, _ := metaValue(t, out.Metadata, middleware.KeyLLMResolvedProviderID) assert.Equal(t, "litellm", resolved) } + +// TestRouter_DatedAnthropicModelRoutes covers a client pinning a release +// date on a model the operator registered undated. Exact matches still win, +// so an operator who registers both dated releases keeps them distinct. +func TestRouter_DatedAnthropicModelRoutes(t *testing.T) { + mw := New(Config{Providers: []ProviderRoute{{ + ID: "anthropic-prod", + Vendor: "anthropic", + Models: []string{"claude-sonnet-4-5"}, + AllowedGroupIDs: []string{defaultTestGroup}, + UpstreamScheme: "https", + UpstreamHost: "api.anthropic.com", + }}}) + + in := newInputWithModelAndURL("claude-sonnet-4-5-20250929", "/v1/messages") + in.Metadata = append(in.Metadata, middleware.KV{Key: middleware.KeyLLMProvider, Value: "anthropic"}) + + out, err := mw.Invoke(context.Background(), in) + require.NoError(t, err) + require.NotNil(t, out) + assert.Equal(t, middleware.DecisionAllow, out.Decision, "a dated id must route to the undated registration") + require.NotNil(t, out.Mutations) + require.NotNil(t, out.Mutations.RewriteUpstream) + assert.Equal(t, "api.anthropic.com", out.Mutations.RewriteUpstream.Host) +} diff --git a/shared/llm/model.go b/shared/llm/model.go index 08e42e5a4..95a6f694e 100644 --- a/shared/llm/model.go +++ b/shared/llm/model.go @@ -46,6 +46,22 @@ func NormalizeBedrockModel(modelID string) string { return bedrockVersionSuffix.ReplaceAllString(m, "") } +// anthropicDateSuffix matches the trailing "-YYYYMMDD" release-date suffix +// Anthropic appends to a pinned model id. No other vendor in the catalog +// ends an id in eight consecutive digits, so the pattern is safe to apply +// before a lookup regardless of surface. +var anthropicDateSuffix = regexp.MustCompile(`-\d{8}$`) + +// NormalizeAnthropicModel strips the trailing release-date suffix from a +// first-party Anthropic model id, e.g. "claude-sonnet-4-5-20250929" -> +// "claude-sonnet-4-5", so a dated id a client pins matches the undated one +// the operator registered. Callers try the verbatim id first and fall back +// to this, so two dated releases of the same family stay distinct wherever +// both are registered explicitly. +func NormalizeAnthropicModel(modelID string) string { + return anthropicDateSuffix.ReplaceAllString(modelID, "") +} + // NormalizeVertexModel strips the "@version" suffix from a Vertex AI model id // (e.g. "claude-sonnet-4-5@20250929" -> "claude-sonnet-4-5") so it matches // the catalog/pricing key. Vertex publisher models are priced under their diff --git a/shared/llm/model_test.go b/shared/llm/model_test.go index 42f2e9ca5..c1d5b63f6 100644 --- a/shared/llm/model_test.go +++ b/shared/llm/model_test.go @@ -34,3 +34,21 @@ func TestNormalizeVertexModel(t *testing.T) { require.Equal(t, want, NormalizeVertexModel(in), "normalize %q", in) } } + +func TestNormalizeAnthropicModel(t *testing.T) { + cases := map[string]string{ + "claude-sonnet-4-5-20250929": "claude-sonnet-4-5", + "claude-3-5-haiku-20241022": "claude-3-5-haiku", + "claude-sonnet-5": "claude-sonnet-5", + "claude-opus-4-8": "claude-opus-4-8", + // Other vendors' ids must survive untouched: none of them end in + // eight consecutive digits. + "gpt-4o": "gpt-4o", + "gpt-4o-2024-08-06": "gpt-4o-2024-08-06", + "anthropic.claude-haiku-4-5": "anthropic.claude-haiku-4-5", + "": "", + } + for in, want := range cases { + require.Equal(t, want, NormalizeAnthropicModel(in), "normalize %q", in) + } +}