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) + } +}