Files
OmniRoute/src/lib/pricingSync.ts

402 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* pricingSync.ts — External pricing sync engine.
*
* Fetches pricing data from external sources (LiteLLM) and stores it
* in a separate namespace (`pricing_synced`) so user overrides are
* never touched.
*
* Resolution order: user overrides > synced external > hardcoded defaults
*
* Opt-in via PRICING_SYNC_ENABLED=true (default: false).
*/
import { getDbInstance } from "./db/core";
import { invalidateDbCache } from "./db/readCache";
import { backupDbFile } from "./db/backup";
// ─── Types ───────────────────────────────────────────────
type PricingEntry = {
input: number;
output: number;
cached?: number;
cache_creation?: number;
};
type PricingModels = Record<string, PricingEntry>;
type PricingByProvider = Record<string, PricingModels>;
interface LiteLLMModelInfo {
input_cost_per_token?: number;
output_cost_per_token?: number;
cache_read_input_token_cost?: number;
cache_creation_input_token_cost?: number;
litellm_provider?: string;
mode?: string;
}
interface SyncStatus {
enabled: boolean;
lastSync: string | null;
lastSyncModelCount: number;
nextSync: string | null;
intervalMs: number;
sources: string[];
}
interface SyncResult {
success: boolean;
modelCount: number;
providerCount: number;
source: string;
dryRun: boolean;
data?: PricingByProvider;
error?: string;
}
// ─── Configuration ───────────────────────────────────────
const SUPPORTED_SOURCES = ["litellm"] as const;
type SupportedSource = (typeof SUPPORTED_SOURCES)[number];
const parsedInterval = parseInt(process.env.PRICING_SYNC_INTERVAL || "86400", 10);
const SYNC_INTERVAL_MS =
Number.isFinite(parsedInterval) && parsedInterval > 0 ? parsedInterval * 1000 : 86400 * 1000;
const SYNC_SOURCES = (process.env.PRICING_SYNC_SOURCES || "litellm")
.split(",")
.map((s) => s.trim())
.filter((s): s is SupportedSource => SUPPORTED_SOURCES.includes(s as SupportedSource));
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", "gemini-cli"],
"vertex_ai-anthropic_models": ["anthropic"],
google: ["gemini", "gemini-cli"],
deepseek: ["if"],
groq: ["groq"],
together_ai: ["openrouter"],
bedrock: ["kiro"],
fireworks_ai: ["fireworks"],
cerebras: ["cerebras"],
nvidia_nim: ["nvidia"],
siliconflow: ["siliconflow"],
};
// ─── Periodic sync state ─────────────────────────────────
let syncTimer: ReturnType<typeof setInterval> | null = null;
let lastSyncTime: string | null = null;
let lastSyncModelCount = 0;
let activeSyncIntervalMs = SYNC_INTERVAL_MS;
// ─── Core: Fetch + Transform ─────────────────────────────
/**
* Fetch raw pricing data from LiteLLM GitHub.
*/
export async function fetchLiteLLMPricing(): Promise<Record<string, LiteLLMModelInfo>> {
const response = await fetch(LITELLM_PRICING_URL, {
signal: AbortSignal.timeout(30000),
});
if (!response.ok) {
throw new Error(`LiteLLM fetch failed [${response.status}]: ${response.statusText}`);
}
const text = await response.text();
try {
return JSON.parse(text) as Record<string, LiteLLMModelInfo>;
} catch {
throw new Error(`LiteLLM returned invalid JSON (${text.slice(0, 100)}...)`);
}
}
/**
* Transform LiteLLM raw data → OmniRoute PricingByProvider format.
*
* Conversion: cost_per_token × 1_000_000 → $/1M tokens (OmniRoute format)
* Filters: only chat/completion modes (skip image/audio/embedding)
*/
export function transformToOmniRoute(raw: Record<string, LiteLLMModelInfo>): PricingByProvider {
const result: PricingByProvider = {};
for (const [modelKey, info] of Object.entries(raw)) {
// Skip non-chat models
if (info.mode && !["chat", "completion"].includes(info.mode)) continue;
// Must have at least input pricing
if (!info.input_cost_per_token && info.input_cost_per_token !== 0) continue;
const inputCost = (info.input_cost_per_token || 0) * 1_000_000;
const outputCost = (info.output_cost_per_token || 0) * 1_000_000;
const entry: PricingEntry = {
input: Math.round(inputCost * 1000) / 1000,
output: Math.round(outputCost * 1000) / 1000,
};
if (info.cache_read_input_token_cost != null) {
entry.cached = Math.round(info.cache_read_input_token_cost * 1_000_000 * 1000) / 1000;
}
if (info.cache_creation_input_token_cost != null) {
entry.cache_creation =
Math.round(info.cache_creation_input_token_cost * 1_000_000 * 1000) / 1000;
}
// Extract model name (strip provider prefix from key)
// LiteLLM keys look like: "openai/gpt-4o", "anthropic/claude-3-opus"
const slashIdx = modelKey.indexOf("/");
const modelName = slashIdx >= 0 ? modelKey.slice(slashIdx + 1) : modelKey;
// Map to OmniRoute providers
const litellmProvider = info.litellm_provider || "";
const omniRouteProviders = LITELLM_PROVIDER_MAP[litellmProvider];
if (omniRouteProviders) {
for (const provider of omniRouteProviders) {
if (!result[provider]) result[provider] = {};
result[provider][modelName] = entry;
}
} else if (litellmProvider) {
// Use litellm_provider as-is for unknown providers
if (!result[litellmProvider]) result[litellmProvider] = {};
result[litellmProvider][modelName] = entry;
}
}
return result;
}
// ─── DB: Synced pricing namespace ────────────────────────
function toRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" ? (value as Record<string, unknown>) : {};
}
/**
* Read synced pricing from `pricing_synced` namespace.
*/
export function getSyncedPricing(): PricingByProvider {
const db = getDbInstance();
const rows = db
.prepare("SELECT key, value FROM key_value WHERE namespace = 'pricing_synced'")
.all();
const synced: PricingByProvider = {};
for (const row of rows) {
const record = toRecord(row);
const key = typeof record.key === "string" ? record.key : null;
const rawValue = typeof record.value === "string" ? record.value : null;
if (!key || rawValue === null) continue;
try {
synced[key] = JSON.parse(rawValue) as PricingModels;
} catch {
console.warn(`[PRICING_SYNC] Corrupted data for provider "${key}", skipping`);
}
}
return synced;
}
/**
* Save synced pricing to `pricing_synced` namespace (full replace).
*/
export function saveSyncedPricing(data: PricingByProvider): void {
const db = getDbInstance();
const del = db.prepare("DELETE FROM key_value WHERE namespace = 'pricing_synced'");
const insert = db.prepare(
"INSERT INTO key_value (namespace, key, value) VALUES ('pricing_synced', ?, ?)"
);
const tx = db.transaction(() => {
del.run();
for (const [provider, models] of Object.entries(data)) {
insert.run(provider, JSON.stringify(models));
}
});
tx();
backupDbFile("pre-write");
invalidateDbCache("pricing");
}
/**
* Clear all synced pricing data.
*/
export function clearSyncedPricing(): void {
const db = getDbInstance();
db.prepare("DELETE FROM key_value WHERE namespace = 'pricing_synced'").run();
backupDbFile("pre-write");
invalidateDbCache("pricing");
}
// ─── Main sync function ─────────────────────────────────
/**
* Fetch, transform, and save pricing from external sources.
*/
export async function syncPricingFromSources(opts?: {
sources?: string[];
dryRun?: boolean;
}): Promise<SyncResult> {
const requestedSources = opts?.sources || SYNC_SOURCES;
const dryRun = opts?.dryRun ?? false;
// Validate sources
const validSources = requestedSources.filter((s): s is SupportedSource =>
SUPPORTED_SOURCES.includes(s as SupportedSource)
);
const invalidSources = requestedSources.filter(
(s) => !SUPPORTED_SOURCES.includes(s as SupportedSource)
);
if (validSources.length === 0) {
const supported = SUPPORTED_SOURCES.join(", ");
return {
success: false,
modelCount: 0,
providerCount: 0,
source: requestedSources.join(","),
dryRun,
error: `No valid sources provided. Supported: ${supported}. Invalid: ${invalidSources.join(", ")}`,
};
}
try {
const aggregated: PricingByProvider = {};
for (const source of validSources) {
if (source === "litellm") {
const raw = await fetchLiteLLMPricing();
const transformed = transformToOmniRoute(raw);
for (const [provider, models] of Object.entries(transformed)) {
if (!aggregated[provider]) aggregated[provider] = {};
Object.assign(aggregated[provider], models);
}
}
}
const modelCount = Object.values(aggregated).reduce(
(sum, models) => sum + Object.keys(models).length,
0
);
const providerCount = Object.keys(aggregated).length;
if (!dryRun) {
saveSyncedPricing(aggregated);
lastSyncTime = new Date().toISOString();
lastSyncModelCount = modelCount;
}
return {
success: true,
modelCount,
providerCount,
source: validSources.join(","),
dryRun,
...(invalidSources.length > 0
? { warnings: [`Unknown sources ignored: ${invalidSources.join(", ")}`] }
: {}),
...(dryRun ? { data: aggregated } : {}),
};
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.warn("[PRICING_SYNC] Sync failed:", message);
return {
success: false,
modelCount: 0,
providerCount: 0,
source: requestedSources.join(","),
dryRun,
error: message,
};
}
}
// ─── Periodic sync ───────────────────────────────────────
/**
* Start periodic pricing sync (non-blocking).
*/
export function startPeriodicSync(intervalMs?: number): void {
if (syncTimer) return; // Already running
const interval = intervalMs ?? SYNC_INTERVAL_MS;
activeSyncIntervalMs = interval;
console.log(`[PRICING_SYNC] Starting periodic sync every ${interval / 1000}s`);
// Initial sync (non-blocking)
syncPricingFromSources()
.then((result) => {
if (result.success) {
console.log(
`[PRICING_SYNC] Initial sync complete: ${result.modelCount} models from ${result.providerCount} providers`
);
}
})
.catch((err) => {
console.warn("[PRICING_SYNC] Initial sync error:", err instanceof Error ? err.message : err);
});
syncTimer = setInterval(() => {
syncPricingFromSources()
.then((result) => {
if (result.success) {
console.log(`[PRICING_SYNC] Periodic sync complete: ${result.modelCount} models`);
}
})
.catch((err) => {
console.warn(
"[PRICING_SYNC] Periodic sync error:",
err instanceof Error ? err.message : err
);
});
}, interval);
if (syncTimer && typeof syncTimer === "object" && "unref" in syncTimer) {
(syncTimer as { unref?: () => void }).unref?.();
}
}
/**
* Stop periodic sync and cleanup timer.
*/
export function stopPeriodicSync(): void {
if (syncTimer) {
clearInterval(syncTimer);
syncTimer = null;
console.log("[PRICING_SYNC] Periodic sync stopped");
}
}
/**
* Get current sync status.
*/
export function getSyncStatus(): SyncStatus {
const enabled = process.env.PRICING_SYNC_ENABLED === "true";
return {
enabled,
lastSync: lastSyncTime,
lastSyncModelCount,
nextSync:
syncTimer && lastSyncTime
? new Date(new Date(lastSyncTime).getTime() + activeSyncIntervalMs).toISOString()
: null,
intervalMs: activeSyncIntervalMs,
sources: SYNC_SOURCES,
};
}
// ─── Init (called from server-init.ts) ───────────────────
/**
* Initialize pricing sync if enabled.
*/
export async function initPricingSync(): Promise<void> {
if (process.env.PRICING_SYNC_ENABLED !== "true") {
console.log("[PRICING_SYNC] Disabled (set PRICING_SYNC_ENABLED=true to enable)");
return;
}
startPeriodicSync();
}