mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 20:32:20 +03:00
402 lines
12 KiB
TypeScript
402 lines
12 KiB
TypeScript
/**
|
||
* 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();
|
||
}
|