diff --git a/.gitignore b/.gitignore index bf0cc4c..840642c 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ docs/superpowers/ *.wasm tools/yaegi-check/llmgw-yaegi-check +tools/pricing-sync/llmgw-pricing-sync dist/ # webui/'s build output (dist/, matched above) and npm dependency tree — diff --git a/Makefile b/Makefile index e7cb667..616b439 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ # two of them concurrently would corrupt each other's containers. .NOTPARALLEL: -.PHONY: test lint yaegi-check admin-ui integration integration-keep integration-up integration-wait integration-down +.PHONY: test lint yaegi-check admin-ui pricing-sync integration integration-keep integration-up integration-wait integration-down test: go test ./... -count=1 @@ -32,6 +32,18 @@ admin-ui: yaegi-check: cd tools/yaegi-check && GOWORK=off go run . $(CURDIR) +# pricing-sync regenerates the repo-root pricing_data_gen.go: the +# built-in, LiteLLM-synced per-model context/cost table feature v0.23's +# metadata resolution falls back to (modelmeta.go's resolveModelMeta). +# Fetches LiteLLM's model_prices_and_context_window.json at BUILD TIME +# only — see tools/pricing-sync/main.go's own doc comment for the +# pruning rule and naming-bridge mechanism. Not run by CI (network +# fetch, and the generated table is committed); an operator re-runs this +# by hand when LiteLLM's own pricing drifts. +pricing-sync: + cd tools/pricing-sync && GOWORK=off go run . $(CURDIR) + gofmt -w pricing_data_gen.go + INTEGRATION_COMPOSE := integration/docker-compose.yml INTEGRATION_HEALTH_URL := http://localhost:19081/v1/models INTEGRATION_ALICE_KEY := sk-int-alice diff --git a/builtin_model_meta.go b/builtin_model_meta.go new file mode 100644 index 0000000..36f3bf0 --- /dev/null +++ b/builtin_model_meta.go @@ -0,0 +1,25 @@ +package traefikllmgateway + +// builtinModelMeta is one built-in, LiteLLM-synced catalog entry (feature +// v0.23): a model's context window and per-token cost, in the same +// micro-USD-per-million-tokens unit ModelMetaConfig uses (modelmeta.go's +// microUSDPerUSD). Zero in any field means "this table has no data for +// it" — resolveModelMeta (modelmeta.go) only ever trusts a field greater +// than zero, never a bare zero value, so there is no ambiguity with a +// genuinely free or zero-context entry (this table never has one: +// pricing_data_gen.go's generator only ever emits a field it found a +// positive upstream value for). Regenerated wholesale by `make +// pricing-sync` (tools/pricing-sync) — see pricing_data_gen.go's own +// header for the source snapshot and pruning rule, and +// tools/pricing-sync/main.go's own doc comment for the full pruning/ +// naming-bridge design. +// +// Kept in its own file, separate from modelmeta.go's resolution logic: +// this type is the generated table's own data shape, needed to compile +// pricing_data_gen.go on its own, independent of the resolveModelMeta/ +// registry wiring layered on top of it. +type builtinModelMeta struct { + ContextTokens int + InputCostPerMTokMicroUSD int64 + OutputCostPerMTokMicroUSD int64 +} diff --git a/pricing_data_gen.go b/pricing_data_gen.go new file mode 100644 index 0000000..d68dffa --- /dev/null +++ b/pricing_data_gen.go @@ -0,0 +1,249 @@ +package traefikllmgateway + +// Code generated by tools/pricing-sync from LiteLLM's +// model_prices_and_context_window.json — DO NOT EDIT. +// +// Source: https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json +// Snapshot: 2026-08-22, 236 entries kept (pruned from 3111 upstream entries — +// see tools/pricing-sync/main.go's own doc comment for the exact pruning +// rule and bare-id collision resolution). +// +// Regenerate with: make pricing-sync +var builtinModelMetaTable = map[string]builtinModelMeta{ + "MiniMax-M2": {ContextTokens: 200000, InputCostPerMTokMicroUSD: 300000, OutputCostPerMTokMicroUSD: 1200000}, + "MiniMax-M2.1": {ContextTokens: 1000000, InputCostPerMTokMicroUSD: 300000, OutputCostPerMTokMicroUSD: 1200000}, + "MiniMax-M2.1-highspeed": {ContextTokens: 1000000, InputCostPerMTokMicroUSD: 300000, OutputCostPerMTokMicroUSD: 2400000}, + "MiniMax-M2.1-lightning": {ContextTokens: 1000000, InputCostPerMTokMicroUSD: 300000, OutputCostPerMTokMicroUSD: 2400000}, + "MiniMax-M2.5": {ContextTokens: 1000000, InputCostPerMTokMicroUSD: 300000, OutputCostPerMTokMicroUSD: 1200000}, + "MiniMax-M2.5-highspeed": {ContextTokens: 1000000, InputCostPerMTokMicroUSD: 300000, OutputCostPerMTokMicroUSD: 2400000}, + "MiniMax-M2.5-lightning": {ContextTokens: 1000000, InputCostPerMTokMicroUSD: 300000, OutputCostPerMTokMicroUSD: 2400000}, + "MiniMax-M3": {ContextTokens: 1000000, InputCostPerMTokMicroUSD: 300000, OutputCostPerMTokMicroUSD: 1200000}, + "chat-latest": {ContextTokens: 400000, InputCostPerMTokMicroUSD: 5000000, OutputCostPerMTokMicroUSD: 30000000}, + "chatgpt-4o-latest": {ContextTokens: 128000, InputCostPerMTokMicroUSD: 5000000, OutputCostPerMTokMicroUSD: 15000000}, + "claude-3-7-sonnet-20250219": {ContextTokens: 200000, InputCostPerMTokMicroUSD: 3000000, OutputCostPerMTokMicroUSD: 15000000}, + "claude-3-haiku-20240307": {ContextTokens: 200000, InputCostPerMTokMicroUSD: 250000, OutputCostPerMTokMicroUSD: 1250000}, + "claude-3-opus-20240229": {ContextTokens: 200000, InputCostPerMTokMicroUSD: 15000000, OutputCostPerMTokMicroUSD: 75000000}, + "claude-4-opus-20250514": {ContextTokens: 200000, InputCostPerMTokMicroUSD: 15000000, OutputCostPerMTokMicroUSD: 75000000}, + "claude-4-sonnet-20250514": {ContextTokens: 1000000, InputCostPerMTokMicroUSD: 3000000, OutputCostPerMTokMicroUSD: 15000000}, + "claude-fable-5": {ContextTokens: 1000000, InputCostPerMTokMicroUSD: 10000000, OutputCostPerMTokMicroUSD: 50000000}, + "claude-haiku-4-5": {ContextTokens: 200000, InputCostPerMTokMicroUSD: 1000000, OutputCostPerMTokMicroUSD: 5000000}, + "claude-haiku-4-5-20251001": {ContextTokens: 200000, InputCostPerMTokMicroUSD: 1000000, OutputCostPerMTokMicroUSD: 5000000}, + "claude-mythos-5": {ContextTokens: 1000000, InputCostPerMTokMicroUSD: 10000000, OutputCostPerMTokMicroUSD: 50000000}, + "claude-mythos-preview": {ContextTokens: 1000000, InputCostPerMTokMicroUSD: 10000000, OutputCostPerMTokMicroUSD: 50000000}, + "claude-opus-4-1": {ContextTokens: 200000, InputCostPerMTokMicroUSD: 15000000, OutputCostPerMTokMicroUSD: 75000000}, + "claude-opus-4-1-20250805": {ContextTokens: 200000, InputCostPerMTokMicroUSD: 15000000, OutputCostPerMTokMicroUSD: 75000000}, + "claude-opus-4-20250514": {ContextTokens: 200000, InputCostPerMTokMicroUSD: 15000000, OutputCostPerMTokMicroUSD: 75000000}, + "claude-opus-4-5": {ContextTokens: 200000, InputCostPerMTokMicroUSD: 5000000, OutputCostPerMTokMicroUSD: 25000000}, + "claude-opus-4-5-20251101": {ContextTokens: 200000, InputCostPerMTokMicroUSD: 5000000, OutputCostPerMTokMicroUSD: 25000000}, + "claude-opus-4-6": {ContextTokens: 1000000, InputCostPerMTokMicroUSD: 5000000, OutputCostPerMTokMicroUSD: 25000000}, + "claude-opus-4-6-20260205": {ContextTokens: 1000000, InputCostPerMTokMicroUSD: 5000000, OutputCostPerMTokMicroUSD: 25000000}, + "claude-opus-4-7": {ContextTokens: 1000000, InputCostPerMTokMicroUSD: 5000000, OutputCostPerMTokMicroUSD: 25000000}, + "claude-opus-4-7-20260416": {ContextTokens: 1000000, InputCostPerMTokMicroUSD: 5000000, OutputCostPerMTokMicroUSD: 25000000}, + "claude-opus-4-8": {ContextTokens: 1000000, InputCostPerMTokMicroUSD: 5000000, OutputCostPerMTokMicroUSD: 25000000}, + "claude-opus-5": {ContextTokens: 1000000, InputCostPerMTokMicroUSD: 5000000, OutputCostPerMTokMicroUSD: 25000000}, + "claude-sonnet-4-20250514": {ContextTokens: 1000000, InputCostPerMTokMicroUSD: 3000000, OutputCostPerMTokMicroUSD: 15000000}, + "claude-sonnet-4-5": {ContextTokens: 200000, InputCostPerMTokMicroUSD: 3000000, OutputCostPerMTokMicroUSD: 15000000}, + "claude-sonnet-4-5-20250929": {ContextTokens: 200000, InputCostPerMTokMicroUSD: 3000000, OutputCostPerMTokMicroUSD: 15000000}, + "claude-sonnet-4-6": {ContextTokens: 1000000, InputCostPerMTokMicroUSD: 3000000, OutputCostPerMTokMicroUSD: 15000000}, + "claude-sonnet-5": {ContextTokens: 1000000, InputCostPerMTokMicroUSD: 2000000, OutputCostPerMTokMicroUSD: 10000000}, + "daybreak-blue-latest": {ContextTokens: 1050000, InputCostPerMTokMicroUSD: 5000000, OutputCostPerMTokMicroUSD: 30000000}, + "daybreak-red-latest": {ContextTokens: 400000, InputCostPerMTokMicroUSD: 12500000, OutputCostPerMTokMicroUSD: 75000000}, + "deepseek-chat": {ContextTokens: 131072, InputCostPerMTokMicroUSD: 280000, OutputCostPerMTokMicroUSD: 420000}, + "deepseek-coder": {ContextTokens: 128000, InputCostPerMTokMicroUSD: 140000, OutputCostPerMTokMicroUSD: 280000}, + "deepseek-r1": {ContextTokens: 65536, InputCostPerMTokMicroUSD: 550000, OutputCostPerMTokMicroUSD: 2190000}, + "deepseek-reasoner": {ContextTokens: 131072, InputCostPerMTokMicroUSD: 280000, OutputCostPerMTokMicroUSD: 420000}, + "deepseek-v3": {ContextTokens: 65536, InputCostPerMTokMicroUSD: 270000, OutputCostPerMTokMicroUSD: 1100000}, + "deepseek-v3.2": {ContextTokens: 163840, InputCostPerMTokMicroUSD: 280000, OutputCostPerMTokMicroUSD: 400000}, + "deepseek-v4-flash": {ContextTokens: 1000000, InputCostPerMTokMicroUSD: 440000, OutputCostPerMTokMicroUSD: 1320000}, + "deepseek-v4-flash-0731": {ContextTokens: 1000000, InputCostPerMTokMicroUSD: 200000, OutputCostPerMTokMicroUSD: 400000}, + "deepseek-v4-pro": {ContextTokens: 1000000, InputCostPerMTokMicroUSD: 1320000, OutputCostPerMTokMicroUSD: 3960000}, + "ft:gpt-3.5-turbo": {ContextTokens: 16385, InputCostPerMTokMicroUSD: 3000000, OutputCostPerMTokMicroUSD: 6000000}, + "ft:gpt-3.5-turbo-0125": {ContextTokens: 16385, InputCostPerMTokMicroUSD: 3000000, OutputCostPerMTokMicroUSD: 6000000}, + "ft:gpt-3.5-turbo-0613": {ContextTokens: 4096, InputCostPerMTokMicroUSD: 3000000, OutputCostPerMTokMicroUSD: 6000000}, + "ft:gpt-3.5-turbo-1106": {ContextTokens: 16385, InputCostPerMTokMicroUSD: 3000000, OutputCostPerMTokMicroUSD: 6000000}, + "ft:gpt-4-0613": {ContextTokens: 8192, InputCostPerMTokMicroUSD: 30000000, OutputCostPerMTokMicroUSD: 60000000}, + "ft:gpt-4.1-2025-04-14": {ContextTokens: 1047576, InputCostPerMTokMicroUSD: 3000000, OutputCostPerMTokMicroUSD: 12000000}, + "ft:gpt-4.1-mini-2025-04-14": {ContextTokens: 1047576, InputCostPerMTokMicroUSD: 800000, OutputCostPerMTokMicroUSD: 3200000}, + "ft:gpt-4.1-nano-2025-04-14": {ContextTokens: 1047576, InputCostPerMTokMicroUSD: 200000, OutputCostPerMTokMicroUSD: 800000}, + "ft:gpt-4o-2024-08-06": {ContextTokens: 128000, InputCostPerMTokMicroUSD: 3750000, OutputCostPerMTokMicroUSD: 15000000}, + "ft:gpt-4o-2024-11-20": {ContextTokens: 128000, InputCostPerMTokMicroUSD: 3750000, OutputCostPerMTokMicroUSD: 15000000}, + "ft:gpt-4o-mini-2024-07-18": {ContextTokens: 128000, InputCostPerMTokMicroUSD: 300000, OutputCostPerMTokMicroUSD: 1200000}, + "ft:o4-mini-2025-04-16": {ContextTokens: 200000, InputCostPerMTokMicroUSD: 4000000, OutputCostPerMTokMicroUSD: 16000000}, + "glm-5.1": {ContextTokens: 202745, InputCostPerMTokMicroUSD: 1400000, OutputCostPerMTokMicroUSD: 4400000}, + "glm-5.2": {ContextTokens: 1048576, InputCostPerMTokMicroUSD: 1400000, OutputCostPerMTokMicroUSD: 4400000}, + "gpt-3.5-turbo": {ContextTokens: 16385, InputCostPerMTokMicroUSD: 500000, OutputCostPerMTokMicroUSD: 1500000}, + "gpt-3.5-turbo-0125": {ContextTokens: 16385, InputCostPerMTokMicroUSD: 500000, OutputCostPerMTokMicroUSD: 1500000}, + "gpt-3.5-turbo-1106": {ContextTokens: 16385, InputCostPerMTokMicroUSD: 1000000, OutputCostPerMTokMicroUSD: 2000000}, + "gpt-3.5-turbo-16k": {ContextTokens: 16385, InputCostPerMTokMicroUSD: 3000000, OutputCostPerMTokMicroUSD: 4000000}, + "gpt-4": {ContextTokens: 8192, InputCostPerMTokMicroUSD: 30000000, OutputCostPerMTokMicroUSD: 60000000}, + "gpt-4-0125-preview": {ContextTokens: 128000, InputCostPerMTokMicroUSD: 10000000, OutputCostPerMTokMicroUSD: 30000000}, + "gpt-4-0314": {ContextTokens: 8192, InputCostPerMTokMicroUSD: 30000000, OutputCostPerMTokMicroUSD: 60000000}, + "gpt-4-0613": {ContextTokens: 8192, InputCostPerMTokMicroUSD: 30000000, OutputCostPerMTokMicroUSD: 60000000}, + "gpt-4-1106-preview": {ContextTokens: 128000, InputCostPerMTokMicroUSD: 10000000, OutputCostPerMTokMicroUSD: 30000000}, + "gpt-4-turbo": {ContextTokens: 128000, InputCostPerMTokMicroUSD: 10000000, OutputCostPerMTokMicroUSD: 30000000}, + "gpt-4-turbo-2024-04-09": {ContextTokens: 128000, InputCostPerMTokMicroUSD: 10000000, OutputCostPerMTokMicroUSD: 30000000}, + "gpt-4-turbo-preview": {ContextTokens: 128000, InputCostPerMTokMicroUSD: 10000000, OutputCostPerMTokMicroUSD: 30000000}, + "gpt-4.1": {ContextTokens: 1047576, InputCostPerMTokMicroUSD: 2000000, OutputCostPerMTokMicroUSD: 8000000}, + "gpt-4.1-2025-04-14": {ContextTokens: 1047576, InputCostPerMTokMicroUSD: 2000000, OutputCostPerMTokMicroUSD: 8000000}, + "gpt-4.1-mini": {ContextTokens: 1047576, InputCostPerMTokMicroUSD: 400000, OutputCostPerMTokMicroUSD: 1600000}, + "gpt-4.1-mini-2025-04-14": {ContextTokens: 1047576, InputCostPerMTokMicroUSD: 400000, OutputCostPerMTokMicroUSD: 1600000}, + "gpt-4.1-nano": {ContextTokens: 1047576, InputCostPerMTokMicroUSD: 100000, OutputCostPerMTokMicroUSD: 400000}, + "gpt-4.1-nano-2025-04-14": {ContextTokens: 1047576, InputCostPerMTokMicroUSD: 100000, OutputCostPerMTokMicroUSD: 400000}, + "gpt-4o": {ContextTokens: 128000, InputCostPerMTokMicroUSD: 2500000, OutputCostPerMTokMicroUSD: 10000000}, + "gpt-4o-2024-05-13": {ContextTokens: 128000, InputCostPerMTokMicroUSD: 5000000, OutputCostPerMTokMicroUSD: 15000000}, + "gpt-4o-2024-08-06": {ContextTokens: 128000, InputCostPerMTokMicroUSD: 2500000, OutputCostPerMTokMicroUSD: 10000000}, + "gpt-4o-2024-11-20": {ContextTokens: 128000, InputCostPerMTokMicroUSD: 2500000, OutputCostPerMTokMicroUSD: 10000000}, + "gpt-4o-audio-preview": {ContextTokens: 128000, InputCostPerMTokMicroUSD: 2500000, OutputCostPerMTokMicroUSD: 10000000}, + "gpt-4o-audio-preview-2024-12-17": {ContextTokens: 128000, InputCostPerMTokMicroUSD: 2500000, OutputCostPerMTokMicroUSD: 10000000}, + "gpt-4o-audio-preview-2025-06-03": {ContextTokens: 128000, InputCostPerMTokMicroUSD: 2500000, OutputCostPerMTokMicroUSD: 10000000}, + "gpt-4o-mini": {ContextTokens: 128000, InputCostPerMTokMicroUSD: 150000, OutputCostPerMTokMicroUSD: 600000}, + "gpt-4o-mini-2024-07-18": {ContextTokens: 128000, InputCostPerMTokMicroUSD: 150000, OutputCostPerMTokMicroUSD: 600000}, + "gpt-4o-mini-audio-preview": {ContextTokens: 128000, InputCostPerMTokMicroUSD: 150000, OutputCostPerMTokMicroUSD: 600000}, + "gpt-4o-mini-audio-preview-2024-12-17": {ContextTokens: 128000, InputCostPerMTokMicroUSD: 150000, OutputCostPerMTokMicroUSD: 600000}, + "gpt-4o-mini-search-preview": {ContextTokens: 128000, InputCostPerMTokMicroUSD: 150000, OutputCostPerMTokMicroUSD: 600000}, + "gpt-4o-mini-search-preview-2025-03-11": {ContextTokens: 128000, InputCostPerMTokMicroUSD: 150000, OutputCostPerMTokMicroUSD: 600000}, + "gpt-4o-search-preview": {ContextTokens: 128000, InputCostPerMTokMicroUSD: 2500000, OutputCostPerMTokMicroUSD: 10000000}, + "gpt-4o-search-preview-2025-03-11": {ContextTokens: 128000, InputCostPerMTokMicroUSD: 2500000, OutputCostPerMTokMicroUSD: 10000000}, + "gpt-5": {ContextTokens: 272000, InputCostPerMTokMicroUSD: 1250000, OutputCostPerMTokMicroUSD: 10000000}, + "gpt-5-2025-08-07": {ContextTokens: 272000, InputCostPerMTokMicroUSD: 1250000, OutputCostPerMTokMicroUSD: 10000000}, + "gpt-5-chat": {ContextTokens: 128000, InputCostPerMTokMicroUSD: 1250000, OutputCostPerMTokMicroUSD: 10000000}, + "gpt-5-chat-latest": {ContextTokens: 128000, InputCostPerMTokMicroUSD: 1250000, OutputCostPerMTokMicroUSD: 10000000}, + "gpt-5-mini": {ContextTokens: 272000, InputCostPerMTokMicroUSD: 250000, OutputCostPerMTokMicroUSD: 2000000}, + "gpt-5-mini-2025-08-07": {ContextTokens: 272000, InputCostPerMTokMicroUSD: 250000, OutputCostPerMTokMicroUSD: 2000000}, + "gpt-5-nano": {ContextTokens: 272000, InputCostPerMTokMicroUSD: 50000, OutputCostPerMTokMicroUSD: 400000}, + "gpt-5-nano-2025-08-07": {ContextTokens: 272000, InputCostPerMTokMicroUSD: 50000, OutputCostPerMTokMicroUSD: 400000}, + "gpt-5-search-api": {ContextTokens: 272000, InputCostPerMTokMicroUSD: 1250000, OutputCostPerMTokMicroUSD: 10000000}, + "gpt-5-search-api-2025-10-14": {ContextTokens: 272000, InputCostPerMTokMicroUSD: 1250000, OutputCostPerMTokMicroUSD: 10000000}, + "gpt-5.1": {ContextTokens: 272000, InputCostPerMTokMicroUSD: 1250000, OutputCostPerMTokMicroUSD: 10000000}, + "gpt-5.1-2025-11-13": {ContextTokens: 272000, InputCostPerMTokMicroUSD: 1250000, OutputCostPerMTokMicroUSD: 10000000}, + "gpt-5.1-chat-latest": {ContextTokens: 128000, InputCostPerMTokMicroUSD: 1250000, OutputCostPerMTokMicroUSD: 10000000}, + "gpt-5.2": {ContextTokens: 272000, InputCostPerMTokMicroUSD: 1750000, OutputCostPerMTokMicroUSD: 14000000}, + "gpt-5.2-2025-12-11": {ContextTokens: 272000, InputCostPerMTokMicroUSD: 1750000, OutputCostPerMTokMicroUSD: 14000000}, + "gpt-5.2-chat-latest": {ContextTokens: 128000, InputCostPerMTokMicroUSD: 1750000, OutputCostPerMTokMicroUSD: 14000000}, + "gpt-5.3-chat-latest": {ContextTokens: 128000, InputCostPerMTokMicroUSD: 1750000, OutputCostPerMTokMicroUSD: 14000000}, + "gpt-5.4": {ContextTokens: 1050000, InputCostPerMTokMicroUSD: 2500000, OutputCostPerMTokMicroUSD: 15000000}, + "gpt-5.4-2026-03-05": {ContextTokens: 1050000, InputCostPerMTokMicroUSD: 2500000, OutputCostPerMTokMicroUSD: 15000000}, + "gpt-5.4-mini": {ContextTokens: 272000, InputCostPerMTokMicroUSD: 750000, OutputCostPerMTokMicroUSD: 4500000}, + "gpt-5.4-mini-2026-03-17": {ContextTokens: 272000, InputCostPerMTokMicroUSD: 750000, OutputCostPerMTokMicroUSD: 4500000}, + "gpt-5.4-nano": {ContextTokens: 272000, InputCostPerMTokMicroUSD: 200000, OutputCostPerMTokMicroUSD: 1250000}, + "gpt-5.4-nano-2026-03-17": {ContextTokens: 272000, InputCostPerMTokMicroUSD: 200000, OutputCostPerMTokMicroUSD: 1250000}, + "gpt-5.5": {ContextTokens: 1050000, InputCostPerMTokMicroUSD: 5000000, OutputCostPerMTokMicroUSD: 30000000}, + "gpt-5.5-2026-04-23": {ContextTokens: 1050000, InputCostPerMTokMicroUSD: 5000000, OutputCostPerMTokMicroUSD: 30000000}, + "gpt-5.6": {ContextTokens: 922000, InputCostPerMTokMicroUSD: 5000000, OutputCostPerMTokMicroUSD: 30000000}, + "gpt-5.6-cyber": {ContextTokens: 400000, InputCostPerMTokMicroUSD: 12500000, OutputCostPerMTokMicroUSD: 75000000}, + "gpt-5.6-luna": {ContextTokens: 922000, InputCostPerMTokMicroUSD: 200000, OutputCostPerMTokMicroUSD: 1200000}, + "gpt-5.6-sol": {ContextTokens: 922000, InputCostPerMTokMicroUSD: 5000000, OutputCostPerMTokMicroUSD: 30000000}, + "gpt-5.6-terra": {ContextTokens: 922000, InputCostPerMTokMicroUSD: 2000000, OutputCostPerMTokMicroUSD: 12000000}, + "gpt-audio": {ContextTokens: 128000, InputCostPerMTokMicroUSD: 2500000, OutputCostPerMTokMicroUSD: 10000000}, + "gpt-audio-1.5": {ContextTokens: 128000, InputCostPerMTokMicroUSD: 2500000, OutputCostPerMTokMicroUSD: 10000000}, + "gpt-audio-2025-08-28": {ContextTokens: 128000, InputCostPerMTokMicroUSD: 2500000, OutputCostPerMTokMicroUSD: 10000000}, + "gpt-audio-mini": {ContextTokens: 128000, InputCostPerMTokMicroUSD: 600000, OutputCostPerMTokMicroUSD: 2400000}, + "gpt-audio-mini-2025-10-06": {ContextTokens: 128000, InputCostPerMTokMicroUSD: 600000, OutputCostPerMTokMicroUSD: 2400000}, + "gpt-audio-mini-2025-12-15": {ContextTokens: 128000, InputCostPerMTokMicroUSD: 600000, OutputCostPerMTokMicroUSD: 2400000}, + "gpt-oss-120b": {ContextTokens: 131072, InputCostPerMTokMicroUSD: 350000, OutputCostPerMTokMicroUSD: 750000}, + "grok-2": {ContextTokens: 131072, InputCostPerMTokMicroUSD: 2000000, OutputCostPerMTokMicroUSD: 10000000}, + "grok-2-1212": {ContextTokens: 131072, InputCostPerMTokMicroUSD: 2000000, OutputCostPerMTokMicroUSD: 10000000}, + "grok-2-latest": {ContextTokens: 131072, InputCostPerMTokMicroUSD: 2000000, OutputCostPerMTokMicroUSD: 10000000}, + "grok-2-vision": {ContextTokens: 32768, InputCostPerMTokMicroUSD: 2000000, OutputCostPerMTokMicroUSD: 10000000}, + "grok-2-vision-1212": {ContextTokens: 32768, InputCostPerMTokMicroUSD: 2000000, OutputCostPerMTokMicroUSD: 10000000}, + "grok-2-vision-latest": {ContextTokens: 32768, InputCostPerMTokMicroUSD: 2000000, OutputCostPerMTokMicroUSD: 10000000}, + "grok-3": {ContextTokens: 131072, InputCostPerMTokMicroUSD: 3000000, OutputCostPerMTokMicroUSD: 15000000}, + "grok-3-beta": {ContextTokens: 131072, InputCostPerMTokMicroUSD: 3000000, OutputCostPerMTokMicroUSD: 15000000}, + "grok-3-fast-beta": {ContextTokens: 131072, InputCostPerMTokMicroUSD: 5000000, OutputCostPerMTokMicroUSD: 25000000}, + "grok-3-fast-latest": {ContextTokens: 131072, InputCostPerMTokMicroUSD: 5000000, OutputCostPerMTokMicroUSD: 25000000}, + "grok-3-latest": {ContextTokens: 131072, InputCostPerMTokMicroUSD: 3000000, OutputCostPerMTokMicroUSD: 15000000}, + "grok-3-mini": {ContextTokens: 131072, InputCostPerMTokMicroUSD: 300000, OutputCostPerMTokMicroUSD: 500000}, + "grok-3-mini-beta": {ContextTokens: 131072, InputCostPerMTokMicroUSD: 300000, OutputCostPerMTokMicroUSD: 500000}, + "grok-3-mini-fast": {ContextTokens: 131072, InputCostPerMTokMicroUSD: 600000, OutputCostPerMTokMicroUSD: 4000000}, + "grok-3-mini-fast-beta": {ContextTokens: 131072, InputCostPerMTokMicroUSD: 600000, OutputCostPerMTokMicroUSD: 4000000}, + "grok-3-mini-fast-latest": {ContextTokens: 131072, InputCostPerMTokMicroUSD: 600000, OutputCostPerMTokMicroUSD: 4000000}, + "grok-3-mini-latest": {ContextTokens: 131072, InputCostPerMTokMicroUSD: 300000, OutputCostPerMTokMicroUSD: 500000}, + "grok-4": {ContextTokens: 256000, InputCostPerMTokMicroUSD: 3000000, OutputCostPerMTokMicroUSD: 15000000}, + "grok-4-0709": {ContextTokens: 256000, InputCostPerMTokMicroUSD: 3000000, OutputCostPerMTokMicroUSD: 15000000}, + "grok-4-1-fast": {ContextTokens: 2000000, InputCostPerMTokMicroUSD: 200000, OutputCostPerMTokMicroUSD: 500000}, + "grok-4-1-fast-non-reasoning": {ContextTokens: 2000000, InputCostPerMTokMicroUSD: 200000, OutputCostPerMTokMicroUSD: 500000}, + "grok-4-1-fast-non-reasoning-latest": {ContextTokens: 2000000, InputCostPerMTokMicroUSD: 200000, OutputCostPerMTokMicroUSD: 500000}, + "grok-4-1-fast-reasoning": {ContextTokens: 2000000, InputCostPerMTokMicroUSD: 200000, OutputCostPerMTokMicroUSD: 500000}, + "grok-4-1-fast-reasoning-latest": {ContextTokens: 2000000, InputCostPerMTokMicroUSD: 200000, OutputCostPerMTokMicroUSD: 500000}, + "grok-4-fast-non-reasoning": {ContextTokens: 2000000, InputCostPerMTokMicroUSD: 200000, OutputCostPerMTokMicroUSD: 500000}, + "grok-4-fast-reasoning": {ContextTokens: 2000000, InputCostPerMTokMicroUSD: 200000, OutputCostPerMTokMicroUSD: 500000}, + "grok-4-latest": {ContextTokens: 256000, InputCostPerMTokMicroUSD: 3000000, OutputCostPerMTokMicroUSD: 15000000}, + "grok-4.20-0309-non-reasoning": {ContextTokens: 1000000, InputCostPerMTokMicroUSD: 1250000, OutputCostPerMTokMicroUSD: 2500000}, + "grok-4.20-0309-reasoning": {ContextTokens: 1000000, InputCostPerMTokMicroUSD: 1250000, OutputCostPerMTokMicroUSD: 2500000}, + "grok-4.20-beta-0309-non-reasoning": {ContextTokens: 1000000, InputCostPerMTokMicroUSD: 1250000, OutputCostPerMTokMicroUSD: 2500000}, + "grok-4.20-beta-0309-reasoning": {ContextTokens: 1000000, InputCostPerMTokMicroUSD: 1250000, OutputCostPerMTokMicroUSD: 2500000}, + "grok-4.20-multi-agent-0309": {ContextTokens: 1000000, InputCostPerMTokMicroUSD: 1250000, OutputCostPerMTokMicroUSD: 2500000}, + "grok-4.20-multi-agent-beta-0309": {ContextTokens: 1000000, InputCostPerMTokMicroUSD: 1250000, OutputCostPerMTokMicroUSD: 2500000}, + "grok-4.3": {ContextTokens: 1000000, InputCostPerMTokMicroUSD: 1250000, OutputCostPerMTokMicroUSD: 2500000}, + "grok-4.3-latest": {ContextTokens: 1000000, InputCostPerMTokMicroUSD: 1250000, OutputCostPerMTokMicroUSD: 2500000}, + "grok-4.5": {ContextTokens: 500000, InputCostPerMTokMicroUSD: 2000000, OutputCostPerMTokMicroUSD: 6000000}, + "grok-4.5-latest": {ContextTokens: 500000, InputCostPerMTokMicroUSD: 2000000, OutputCostPerMTokMicroUSD: 6000000}, + "grok-4.6": {ContextTokens: 500000, InputCostPerMTokMicroUSD: 2000000, OutputCostPerMTokMicroUSD: 6000000}, + "grok-beta": {ContextTokens: 131072, InputCostPerMTokMicroUSD: 5000000, OutputCostPerMTokMicroUSD: 15000000}, + "grok-build-0.1": {ContextTokens: 256000, InputCostPerMTokMicroUSD: 1000000, OutputCostPerMTokMicroUSD: 2000000}, + "grok-code-fast": {ContextTokens: 256000, InputCostPerMTokMicroUSD: 1000000, OutputCostPerMTokMicroUSD: 2000000}, + "grok-code-fast-1": {ContextTokens: 256000, InputCostPerMTokMicroUSD: 1000000, OutputCostPerMTokMicroUSD: 2000000}, + "grok-code-fast-1-0825": {ContextTokens: 256000, InputCostPerMTokMicroUSD: 1000000, OutputCostPerMTokMicroUSD: 2000000}, + "grok-vision-beta": {ContextTokens: 8192, InputCostPerMTokMicroUSD: 5000000, OutputCostPerMTokMicroUSD: 15000000}, + "kimi-k2-0711-preview": {ContextTokens: 131072, InputCostPerMTokMicroUSD: 600000, OutputCostPerMTokMicroUSD: 2500000}, + "kimi-k2-0905-preview": {ContextTokens: 262144, InputCostPerMTokMicroUSD: 600000, OutputCostPerMTokMicroUSD: 2500000}, + "kimi-k2-thinking": {ContextTokens: 262144, InputCostPerMTokMicroUSD: 600000, OutputCostPerMTokMicroUSD: 2500000}, + "kimi-k2-thinking-turbo": {ContextTokens: 262144, InputCostPerMTokMicroUSD: 1150000, OutputCostPerMTokMicroUSD: 8000000}, + "kimi-k2-turbo-preview": {ContextTokens: 262144, InputCostPerMTokMicroUSD: 1150000, OutputCostPerMTokMicroUSD: 8000000}, + "kimi-k2.5": {ContextTokens: 262144, InputCostPerMTokMicroUSD: 600000, OutputCostPerMTokMicroUSD: 3000000}, + "kimi-k2.6": {ContextTokens: 262144, InputCostPerMTokMicroUSD: 950000, OutputCostPerMTokMicroUSD: 4000000}, + "kimi-k2.7-code": {ContextTokens: 229376, InputCostPerMTokMicroUSD: 950000, OutputCostPerMTokMicroUSD: 4000000}, + "kimi-k3": {ContextTokens: 1048576, InputCostPerMTokMicroUSD: 3000000, OutputCostPerMTokMicroUSD: 15000000}, + "kimi-latest": {ContextTokens: 131072, InputCostPerMTokMicroUSD: 2000000, OutputCostPerMTokMicroUSD: 5000000}, + "kimi-latest-128k": {ContextTokens: 131072, InputCostPerMTokMicroUSD: 2000000, OutputCostPerMTokMicroUSD: 5000000}, + "kimi-latest-32k": {ContextTokens: 32768, InputCostPerMTokMicroUSD: 1000000, OutputCostPerMTokMicroUSD: 3000000}, + "kimi-latest-8k": {ContextTokens: 8192, InputCostPerMTokMicroUSD: 200000, OutputCostPerMTokMicroUSD: 2000000}, + "kimi-thinking-preview": {ContextTokens: 131072, InputCostPerMTokMicroUSD: 600000, OutputCostPerMTokMicroUSD: 2500000}, + "llama-3.3-70b": {ContextTokens: 128000, InputCostPerMTokMicroUSD: 850000, OutputCostPerMTokMicroUSD: 1200000}, + "llama3.1-70b": {ContextTokens: 128000, InputCostPerMTokMicroUSD: 600000, OutputCostPerMTokMicroUSD: 600000}, + "llama3.1-8b": {ContextTokens: 128000, InputCostPerMTokMicroUSD: 100000, OutputCostPerMTokMicroUSD: 100000}, + "moonshot-v1-128k": {ContextTokens: 131072, InputCostPerMTokMicroUSD: 2000000, OutputCostPerMTokMicroUSD: 5000000}, + "moonshot-v1-128k-0430": {ContextTokens: 131072, InputCostPerMTokMicroUSD: 2000000, OutputCostPerMTokMicroUSD: 5000000}, + "moonshot-v1-128k-vision-preview": {ContextTokens: 131072, InputCostPerMTokMicroUSD: 2000000, OutputCostPerMTokMicroUSD: 5000000}, + "moonshot-v1-32k": {ContextTokens: 32768, InputCostPerMTokMicroUSD: 1000000, OutputCostPerMTokMicroUSD: 3000000}, + "moonshot-v1-32k-0430": {ContextTokens: 32768, InputCostPerMTokMicroUSD: 1000000, OutputCostPerMTokMicroUSD: 3000000}, + "moonshot-v1-32k-vision-preview": {ContextTokens: 32768, InputCostPerMTokMicroUSD: 1000000, OutputCostPerMTokMicroUSD: 3000000}, + "moonshot-v1-8k": {ContextTokens: 8192, InputCostPerMTokMicroUSD: 200000, OutputCostPerMTokMicroUSD: 2000000}, + "moonshot-v1-8k-0430": {ContextTokens: 8192, InputCostPerMTokMicroUSD: 200000, OutputCostPerMTokMicroUSD: 2000000}, + "moonshot-v1-8k-vision-preview": {ContextTokens: 8192, InputCostPerMTokMicroUSD: 200000, OutputCostPerMTokMicroUSD: 2000000}, + "moonshot-v1-auto": {ContextTokens: 131072, InputCostPerMTokMicroUSD: 2000000, OutputCostPerMTokMicroUSD: 5000000}, + "o1": {ContextTokens: 200000, InputCostPerMTokMicroUSD: 15000000, OutputCostPerMTokMicroUSD: 60000000}, + "o1-2024-12-17": {ContextTokens: 200000, InputCostPerMTokMicroUSD: 15000000, OutputCostPerMTokMicroUSD: 60000000}, + "o3": {ContextTokens: 200000, InputCostPerMTokMicroUSD: 2000000, OutputCostPerMTokMicroUSD: 8000000}, + "o3-2025-04-16": {ContextTokens: 200000, InputCostPerMTokMicroUSD: 2000000, OutputCostPerMTokMicroUSD: 8000000}, + "o3-mini": {ContextTokens: 200000, InputCostPerMTokMicroUSD: 1100000, OutputCostPerMTokMicroUSD: 4400000}, + "o3-mini-2025-01-31": {ContextTokens: 200000, InputCostPerMTokMicroUSD: 1100000, OutputCostPerMTokMicroUSD: 4400000}, + "o4-mini": {ContextTokens: 200000, InputCostPerMTokMicroUSD: 1100000, OutputCostPerMTokMicroUSD: 4400000}, + "o4-mini-2025-04-16": {ContextTokens: 200000, InputCostPerMTokMicroUSD: 1100000, OutputCostPerMTokMicroUSD: 4400000}, + "qwen-3-32b": {ContextTokens: 128000, InputCostPerMTokMicroUSD: 400000, OutputCostPerMTokMicroUSD: 800000}, + "qwen-coder": {ContextTokens: 1000000, InputCostPerMTokMicroUSD: 300000, OutputCostPerMTokMicroUSD: 1500000}, + "qwen-max": {ContextTokens: 30720, InputCostPerMTokMicroUSD: 1600000, OutputCostPerMTokMicroUSD: 6400000}, + "qwen-plus": {ContextTokens: 129024, InputCostPerMTokMicroUSD: 400000, OutputCostPerMTokMicroUSD: 1200000}, + "qwen-plus-2025-01-25": {ContextTokens: 129024, InputCostPerMTokMicroUSD: 400000, OutputCostPerMTokMicroUSD: 1200000}, + "qwen-plus-2025-04-28": {ContextTokens: 129024, InputCostPerMTokMicroUSD: 400000, OutputCostPerMTokMicroUSD: 1200000}, + "qwen-plus-2025-07-14": {ContextTokens: 129024, InputCostPerMTokMicroUSD: 400000, OutputCostPerMTokMicroUSD: 1200000}, + "qwen-turbo": {ContextTokens: 129024, InputCostPerMTokMicroUSD: 50000, OutputCostPerMTokMicroUSD: 200000}, + "qwen-turbo-2024-11-01": {ContextTokens: 1000000, InputCostPerMTokMicroUSD: 50000, OutputCostPerMTokMicroUSD: 200000}, + "qwen-turbo-2025-04-28": {ContextTokens: 1000000, InputCostPerMTokMicroUSD: 50000, OutputCostPerMTokMicroUSD: 200000}, + "qwen-turbo-latest": {ContextTokens: 1000000, InputCostPerMTokMicroUSD: 50000, OutputCostPerMTokMicroUSD: 200000}, + "qwen3-next-80b-a3b-instruct": {ContextTokens: 262144, InputCostPerMTokMicroUSD: 150000, OutputCostPerMTokMicroUSD: 1200000}, + "qwen3-next-80b-a3b-thinking": {ContextTokens: 262144, InputCostPerMTokMicroUSD: 150000, OutputCostPerMTokMicroUSD: 1200000}, + "qwen3-vl-235b-a22b-instruct": {ContextTokens: 131072, InputCostPerMTokMicroUSD: 400000, OutputCostPerMTokMicroUSD: 1600000}, + "qwen3-vl-235b-a22b-thinking": {ContextTokens: 131072, InputCostPerMTokMicroUSD: 400000, OutputCostPerMTokMicroUSD: 4000000}, + "qwen3-vl-32b-instruct": {ContextTokens: 131072, InputCostPerMTokMicroUSD: 160000, OutputCostPerMTokMicroUSD: 640000}, + "qwen3-vl-32b-thinking": {ContextTokens: 131072, InputCostPerMTokMicroUSD: 160000, OutputCostPerMTokMicroUSD: 2870000}, + "qwen3.7-max": {ContextTokens: 991808, InputCostPerMTokMicroUSD: 2500000, OutputCostPerMTokMicroUSD: 7500000}, + "qwen3.8-max": {ContextTokens: 991808, InputCostPerMTokMicroUSD: 2000000, OutputCostPerMTokMicroUSD: 6000000}, + "qwq-plus": {ContextTokens: 98304, InputCostPerMTokMicroUSD: 800000, OutputCostPerMTokMicroUSD: 2400000}, + "text-embedding-3-large": {ContextTokens: 8191, InputCostPerMTokMicroUSD: 130000, OutputCostPerMTokMicroUSD: 0}, + "text-embedding-3-small": {ContextTokens: 8191, InputCostPerMTokMicroUSD: 20000, OutputCostPerMTokMicroUSD: 0}, + "text-embedding-ada-002": {ContextTokens: 8191, InputCostPerMTokMicroUSD: 100000, OutputCostPerMTokMicroUSD: 0}, + "text-embedding-ada-002-v2": {ContextTokens: 8191, InputCostPerMTokMicroUSD: 100000, OutputCostPerMTokMicroUSD: 0}, + "zai-glm-4.6": {ContextTokens: 128000, InputCostPerMTokMicroUSD: 2250000, OutputCostPerMTokMicroUSD: 2750000}, + "zai-glm-4.7": {ContextTokens: 128000, InputCostPerMTokMicroUSD: 2250000, OutputCostPerMTokMicroUSD: 2750000}, +} diff --git a/tools/pricing-sync/go.mod b/tools/pricing-sync/go.mod new file mode 100644 index 0000000..d89f5fb --- /dev/null +++ b/tools/pricing-sync/go.mod @@ -0,0 +1,3 @@ +module llmgw-pricing-sync + +go 1.22 diff --git a/tools/pricing-sync/main.go b/tools/pricing-sync/main.go new file mode 100644 index 0000000..2a7e786 --- /dev/null +++ b/tools/pricing-sync/main.go @@ -0,0 +1,497 @@ +// Command pricing-sync regenerates the plugin's repo-root +// pricing_data_gen.go: a built-in, LiteLLM-synced table of per-model +// context window and per-token cost (feature v0.23's builtin layer, +// modelmeta.go's builtinModelMeta/builtinModelMetaTable), which +// resolveModelMeta falls back to when neither an operator's modelMeta +// config override nor a provider's own discovery capture knows a value. +// +// It fetches LiteLLM's community-maintained +// model_prices_and_context_window.json (litellmSourceURL below — 3111 +// entries as of this feature's own snapshot, 2026-08) at BUILD TIME +// only, never at plugin runtime: the plugin module stays free of any +// network dependency, and the generated table is a plain, checked-in Go +// map literal an operator's `go build`/Yaegi interpretation never +// fetches anything to produce. +// +// # Pruning rule +// +// A 3111-entry table in Yaegi-interpreted source is a real startup cost +// (parsing and evaluating a large map literal under an AST interpreter +// is far slower than under a compiler) — this tool prunes aggressively, +// on three independent axes, ALL of which an entry must pass to survive: +// +// 1. Provider allowlist (allowedProviders below): only LiteLLM's own +// "litellm_provider" tag values this gateway's provider TYPES can +// meaningfully match against an operator's OWN model ids — +// "openai", "anthropic", "deepseek", "xai" (Grok), "minimax", +// "cerebras", "dashscope" (Alibaba Cloud's DashScope API, which is +// where LiteLLM files its Qwen models — verified against a live +// fetch of the source JSON while building this generator, 2026-08: +// there is no "qwen" or "alibaba" litellm_provider tag, and no +// "qwen/"-prefixed key exists in the source either), and "moonshot" +// (Kimi models — coverage-audit addendum, 2026-08). Every other +// provider LiteLLM tracks (bedrock, azure, vertex_ai, openrouter, +// novita, github_copilot, ...) is dropped outright — this gateway +// has no adapter type that would ever route to most of them under a +// bare model id anyway. Notably NOT allowlisted despite being +// considered during the coverage audit: "openrouter" (no entry in +// the fetched source carries a ":free"-suffixed key — a claimed +// free-tier naming convention that does not appear in this file as +// fetched) and "novita" (where LiteLLM does track a few otherwise- +// uncovered ids, e.g. xiaomi's mimo-v2.5 family, but adding either +// whole catalog — openrouter alone re-adds ~100 entries — cuts +// against this rule's own "keep the interpreted table small" goal; +// left as an operator decision, not made unilaterally here). +// 2. Mode allowlist (allowedModes below): "chat" and "embedding" only — +// LiteLLM also prices image generation, audio transcription/speech, +// moderation, and rerank endpoints under the same file, none of +// which this feature's context-window/cost metadata is meant to +// describe. +// 3. A well-formed entry: both input_cost_per_token and +// output_cost_per_token must decode as JSON numbers (many LiteLLM +// entries price only per-image or per-request and leave these +// null); an entry whose JSON shape does not even decode into +// litellmEntry at all (LiteLLM's own "sample_spec" schema-example +// key mixes description STRINGS into fields this tool expects as +// numbers) is skipped rather than aborting the whole sync — this is +// a defensive fallback, not just a hardcoded "sample_spec" skip, so +// a future upstream schema-example entry with some other key name +// degrades the same way. +// +// # Bare-id collision resolution +// +// The plugin's builtin tables (both the pre-existing builtinPricing, +// pricing.go, and this feature's builtinModelMetaTable) are keyed by +// BARE model id only, never "provider/model" — an operator's own +// provider config key never matches LiteLLM's canonical provider name, +// so a provider-qualified builtin entry would never be reachable via +// resolveModelMeta's actual lookup (modelmeta.go). LiteLLM itself often +// carries BOTH a bare key ("deepseek-chat") and one or more +// provider-prefixed keys for the identical or a re-hosted model +// ("deepseek/deepseek-chat", sometimes "dashscope/deepseek-v4-flash" +// with DIFFERENT pricing served through a different host) — chooseWinner +// resolves this deterministically: prefer a key that carries no +// "provider/" prefix at all, then the earliest provider in +// providerPriority (declared in the same order as allowedProviders' +// documentation above), then the lexicographically smallest full key as +// a final, always-deterministic tiebreak. +// +// # Naming bridges +// +// LiteLLM's own model naming occasionally disagrees with what a +// provider's own live API reports for the IDENTICAL model — not a +// pricing disagreement (chooseWinner's problem above), but a pure label +// mismatch. namingBridges (below) is a small, explicitly data-driven +// table for exactly this: each entry matches a bare id's suffix under +// one provider and additionally emits an identical-valued duplicate +// under a second suffix, so a future one-off naming split is a one-line +// table addition, not new code. Never invents a value: a bridge only +// ever duplicates an entry LiteLLM already priced, and never overwrites +// an id LiteLLM already prices directly under its own bridged name. +package main + +import ( + "context" + "encoding/json" + "fmt" + "go/format" + "io" + "math" + "net/http" + "os" + "path/filepath" + "sort" + "strings" + "time" +) + +// litellmSourceURL is LiteLLM's community-maintained model price/context +// table, fetched fresh on every `make pricing-sync` run — never at +// plugin runtime (this tool's own doc comment above). +const litellmSourceURL = "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json" + +// fetchTimeout bounds the network fetch: generous for a ~2MB JSON file +// over a normal connection, while still failing a hung request instead +// of blocking a `make pricing-sync` run indefinitely. +const fetchTimeout = 60 * time.Second + +// microUSDPerUSDPerToken converts a LiteLLM per-SINGLE-token USD price +// to micro-USD PER MILLION tokens in one multiplication: +// cost_per_token (USD/token) * 1_000_000 (tokens/Mtok) * 1_000_000 +// (micro-USD/USD) = cost_per_token * 1e12. +const microUSDPerUSDPerToken = 1e12 + +// sampleSpecKey is LiteLLM's own schema-example entry — explicitly named +// here (in addition to the generic decode-failure fallback the package +// doc comment describes) so the pruning rule reads unambiguously for +// this specific, known case. +const sampleSpecKey = "sample_spec" + +// allowedProviders is the pruning rule's provider allowlist — see the +// package doc comment for the full rationale and the qwen/alibaba -> +// dashscope mapping. +var allowedProviders = map[string]bool{ + "openai": true, + "anthropic": true, + "deepseek": true, + "xai": true, // Grok + "minimax": true, + "cerebras": true, + "dashscope": true, // Qwen / Alibaba Cloud + // moonshot: Kimi models (coverage-audit addendum, 2026-08) — + // verified live against a fetch of the source JSON: 23 entries, + // litellm_provider "moonshot", all mode "chat", bare ids like + // "kimi-k2.5"/"kimi-k3". Added as a first-class allowlisted provider + // (not a per-id bridge): builtinModelMetaTable is already keyed by + // bare id only (bareModelID strips ANY matching allowlisted + // provider's own "provider/" prefix), so an operator's own + // Kimi-serving deployment — whether addressed directly or through an + // aggregator such as Alibaba's DashScope — resolves against these + // bare ids the same way any other allowlisted provider's entries do. + "moonshot": true, +} + +// providerPriority orders allowedProviders for chooseWinner's collision +// tiebreak — same set, as a stable slice instead of a randomly-ordered +// map. +var providerPriority = []string{"openai", "anthropic", "deepseek", "xai", "minimax", "cerebras", "dashscope", "moonshot"} + +// allowedModes is the pruning rule's mode allowlist — see the package +// doc comment. +var allowedModes = map[string]bool{"chat": true, "embedding": true} + +// namingBridge is one entry in namingBridges — see the package doc +// comment's "Naming bridges" section. +type namingBridge struct { + provider string + fromSuffix string + toSuffix string +} + +// namingBridges documents known cases where a LiteLLM key's own naming +// differs from what an operator's live discovery reports for the exact +// same model. Seeded with MiniMax's dual naming (generator addendum, +// 2026-08): LiteLLM's own model list calls the fast tier "-lightning" +// (its international/global naming); MiniMax's own live API +// (api.minimax.io, verified against a running deployment discovering +// bare ids MiniMax-M2, -M2.1, -M2.1-highspeed, -M2.5, -M2.5-highspeed, +// -M2.7, -M2.7-highspeed, -M3) reports the identical models as +// "-highspeed" — same weights, same price, a different marketing label +// per region. -M2.7/-M2.7-highspeed have no LiteLLM entry at all as of +// this generator's own snapshot (verified live) and are deliberately +// left uncovered here — applyNamingBridges only ever duplicates an +// entry LiteLLM already priced, never invents one; -M2.7 is a candidate +// for an operator's own modelMeta config override instead. +var namingBridges = []namingBridge{ + {provider: "minimax", fromSuffix: "-lightning", toSuffix: "-highspeed"}, +} + +// applyNamingBridges adds one extra table entry per namingBridges match: +// for each winning candidate whose provider and bare-id suffix match a +// bridge, it duplicates that entry (identical ContextTokens/cost — the +// SAME model under a different label, not a different price point) +// under the bridged id. A bridged id that collides with an id already +// present in table (because LiteLLM prices it directly, under its own +// entry) is left untouched — an entry LiteLLM already prices directly +// always wins over a bridged duplicate. +func applyNamingBridges(table map[string]builtinEntry, winners map[string]candidate) { + for bare, w := range winners { + for _, br := range namingBridges { + if w.provider != br.provider || !strings.HasSuffix(bare, br.fromSuffix) { + continue + } + bridged := strings.TrimSuffix(bare, br.fromSuffix) + br.toSuffix + if _, exists := table[bridged]; exists { + continue + } + table[bridged] = w.entry + } + } +} + +// litellmEntry is the subset of one LiteLLM model entry this tool reads. +// Cost and max-token fields are *float64, not *int: most LiteLLM entries +// report integer token counts, but a handful (observed live among xai's +// own entries, e.g. "xai/grok-4-fast-reasoning") report +// max_input_tokens/max_tokens as a JSON float (2000000.0) — decoding +// those into *int would fail outright and drop the entry. +type litellmEntry struct { + InputCostPerToken *float64 `json:"input_cost_per_token"` + OutputCostPerToken *float64 `json:"output_cost_per_token"` + MaxInputTokens *float64 `json:"max_input_tokens"` + MaxTokens *float64 `json:"max_tokens"` + LiteLLMProvider string `json:"litellm_provider"` + Mode string `json:"mode"` +} + +// builtinEntry mirrors the plugin's own builtinModelMeta (modelmeta.go) +// field for field — kept as a separate, tool-local type rather than +// importing the plugin package, so this generator never depends on the +// (interpreted-only-constrained) plugin module at all. +type builtinEntry struct { + ContextTokens int + InputCostPerMTokMicroUSD int64 + OutputCostPerMTokMicroUSD int64 +} + +// candidate is one surviving LiteLLM key considered for a given bare +// model id — chooseWinner's input, when more than one key collapses to +// the same bare id. +type candidate struct { + fullKey string + provider string + entry builtinEntry + bare bool // true when fullKey itself carried no "provider/" prefix +} + +func main() { + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, "pricing-sync: FAIL:", err) + os.Exit(1) + } +} + +func run() error { + repoRoot := "." + if len(os.Args) > 1 { + repoRoot = os.Args[1] + } + outPath := filepath.Join(repoRoot, "pricing_data_gen.go") + + ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) + defer cancel() + + data, err := fetchLiteLLMPrices(ctx, litellmSourceURL) + if err != nil { + return fmt.Errorf("fetch %s: %w", litellmSourceURL, err) + } + + table, totalUpstream, err := parseAndPrune(data) + if err != nil { + return fmt.Errorf("parse and prune: %w", err) + } + + src, err := renderGoSource(table, sourceMeta{ + snapshot: time.Now().UTC().Format("2006-01-02"), + totalUpstream: totalUpstream, + kept: len(table), + }) + if err != nil { + return fmt.Errorf("render generated source: %w", err) + } + + if err := os.WriteFile(outPath, []byte(src), 0o644); err != nil { //nolint:gosec // generated Go source, not a secret + return fmt.Errorf("write %s: %w", outPath, err) + } + + fmt.Printf("pricing-sync: wrote %s: %d entries kept (pruned from %d upstream entries)\n", outPath, len(table), totalUpstream) + return nil +} + +// fetchLiteLLMPrices issues the network fetch, returning the raw +// response body. +func fetchLiteLLMPrices(ctx context.Context, url string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("build request: %w", err) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("do request: %w", err) + } + defer resp.Body.Close() //nolint:errcheck // read-side close + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("status %d", resp.StatusCode) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read body: %w", err) + } + return body, nil +} + +// parseAndPrune decodes data (LiteLLM's top-level {key: entry} JSON +// object) and applies the pruning rule (package doc comment): the +// provider and mode allowlists, well-formed cost fields, and +// chooseWinner's deterministic bare-id collision resolution. It returns +// the pruned table and the total number of top-level keys data carried +// (including ones this function skips), for the summary line run prints. +func parseAndPrune(data []byte) (map[string]builtinEntry, int, error) { + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return nil, 0, fmt.Errorf("decode top-level object: %w", err) + } + + byBare := make(map[string][]candidate) + for key, rawEntry := range raw { + if key == sampleSpecKey { + continue + } + var e litellmEntry + if err := json.Unmarshal(rawEntry, &e); err != nil { + // Not this tool's problem to solve generically — an entry + // whose shape does not match litellmEntry at all (the + // package doc comment's "sample_spec-shaped" fallback) is + // simply not a model this table can describe. + continue + } + if !allowedProviders[e.LiteLLMProvider] || !allowedModes[e.Mode] { + continue + } + if e.InputCostPerToken == nil || e.OutputCostPerToken == nil { + continue + } + + bare, isBareKey := bareModelID(key, e.LiteLLMProvider) + byBare[bare] = append(byBare[bare], candidate{ + fullKey: key, + provider: e.LiteLLMProvider, + bare: isBareKey, + entry: builtinEntry{ + ContextTokens: contextTokens(e), + InputCostPerMTokMicroUSD: microUSDPerMTok(*e.InputCostPerToken), + OutputCostPerMTokMicroUSD: microUSDPerMTok(*e.OutputCostPerToken), + }, + }) + } + + winners := make(map[string]candidate, len(byBare)) + for bare, candidates := range byBare { + winners[bare] = chooseWinner(candidates) + } + + table := make(map[string]builtinEntry, len(winners)) + for bare, w := range winners { + table[bare] = w.entry + } + applyNamingBridges(table, winners) + + return table, len(raw), nil +} + +// bareModelID strips a literal "{provider}/" prefix from key when +// present, returning the bare id and whether key itself already WAS +// bare (carried no such prefix) — chooseWinner's first, strongest +// collision tiebreak. +func bareModelID(key, provider string) (bare string, isBareKey bool) { + prefix := provider + "/" + if strings.HasPrefix(key, prefix) { + return strings.TrimPrefix(key, prefix), false + } + return key, true +} + +// contextTokens picks e's context window: max_input_tokens when +// reported, else max_tokens (LiteLLM's own documented fallback — see +// this repo's litellm_prices.json "sample_spec" field descriptions), +// else 0 (unknown — resolveModelMeta, modelmeta.go, never trusts a +// zero). +func contextTokens(e litellmEntry) int { + if e.MaxInputTokens != nil && *e.MaxInputTokens > 0 { + return int(math.Round(*e.MaxInputTokens)) + } + if e.MaxTokens != nil && *e.MaxTokens > 0 { + return int(math.Round(*e.MaxTokens)) + } + return 0 +} + +// microUSDPerMTok converts a LiteLLM per-token USD price to micro-USD +// per million tokens, rounded to the nearest micro-USD — the same +// round-to-nearest discipline pricing.go's costMicros already documents +// for the plugin's own cost-accounting math, to avoid float64 +// representation drift undershooting by one unit. +func microUSDPerMTok(costPerToken float64) int64 { + return int64(math.Round(costPerToken * microUSDPerUSDPerToken)) +} + +// chooseWinner picks the deterministic survivor among candidates that +// all collapsed to the same bare model id — see the package doc +// comment's "Bare-id collision resolution" section for the full +// rationale. candidates is never empty (only ever built by appending at +// least one entry, parseAndPrune above). +func chooseWinner(candidates []candidate) candidate { + best := candidates[0] + for _, c := range candidates[1:] { + if candidateLess(c, best) { + best = c + } + } + return best +} + +// candidateLess reports whether a should win over b: a bare key (no +// "provider/" prefix) beats a prefixed one; failing that, an earlier +// providerPriority index wins; failing that, the lexicographically +// smaller full key wins — always deterministic, since fullKey is unique +// per candidate by construction. +func candidateLess(a, b candidate) bool { + if a.bare != b.bare { + return a.bare + } + ai, bi := providerPriorityIndex(a.provider), providerPriorityIndex(b.provider) + if ai != bi { + return ai < bi + } + return a.fullKey < b.fullKey +} + +// providerPriorityIndex returns provider's index in providerPriority, or +// len(providerPriority) if somehow absent (defensive only — every +// candidate's provider already passed the allowedProviders check, which +// is drawn from the exact same set). +func providerPriorityIndex(provider string) int { + for i, p := range providerPriority { + if p == provider { + return i + } + } + return len(providerPriority) +} + +// sourceMeta is renderGoSource's header-comment material. +type sourceMeta struct { + snapshot string + totalUpstream int + kept int +} + +// renderGoSource formats table as the generated pricing_data_gen.go +// source: a documented header, then a single, gofmt-canonical map +// literal — plain, no generics (the plugin package is Yaegi-interpreted; +// see this tool's own package doc comment and modelmeta.go's +// builtinModelMeta doc comment) — entries sorted by key for a stable, +// reviewable diff on every regeneration. +func renderGoSource(table map[string]builtinEntry, meta sourceMeta) (string, error) { + keys := make([]string, 0, len(table)) + for k := range table { + keys = append(keys, k) + } + sort.Strings(keys) + + var b strings.Builder + b.WriteString("package traefikllmgateway\n\n") + fmt.Fprintf(&b, "// Code generated by tools/pricing-sync from LiteLLM's\n") + fmt.Fprintf(&b, "// model_prices_and_context_window.json — DO NOT EDIT.\n") + fmt.Fprintf(&b, "//\n") + fmt.Fprintf(&b, "// Source: %s\n", litellmSourceURL) + fmt.Fprintf(&b, "// Snapshot: %s, %d entries kept (pruned from %d upstream entries —\n", meta.snapshot, meta.kept, meta.totalUpstream) + fmt.Fprintf(&b, "// see tools/pricing-sync/main.go's own doc comment for the exact pruning\n") + fmt.Fprintf(&b, "// rule and bare-id collision resolution).\n") + fmt.Fprintf(&b, "//\n") + fmt.Fprintf(&b, "// Regenerate with: make pricing-sync\n") + b.WriteString("var builtinModelMetaTable = map[string]builtinModelMeta{\n") + for _, k := range keys { + e := table[k] + fmt.Fprintf(&b, "\t%q: {ContextTokens: %d, InputCostPerMTokMicroUSD: %d, OutputCostPerMTokMicroUSD: %d},\n", + k, e.ContextTokens, e.InputCostPerMTokMicroUSD, e.OutputCostPerMTokMicroUSD) + } + b.WriteString("}\n") + + formatted, err := format.Source([]byte(b.String())) + if err != nil { + return "", fmt.Errorf("gofmt generated source: %w", err) + } + return string(formatted), nil +} diff --git a/tools/pricing-sync/main_test.go b/tools/pricing-sync/main_test.go new file mode 100644 index 0000000..b18e3ad --- /dev/null +++ b/tools/pricing-sync/main_test.go @@ -0,0 +1,244 @@ +package main + +import ( + "go/parser" + "go/token" + "reflect" + "strings" + "testing" +) + +// fixtureJSON is a small, hand-built stand-in for LiteLLM's real +// model_prices_and_context_window.json, covering every branch of the +// pruning rule documented on this package (main.go's own doc comment): +// the sample_spec schema-example entry, a disallowed provider, a +// disallowed mode, null cost fields, a bare-vs-provider-prefixed +// collision with identical values, a three-way collision with +// DIFFERING values across providers (deepseek-v4-flash, mirroring the +// real dashscope/deepseek disagreement this tool was built against), an +// xai entry with float-valued (not int) max_input_tokens/max_tokens, an +// embedding-mode entry with a genuine zero output cost, and an entry +// with no max_input_tokens/max_tokens at all. +const fixtureJSON = `{ + "sample_spec": { + "litellm_provider": "one of https://docs.litellm.ai/docs/providers", + "input_cost_per_token": 0.0, + "output_cost_per_token": 0.0, + "max_input_tokens": "max input tokens, if the provider specifies it." + }, + "gpt-5": { + "litellm_provider": "openai", "mode": "chat", + "input_cost_per_token": 1.25e-06, "output_cost_per_token": 1.0e-05, + "max_input_tokens": 400000, "max_tokens": 128000 + }, + "bedrock/some-model": { + "litellm_provider": "bedrock", "mode": "chat", + "input_cost_per_token": 1e-06, "output_cost_per_token": 1e-06, + "max_input_tokens": 100000 + }, + "openai/dalle": { + "litellm_provider": "openai", "mode": "image_generation", + "output_cost_per_image": 0.04 + }, + "openai/null-cost": { + "litellm_provider": "openai", "mode": "chat", + "input_cost_per_token": null, "output_cost_per_token": null, + "max_input_tokens": 1000 + }, + "deepseek-chat": { + "litellm_provider": "deepseek", "mode": "chat", + "input_cost_per_token": 2.8e-07, "output_cost_per_token": 4.2e-07, + "max_input_tokens": 131072, "max_tokens": 8192 + }, + "deepseek/deepseek-chat": { + "litellm_provider": "deepseek", "mode": "chat", + "input_cost_per_token": 2.8e-07, "output_cost_per_token": 4.2e-07, + "max_input_tokens": 131072, "max_tokens": 8192 + }, + "dashscope/deepseek-v4-flash": { + "litellm_provider": "dashscope", "mode": "chat", + "input_cost_per_token": 2e-07, "output_cost_per_token": 4e-07, + "max_input_tokens": 1000000 + }, + "deepseek-v4-flash": { + "litellm_provider": "deepseek", "mode": "chat", + "input_cost_per_token": 4.4e-07, "output_cost_per_token": 1.32e-06, + "max_input_tokens": 131072 + }, + "deepseek/deepseek-v4-flash": { + "litellm_provider": "deepseek", "mode": "chat", + "input_cost_per_token": 4.4e-07, "output_cost_per_token": 1.32e-06, + "max_input_tokens": 131072 + }, + "xai/grok-4-fast-reasoning": { + "litellm_provider": "xai", "mode": "chat", + "input_cost_per_token": 2e-07, "output_cost_per_token": 5e-07, + "max_input_tokens": 2000000.0, "max_tokens": 2000000.0 + }, + "openai/whisper-notmode": { + "litellm_provider": "openai", "mode": "audio_transcription", + "input_cost_per_token": 1e-06, "output_cost_per_token": 1e-06, + "max_input_tokens": 16000 + }, + "text-embedding-3-large": { + "litellm_provider": "openai", "mode": "embedding", + "input_cost_per_token": 1.3e-07, "output_cost_per_token": 0.0, + "max_input_tokens": 8191, "max_tokens": 8191 + }, + "openai/no-max-tokens-at-all": { + "litellm_provider": "openai", "mode": "chat", + "input_cost_per_token": 1e-06, "output_cost_per_token": 2e-06 + }, + "minimax/MiniMax-M2.1-lightning": { + "litellm_provider": "minimax", "mode": "chat", + "input_cost_per_token": 3e-07, "output_cost_per_token": 2.4e-06, + "max_input_tokens": 1000000 + }, + "claude-haiku-4-5": { + "litellm_provider": "anthropic", "mode": "chat", + "input_cost_per_token": 1e-06, "output_cost_per_token": 5e-06, + "max_input_tokens": 200000, "max_tokens": 64000 + }, + "moonshot/kimi-k2.5": { + "litellm_provider": "moonshot", "mode": "chat", + "input_cost_per_token": 6e-07, "output_cost_per_token": 2.5e-06, + "max_input_tokens": 262144 + } +}` + +// fixtureUpstreamKeyCount is fixtureJSON's total top-level key count +// (including every pruned-out entry and sample_spec) — parseAndPrune's +// second return value. +const fixtureUpstreamKeyCount = 17 + +// TestParseAndPrune_Golden is the generator's golden test (task +// requirement): a small fixture JSON in, an exact expected pruned table +// out, covering every pruning-rule branch fixtureJSON's own doc comment +// lists. +func TestParseAndPrune_Golden(t *testing.T) { + got, totalUpstream, err := parseAndPrune([]byte(fixtureJSON)) + if err != nil { + t.Fatalf("parseAndPrune: %v", err) + } + if totalUpstream != fixtureUpstreamKeyCount { + t.Errorf("totalUpstream = %d, want %d", totalUpstream, fixtureUpstreamKeyCount) + } + + want := map[string]builtinEntry{ + "gpt-5": {ContextTokens: 400000, InputCostPerMTokMicroUSD: 1_250_000, OutputCostPerMTokMicroUSD: 10_000_000}, + // deepseek-chat: bare key wins over the identical-valued + // "deepseek/deepseek-chat" prefixed key (rule: bare beats + // prefixed regardless of value equality). + "deepseek-chat": {ContextTokens: 131072, InputCostPerMTokMicroUSD: 280_000, OutputCostPerMTokMicroUSD: 420_000}, + // deepseek-v4-flash: three colliding keys, DIFFERING values + // between "dashscope/..." and the deepseek-provider ones — the + // bare "deepseek-v4-flash" key wins (rule 1: bare beats + // prefixed), not the higher- or lower-priced alternative. + "deepseek-v4-flash": {ContextTokens: 131072, InputCostPerMTokMicroUSD: 440_000, OutputCostPerMTokMicroUSD: 1_320_000}, + "grok-4-fast-reasoning": {ContextTokens: 2_000_000, InputCostPerMTokMicroUSD: 200_000, OutputCostPerMTokMicroUSD: 500_000}, + // text-embedding-3-large: a genuine, known zero output cost + // (embeddings bill input tokens only) must survive, not be + // treated as "missing". + "text-embedding-3-large": {ContextTokens: 8191, InputCostPerMTokMicroUSD: 130_000, OutputCostPerMTokMicroUSD: 0}, + // no-max-tokens-at-all: neither max_input_tokens nor max_tokens + // present at all -> ContextTokens 0 (unknown), cost still kept. + "no-max-tokens-at-all": {ContextTokens: 0, InputCostPerMTokMicroUSD: 1_000_000, OutputCostPerMTokMicroUSD: 2_000_000}, + // MiniMax-M2.1-lightning: LiteLLM's own entry, kept as-is. + "MiniMax-M2.1-lightning": {ContextTokens: 1_000_000, InputCostPerMTokMicroUSD: 300_000, OutputCostPerMTokMicroUSD: 2_400_000}, + // MiniMax-M2.1-highspeed: the naming-bridge duplicate (generator + // addendum) — identical values, no LiteLLM entry of its own in + // this fixture. + "MiniMax-M2.1-highspeed": {ContextTokens: 1_000_000, InputCostPerMTokMicroUSD: 300_000, OutputCostPerMTokMicroUSD: 2_400_000}, + // Coverage-audit sanity (addendum, 2026-08): openai-family and + // anthropic bare ids resolve as LiteLLM's own canonical bare + // keys, with no bridging or prefix-stripping needed — "gpt-5" + // above already covers the openai family; this covers anthropic. + "claude-haiku-4-5": {ContextTokens: 200_000, InputCostPerMTokMicroUSD: 1_000_000, OutputCostPerMTokMicroUSD: 5_000_000}, + // moonshot: Kimi models (coverage-audit addendum) — a + // provider-prefixed key, bareModelID strips "moonshot/" the same + // way it strips any other allowlisted provider's own prefix. + "kimi-k2.5": {ContextTokens: 262144, InputCostPerMTokMicroUSD: 600_000, OutputCostPerMTokMicroUSD: 2_500_000}, + } + + if !reflect.DeepEqual(got, want) { + t.Errorf("parseAndPrune(fixtureJSON) =\n%#v\nwant\n%#v", got, want) + } +} + +// TestRenderGoSource_ValidSortedGo asserts renderGoSource produces +// syntactically valid, gofmt-canonical Go source (parseable by go/ +// parser) whose entries appear in sorted key order, and that its header +// documents the pruning provenance (source URL, snapshot, counts). +func TestRenderGoSource_ValidSortedGo(t *testing.T) { + table := map[string]builtinEntry{ + "zzz-model": {ContextTokens: 1, InputCostPerMTokMicroUSD: 1, OutputCostPerMTokMicroUSD: 1}, + "aaa-model": {ContextTokens: 2, InputCostPerMTokMicroUSD: 2, OutputCostPerMTokMicroUSD: 2}, + } + src, err := renderGoSource(table, sourceMeta{snapshot: "2026-08-22", totalUpstream: 3111, kept: 2}) + if err != nil { + t.Fatalf("renderGoSource: %v", err) + } + + if _, err := parser.ParseFile(token.NewFileSet(), "pricing_data_gen.go", src, parser.AllErrors); err != nil { + t.Fatalf("generated source does not parse as valid Go: %v\n%s", err, src) + } + if !strings.Contains(src, "package traefikllmgateway") { + t.Error("generated source missing package declaration") + } + if !strings.Contains(src, litellmSourceURL) { + t.Error("generated source missing source URL provenance comment") + } + if !strings.Contains(src, "2026-08-22") || !strings.Contains(src, "3111") { + t.Error("generated source missing snapshot date / upstream count provenance comment") + } + if got, want := strings.Index(src, `"aaa-model"`), strings.Index(src, `"zzz-model"`); got == -1 || want == -1 || got > want { + t.Errorf(`entries not sorted: index("aaa-model")=%d, index("zzz-model")=%d, want aaa before zzz`, got, want) + } +} + +// TestChooseWinner_Deterministic asserts chooseWinner's tiebreak order +// (bare > provider priority > lexicographic full key) directly, beyond +// what the golden test above exercises indirectly. +func TestChooseWinner_Deterministic(t *testing.T) { + cands := []candidate{ + {fullKey: "dashscope/x", provider: "dashscope", bare: false, entry: builtinEntry{ContextTokens: 1}}, + {fullKey: "deepseek/x", provider: "deepseek", bare: false, entry: builtinEntry{ContextTokens: 2}}, + {fullKey: "x", provider: "deepseek", bare: true, entry: builtinEntry{ContextTokens: 3}}, + } + if got := chooseWinner(cands); got.entry.ContextTokens != 3 { + t.Errorf("chooseWinner picked ContextTokens=%d, want 3 (the bare candidate)", got.entry.ContextTokens) + } + + // With no bare candidate, provider priority decides: "deepseek" + // precedes "dashscope" in providerPriority. + noBare := []candidate{ + {fullKey: "dashscope/x", provider: "dashscope", bare: false, entry: builtinEntry{ContextTokens: 1}}, + {fullKey: "deepseek/x", provider: "deepseek", bare: false, entry: builtinEntry{ContextTokens: 2}}, + } + if got := chooseWinner(noBare); got.entry.ContextTokens != 2 { + t.Errorf("chooseWinner picked ContextTokens=%d, want 2 (deepseek outranks dashscope)", got.entry.ContextTokens) + } +} + +// TestApplyNamingBridges_DoesNotOverwriteExistingEntry asserts a bridged +// id that LiteLLM already prices directly (its own independent winning +// candidate) is left untouched by the bridge — an entry LiteLLM prices +// directly always wins over a bridged duplicate (generator addendum). +func TestApplyNamingBridges_DoesNotOverwriteExistingEntry(t *testing.T) { + winners := map[string]candidate{ + "MiniMax-M2.1-lightning": {provider: "minimax", entry: builtinEntry{ContextTokens: 1_000_000, InputCostPerMTokMicroUSD: 300_000, OutputCostPerMTokMicroUSD: 2_400_000}}, + // A directly-priced, independent "-highspeed" entry with a + // DIFFERENT value than the lightning entry would bridge to. + "MiniMax-M2.1-highspeed": {provider: "minimax", entry: builtinEntry{ContextTokens: 500_000, InputCostPerMTokMicroUSD: 999, OutputCostPerMTokMicroUSD: 999}}, + } + table := make(map[string]builtinEntry, len(winners)) + for bare, w := range winners { + table[bare] = w.entry + } + applyNamingBridges(table, winners) + + want := builtinEntry{ContextTokens: 500_000, InputCostPerMTokMicroUSD: 999, OutputCostPerMTokMicroUSD: 999} + if got := table["MiniMax-M2.1-highspeed"]; got != want { + t.Errorf(`table["MiniMax-M2.1-highspeed"] = %+v, want %+v (its own direct LiteLLM entry, not the bridged duplicate)`, got, want) + } +}