diff --git a/changelog.d/fixes/18644-opencode-go-litellm-bucket.md b/changelog.d/fixes/18644-opencode-go-litellm-bucket.md new file mode 100644 index 000000000000..8ffa0b392112 --- /dev/null +++ b/changelog.d/fixes/18644-opencode-go-litellm-bucket.md @@ -0,0 +1 @@ +- **fix(pricing):** OpenCode Go requests are priced from LiteLLM-synced pricing again; LiteLLM's `opencode_go` bucket was stored under a key no cost lookup reads, so every `opencode-go` request booked as unpriced $0 ([#18644](https://github.com/cheaper-inference/OmniRoute/pull/18644)) diff --git a/src/lib/db/settings/pricing.ts b/src/lib/db/settings/pricing.ts index 1bf8f7dc9708..4845d6385b8e 100644 --- a/src/lib/db/settings/pricing.ts +++ b/src/lib/db/settings/pricing.ts @@ -7,6 +7,7 @@ import { backupDbFile } from "../backup"; import { getCachedPricing, invalidateDbCache } from "../readCache"; import { PROVIDER_ID_TO_ALIAS } from "@omniroute/open-sse/config/providerModels.ts"; import { invalidateUserPricingMemo } from "@/lib/catalogUserPricing"; +import { remapHistoricalLitellmRows } from "@/lib/litellmProviderMap"; import { type JsonRecord, toRecord } from "./shared"; type PricingModels = Record; @@ -107,9 +108,11 @@ async function getPricingLayers() { // Layer 1: Hardcoded defaults (lowest priority) const { getDefaultPricing } = await import("@/shared/constants/pricing"); + const litellm = readPricingNamespace(db, "pricing_synced"); + remapHistoricalLitellmRows(litellm); return { defaults: getDefaultPricing(), - litellm: readPricingNamespace(db, "pricing_synced"), + litellm, modelsDev: readPricingNamespace(db, "models_dev_pricing"), user: readPricingNamespace(db, "pricing"), }; diff --git a/src/lib/litellmProviderMap.ts b/src/lib/litellmProviderMap.ts new file mode 100644 index 000000000000..c41470b82b96 --- /dev/null +++ b/src/lib/litellmProviderMap.ts @@ -0,0 +1,59 @@ +/** + * LiteLLM `litellm_provider` bucket → OmniRoute pricing aliases, shared by the LiteLLM + * sync (writes) and the pricing layers (reads). + */ + +export const LITELLM_PROVIDER_MAP: Record = { + openai: ["openai", "cx"], + anthropic: ["anthropic", "cc"], + vertex_ai: ["gemini"], + "vertex_ai-anthropic_models": ["anthropic"], + google: ["gemini"], + // Registry ALIAS, not registry id — pricingSync writes/reads are keyed by + // alias everywhere else (see getPricingForModel(provider, model) callers). + // Four of these previously used the provider's `id` string, which is not a + // valid pricing-lookup key for that provider and, worse, for `deepseek` a + // real (but wrong) alias existed under that string — silently routing + // DeepSeek's synced pricing onto Qoder (open-sse/config/providers/registry/ + // qoder/index.ts, alias "if", an unrelated third-party API) instead of + // DeepSeek (alias "ds"). `bedrock`/`bedrock_converse` and `cloudflare` + // pointed at their provider's `id` ("kiro", "cloudflare-ai") rather than + // its `alias` ("kr", "cf") — not wrong-provider, just a dead key nothing + // downstream ever looks up, so those two providers silently never received + // synced pricing at all. + deepseek: ["ds"], + groq: ["groq"], + together_ai: ["openrouter"], + bedrock: ["kr"], + fireworks_ai: ["fireworks"], + cerebras: ["cerebras"], + nvidia_nim: ["nvidia"], + siliconflow: ["siliconflow"], + "vertex_ai-language_models": ["gemini"], + "vertex_ai-mistral_models": ["mistral"], + gemini: ["gemini"], + bedrock_converse: ["kr"], + cloudflare: ["cf"], + // #18598: LiteLLM's bucket uses an underscore; the registry alias is `opencode-go`. + opencode_go: ["opencode-go"], + // stability-ai has no chat-completions registry entry (image-only: + // open-sse/config/providers/registry/stability-ai/imageModels.ts) — left + // as-is rather than guessed at; not the same bug shape as the three above. + stability: ["stability-ai"], +}; + +/** + * #18598: a sync that ran before a LiteLLM provider got its map entry stored that bucket + * under its raw LiteLLM name (e.g. `opencode_go`), which no lookup reads. Re-apply the + * current map to such rows in place so they price requests before the next sync replaces + * them. Rows already stored under the mapped alias win, model by model. + */ +export function remapHistoricalLitellmRows(rows: Record>): void { + for (const [litellmProvider, aliases] of Object.entries(LITELLM_PROVIDER_MAP)) { + const historical = rows[litellmProvider]; + if (!historical || aliases.includes(litellmProvider)) continue; + for (const alias of aliases) { + rows[alias] = { ...historical, ...rows[alias] }; + } + } +} diff --git a/src/lib/pricingSync.ts b/src/lib/pricingSync.ts index e828d962ed94..414bfa54648f 100644 --- a/src/lib/pricingSync.ts +++ b/src/lib/pricingSync.ts @@ -13,6 +13,7 @@ import { getDbInstance } from "./db/core"; import { invalidateDbCache, getModelCatalogCacheVersion } from "./db/readCache"; import { backupDbFile } from "./db/backup"; +import { LITELLM_PROVIDER_MAP, remapHistoricalLitellmRows } from "./litellmProviderMap"; // ─── Types ─────────────────────────────────────────────── @@ -97,45 +98,6 @@ const SYNC_SOURCES = (process.env.PRICING_SYNC_SOURCES || "litellm") const LITELLM_PRICING_URL = "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json"; -// ─── Provider mapping: LiteLLM provider → OmniRoute aliases ───── - -const LITELLM_PROVIDER_MAP: Record = { - openai: ["openai", "cx"], - anthropic: ["anthropic", "cc"], - vertex_ai: ["gemini"], - "vertex_ai-anthropic_models": ["anthropic"], - google: ["gemini"], - // Registry ALIAS, not registry id — pricingSync writes/reads are keyed by - // alias everywhere else (see getPricingForModel(provider, model) callers). - // Four of these previously used the provider's `id` string, which is not a - // valid pricing-lookup key for that provider and, worse, for `deepseek` a - // real (but wrong) alias existed under that string — silently routing - // DeepSeek's synced pricing onto Qoder (open-sse/config/providers/registry/ - // qoder/index.ts, alias "if", an unrelated third-party API) instead of - // DeepSeek (alias "ds"). `bedrock`/`bedrock_converse` and `cloudflare` - // pointed at their provider's `id` ("kiro", "cloudflare-ai") rather than - // its `alias` ("kr", "cf") — not wrong-provider, just a dead key nothing - // downstream ever looks up, so those two providers silently never received - // synced pricing at all. - deepseek: ["ds"], - groq: ["groq"], - together_ai: ["openrouter"], - bedrock: ["kr"], - fireworks_ai: ["fireworks"], - cerebras: ["cerebras"], - nvidia_nim: ["nvidia"], - siliconflow: ["siliconflow"], - "vertex_ai-language_models": ["gemini"], - "vertex_ai-mistral_models": ["mistral"], - gemini: ["gemini"], - bedrock_converse: ["kr"], - cloudflare: ["cf"], - // stability-ai has no chat-completions registry entry (image-only: - // open-sse/config/providers/registry/stability-ai/imageModels.ts) — left - // as-is rather than guessed at; not the same bug shape as the three above. - stability: ["stability-ai"], -}; - // ─── Periodic sync state ───────────────────────────────── let syncTimer: ReturnType | null = null; @@ -284,6 +246,7 @@ export function getSyncedPricing(): PricingByProvider { console.warn(`[PRICING_SYNC] Corrupted data for provider "${key}", skipping`); } } + remapHistoricalLitellmRows(synced); pricingMemo = synced; pricingMemoVersion = currentVersion; return synced; diff --git a/tests/unit/pricing-sync-opencode-go-18598.test.ts b/tests/unit/pricing-sync-opencode-go-18598.test.ts new file mode 100644 index 000000000000..1d15a66a3ba6 --- /dev/null +++ b/tests/unit/pricing-sync-opencode-go-18598.test.ts @@ -0,0 +1,72 @@ +/** + * #18598: LiteLLM files OpenCode Go prices under the `opencode_go` provider, which + * had no LITELLM_PROVIDER_MAP entry, so the sync stored them under `opencode_go` — + * a key no cost lookup uses — and every `opencode-go` request booked $0 (unpriced). + */ + +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-pricing-18598-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.DISABLE_SQLITE_AUTO_BACKUP = "true"; + +const core = await import("../../src/lib/db/core.ts"); +const { transformToOmniRoute, saveSyncedPricing, getSyncedPricing } = + await import("../../src/lib/pricingSync.ts"); +const { calculateCostDetailed } = await import("../../src/lib/usage/costCalculator.ts"); + +// Shape and values copied from LiteLLM's model_prices_and_context_window.json. +const RAW = { + "opencode_go/glm-5.2": { + input_cost_per_token: 0.0000014, + output_cost_per_token: 0.0000044, + litellm_provider: "opencode_go", + mode: "chat", + }, +}; + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +test("LiteLLM opencode_go prices are stored under the opencode-go alias", () => { + const result = transformToOmniRoute(RAW); + assert.equal(result["opencode-go"]?.["glm-5.2"]?.input, 1.4); + assert.equal(result["opencode-go"]?.["glm-5.2"]?.output, 4.4); + assert.equal(result.opencode_go, undefined); +}); + +test("an opencode-go request is priced from LiteLLM-synced pricing", async () => { + saveSyncedPricing(transformToOmniRoute(RAW)); + const result = await calculateCostDetailed("opencode-go", "glm-5.2", { + prompt_tokens: 1_000_000, + completion_tokens: 1_000_000, + }); + assert.equal(result.priced, true); + assert.ok(Math.abs(result.costUsd - 5.8) < 1e-9, `expected $5.80, got ${result.costUsd}`); +}); + +test("a row a pre-fix sync stored under opencode_go still prices opencode-go before a re-sync", async () => { + saveSyncedPricing({ opencode_go: { "glm-5.2": { input: 1.4, output: 4.4 } } }); + const result = await calculateCostDetailed("opencode-go", "glm-5.2", { + prompt_tokens: 1_000_000, + completion_tokens: 1_000_000, + }); + assert.equal(result.priced, true); + assert.ok(Math.abs(result.costUsd - 5.8) < 1e-9, `expected $5.80, got ${result.costUsd}`); +}); + +test("a row already stored under opencode-go wins over a historical opencode_go row", () => { + saveSyncedPricing({ + opencode_go: { "glm-5.2": { input: 9, output: 9 }, "kimi-k3": { input: 3, output: 15 } }, + "opencode-go": { "glm-5.2": { input: 1.4, output: 4.4 } }, + }); + const synced = getSyncedPricing(); + assert.equal(synced["opencode-go"]["glm-5.2"].input, 1.4); + assert.equal(synced["opencode-go"]["kimi-k3"].input, 3); +});