Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/fixes/18644-opencode-go-litellm-bucket.md
Original file line number Diff line number Diff line change
@@ -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))
5 changes: 4 additions & 1 deletion src/lib/db/settings/pricing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, JsonRecord>;
Expand Down Expand Up @@ -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"),
};
Expand Down
59 changes: 59 additions & 0 deletions src/lib/litellmProviderMap.ts
Original file line number Diff line number Diff line change
@@ -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<string, string[]> = {
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<T>(rows: Record<string, Record<string, T>>): 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] };
}
}
}
41 changes: 2 additions & 39 deletions src/lib/pricingSync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ───────────────────────────────────────────────

Expand Down Expand Up @@ -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<string, string[]> = {
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<typeof setInterval> | null = null;
Expand Down Expand Up @@ -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;
Expand Down
72 changes: 72 additions & 0 deletions tests/unit/pricing-sync-opencode-go-18598.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
Loading