diff --git a/src/lib/modelsDevSync.ts b/src/lib/modelsDevSync.ts index 4c7afefaa8..fd9ec1dff0 100644 --- a/src/lib/modelsDevSync.ts +++ b/src/lib/modelsDevSync.ts @@ -21,41 +21,29 @@ import { getDbInstance } from "./db/core"; import { invalidateDbCache } from "./db/readCache"; import { backupDbFile } from "./db/backup"; +import { + transformModelsDevToPricing, + transformModelsDevToCapabilities, +} from "./modelsDevSync/transform"; +import type { + PricingModels, + PricingByProvider, + ModelCapabilityEntry, + CapabilitiesByProvider, + ModelsDevData, +} from "./modelsDevSync/transform"; + +// Re-export the pure transform layer (moved to ./modelsDevSync/transform) +// so this module's public API is unchanged. +export { + mapProviderId, + transformModelsDevToPricing, + transformModelsDevToCapabilities, +} from "./modelsDevSync/transform"; +export type { ModelCapabilityEntry, CapabilitiesByProvider } from "./modelsDevSync/transform"; + // ─── Types ─────────────────────────────────────────────── -type PricingEntry = { - input: number; - output: number; - cached?: number; - cache_creation?: number; - reasoning?: number; -}; - -type PricingModels = Record; -type PricingByProvider = Record; - -export interface ModelCapabilityEntry { - tool_call: boolean | null; - reasoning: boolean | null; - attachment: boolean | null; - structured_output: boolean | null; - temperature: boolean | null; - modalities_input: string; // JSON array - modalities_output: string; // JSON array - knowledge_cutoff: string | null; - release_date: string | null; - last_updated: string | null; - status: string | null; - family: string | null; - open_weights: boolean | null; - limit_context: number | null; - limit_input: number | null; - limit_output: number | null; - interleaved_field: string | null; -} - -export type CapabilitiesByProvider = Record>; - interface SyncStatus { enabled: boolean; lastSync: string | null; @@ -75,65 +63,6 @@ interface SyncResult { error?: string; } -// ─── models.dev API types (raw) ────────────────────────── - -interface ModelsDevCost { - input?: number; - output?: number; - reasoning?: number; - cache_read?: number; - cache_write?: number; - input_audio?: number; - output_audio?: number; -} - -interface ModelsDevLimit { - context?: number; - input?: number; - output?: number; -} - -interface ModelsDevModalities { - input?: string[]; - output?: string[]; -} - -interface ModelsDevInterleaved { - field?: string; -} - -interface ModelsDevModel { - id: string; - name: string; - family?: string; - attachment?: boolean; - reasoning?: boolean; - tool_call?: boolean; - structured_output?: boolean; - temperature?: boolean; - knowledge?: string; - release_date?: string; - last_updated?: string; - open_weights?: boolean; - status?: string; - cost?: ModelsDevCost; - limit?: ModelsDevLimit; - modalities?: ModelsDevModalities; - interleaved?: ModelsDevInterleaved | boolean; -} - -interface ModelsDevProvider { - id: string; - name?: string; - env?: string[]; - npm?: string; - api?: string; - doc?: string; - models: Record; -} - -type ModelsDevData = Record; - // ─── Configuration ─────────────────────────────────────── const MODELS_DEV_API_URL = "https://models.dev/api.json"; @@ -142,89 +71,6 @@ const parsedInterval = parseInt(process.env.MODELS_DEV_SYNC_INTERVAL || "86400", const SYNC_INTERVAL_MS = Number.isFinite(parsedInterval) && parsedInterval > 0 ? parsedInterval * 1000 : 86400 * 1000; -// ─── Provider mapping: models.dev provider ID → OmniRoute provider IDs/aliases ── -// -// models.dev uses canonical provider IDs (e.g. "openai", "anthropic", "google"). -// OmniRoute uses both full IDs and short aliases (e.g. "cc" for claude, "cx" for codex). -// We map each models.dev provider to ALL OmniRoute identifiers that should receive -// its pricing/capability data. - -const MODELS_DEV_PROVIDER_MAP: Record = { - // Major providers - openai: ["openai", "cx"], // cx = Codex (uses OpenAI models) - anthropic: ["anthropic", "cc"], // cc = Claude Code - google: ["gemini"], - "google-vertex": ["gemini", "vertex"], - "google-vertex-anthropic": ["anthropic", "cc", "vertex"], - vertex_ai: ["gemini", "vertex"], - deepseek: ["deepseek", "if"], // if = Qoder (routes through DeepSeek) - groq: ["groq"], - xai: ["xai"], - mistral: ["mistral"], - togetherai: ["together", "openrouter"], - together_ai: ["together", "openrouter"], - "fireworks-ai": ["fireworks"], - fireworks: ["fireworks"], - cerebras: ["cerebras"], - cohere: ["cohere"], - nvidia: ["nvidia"], - nebius: ["nebius"], - siliconflow: ["siliconflow"], - hyperbolic: ["hyperbolic"], - huggingface: ["hf", "huggingface"], - openrouter: ["openrouter"], - perplexity: ["pplx", "perplexity"], - // OAuth / special providers - bedrock: ["kiro", "kr"], // kr = Kiro (AWS Bedrock) - "github-copilot": ["github", "gh"], - "github-models": ["github", "gh"], - kilo: ["kilocode", "kc", "kilo-gateway"], - kilocode: ["kilocode", "kc", "kilo-gateway"], - "kimi-for-coding": ["kimi-coding", "kmc", "kimi-coding-apikey", "kmca"], - // The `opencode` models.dev entry used to map only to "opencode-zen" because - // that is the historical alias pair. But OmniRoute's catalog & combo targets - // reference models under BOTH provider IDs: - // - `opencode-zen/big-pickle` (alias form) - // - `opencode/big-pickle` (canonical id form, used by live API catalog - // and by combos like "Opencode FREE Omni") - // If we only store synced capabilities under "opencode-zen", the canonical - // `opencode/` lookup in getCanonicalModelMetadata returns null and - // any combo that targets `opencode/...` ends up with no computed context. - // Symmetric mapping keeps both lookup paths populated. - opencode: ["opencode", "opencode-zen"], - "opencode-go": ["opencode-go", "opencode-zen"], - // Additional providers that may overlap with OmniRoute - alibaba: ["ali", "alibaba"], - "alibaba-cn": ["ali-cn", "alibaba-cn", "alibaba-china"], - "alibaba-coding-plan": ["bcp", "bailian-coding-plan"], - zai: ["zai", "glm"], // GLM models via Z.AI - "zai-coding-plan": ["zai", "glm"], - moonshotai: ["moonshot", "kimi"], - "moonshotai-cn": ["moonshot", "kimi"], - moonshot: ["moonshot", "kimi", "kimi-coding", "kmc", "kmca"], - minimax: ["minimax", "minimax-cn"], - "minimax-cn": ["minimax-cn"], - longcat: ["lc", "longcat"], - pollinations: ["pol", "pollinations"], - puter: ["pu", "puter"], - cloudflare: ["cf"], - scaleway: ["scw"], - ollama: ["ollamacloud", "ollama-cloud"], - blackbox: ["bb", "blackbox"], - cline: ["cl", "cline"], - cursor: ["cu", "cursor"], - github: ["gh", "github"], - // Fallback: if no mapping exists, use the models.dev ID as-is -}; - -/** - * Map a models.dev provider ID to OmniRoute provider IDs. - * Returns array of provider identifiers (may include aliases). - */ -export function mapProviderId(modelsDevProviderId: string): string[] { - return MODELS_DEV_PROVIDER_MAP[modelsDevProviderId] || [modelsDevProviderId]; -} - // ─── Periodic sync state ───────────────────────────────── let syncTimer: ReturnType | null = null; @@ -312,99 +158,6 @@ export async function fetchModelsDev(signal?: AbortSignal): Promise { diff --git a/src/lib/modelsDevSync/transform.ts b/src/lib/modelsDevSync/transform.ts new file mode 100644 index 0000000000..77d20ac46e --- /dev/null +++ b/src/lib/modelsDevSync/transform.ts @@ -0,0 +1,279 @@ +/** + * modelsDevSync/transform — pure data model + transform layer. + * + * Extracted verbatim from modelsDevSync.ts. Holds the models.dev data-model + * types, the provider-id mapping table, and the raw→OmniRoute transform + * functions. Zero imports, no DB access, no module state — pure functions and + * static data. The host (modelsDevSync.ts) imports these for its sync + * orchestration and re-exports the originally-public symbols. + * + * @module lib/modelsDevSync/transform + */ + +export type PricingEntry = { + input: number; + output: number; + cached?: number; + cache_creation?: number; + reasoning?: number; +}; + +export type PricingModels = Record; +export type PricingByProvider = Record; + +export interface ModelCapabilityEntry { + tool_call: boolean | null; + reasoning: boolean | null; + attachment: boolean | null; + structured_output: boolean | null; + temperature: boolean | null; + modalities_input: string; // JSON array + modalities_output: string; // JSON array + knowledge_cutoff: string | null; + release_date: string | null; + last_updated: string | null; + status: string | null; + family: string | null; + open_weights: boolean | null; + limit_context: number | null; + limit_input: number | null; + limit_output: number | null; + interleaved_field: string | null; +} + +export type CapabilitiesByProvider = Record>; + +// ─── models.dev API types (raw) ────────────────────────── + +export interface ModelsDevCost { + input?: number; + output?: number; + reasoning?: number; + cache_read?: number; + cache_write?: number; + input_audio?: number; + output_audio?: number; +} + +export interface ModelsDevLimit { + context?: number; + input?: number; + output?: number; +} + +export interface ModelsDevModalities { + input?: string[]; + output?: string[]; +} + +export interface ModelsDevInterleaved { + field?: string; +} + +export interface ModelsDevModel { + id: string; + name: string; + family?: string; + attachment?: boolean; + reasoning?: boolean; + tool_call?: boolean; + structured_output?: boolean; + temperature?: boolean; + knowledge?: string; + release_date?: string; + last_updated?: string; + open_weights?: boolean; + status?: string; + cost?: ModelsDevCost; + limit?: ModelsDevLimit; + modalities?: ModelsDevModalities; + interleaved?: ModelsDevInterleaved | boolean; +} + +export interface ModelsDevProvider { + id: string; + name?: string; + env?: string[]; + npm?: string; + api?: string; + doc?: string; + models: Record; +} + +export type ModelsDevData = Record; + +// ─── Provider mapping: models.dev provider ID → OmniRoute provider IDs/aliases ── +// +// models.dev uses canonical provider IDs (e.g. "openai", "anthropic", "google"). +// OmniRoute uses both full IDs and short aliases (e.g. "cc" for claude, "cx" for codex). +// We map each models.dev provider to ALL OmniRoute identifiers that should receive +// its pricing/capability data. + +export const MODELS_DEV_PROVIDER_MAP: Record = { + // Major providers + openai: ["openai", "cx"], // cx = Codex (uses OpenAI models) + anthropic: ["anthropic", "cc"], // cc = Claude Code + google: ["gemini"], + "google-vertex": ["gemini", "vertex"], + "google-vertex-anthropic": ["anthropic", "cc", "vertex"], + vertex_ai: ["gemini", "vertex"], + deepseek: ["deepseek", "if"], // if = Qoder (routes through DeepSeek) + groq: ["groq"], + xai: ["xai"], + mistral: ["mistral"], + togetherai: ["together", "openrouter"], + together_ai: ["together", "openrouter"], + "fireworks-ai": ["fireworks"], + fireworks: ["fireworks"], + cerebras: ["cerebras"], + cohere: ["cohere"], + nvidia: ["nvidia"], + nebius: ["nebius"], + siliconflow: ["siliconflow"], + hyperbolic: ["hyperbolic"], + huggingface: ["hf", "huggingface"], + openrouter: ["openrouter"], + perplexity: ["pplx", "perplexity"], + // OAuth / special providers + bedrock: ["kiro", "kr"], // kr = Kiro (AWS Bedrock) + "github-copilot": ["github", "gh"], + "github-models": ["github", "gh"], + kilo: ["kilocode", "kc", "kilo-gateway"], + kilocode: ["kilocode", "kc", "kilo-gateway"], + "kimi-for-coding": ["kimi-coding", "kmc", "kimi-coding-apikey", "kmca"], + // The `opencode` models.dev entry used to map only to "opencode-zen" because + // that is the historical alias pair. But OmniRoute's catalog & combo targets + // reference models under BOTH provider IDs: + // - `opencode-zen/big-pickle` (alias form) + // - `opencode/big-pickle` (canonical id form, used by live API catalog + // and by combos like "Opencode FREE Omni") + // If we only store synced capabilities under "opencode-zen", the canonical + // `opencode/` lookup in getCanonicalModelMetadata returns null and + // any combo that targets `opencode/...` ends up with no computed context. + // Symmetric mapping keeps both lookup paths populated. + opencode: ["opencode", "opencode-zen"], + "opencode-go": ["opencode-go", "opencode-zen"], + // Additional providers that may overlap with OmniRoute + alibaba: ["ali", "alibaba"], + "alibaba-cn": ["ali-cn", "alibaba-cn", "alibaba-china"], + "alibaba-coding-plan": ["bcp", "bailian-coding-plan"], + zai: ["zai", "glm"], // GLM models via Z.AI + "zai-coding-plan": ["zai", "glm"], + moonshotai: ["moonshot", "kimi"], + "moonshotai-cn": ["moonshot", "kimi"], + moonshot: ["moonshot", "kimi", "kimi-coding", "kmc", "kmca"], + minimax: ["minimax", "minimax-cn"], + "minimax-cn": ["minimax-cn"], + longcat: ["lc", "longcat"], + pollinations: ["pol", "pollinations"], + puter: ["pu", "puter"], + cloudflare: ["cf"], + scaleway: ["scw"], + ollama: ["ollamacloud", "ollama-cloud"], + blackbox: ["bb", "blackbox"], + cline: ["cl", "cline"], + cursor: ["cu", "cursor"], + github: ["gh", "github"], + // Fallback: if no mapping exists, use the models.dev ID as-is +}; + +/** + * Map a models.dev provider ID to OmniRoute provider IDs. + * Returns array of provider identifiers (may include aliases). + */ +export function mapProviderId(modelsDevProviderId: string): string[] { + return MODELS_DEV_PROVIDER_MAP[modelsDevProviderId] || [modelsDevProviderId]; +} + +// ─── Transform: Pricing ────────────────────────────────── + +/** + * Transform models.dev raw data → OmniRoute PricingByProvider format. + * + * models.dev costs are already in $/1M tokens (same as OmniRoute format). + * Maps: cache_read → cached, cache_write → cache_creation. + */ +export function transformModelsDevToPricing(raw: ModelsDevData): PricingByProvider { + const result: PricingByProvider = {}; + + for (const [providerId, providerData] of Object.entries(raw)) { + const omniRouteProviders = mapProviderId(providerId); + + for (const [modelId, model] of Object.entries(providerData.models || {})) { + if (!model.cost) continue; + + // Must have at least input pricing + if (model.cost.input == null) continue; + + const entry: PricingEntry = { + input: model.cost.input, + output: model.cost.output ?? 0, + }; + + if (model.cost.cache_read != null) { + entry.cached = model.cost.cache_read; + } + if (model.cost.cache_write != null) { + entry.cache_creation = model.cost.cache_write; + } + if (model.cost.reasoning != null) { + entry.reasoning = model.cost.reasoning; + } + + // Write to ALL mapped OmniRoute providers + for (const omniProvider of omniRouteProviders) { + if (!result[omniProvider]) result[omniProvider] = {}; + result[omniProvider][modelId] = entry; + } + } + } + + return result; +} + +// ─── Transform: Capabilities ───────────────────────────── + +/** + * Transform models.dev raw data → CapabilitiesByProvider format. + */ +export function transformModelsDevToCapabilities(raw: ModelsDevData): CapabilitiesByProvider { + const result: CapabilitiesByProvider = {}; + + for (const [providerId, providerData] of Object.entries(raw)) { + const omniRouteProviders = mapProviderId(providerId); + + for (const [modelId, model] of Object.entries(providerData.models || {})) { + const cap: ModelCapabilityEntry = { + tool_call: model.tool_call ?? null, + reasoning: model.reasoning ?? null, + attachment: model.attachment ?? null, + structured_output: model.structured_output ?? null, + temperature: model.temperature ?? null, + modalities_input: JSON.stringify(model.modalities?.input ?? []), + modalities_output: JSON.stringify(model.modalities?.output ?? []), + knowledge_cutoff: model.knowledge ?? null, + release_date: model.release_date ?? null, + last_updated: model.last_updated ?? null, + status: model.status ?? null, + family: model.family ?? null, + open_weights: model.open_weights ?? null, + limit_context: model.limit?.context ?? null, + limit_input: model.limit?.input ?? null, + limit_output: model.limit?.output ?? null, + interleaved_field: + typeof model.interleaved === "object" && model.interleaved?.field + ? model.interleaved.field + : model.interleaved === true + ? "reasoning_content" + : null, + }; + + for (const omniProvider of omniRouteProviders) { + if (!result[omniProvider]) result[omniProvider] = {}; + result[omniProvider][modelId] = cap; + } + } + } + + return result; +} diff --git a/tests/unit/modelsdevsync-transform-split.test.ts b/tests/unit/modelsdevsync-transform-split.test.ts new file mode 100644 index 0000000000..d152b86fec --- /dev/null +++ b/tests/unit/modelsdevsync-transform-split.test.ts @@ -0,0 +1,109 @@ +/** + * Split-guard — modelsDevSync ↔ modelsDevSync/transform + * + * Guards the extraction of the pure transform layer (provider-id mapping + + * raw→OmniRoute pricing/capability transforms) into the leaf + * src/lib/modelsDevSync/transform.ts. Characterizes the provider map and the + * two transform functions, and proves the host still re-exports them so its + * public API is unchanged. DB-free by design — the transform layer is pure. + */ + +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +import { + mapProviderId, + transformModelsDevToPricing, + transformModelsDevToCapabilities, + MODELS_DEV_PROVIDER_MAP, +} from "../../src/lib/modelsDevSync/transform.ts"; + +describe("modelsDevSync/transform split-guard", () => { + it("maps known models.dev provider ids to OmniRoute aliases", () => { + assert.deepEqual(mapProviderId("openai"), ["openai", "cx"]); + assert.deepEqual(mapProviderId("anthropic"), ["anthropic", "cc"]); + assert.deepEqual(mapProviderId("zai"), ["zai", "glm"]); + assert.deepEqual(mapProviderId("bedrock"), ["kiro", "kr"]); + assert.deepEqual(mapProviderId("opencode"), ["opencode", "opencode-zen"]); + }); + + it("falls back to the raw id for unmapped providers", () => { + assert.deepEqual(mapProviderId("some-unknown-provider"), ["some-unknown-provider"]); + }); + + it("exports a non-trivial provider map table", () => { + assert.ok(MODELS_DEV_PROVIDER_MAP.openai); + assert.ok(Object.keys(MODELS_DEV_PROVIDER_MAP).length > 30); + }); + + it("transforms cost → pricing and writes to every mapped provider", () => { + const raw = { + openai: { + id: "openai", + models: { + "gpt-x": { + id: "gpt-x", + name: "GPT-X", + cost: { input: 1, output: 2, cache_read: 0.5, cache_write: 3, reasoning: 4 }, + }, + }, + }, + }; + const pricing = transformModelsDevToPricing(raw as never); + assert.deepEqual(pricing.openai["gpt-x"], { + input: 1, + output: 2, + cached: 0.5, + cache_creation: 3, + reasoning: 4, + }); + // openai maps to ["openai", "cx"] — both must receive the entry. + assert.deepEqual(pricing.cx["gpt-x"], pricing.openai["gpt-x"]); + }); + + it("skips models with no cost or missing input pricing", () => { + const raw = { + openai: { + id: "openai", + models: { + "no-cost": { id: "no-cost", name: "x" }, + "no-input": { id: "no-input", name: "y", cost: { output: 5 } }, + }, + }, + }; + const pricing = transformModelsDevToPricing(raw as never); + assert.equal(pricing.openai, undefined); + }); + + it("transforms capabilities incl. interleaved=true → reasoning_content", () => { + const raw = { + anthropic: { + id: "anthropic", + models: { + "claude-x": { + id: "claude-x", + name: "Claude X", + tool_call: true, + reasoning: true, + interleaved: true, + modalities: { input: ["text", "image"], output: ["text"] }, + }, + }, + }, + }; + const caps = transformModelsDevToCapabilities(raw as never); + assert.equal(caps.anthropic["claude-x"].tool_call, true); + assert.equal(caps.anthropic["claude-x"].interleaved_field, "reasoning_content"); + assert.equal(caps.anthropic["claude-x"].modalities_input, JSON.stringify(["text", "image"])); + // anthropic maps to ["anthropic", "cc"]. + assert.deepEqual(caps.cc["claude-x"], caps.anthropic["claude-x"]); + }); + + it("host re-exports the pure transform functions (public API preserved)", async () => { + const host = await import("../../src/lib/modelsDevSync.ts"); + assert.equal(typeof host.mapProviderId, "function"); + assert.equal(typeof host.transformModelsDevToPricing, "function"); + assert.equal(typeof host.transformModelsDevToCapabilities, "function"); + assert.deepEqual(host.mapProviderId("anthropic"), mapProviderId("anthropic")); + }); +});