From ee4cd0d795c89162ee403679232e075959aaddcb Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Wed, 5 Aug 2026 16:48:14 -0300 Subject: [PATCH] fix(api): consult LiteLLM pricing_synced layer in resolveCatalogPricing so deployed models absent from models.dev and defaults get pricing in /v1/models (#9364) Closes #9364 --- changelog.d/fixes/9364-models-pricing-gap.md | 1 + src/lib/modelMetadataRegistry.ts | 40 +++++++++ .../model-pricing-litellm-gap-9364.test.ts | 81 +++++++++++++++++++ 3 files changed, 122 insertions(+) create mode 100644 changelog.d/fixes/9364-models-pricing-gap.md create mode 100644 tests/unit/model-pricing-litellm-gap-9364.test.ts diff --git a/changelog.d/fixes/9364-models-pricing-gap.md b/changelog.d/fixes/9364-models-pricing-gap.md new file mode 100644 index 0000000000..182fc0061f --- /dev/null +++ b/changelog.d/fixes/9364-models-pricing-gap.md @@ -0,0 +1 @@ +- fix(api): consult LiteLLM pricing_synced layer in resolveCatalogPricing so deployed models absent from models.dev and defaults get pricing in /v1/models (#9364) diff --git a/src/lib/modelMetadataRegistry.ts b/src/lib/modelMetadataRegistry.ts index 9f4e93aa2a..e0d49968b7 100644 --- a/src/lib/modelMetadataRegistry.ts +++ b/src/lib/modelMetadataRegistry.ts @@ -12,6 +12,7 @@ import { import { AI_PROVIDERS } from "@/shared/constants/providers"; import { PROVIDER_ID_TO_ALIAS, PROVIDER_MODELS } from "@/shared/constants/models"; import { getSyncStatus, getSyncedCapability, getModelsDevPricing } from "@/lib/modelsDevSync"; +import { getSyncedPricing } from "@/lib/pricingSync"; import { getPricingForModel as getDefaultPricingForModel } from "@/shared/constants/pricing"; import { CANONICAL_EFFORT_VALUES, @@ -335,6 +336,45 @@ function resolveCatalogPricing( // pricing lookup must never break catalog assembly } + // LiteLLM-synced pricing (`pricing_synced` namespace) — Layer 3 in the + // documented resolution order (user > models.dev > LiteLLM > defaults). + // Consulted only when models.dev returned nothing, matching the order + // already implemented in db/settings/pricing.ts::getPricing(). + try { + const litellm = getSyncedPricing() as Record< + string, + Record> + >; + const providerPricing = + findInsensitive(litellm, provider) || + findInsensitive(litellm, provider.replace(/-cn$/, "")); + if (providerPricing) { + const modelPricing = + findInsensitive(providerPricing, model) || + findInsensitive(providerPricing, model.replace(/\./g, "-")) || + findInsensitive( + providerPricing, + model.includes("/") ? model.split("/").pop() || model : model + ); + if (modelPricing && typeof modelPricing === "object") { + const input = modelPricing.input; + const output = modelPricing.output; + if (typeof input === "number" || typeof output === "number") { + const pricing: Record = {}; + if (typeof input === "number") pricing.input = input; + if (typeof output === "number") pricing.output = output; + if (typeof modelPricing.cached === "number") pricing.cached = modelPricing.cached; + if (typeof modelPricing.cache_creation === "number") { + pricing.cache_creation = modelPricing.cache_creation; + } + return pricing; + } + } + } + } catch { + // pricing lookup must never break catalog assembly + } + try { const defaults = getDefaultPricingForModel(provider, model) as Record | null; if (defaults && (typeof defaults.input === "number" || typeof defaults.output === "number")) { diff --git a/tests/unit/model-pricing-litellm-gap-9364.test.ts b/tests/unit/model-pricing-litellm-gap-9364.test.ts new file mode 100644 index 0000000000..cf4a75694e --- /dev/null +++ b/tests/unit/model-pricing-litellm-gap-9364.test.ts @@ -0,0 +1,81 @@ +import assert from "node:assert/strict"; +import { describe, it, before, after } from "node:test"; +import { enrichCatalogModelEntry } from "../../src/lib/modelMetadataRegistry.ts"; +import { + saveModelsDevPricing, + clearModelsDevPricing, + type PricingByProvider as ModelsDevPricingByProvider, +} from "../../src/lib/modelsDevSync.ts"; +import { + saveSyncedPricing, + clearSyncedPricing, + type PricingByProvider as SyncedPricingByProvider, +} from "../../src/lib/pricingSync.ts"; + +type CatalogPricing = { + input?: number; + output?: number; + cached?: number; + cache_creation?: number; +}; + +// #9364: resolveCatalogPricing() only consults models_dev_pricing and hardcoded +// defaults, skipping the LiteLLM `pricing_synced` namespace entirely. A model +// whose pricing exists ONLY in pricing_synced (the documented Layer 3) gets +// `pricing: null` in the /v1/models catalog. This test seeds pricing_synced +// with pricing for a model absent from both models_dev_pricing and hardcoded +// defaults, then asserts enrichCatalogModelEntry() surfaces it. + +describe("catalog pricing LiteLLM gap (#9364)", () => { + before(() => { + // Seed ONLY the LiteLLM namespace with a model that is absent from both + // models.dev and hardcoded defaults (babbage-002 is not in default-pricing + // and not registered in the provider registry, so neither layer can match). + const synced: SyncedPricingByProvider = { + openai: { + "babbage-002": { input: 0.4, output: 0.4 }, + }, + }; + saveSyncedPricing(synced); + + // Ensure models_dev_pricing has a different model so we prove the LiteLLM + // layer is being consulted, not accidentally overlapping with models.dev. + const modelsDev: ModelsDevPricingByProvider = { + openai: { + "gpt-4o": { input: 2.5, output: 10 }, + }, + }; + saveModelsDevPricing(modelsDev); + }); + + after(() => { + try { + clearSyncedPricing(); + clearModelsDevPricing(); + } catch { + // ignore + } + }); + + it("attaches LiteLLM-synced pricing onto catalog entries absent from models.dev and defaults", () => { + const entry = enrichCatalogModelEntry({ + id: "openai/babbage-002", + owned_by: "openai", + root: "babbage-002", + }); + assert.ok(entry.pricing, "pricing should resolve from pricing_synced (LiteLLM) layer"); + assert.equal((entry.pricing as CatalogPricing).input, 0.4); + assert.equal((entry.pricing as CatalogPricing).output, 0.4); + }); + + it("still resolves models.dev pricing when present (precedence preserved)", () => { + const entry = enrichCatalogModelEntry({ + id: "openai/gpt-4o", + owned_by: "openai", + root: "gpt-4o", + }); + assert.ok(entry.pricing); + assert.equal((entry.pricing as CatalogPricing).input, 2.5); + assert.equal((entry.pricing as CatalogPricing).output, 10); + }); +});