diff --git a/package.json b/package.json index 901cca7..9884079 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,9 @@ "preview": "vite preview", "verify": "bun run build && bun run test && bun run lint", "deploy": "bun run verify && wrangler deploy", - "preview:cf": "vite build && wrangler dev" + "preview:cf": "vite build && wrangler dev", + "sync:models": "bun run scripts/sync-models.ts", + "sync:models:check": "bun run scripts/sync-models.ts --check" }, "dependencies": { "react": "^19.2.8", diff --git a/scripts/sync-models.ts b/scripts/sync-models.ts new file mode 100644 index 0000000..6cc1dce --- /dev/null +++ b/scripts/sync-models.ts @@ -0,0 +1,203 @@ +#!/usr/bin/env bun +/** + * Sync script for models.dev catalog snapshot. + * Fetches models.json and api.json from https://models.dev, prunes, joins reasoning efforts, + * and generates src/data/modelCatalog.generated.ts. + */ +import { resolve } from 'node:path' + +const MODELS_DEV_URL = 'https://models.dev/models.json' +const API_DEV_URL = 'https://models.dev/api.json' + +const PROVIDER_ALIASES: Record = { + google: ['google', 'google-vertex'], + anthropic: ['anthropic', 'google-vertex-anthropic', 'amazon-bedrock'], + zhipuai: ['zhipuai', 'zai'], + openai: ['openai', 'azure'], +} + +// Allowlist for reasoning effort options, filtering out 'none', 'default', null, etc. +// Per plan recommendation: PR 1 ships with ['minimal', 'low', 'medium', 'high', 'xhigh', 'max'] +const EFFORT_ALLOWLIST = ['minimal', 'low', 'medium', 'high', 'xhigh', 'max'] as const + +interface RawModel { + name?: string + limit?: { + context?: number + output?: number + input?: number + } +} + +interface RawReasoningOption { + type: string + values?: (string | null)[] +} + +interface RawProviderModel { + reasoning_options?: RawReasoningOption[] +} + +interface RawProvider { + models?: Record +} + +async function main() { + const isCheckMode = process.argv.includes('--check') + + console.log('Fetching models.dev catalog...') + const [modelsRes, apiRes] = await Promise.all([ + fetch(MODELS_DEV_URL), + fetch(API_DEV_URL), + ]) + + if (!modelsRes.ok) { + throw new Error(`Failed to fetch ${MODELS_DEV_URL}: HTTP ${modelsRes.status}`) + } + if (!apiRes.ok) { + throw new Error(`Failed to fetch ${API_DEV_URL}: HTTP ${apiRes.status}`) + } + + const rawModels = (await modelsRes.json()) as Record + const rawApi = (await apiRes.json()) as Record + + const rawKeys = Object.keys(rawModels) + if (rawKeys.length < 100) { + throw new Error(`Sanity check failed: models.dev returned only ${rawKeys.length} models (< 100). Aborting.`) + } + + // Build pruned catalog + // Keys sorted deterministically + const sortedKeys = rawKeys.sort() + + let usableCount = 0 + let effortsCount = 0 + let warningCount = 0 + + interface GeneratedEntry { + n: string + c: number + o?: number + e?: string[] + } + + const prunedCatalog: Record = {} + + for (const fullKey of sortedKeys) { + const raw = rawModels[fullKey] + const context = raw?.limit?.context + + // 1. Prune entry with context <= 0 or missing (image/realtime) + if (!context || context <= 0) { + continue + } + + usableCount++ + + const entry: GeneratedEntry = { + n: raw.name || fullKey, + c: context, + } + + // 2. Emit 'o' only when limit.output > 0 + if (raw.limit?.output && raw.limit.output > 0) { + entry.o = raw.limit.output + } + + // Mitigation check from plan: check if raw.limit.input < context - (entry.o ?? 0) + if (raw.limit?.input && entry.o && raw.limit.input < (context - entry.o)) { + console.warn(`[warning] ${fullKey}: limit.input (${raw.limit.input}) < context - out (${context - entry.o})`) + warningCount++ + } + + // 3. Extract and sanitize reasoning efforts from api.json + const [lab, id] = fullKey.split('/', 2) + const providersToCheck = PROVIDER_ALIASES[lab] ?? [lab] + let foundEfforts: string[] | null = null + + for (const p of providersToCheck) { + const providerData = rawApi[p] + if (!providerData?.models) continue + const candidate = providerData.models[fullKey] || providerData.models[id] + if (candidate?.reasoning_options) { + const effortOpt = candidate.reasoning_options.find((opt) => opt.type === 'effort') + if (effortOpt?.values && Array.isArray(effortOpt.values)) { + // Filter against allowlist preserving order + const sanitized = effortOpt.values + .filter((v): v is string => typeof v === 'string' && (EFFORT_ALLOWLIST as readonly string[]).includes(v)) + if (sanitized.length > 0) { + foundEfforts = sanitized + break + } + } + } + } + + if (foundEfforts && foundEfforts.length > 0) { + entry.e = foundEfforts + effortsCount++ + } + + prunedCatalog[fullKey] = entry + } + + // Format generated code deterministically + const today = new Date().toISOString().slice(0, 10) + const lines: string[] = [] + lines.push('// @generated by scripts/sync-models.ts — DO NOT EDIT') + lines.push('// Source: https://models.dev/models.json + api.json (MIT)') + lines.push("import type { CatalogEntry } from './modelCatalog.ts'") + lines.push('') + lines.push(`export const CATALOG_GENERATED_AT = '${today}'`) + lines.push(`export const CATALOG_SOURCE_COUNT = ${usableCount}`) + lines.push('') + lines.push('export const MODEL_CATALOG: Record = {') + + for (const [key, entry] of Object.entries(prunedCatalog)) { + // deterministic field order: n, c, o, e + const parts: string[] = [] + parts.push(`"n":${JSON.stringify(entry.n)}`) + parts.push(`"c":${entry.c}`) + if (entry.o !== undefined) { + parts.push(`"o":${entry.o}`) + } + if (entry.e && entry.e.length > 0) { + parts.push(`"e":${JSON.stringify(entry.e)}`) + } + lines.push(` ${JSON.stringify(key)}: {${parts.join(',')}},`) + } + + lines.push('}') + lines.push('') + + const generatedContent = lines.join('\n') + const targetPath = resolve(import.meta.dir, '../src/data/modelCatalog.generated.ts') + + if (isCheckMode) { + const existingContent = await Bun.file(targetPath).text().catch(() => '') + // Compare ignoring lines with CATALOG_GENERATED_AT or Generated + const normalize = (s: string) => + s + .split('\n') + .filter((l) => !l.includes('CATALOG_GENERATED_AT') && !l.includes('@generated')) + .join('\n') + + if (normalize(existingContent) !== normalize(generatedContent)) { + console.error('Check failed: src/data/modelCatalog.generated.ts is out of sync with models.dev.') + process.exit(1) + } + console.log('Check passed: src/data/modelCatalog.generated.ts is up to date.') + return + } + + await Bun.write(targetPath, generatedContent) + const byteSize = new TextEncoder().encode(generatedContent).length + console.log( + `Synced ${usableCount} models, ${effortsCount} with efforts, ${warningCount} warnings, ${byteSize} bytes written to ${targetPath}` + ) +} + +main().catch((err) => { + console.error(err) + process.exit(1) +}) diff --git a/src/components/ModelList.tsx b/src/components/ModelList.tsx index 68833c4..7f575be 100644 --- a/src/components/ModelList.tsx +++ b/src/components/ModelList.tsx @@ -4,6 +4,8 @@ import { Card } from './ui/Card' import { Badge } from './ui/Badge' import { Input } from './ui/Input' import { Search, CheckSquare, Square, Eye, Wrench, Layers, RefreshCw } from 'lucide-react' +import { resolveModelLimits } from '../data/modelCatalog.ts' +import { CATALOG_GENERATED_AT } from '../data/modelCatalog.generated.ts' interface ModelListProps { models: Model[] @@ -93,6 +95,19 @@ export const ModelList: React.FC = ({ ) : ( filteredModels.map((model) => { const isSelected = selectedIds.includes(model.id) + const limits = resolveModelLimits(model) + const isCatalog = limits.source === 'models.dev' + const tooltipContext = isCatalog + ? `Context window: ${limits.contextWindow.toLocaleString()} tokens (models.dev: ${limits.catalogName ?? limits.catalogId}, snapshot ${CATALOG_GENERATED_AT})` + : limits.source === 'gateway' + ? `Context window: ${limits.contextWindow.toLocaleString()} tokens (gateway reported)` + : `Context window: ${limits.contextWindow.toLocaleString()} tokens (default fallback)` + const tooltipOutput = isCatalog + ? `Max output: ${limits.maxOutputTokens.toLocaleString()} tokens (models.dev: ${limits.catalogName ?? limits.catalogId})` + : limits.source === 'gateway' + ? `Max output: ${limits.maxOutputTokens.toLocaleString()} tokens (gateway reported)` + : `Max output: ${limits.maxOutputTokens.toLocaleString()} tokens (default fallback)` + return (
= ({
- {model.contextWindow && ( - - - {(model.contextWindow / 1000).toFixed(0)}k - - )} + + + {(limits.contextWindow / 1000).toFixed(0)}k + + + ↑{(limits.maxOutputTokens / 1000).toFixed(0)}k +
{model.vision && ( diff --git a/src/components/ui/CodeBlock.tsx b/src/components/ui/CodeBlock.tsx index 39252f5..d380dd2 100644 --- a/src/components/ui/CodeBlock.tsx +++ b/src/components/ui/CodeBlock.tsx @@ -25,8 +25,8 @@ export const CodeBlock: React.FC = ({ } return ( -
-
+
+
{language ? language.toUpperCase() : 'CODE'}
-
+
           {code}
         
diff --git a/src/data/mockModels.ts b/src/data/mockModels.ts index 167e094..57f22a6 100644 --- a/src/data/mockModels.ts +++ b/src/data/mockModels.ts @@ -6,7 +6,6 @@ export const mockModels: Model[] = [ name: 'Claude Opus 4.7 (Anthropic via 9router)', family: 'claude-opus-4-7', provider: 'Anthropic', - contextWindow: 200000, vision: true, toolCalling: true, description: 'Most powerful reasoning model for complex architecture and codebases', @@ -16,7 +15,6 @@ export const mockModels: Model[] = [ name: 'Claude Sonnet 4.5', family: 'claude-sonnet-4-5', provider: 'Anthropic', - contextWindow: 200000, vision: true, toolCalling: true, description: 'High-speed intelligent coding powerhouse with strong agentic skills', @@ -26,7 +24,6 @@ export const mockModels: Model[] = [ name: 'Claude Sonnet 4.5 (KR Route)', family: 'claude-sonnet-4-5', provider: 'Anthropic', - contextWindow: 200000, vision: true, toolCalling: true, description: 'Alternative regional routing for low latency', @@ -36,7 +33,6 @@ export const mockModels: Model[] = [ name: 'GPT-4o', family: 'gpt-4o', provider: 'OpenAI', - contextWindow: 128000, vision: true, toolCalling: true, description: 'Flagship multimodal omni model with rapid reasoning and code output', @@ -46,7 +42,6 @@ export const mockModels: Model[] = [ name: 'o3-mini (High)', family: 'o3-mini', provider: 'OpenAI', - contextWindow: 200000, vision: false, toolCalling: true, reasoning: true, @@ -57,7 +52,6 @@ export const mockModels: Model[] = [ name: 'Gemini 3 Flash Preview', family: 'gemini-3-flash', provider: 'Google Vertex', - contextWindow: 1000000, vision: true, toolCalling: true, description: 'Ultra high-speed large 1M token context model for massive file ingestion', @@ -67,7 +61,6 @@ export const mockModels: Model[] = [ name: 'DeepSeek R1', family: 'deepseek-r1', provider: 'DeepSeek', - contextWindow: 64000, vision: false, toolCalling: true, reasoning: true, diff --git a/src/data/modelCatalog.generated.ts b/src/data/modelCatalog.generated.ts new file mode 100644 index 0000000..90885f0 --- /dev/null +++ b/src/data/modelCatalog.generated.ts @@ -0,0 +1,371 @@ +// @generated by scripts/sync-models.ts — DO NOT EDIT +// Source: https://models.dev/models.json + api.json (MIT) +import type { CatalogEntry } from './modelCatalog.ts' + +export const CATALOG_GENERATED_AT = '2026-09-04' +export const CATALOG_SOURCE_COUNT = 362 + +export const MODEL_CATALOG: Record = { + "aisingapore/gemma-sea-lion-v4-27b-it": {"n":"Gemma-SEA-LION-v4-27B-IT","c":128000,"o":128000}, + "alibaba/qwen-flash": {"n":"Qwen Flash","c":1000000,"o":32768}, + "alibaba/qwen-max": {"n":"Qwen Max","c":32768,"o":8192}, + "alibaba/qwen-omni-turbo": {"n":"Qwen-Omni Turbo","c":32768,"o":2048}, + "alibaba/qwen-plus": {"n":"Qwen Plus","c":1000000,"o":32768}, + "alibaba/qwen-turbo": {"n":"Qwen Turbo","c":1000000,"o":16384}, + "alibaba/qwen-vl-max": {"n":"Qwen-VL Max","c":131072,"o":8192}, + "alibaba/qwen-vl-plus": {"n":"Qwen-VL Plus","c":131072,"o":8192}, + "alibaba/qwen2-5-vl-72b-instruct": {"n":"Qwen2.5-VL 72B Instruct","c":131072,"o":8192}, + "alibaba/qwen2.5-coder-0.5b": {"n":"Qwen2.5-Coder-0.5B","c":32768,"o":8192}, + "alibaba/qwen2.5-coder-32b-instruct": {"n":"Qwen2.5-Coder-32B-Instruct","c":131072,"o":8192}, + "alibaba/qwen3-235b-a22b": {"n":"Qwen3 235B-A22B","c":131072,"o":16384}, + "alibaba/qwen3-235b-a22b-instruct-2507": {"n":"Qwen3 235B-A22B Instruct 2507","c":262144,"o":16384}, + "alibaba/qwen3-30b-a3b": {"n":"Qwen3 30B A3B","c":131072,"o":16384}, + "alibaba/qwen3-32b": {"n":"Qwen3 32B","c":131072,"o":16384}, + "alibaba/qwen3-coder-30b-a3b-instruct": {"n":"Qwen3-Coder 30B-A3B Instruct","c":262144,"o":65536}, + "alibaba/qwen3-coder-480b-a35b-instruct": {"n":"Qwen3-Coder 480B-A35B Instruct","c":262144,"o":65536}, + "alibaba/qwen3-coder-flash": {"n":"Qwen3 Coder Flash","c":1000000,"o":65536}, + "alibaba/qwen3-coder-next": {"n":"Qwen3 Coder Next","c":262144,"o":65536}, + "alibaba/qwen3-coder-plus": {"n":"Qwen3 Coder Plus","c":1048576,"o":65536}, + "alibaba/qwen3-max": {"n":"Qwen3 Max","c":262144,"o":65536}, + "alibaba/qwen3-next-80b-a3b-instruct": {"n":"Qwen3-Next 80B-A3B Instruct","c":131072,"o":32768}, + "alibaba/qwen3-next-80b-a3b-thinking": {"n":"Qwen3-Next 80B-A3B (Thinking)","c":131072,"o":32768}, + "alibaba/qwen3-vl-235b-a22b-instruct": {"n":"Qwen3 VL 235B A22B Instruct","c":131072,"o":32768}, + "alibaba/qwen3-vl-235b-a22b-thinking": {"n":"Qwen3 VL 235B A22B Thinking","c":131072,"o":32768}, + "alibaba/qwen3-vl-plus": {"n":"Qwen3-VL Plus","c":262144,"o":32768}, + "alibaba/qwen3.5-122b-a10b": {"n":"Qwen3.5 122B-A10B","c":262144,"o":65536}, + "alibaba/qwen3.5-27b": {"n":"Qwen3.5 27B","c":262144,"o":65536}, + "alibaba/qwen3.5-35b-a3b": {"n":"Qwen3.5 35B-A3B","c":262144,"o":65536}, + "alibaba/qwen3.5-397b-a17b": {"n":"Qwen3.5 397B-A17B","c":262144,"o":65536}, + "alibaba/qwen3.5-9b": {"n":"Qwen3.5 9B","c":262144,"o":65536}, + "alibaba/qwen3.5-flash": {"n":"Qwen3.5 Flash","c":1000000,"o":65536}, + "alibaba/qwen3.5-plus": {"n":"Qwen3.5 Plus","c":1000000,"o":65536}, + "alibaba/qwen3.6-27b": {"n":"Qwen3.6 27B","c":262144,"o":65536}, + "alibaba/qwen3.6-35b-a3b": {"n":"Qwen3.6 35B-A3B","c":262144,"o":65536}, + "alibaba/qwen3.6-flash": {"n":"Qwen3.6 Flash","c":1000000,"o":65536}, + "alibaba/qwen3.6-max-preview": {"n":"Qwen3.6 Max Preview","c":262144,"o":65536}, + "alibaba/qwen3.6-plus": {"n":"Qwen3.6 Plus","c":1000000,"o":65536}, + "alibaba/qwen3.7-flash": {"n":"Qwen3.7 Flash","c":1000000,"o":65536}, + "alibaba/qwen3.7-max": {"n":"Qwen3.7 Max","c":1000000,"o":65536}, + "alibaba/qwen3.7-plus": {"n":"Qwen3.7 Plus","c":1000000,"o":64000}, + "alibaba/qwen3.8-2.4t-a95b": {"n":"Qwen3.8 2.4T A95B","c":262144,"o":131072}, + "alibaba/qwen3.8-27b": {"n":"Qwen3.8 27B","c":262144,"o":32768}, + "alibaba/qwen3.8-flash": {"n":"Qwen3.8 Flash","c":1000000,"o":131072,"e":["low","medium","xhigh"]}, + "alibaba/qwen3.8-flash-next": {"n":"Qwen3.8 Flash Next","c":262144,"o":131072}, + "alibaba/qwen3.8-max": {"n":"Qwen3.8 Max","c":1000000,"o":131072,"e":["low","medium","xhigh"]}, + "alibaba/qwen3.8-max-0902": {"n":"Qwen3.8 Max 0902","c":1000000,"o":131072}, + "alibaba/qwen3.8-max-preview": {"n":"Qwen3.8 Max Preview","c":1000000,"o":131072}, + "alibaba/qwq-32b": {"n":"QwQ 32B","c":131072,"o":8192}, + "alibaba/qwq-plus": {"n":"QwQ Plus","c":131072,"o":8192}, + "anthropic/claude-3-5-haiku-20241022": {"n":"Claude Haiku 3.5","c":200000,"o":8192}, + "anthropic/claude-3-5-sonnet-20241022": {"n":"Claude Sonnet 3.5 v2","c":200000,"o":8192}, + "anthropic/claude-3-7-sonnet-20250219": {"n":"Claude Sonnet 3.7","c":200000,"o":64000}, + "anthropic/claude-3-haiku-20240307": {"n":"Claude Haiku 3","c":200000,"o":4096}, + "anthropic/claude-fable-5": {"n":"Claude Fable 5","c":1000000,"o":128000,"e":["low","medium","high","xhigh","max"]}, + "anthropic/claude-fable-5-1": {"n":"Claude Fable 5.1","c":1000000,"o":128000,"e":["low","medium","high","xhigh","max"]}, + "anthropic/claude-haiku-4-5": {"n":"Claude Haiku 4.5 (latest)","c":200000,"o":64000}, + "anthropic/claude-haiku-4-5-20251001": {"n":"Claude Haiku 4.5","c":200000,"o":64000}, + "anthropic/claude-mythos-5": {"n":"Claude Mythos 5","c":1000000,"o":128000}, + "anthropic/claude-opus-4-0": {"n":"Claude Opus 4 (latest)","c":200000,"o":32000}, + "anthropic/claude-opus-4-1": {"n":"Claude Opus 4.1 (latest)","c":200000,"o":32000}, + "anthropic/claude-opus-4-1-20250805": {"n":"Claude Opus 4.1","c":200000,"o":32000}, + "anthropic/claude-opus-4-20250514": {"n":"Claude Opus 4","c":200000,"o":32000}, + "anthropic/claude-opus-4-5": {"n":"Claude Opus 4.5 (latest)","c":200000,"o":64000,"e":["low","medium","high"]}, + "anthropic/claude-opus-4-5-20251101": {"n":"Claude Opus 4.5","c":200000,"o":64000,"e":["low","medium","high"]}, + "anthropic/claude-opus-4-6": {"n":"Claude Opus 4.6","c":1000000,"o":128000,"e":["low","medium","high","max"]}, + "anthropic/claude-opus-4-7": {"n":"Claude Opus 4.7","c":1000000,"o":128000,"e":["low","medium","high","xhigh","max"]}, + "anthropic/claude-opus-4-8": {"n":"Claude Opus 4.8","c":1000000,"o":128000,"e":["low","medium","high","xhigh","max"]}, + "anthropic/claude-opus-5": {"n":"Claude Opus 5","c":1000000,"o":128000,"e":["low","medium","high","xhigh","max"]}, + "anthropic/claude-sonnet-4-0": {"n":"Claude Sonnet 4 (latest)","c":200000,"o":64000}, + "anthropic/claude-sonnet-4-20250514": {"n":"Claude Sonnet 4","c":200000,"o":64000}, + "anthropic/claude-sonnet-4-5": {"n":"Claude Sonnet 4.5 (latest)","c":200000,"o":64000}, + "anthropic/claude-sonnet-4-5-20250929": {"n":"Claude Sonnet 4.5","c":200000,"o":64000}, + "anthropic/claude-sonnet-4-6": {"n":"Claude Sonnet 4.6","c":1000000,"o":64000,"e":["low","medium","high","max"]}, + "anthropic/claude-sonnet-5": {"n":"Claude Sonnet 5","c":1000000,"o":128000,"e":["low","medium","high","xhigh","max"]}, + "arcee-ai/trinity-large-preview": {"n":"Trinity Large Preview","c":524288,"o":262144}, + "arcee-ai/trinity-large-thinking": {"n":"Trinity Large Thinking","c":524288,"o":262144}, + "arcee-ai/trinity-mini": {"n":"Trinity Mini","c":131072,"o":131072}, + "arcee-ai/trinity-nano-preview": {"n":"Trinity Nano Preview","c":131072,"o":131072}, + "bytedance-seed/seed-1-6": {"n":"Seed 1.6","c":256000,"o":64000}, + "bytedance-seed/seed-1-6-flash": {"n":"Seed 1.6 Flash","c":256000,"o":32000}, + "bytedance-seed/seed-1-6-vision": {"n":"Seed 1.6 Vision","c":256000,"o":32000}, + "bytedance-seed/seed-1-8": {"n":"Seed 1.8","c":256000,"o":64000}, + "bytedance-seed/seed-2.0-code": {"n":"Seed 2.0 Code","c":262144,"o":131072}, + "bytedance-seed/seed-2.0-lite": {"n":"Seed 2.0 Lite","c":256000,"o":32000}, + "bytedance-seed/seed-2.0-mini": {"n":"Seed 2.0 Mini","c":256000,"o":32000}, + "bytedance-seed/seed-2.0-pro": {"n":"Seed 2.0 Pro","c":256000,"o":128000}, + "bytedance-seed/seed-2.1-pro": {"n":"Seed 2.1 Pro","c":256000,"o":256000}, + "bytedance-seed/seed-2.1-turbo": {"n":"Seed 2.1 Turbo","c":256000,"o":256000}, + "bytedance-seed/seed-character": {"n":"Seed Character","c":256000,"o":256000}, + "bytedance-seed/seed-evolving": {"n":"Seed Evolving","c":256000,"o":256000}, + "cohere/c4ai-aya-expanse-32b": {"n":"Aya Expanse 32B","c":128000,"o":4000}, + "cohere/c4ai-aya-expanse-8b": {"n":"Aya Expanse 8B","c":8000,"o":4000}, + "cohere/c4ai-aya-vision-32b": {"n":"Aya Vision 32B","c":16000,"o":4000}, + "cohere/c4ai-aya-vision-8b": {"n":"Aya Vision 8B","c":16000,"o":4000}, + "cohere/command-a-03-2025": {"n":"Command A","c":256000,"o":8000}, + "cohere/command-a-plus-05-2026": {"n":"Command A Plus","c":128000,"o":64000}, + "cohere/command-a-reasoning-08-2025": {"n":"Command A Reasoning","c":256000,"o":32000}, + "cohere/command-a-translate-08-2025": {"n":"Command A Translate","c":8000,"o":8000}, + "cohere/command-a-vision-07-2025": {"n":"Command A Vision","c":128000,"o":8000}, + "cohere/command-r-08-2024": {"n":"Command R","c":128000,"o":4000}, + "cohere/command-r-plus-08-2024": {"n":"Command R+","c":128000,"o":4000}, + "cohere/command-r7b-12-2024": {"n":"Command R7B","c":128000,"o":4000}, + "cohere/command-r7b-arabic-02-2025": {"n":"Command R7B Arabic","c":128000,"o":4000}, + "cohere/north-mini-code-1-0": {"n":"North Mini Code","c":256000,"o":64000,"e":["high"]}, + "deepreinforce/ornith-1.0-31b": {"n":"Ornith 1.0 31B","c":262144}, + "deepreinforce/ornith-1.0-35b": {"n":"Ornith 1.0 35B","c":262144}, + "deepreinforce/ornith-1.0-397b": {"n":"Ornith 1.0 397B","c":262144}, + "deepreinforce/ornith-1.0-9b": {"n":"Ornith 1.0 9B","c":262144}, + "deepreinforce/ornith-1.5-35b-a3b": {"n":"Ornith 1.5 35B A3B","c":262144}, + "deepseek/deepseek-chat": {"n":"DeepSeek Chat","c":1000000,"o":384000}, + "deepseek/deepseek-ocr-2": {"n":"DeepSeek OCR 2","c":8192,"o":8192}, + "deepseek/deepseek-r1": {"n":"DeepSeek-R1","c":128000,"o":32768}, + "deepseek/deepseek-r1-distill-qwen-32b": {"n":"DeepSeek-R1-Distill-Qwen-32B","c":131072,"o":32768}, + "deepseek/deepseek-reasoner": {"n":"DeepSeek Reasoner","c":1000000,"o":384000}, + "deepseek/deepseek-v3": {"n":"DeepSeek-V3","c":131072,"o":8192}, + "deepseek/deepseek-v3-0324": {"n":"DeepSeek V3 0324","c":163840,"o":163840}, + "deepseek/deepseek-v3.1": {"n":"DeepSeek-V3.1","c":131072,"o":8192}, + "deepseek/deepseek-v3.2": {"n":"DeepSeek V3.2","c":128000,"o":64000}, + "deepseek/deepseek-v4-flash": {"n":"DeepSeek V4 Flash","c":1000000,"o":384000,"e":["low","high","max"]}, + "deepseek/deepseek-v4-flash-0731": {"n":"DeepSeek V4 Flash 0731","c":1000000,"o":384000}, + "deepseek/deepseek-v4-flash-vision-exp": {"n":"DeepSeek V4 Flash Vision Exp","c":1000000,"o":384000,"e":["low","high","max"]}, + "deepseek/deepseek-v4-pro": {"n":"DeepSeek V4 Pro","c":1000000,"o":384000,"e":["high","max"]}, + "deepseek/deepseek-v4-pro-0423": {"n":"DeepSeek V4 Pro 0423","c":1000000,"o":384000}, + "deepseek/deepseek-v4-pro-0813": {"n":"DeepSeek V4 Pro 0813","c":1000000,"o":384000}, + "google/deep-research-max-preview-04-2026": {"n":"Deep Research Max Preview","c":1048576,"o":65536}, + "google/deep-research-preview-04-2026": {"n":"Gemini Deep Research Preview","c":1048576,"o":65536}, + "google/gemini-2.0-flash": {"n":"Gemini 2.0 Flash","c":1048576,"o":8192}, + "google/gemini-2.0-flash-lite": {"n":"Gemini 2.0 Flash-Lite","c":1048576,"o":8192}, + "google/gemini-2.5-computer-use-preview-10-2025": {"n":"Gemini 2.5 Computer Use Preview","c":128000,"o":64000}, + "google/gemini-2.5-flash": {"n":"Gemini 2.5 Flash","c":1048576,"o":65536}, + "google/gemini-2.5-flash-image": {"n":"Nano Banana","c":32768,"o":32768}, + "google/gemini-2.5-flash-lite": {"n":"Gemini 2.5 Flash-Lite","c":1048576,"o":65536}, + "google/gemini-2.5-flash-tts": {"n":"Gemini 2.5 Flash TTS","c":32768,"o":16384}, + "google/gemini-2.5-pro": {"n":"Gemini 2.5 Pro","c":1048576,"o":65536}, + "google/gemini-2.5-pro-tts": {"n":"Gemini 2.5 Pro TTS","c":32768,"o":16384}, + "google/gemini-3-flash-preview": {"n":"Gemini 3 Flash Preview","c":1048576,"o":65536,"e":["minimal","low","medium","high"]}, + "google/gemini-3-pro-image": {"n":"Nano Banana Pro","c":65536,"o":32768,"e":["low","high"]}, + "google/gemini-3-pro-image-preview": {"n":"Nano Banana Pro","c":65536,"o":32768}, + "google/gemini-3-pro-preview": {"n":"Gemini 3 Pro Preview","c":1048576,"o":65536}, + "google/gemini-3.1-flash-image": {"n":"Nano Banana 2","c":131072,"o":32768,"e":["minimal","high"]}, + "google/gemini-3.1-flash-image-preview": {"n":"Nano Banana 2","c":65536,"o":65536,"e":["minimal","high"]}, + "google/gemini-3.1-flash-lite": {"n":"Gemini 3.1 Flash Lite","c":1048576,"o":65536,"e":["minimal","low","medium","high"]}, + "google/gemini-3.1-flash-lite-image": {"n":"Nano Banana 2 Lite","c":65536,"o":4096,"e":["minimal","high"]}, + "google/gemini-3.1-flash-lite-preview": {"n":"Gemini 3.1 Flash Lite Preview","c":1048576,"o":65536,"e":["minimal","low","medium","high"]}, + "google/gemini-3.1-flash-live-preview": {"n":"Gemini 3.1 Flash Live Preview","c":131072,"o":65536,"e":["minimal","low","medium","high"]}, + "google/gemini-3.1-flash-tts-preview": {"n":"Gemini 3.1 Flash TTS Preview","c":8192,"o":16384}, + "google/gemini-3.1-pro-preview": {"n":"Gemini 3.1 Pro Preview","c":1048576,"o":65536,"e":["low","medium","high"]}, + "google/gemini-3.1-pro-preview-customtools": {"n":"Gemini 3.1 Pro Preview Custom Tools","c":1048576,"o":65536,"e":["low","medium","high"]}, + "google/gemini-3.5-flash": {"n":"Gemini 3.5 Flash","c":1048576,"o":65536,"e":["minimal","low","medium","high"]}, + "google/gemini-3.5-flash-lite": {"n":"Gemini 3.5 Flash Lite","c":1048576,"o":65536,"e":["minimal","low","medium","high"]}, + "google/gemini-3.5-live-translate-preview": {"n":"Gemini 3.5 Live Translate Preview","c":131072,"o":65536}, + "google/gemini-3.6-flash": {"n":"Gemini 3.6 Flash","c":1048576,"o":65536,"e":["minimal","low","medium","high"]}, + "google/gemini-3.7-flash": {"n":"Gemini 3.7 Flash","c":1048576,"o":65536,"e":["low","medium","high"]}, + "google/gemini-3.8-flash": {"n":"Gemini 3.8 Flash","c":1048576,"o":65536,"e":["low","medium","high"]}, + "google/gemini-embedding-001": {"n":"Gemini Embedding 001","c":2048,"o":1}, + "google/gemini-embedding-2": {"n":"Gemini Embedding 2","c":8192,"o":3072}, + "google/gemini-flash-latest": {"n":"Gemini Flash Latest","c":1048576,"o":65536,"e":["low","medium","high"]}, + "google/gemini-flash-lite-latest": {"n":"Gemini Flash-Lite Latest","c":1048576,"o":65536,"e":["minimal","low","medium","high"]}, + "google/gemini-omni-flash-preview": {"n":"Gemini Omni Flash Preview","c":1048576,"o":57920}, + "google/gemini-robotics-er-1.6-preview": {"n":"Gemini Robotics-ER 1.6 Preview","c":131072,"o":65536}, + "google/gemma-4-26b-a4b-it": {"n":"Gemma 4 26B A4B IT","c":262144,"o":32768}, + "google/gemma-4-31b-it": {"n":"Gemma 4 31B IT","c":262144,"o":32768}, + "google/gemma-4-E2B-it": {"n":"Gemma 4 E2B IT","c":131072,"o":8192}, + "google/gemma-4-E4B-it": {"n":"Gemma 4 E4B IT","c":131072,"o":8192}, + "google/lyria-3-clip-preview": {"n":"Lyria 3 Clip Preview","c":131072,"o":65536}, + "google/lyria-3-pro-preview": {"n":"Lyria 3 Pro Preview","c":131072,"o":8192}, + "google/veo-3.1-fast-generate-preview": {"n":"Veo 3.1 Fast Preview","c":1024}, + "google/veo-3.1-generate-preview": {"n":"Veo 3.1 Preview","c":1024,"o":1}, + "google/veo-3.1-lite-generate-preview": {"n":"Veo 3.1 Lite Preview","c":1024}, + "ibm/granite-4-h-micro": {"n":"Granite-4.0-H-Micro","c":131072,"o":131072}, + "ibm/granite-4-h-small": {"n":"Granite-4.0-H-Small","c":131072,"o":131072}, + "inclusionai/ling-3.0-flash-fin": {"n":"Ling 3.0 Flash Fin","c":262144,"o":32768}, + "meituan/longcat-2.0": {"n":"LongCat-2.0","c":1000000,"o":131072}, + "meta/llama-3.1-8b-instruct": {"n":"Llama-3.1-8B-Instruct","c":128000,"o":4096}, + "meta/llama-3.2-11b-vision-instruct": {"n":"Llama-3.2-11B-Vision-Instruct","c":128000,"o":4096}, + "meta/llama-3.2-1b": {"n":"Llama-3.2-1B","c":131072,"o":8192}, + "meta/llama-3.2-3b": {"n":"Llama-3.2-3B","c":131072,"o":8192}, + "meta/llama-3.3-70b-instruct": {"n":"Llama-3.3-70B-Instruct","c":128000,"o":4096}, + "meta/llama-4-maverick-17b-instruct": {"n":"Llama 4 Maverick 17B Instruct","c":1000000,"o":16384}, + "meta/llama-4-scout-17b-instruct": {"n":"Llama 4 Scout 17B Instruct","c":3500000,"o":16384}, + "meta/llama-guard-3-8b": {"n":"Llama-Guard-3-8B","c":128000,"o":4096}, + "meta/muse-glimmer-30b": {"n":"Muse Glimmer 30B","c":131072,"o":131072}, + "meta/muse-spark-1.1": {"n":"Muse Spark 1.1","c":1048576,"o":131072,"e":["minimal","low","medium","high","xhigh"]}, + "meta/muse-spark-1.2": {"n":"Muse Spark 1.2","c":1048576,"o":131072,"e":["minimal","low","medium","high","xhigh"]}, + "meta/muse-spark-1.3": {"n":"Muse Spark 1.3","c":1048576,"o":131072,"e":["minimal","low","medium","high","xhigh"]}, + "microsoft/mai-code-1-flash": {"n":"MAI-Code-1-Flash","c":256000,"o":128000}, + "microsoft/mai-code-1.1-flash": {"n":"MAI-Code-1.1-Flash","c":256000,"o":128000}, + "microsoft/phi-4-mini": {"n":"Phi-4-mini","c":128000,"o":4096}, + "minimax/MiniMax-M2": {"n":"MiniMax-M2","c":204800,"o":131072}, + "minimax/MiniMax-M2-Her": {"n":"MiniMax-M2 Her","c":65536,"o":2048}, + "minimax/MiniMax-M2.1": {"n":"MiniMax-M2.1","c":204800,"o":131072}, + "minimax/MiniMax-M2.5": {"n":"MiniMax-M2.5","c":204800,"o":131072}, + "minimax/MiniMax-M2.5-highspeed": {"n":"MiniMax-M2.5-highspeed","c":204800,"o":131072}, + "minimax/MiniMax-M2.7": {"n":"MiniMax-M2.7","c":204800,"o":131072}, + "minimax/MiniMax-M2.7-highspeed": {"n":"MiniMax-M2.7-highspeed","c":204800,"o":131072}, + "minimax/MiniMax-M3": {"n":"MiniMax-M3","c":1048576,"o":512000}, + "mistral/codestral-22b-v0.1": {"n":"Codestral-22B-v0.1","c":32768,"o":8192}, + "mistral/codestral-latest": {"n":"Codestral (latest)","c":256000,"o":4096}, + "mistral/devstral-2512": {"n":"Devstral 2","c":262144,"o":262144}, + "mistral/devstral-medium-2507": {"n":"Devstral Medium","c":128000,"o":128000}, + "mistral/devstral-medium-latest": {"n":"Devstral 2 (latest)","c":262144,"o":262144}, + "mistral/devstral-small-2507": {"n":"Devstral Small","c":128000,"o":128000}, + "mistral/magistral-medium-latest": {"n":"Magistral Medium (latest)","c":128000,"o":16384}, + "mistral/magistral-small-2506": {"n":"Magistral Small","c":131072,"o":8192}, + "mistral/ministral-8b-instruct-2410": {"n":"Ministral 8B Instruct","c":131072,"o":8192}, + "mistral/mistral-large-2411": {"n":"Mistral Large 2.1","c":131072,"o":16384}, + "mistral/mistral-large-2512": {"n":"Mistral Large 3","c":262144,"o":262144}, + "mistral/mistral-large-latest": {"n":"Mistral Large (latest)","c":262144,"o":262144}, + "mistral/mistral-medium-2505": {"n":"Mistral Medium 3","c":131072,"o":131072}, + "mistral/mistral-medium-2604": {"n":"Mistral Medium 3.5","c":262144,"o":262144,"e":["high"]}, + "mistral/mistral-medium-latest": {"n":"Mistral Medium (latest)","c":262144,"o":262144,"e":["high"]}, + "mistral/mistral-nemo": {"n":"Mistral Nemo","c":128000,"o":128000}, + "mistral/mistral-small-2506": {"n":"Mistral Small 3.2","c":128000,"o":16384}, + "mistral/mistral-small-2603": {"n":"Mistral Small 4","c":256000,"o":256000,"e":["high"]}, + "mistral/mistral-small-3-1-24b-instruct-2503": {"n":"Mistral Small 3.1 24B","c":128000,"o":16384}, + "mistral/mistral-small-latest": {"n":"Mistral Small (latest)","c":256000,"o":256000,"e":["high"]}, + "mistral/pixtral-12b": {"n":"Pixtral 12B","c":128000,"o":128000}, + "mistral/pixtral-large-latest": {"n":"Pixtral Large (latest)","c":128000,"o":128000}, + "mistral/voxtral-small-latest": {"n":"Voxtral Small (latest)","c":32000,"o":32000}, + "moonshotai/kimi-k2-thinking": {"n":"Kimi K2 Thinking","c":262144,"o":262144}, + "moonshotai/kimi-k2-thinking-turbo": {"n":"Kimi K2 Thinking Turbo","c":262144,"o":262144}, + "moonshotai/kimi-k2.5": {"n":"Kimi K2.5","c":262144,"o":262144}, + "moonshotai/kimi-k2.6": {"n":"Kimi K2.6","c":262144,"o":262144}, + "moonshotai/kimi-k2.7-code": {"n":"Kimi K2.7 Code","c":262144,"o":262144}, + "moonshotai/kimi-k2.7-code-highspeed": {"n":"Kimi K2.7 Code Highspeed","c":262144,"o":262144}, + "moonshotai/kimi-k3": {"n":"Kimi K3","c":1048576,"o":131072,"e":["low","high","max"]}, + "nvidia/llama-3.1-nemotron-70b-instruct": {"n":"Llama 3.1 Nemotron 70B Instruct","c":128000,"o":8192}, + "nvidia/llama-3.1-nemotron-safety-guard-8b-v3": {"n":"Llama 3.1 Nemotron Safety Guard 8B v3","c":128000,"o":4096}, + "nvidia/llama-3.1-nemotron-ultra-253b": {"n":"Llama 3.1 Nemotron Ultra 253B","c":128000,"o":8192}, + "nvidia/llama-3.3-nemotron-super-49b-v1": {"n":"Llama 3.3 Nemotron Super 49B v1","c":131072,"o":131072}, + "nvidia/llama-3.3-nemotron-super-49b-v1.5": {"n":"Llama 3.3 Nemotron Super 49B v1.5","c":131072,"o":131072}, + "nvidia/llama-nemotron-embed-vl-1b-v2": {"n":"Llama Nemotron Embed VL 1B v2","c":32768,"o":2048}, + "nvidia/llama-nemotron-rerank-vl-1b-v2": {"n":"Llama Nemotron Rerank VL 1B v2","c":128000,"o":4096}, + "nvidia/mistral-nemotron": {"n":"Mistral Nemotron","c":128000,"o":8192}, + "nvidia/nemotron-3-content-safety": {"n":"Nemotron 3 Content Safety","c":128000,"o":4096}, + "nvidia/nemotron-3-nano-30b-a3b": {"n":"Nemotron 3 Nano 30B A3B","c":262144,"o":262144}, + "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning": {"n":"Nemotron 3 Nano Omni 30B A3B Reasoning","c":256000,"o":65536}, + "nvidia/nemotron-3-super-120b-a12b": {"n":"Nemotron 3 Super 120B A12B","c":262144,"o":262144}, + "nvidia/nemotron-3-ultra-550b-a55b": {"n":"Nemotron 3 Ultra 550B A55B","c":1000000,"o":128000}, + "nvidia/nemotron-3.5-content-safety": {"n":"Nemotron 3.5 Content Safety","c":128000,"o":8192}, + "nvidia/nemotron-3.5-lightning": {"n":"Nemotron 3.5 Lightning 30B A3B","c":262144,"o":262144}, + "nvidia/nemotron-cascade-2-30b-a3b": {"n":"Nemotron Cascade 2 30B A3B","c":256000,"o":32768}, + "nvidia/nemotron-content-safety-reasoning-4b": {"n":"Nemotron Content Safety Reasoning 4B","c":128000,"o":4096}, + "nvidia/nemotron-mini-4b-instruct": {"n":"Nemotron Mini 4B Instruct","c":128000,"o":8192}, + "nvidia/nemotron-nano-12b-v2-vl": {"n":"Nemotron Nano 12B v2 VL","c":128000,"o":128000}, + "nvidia/nemotron-nano-9b-v2": {"n":"Nemotron Nano 9B v2","c":131072,"o":131072}, + "nvidia/nemotron-voicechat": {"n":"Nemotron VoiceChat","c":128000,"o":8192}, + "openai/gpt-3.5-turbo": {"n":"GPT-3.5-turbo","c":16385,"o":4096}, + "openai/gpt-4": {"n":"GPT-4","c":8192,"o":8192}, + "openai/gpt-4-turbo": {"n":"GPT-4 Turbo","c":128000,"o":4096}, + "openai/gpt-4.1": {"n":"GPT-4.1","c":1047576,"o":32768}, + "openai/gpt-4.1-mini": {"n":"GPT-4.1 mini","c":1047576,"o":32768}, + "openai/gpt-4.1-nano": {"n":"GPT-4.1 nano","c":1047576,"o":32768}, + "openai/gpt-4o": {"n":"GPT-4o","c":128000,"o":16384}, + "openai/gpt-4o-2024-05-13": {"n":"GPT-4o (2024-05-13)","c":128000,"o":4096}, + "openai/gpt-4o-2024-08-06": {"n":"GPT-4o (2024-08-06)","c":128000,"o":16384}, + "openai/gpt-4o-2024-11-20": {"n":"GPT-4o (2024-11-20)","c":128000,"o":16384}, + "openai/gpt-4o-mini": {"n":"GPT-4o mini","c":128000,"o":16384}, + "openai/gpt-5": {"n":"GPT-5","c":400000,"o":128000,"e":["minimal","low","medium","high"]}, + "openai/gpt-5-chat-latest": {"n":"GPT-5 Chat (latest)","c":400000,"o":128000}, + "openai/gpt-5-codex": {"n":"GPT-5-Codex","c":400000,"o":128000,"e":["low","medium","high"]}, + "openai/gpt-5-mini": {"n":"GPT-5 Mini","c":400000,"o":128000,"e":["minimal","low","medium","high"]}, + "openai/gpt-5-nano": {"n":"GPT-5 Nano","c":400000,"o":128000,"e":["minimal","low","medium","high"]}, + "openai/gpt-5-pro": {"n":"GPT-5 Pro","c":400000,"o":272000,"e":["high"]}, + "openai/gpt-5.1": {"n":"GPT-5.1","c":400000,"o":128000,"e":["low","medium","high"]}, + "openai/gpt-5.1-chat-latest": {"n":"GPT-5.1 Chat","c":128000,"o":16384}, + "openai/gpt-5.1-codex": {"n":"GPT-5.1 Codex","c":400000,"o":128000,"e":["low","medium","high"]}, + "openai/gpt-5.1-codex-max": {"n":"GPT-5.1 Codex Max","c":400000,"o":128000,"e":["low","medium","high","xhigh"]}, + "openai/gpt-5.1-codex-mini": {"n":"GPT-5.1 Codex mini","c":400000,"o":128000,"e":["low","medium","high"]}, + "openai/gpt-5.2": {"n":"GPT-5.2","c":400000,"o":128000,"e":["low","medium","high","xhigh"]}, + "openai/gpt-5.2-chat-latest": {"n":"GPT-5.2 Chat","c":128000,"o":16384,"e":["medium"]}, + "openai/gpt-5.2-codex": {"n":"GPT-5.2 Codex","c":400000,"o":128000,"e":["low","medium","high","xhigh"]}, + "openai/gpt-5.2-pro": {"n":"GPT-5.2 Pro","c":400000,"o":128000,"e":["medium","high","xhigh"]}, + "openai/gpt-5.3-chat-latest": {"n":"GPT-5.3 Chat (latest)","c":128000,"o":16384}, + "openai/gpt-5.3-codex": {"n":"GPT-5.3 Codex","c":400000,"o":128000,"e":["low","medium","high","xhigh"]}, + "openai/gpt-5.3-codex-spark": {"n":"GPT-5.3 Codex Spark","c":128000,"o":32000,"e":["low","medium","high","xhigh"]}, + "openai/gpt-5.4": {"n":"GPT-5.4","c":1050000,"o":128000,"e":["low","medium","high","xhigh"]}, + "openai/gpt-5.4-mini": {"n":"GPT-5.4 mini","c":400000,"o":128000,"e":["low","medium","high","xhigh"]}, + "openai/gpt-5.4-nano": {"n":"GPT-5.4 nano","c":400000,"o":128000,"e":["low","medium","high","xhigh"]}, + "openai/gpt-5.4-pro": {"n":"GPT-5.4 Pro","c":1050000,"o":128000,"e":["medium","high","xhigh"]}, + "openai/gpt-5.5": {"n":"GPT-5.5","c":1050000,"o":128000,"e":["low","medium","high","xhigh"]}, + "openai/gpt-5.5-instant": {"n":"GPT-5.5 Instant","c":400000,"o":128000}, + "openai/gpt-5.5-pro": {"n":"GPT-5.5 Pro","c":1050000,"o":128000,"e":["medium","high","xhigh"]}, + "openai/gpt-5.6-luna": {"n":"GPT-5.6 Luna","c":1050000,"o":128000,"e":["low","medium","high","xhigh","max"]}, + "openai/gpt-5.6-sol": {"n":"GPT-5.6 Sol","c":1050000,"o":128000,"e":["low","medium","high","xhigh","max"]}, + "openai/gpt-5.6-terra": {"n":"GPT-5.6 Terra","c":1050000,"o":128000,"e":["low","medium","high","xhigh","max"]}, + "openai/gpt-oss-120b": {"n":"GPT OSS 120B","c":131072,"o":32768}, + "openai/gpt-oss-20b": {"n":"GPT OSS 20B","c":131072,"o":32768}, + "openai/gpt-oss-safeguard-120b": {"n":"GPT OSS Safeguard 120B","c":131072,"o":32768}, + "openai/gpt-realtime-2.1": {"n":"GPT-Realtime-2.1","c":128000,"o":32000,"e":["minimal","low","medium","high","xhigh"]}, + "openai/o1": {"n":"o1","c":200000,"o":100000,"e":["low","medium","high"]}, + "openai/o1-pro": {"n":"o1-pro","c":200000,"o":100000,"e":["low","medium","high"]}, + "openai/o3": {"n":"o3","c":200000,"o":100000,"e":["low","medium","high"]}, + "openai/o3-deep-research": {"n":"o3-deep-research","c":200000,"o":100000}, + "openai/o3-mini": {"n":"o3-mini","c":200000,"o":100000,"e":["low","medium","high"]}, + "openai/o3-pro": {"n":"o3-pro","c":200000,"o":100000,"e":["low","medium","high"]}, + "openai/o4-mini": {"n":"o4-mini","c":200000,"o":100000,"e":["low","medium","high"]}, + "openai/o4-mini-deep-research": {"n":"o4-mini-deep-research","c":200000,"o":100000}, + "openai/whisper-large-v3": {"n":"Whisper 3 Large","c":448,"o":4096}, + "openai/whisper-large-v3-turbo": {"n":"Whisper Large v3 Turbo","c":448,"o":448}, + "openbmb/minicpm5-1b": {"n":"MiniCPM5-1B","c":131072,"o":131072}, + "perplexity/sonar": {"n":"Sonar","c":128000,"o":4096}, + "perplexity/sonar-deep-research": {"n":"Sonar Deep Research","c":128000,"o":32768,"e":["minimal","low","medium","high"]}, + "perplexity/sonar-pro": {"n":"Sonar Pro","c":200000,"o":8192}, + "perplexity/sonar-reasoning-pro": {"n":"Sonar Reasoning Pro","c":128000,"o":4096,"e":["minimal","low","medium","high"]}, + "poolside/laguna-m.1": {"n":"Laguna M.1","c":262144,"o":32768}, + "poolside/laguna-s-2.1": {"n":"Laguna S 2.1","c":1048576,"o":32768}, + "poolside/laguna-xs-2.1": {"n":"Laguna XS 2.1","c":262144,"o":32768}, + "poolside/laguna-xs.2": {"n":"Laguna XS.2","c":262144,"o":32768}, + "sakana/fugu": {"n":"Fugu","c":1000000,"e":["high","xhigh"]}, + "sakana/fugu-ultra": {"n":"Fugu Ultra","c":1000000,"e":["high","xhigh"]}, + "sakana/sakana-namazu": {"n":"Sakana Namazu","c":262144,"o":65536}, + "sarvam/sarvam-105b": {"n":"Sarvam 105B","c":131072,"o":131072,"e":["low","medium","high"]}, + "sarvam/sarvam-30b": {"n":"Sarvam 30B","c":128000,"o":128000,"e":["low","medium","high"]}, + "sdaia/allam-2-7b": {"n":"ALLaM-2-7b","c":4096,"o":4096}, + "stepfun/step-3.5-flash": {"n":"Step 3.5 Flash","c":256000,"o":256000,"e":["low","high"]}, + "stepfun/step-3.5-flash-2603": {"n":"Step 3.5 Flash 2603","c":256000,"o":256000,"e":["low","high"]}, + "stepfun/step-3.7-flash": {"n":"Step 3.7 Flash","c":256000,"o":256000,"e":["low","medium","high"]}, + "swiss-ai/apertus-70b": {"n":"Apertus 70B","c":65536,"o":8192}, + "swiss-ai/apertus-8b": {"n":"Apertus 8B","c":65536,"o":8192}, + "tencent/hy3": {"n":"Hy3","c":256000,"o":128000}, + "tencent/hy3-preview": {"n":"Hy3 preview","c":256000,"o":64000}, + "tencent/hy4-preview": {"n":"Hy4 preview","c":1024000,"o":64000}, + "thinkingmachines/inkling": {"n":"Inkling","c":1048576,"o":1048576}, + "thinkingmachines/inkling-small": {"n":"Inkling Small","c":1048576,"o":1048576}, + "trendyol/asure-12b": {"n":"Trendyol Asure 12B","c":131072}, + "upstage/solar-pro2": {"n":"Solar Pro 2","c":65536,"o":8192,"e":["minimal","high"]}, + "upstage/solar-pro3": {"n":"Solar Pro 3","c":131072,"o":8192,"e":["low","medium","high"]}, + "upstage/solar-pro4": {"n":"Solar Pro 4","c":524288,"o":131072,"e":["minimal","low","medium","high","xhigh","max"]}, + "xai/grok-4.1-fast": {"n":"Grok 4.1 Fast","c":2000000,"o":30000}, + "xai/grok-4.20-0309-non-reasoning": {"n":"Grok 4.20 (Non-Reasoning)","c":1000000,"o":30000}, + "xai/grok-4.20-0309-reasoning": {"n":"Grok 4.20 (Reasoning)","c":1000000,"o":30000}, + "xai/grok-4.3": {"n":"Grok 4.3","c":1000000,"o":30000,"e":["low","medium","high"]}, + "xai/grok-4.5": {"n":"Grok 4.5","c":500000,"o":500000,"e":["low","medium","high"]}, + "xai/grok-4.6": {"n":"Grok 4.6","c":500000,"o":500000,"e":["low","medium","high","xhigh"]}, + "xai/grok-build-0.1": {"n":"Grok Build 0.1","c":256000,"o":256000}, + "xai/grok-imagine-image-2.0": {"n":"Grok Imagine Image 2.0","c":8000}, + "xai/grok-imagine-video-1.5": {"n":"Grok Imagine Video 1.5","c":1024}, + "xiaomi/mimo-v2-flash": {"n":"MiMo-V2-Flash","c":262144,"o":65536}, + "xiaomi/mimo-v2-omni": {"n":"MiMo-V2-Omni","c":262144,"o":131072}, + "xiaomi/mimo-v2-pro": {"n":"MiMo-V2-Pro","c":1048576,"o":131072}, + "xiaomi/mimo-v2.5": {"n":"MiMo-V2.5","c":1048576,"o":131072}, + "xiaomi/mimo-v2.5-pro": {"n":"MiMo-V2.5-Pro","c":1048576,"o":131072}, + "xiaomi/mimo-v2.5-pro-ultraspeed": {"n":"MiMo-V2.5-Pro-UltraSpeed","c":1048576,"o":131072}, + "zhipuai/glm-4.5": {"n":"GLM-4.5","c":131072,"o":98304}, + "zhipuai/glm-4.5-air": {"n":"GLM-4.5-Air","c":131072,"o":98304}, + "zhipuai/glm-4.5-flash": {"n":"GLM-4.5-Flash","c":131072,"o":98304}, + "zhipuai/glm-4.5v": {"n":"GLM-4.5V","c":64000,"o":16384}, + "zhipuai/glm-4.6": {"n":"GLM-4.6","c":204800,"o":131072}, + "zhipuai/glm-4.6v": {"n":"GLM-4.6V","c":128000,"o":32768}, + "zhipuai/glm-4.6v-flash": {"n":"GLM-4.6V-Flash","c":128000,"o":32768}, + "zhipuai/glm-4.7": {"n":"GLM-4.7","c":204800,"o":131072}, + "zhipuai/glm-4.7-flash": {"n":"GLM-4.7-Flash","c":200000,"o":131072}, + "zhipuai/glm-4.7-flashx": {"n":"GLM-4.7-FlashX","c":200000,"o":131072}, + "zhipuai/glm-5": {"n":"GLM-5","c":204800,"o":131072}, + "zhipuai/glm-5-turbo": {"n":"GLM-5-Turbo","c":200000,"o":131072}, + "zhipuai/glm-5.1": {"n":"GLM-5.1","c":200000,"o":131072}, + "zhipuai/glm-5.2": {"n":"GLM-5.2","c":1000000,"o":131072,"e":["high","max"]}, + "zhipuai/glm-5.3": {"n":"GLM-5.3","c":1000000,"o":131072,"e":["low","high","max"]}, + "zhipuai/glm-5.3-flash": {"n":"GLM-5.3-Flash","c":1000000,"o":131072,"e":["low","high","max"]}, + "zhipuai/glm-5v-turbo": {"n":"GLM-5V-Turbo","c":200000,"o":131072}, +} diff --git a/src/data/modelCatalog.test.ts b/src/data/modelCatalog.test.ts new file mode 100644 index 0000000..4befcca --- /dev/null +++ b/src/data/modelCatalog.test.ts @@ -0,0 +1,158 @@ +import { describe, it, expect } from 'bun:test' +import { + normalizeModelId, + lookupCatalog, + resolveModelLimits, + FALLBACK_CONTEXT, + FALLBACK_OUTPUT, +} from './modelCatalog.ts' +import { MODEL_CATALOG } from './modelCatalog.generated.ts' + +describe('modelCatalog', () => { + describe('normalizeModelId', () => { + it('normalizes provider prefixed IDs', () => { + expect(normalizeModelId('cc/claude-sonnet-4.5')).toBe('claude-sonnet-4-5') + expect(normalizeModelId('anthropic/claude-opus-4-7')).toBe('claude-opus-4-7') + }) + + it('normalizes bare IDs', () => { + expect(normalizeModelId('claude-sonnet-4.5')).toBe('claude-sonnet-4-5') + }) + + it('normalizes multi-tier IDs', () => { + expect(normalizeModelId('openrouter/anthropic/claude-sonnet-4.5')).toBe('anthropic/claude-sonnet-4-5') + }) + + it('handles uppercase characters', () => { + expect(normalizeModelId('CC/Claude-Sonnet-4.5')).toBe('claude-sonnet-4-5') + }) + }) + + describe('lookupCatalog - verified IDs', () => { + const verifiedMatches: [string, string][] = [ + ['cc/claude-sonnet-4.5', 'anthropic/claude-sonnet-4-5'], + ['kr/claude-sonnet-4.5', 'anthropic/claude-sonnet-4-5'], + ['cc/claude-opus-4-5-20251101', 'anthropic/claude-opus-4-5-20251101'], + ['cx/gpt-5.6-sol', 'openai/gpt-5.6-sol'], + ['cx/gpt-5.3-codex', 'openai/gpt-5.3-codex'], + ['ag/gemini-3.1-pro-preview', 'google/gemini-3.1-pro-preview'], + ['vertex/gemini-3-flash-preview', 'google/gemini-3-flash-preview'], + ['openai/gpt-4o', 'openai/gpt-4o'], + ['openai/o3-mini', 'openai/o3-mini'], + ['deepseek/deepseek-r1', 'deepseek/deepseek-r1'], + ['cc/claude-opus-4-7', 'anthropic/claude-opus-4-7'], + ] + + for (const [queryId, expectedCatalogKey] of verifiedMatches) { + it(`resolves ${queryId} -> ${expectedCatalogKey}`, () => { + const hit = lookupCatalog(queryId) + expect(hit).not.toBeNull() + expect(hit!.key).toBe(expectedCatalogKey) + }) + } + + it('returns null on unknown models', () => { + expect(lookupCatalog('foo/definitely-not-real')).toBeNull() + }) + + it('matches date suffix aliases (tier 4)', () => { + // cc/claude-sonnet-4-6-20260101 does not exist in catalog, but anthropic/claude-sonnet-4-6 does + const hit = lookupCatalog('cc/claude-sonnet-4-6-20260101') + expect(hit).not.toBeNull() + expect(hit!.key).toBe('anthropic/claude-sonnet-4-6') + }) + }) + + describe('catalog integrity & fuzz invariant', () => { + const catalogKeys = Object.keys(MODEL_CATALOG) + + it('contains usable catalog entries', () => { + expect(catalogKeys.length).toBeGreaterThanOrEqual(300) + }) + + it('validates every entry in MODEL_CATALOG adheres to invariants', () => { + for (const key of catalogKeys) { + const entry = MODEL_CATALOG[key] + expect(entry.c).toBeGreaterThan(0) + if (entry.o !== undefined) { + expect(entry.o).toBeGreaterThan(0) + } + if (entry.e !== undefined) { + expect(entry.e.length).toBeGreaterThan(0) + } + + const limits = resolveModelLimits({ id: key }) + expect(limits.maxInputTokens).toBeGreaterThanOrEqual(1) + expect(limits.maxOutputTokens).toBeGreaterThanOrEqual(1) + expect(limits.maxInputTokens + limits.maxOutputTokens).toBeLessThanOrEqual(limits.contextWindow) + expect(limits.source).toBe('models.dev') + } + }) + }) + + describe('resolveModelLimits specifics', () => { + it('preserves non-degenerate limits where output > context / 2 (zhipuai/glm-4.7)', () => { + const hit = lookupCatalog('zhipuai/glm-4.7') + if (hit) { + const limits = resolveModelLimits({ id: 'zhipuai/glm-4.7' }) + expect(limits.maxOutputTokens).toBe(131072) + expect(limits.maxInputTokens).toBe(73728) + expect(limits.maxInputTokens + limits.maxOutputTokens).toBe(204800) + } + }) + + it('preserves non-degenerate limits (openai/gpt-5-pro)', () => { + const hit = lookupCatalog('openai/gpt-5-pro') + if (hit) { + const limits = resolveModelLimits({ id: 'openai/gpt-5-pro' }) + expect(limits.maxOutputTokens).toBe(272000) + expect(limits.maxInputTokens).toBe(128000) + expect(limits.maxInputTokens + limits.maxOutputTokens).toBe(400000) + } + }) + + it('synthesizes degenerate output limit when declared output >= context (xai/grok-4.6)', () => { + const hit = lookupCatalog('xai/grok-4.6') + if (hit && hit.entry.o === hit.entry.c) { + const limits = resolveModelLimits({ id: 'xai/grok-4.6' }) + expect(limits.maxOutputTokens).toBe(125000) + expect(limits.maxInputTokens).toBe(375000) + expect(limits.maxInputTokens + limits.maxOutputTokens).toBe(500000) + } + }) + + it('follows precedence: models.dev wins over gateway arguments', () => { + const limits = resolveModelLimits({ + id: 'cc/claude-sonnet-4.5', + contextWindow: 999999, + maxOutputTokens: 1111, + }) + expect(limits.source).toBe('models.dev') + expect(limits.contextWindow).toBe(200000) + expect(limits.maxOutputTokens).toBe(64000) + expect(limits.maxInputTokens).toBe(136000) + }) + + it('uses gateway limits when uncataloged', () => { + const limits = resolveModelLimits({ + id: 'private/custom-model', + contextWindow: 64000, + maxOutputTokens: 8192, + }) + expect(limits.source).toBe('gateway') + expect(limits.contextWindow).toBe(64000) + expect(limits.maxOutputTokens).toBe(8192) + expect(limits.maxInputTokens).toBe(55808) + }) + + it('uses defaults when uncataloged and no gateway limits provided', () => { + const limits = resolveModelLimits({ + id: 'private/custom-model', + }) + expect(limits.source).toBe('default') + expect(limits.contextWindow).toBe(FALLBACK_CONTEXT) + expect(limits.maxOutputTokens).toBe(FALLBACK_OUTPUT) + expect(limits.maxInputTokens).toBe(FALLBACK_CONTEXT - FALLBACK_OUTPUT) + }) + }) +}) diff --git a/src/data/modelCatalog.ts b/src/data/modelCatalog.ts new file mode 100644 index 0000000..0cbe7c4 --- /dev/null +++ b/src/data/modelCatalog.ts @@ -0,0 +1,153 @@ +// Types, normalize, lookup, and limit resolver for models.dev catalog +import { MODEL_CATALOG } from './modelCatalog.generated.ts' + +export interface CatalogEntry { + /** Display name from models.dev - for tooltip/provenance only, never emitted into tool configs */ + n: string + /** Context window limit, always > 0 */ + c: number + /** Output token limit; absent when models.dev does not declare it */ + o?: number + /** Sanitized reasoning efforts; absent when unavailable */ + e?: readonly string[] +} + +export type LimitSource = 'models.dev' | 'gateway' | 'default' + +export interface ResolvedLimits { + contextWindow: number + maxInputTokens: number + maxOutputTokens: number + reasoningEfforts: string[] + source: LimitSource + catalogId: string | null + catalogName: string | null +} + +export interface ModelLimitsInput { + id: string + contextWindow?: number + maxOutputTokens?: number + reasoning?: boolean +} + +export const FALLBACK_CONTEXT = 128000 +export const FALLBACK_OUTPUT = 16384 +export const DEGENERATE_SHARE = 0.25 +export const DEFAULT_EFFORTS = ['low', 'medium', 'high'] as const + +/** + * Normalizes a model identifier: + * - strips provider prefix before the first slash (if any) + * - converts to lowercase + * - replaces dots with dashes + */ +export function normalizeModelId(id: string): string { + const parts = id.split('/') + const stripped = parts.length > 1 ? parts.slice(1).join('/') : id + return stripped.toLowerCase().replace(/\./g, '-') +} + +/** + * Lazy index mapping normalized aliases to catalog entries. + */ +let catalogIndex: Map | null = null + +function getIndex(): Map { + if (catalogIndex) { + return catalogIndex + } + const index = new Map() + for (const [key, entry] of Object.entries(MODEL_CATALOG)) { + const item = { key, entry } + // 1. Full normalized key: "anthropic/claude-sonnet-4-5" + const fullNormalized = key.toLowerCase().replace(/\./g, '-') + if (!index.has(fullNormalized)) { + index.set(fullNormalized, item) + } + // 2. Stripped normalized alias: "claude-sonnet-4-5" + const stripped = normalizeModelId(key) + if (!index.has(stripped)) { + index.set(stripped, item) + } + } + catalogIndex = index + return index +} + +/** + * Look up a model in the catalog using 4 matching tiers: + * 1. full id lowercased + '.' -> '-' + * 2. normalizeModelId(id) (strip first segment) + * 3. segment after the LAST slash (for 3-tier IDs like openrouter/anthropic/claude-x) + * 4. tier 2 after stripping date suffix (-YYYYMMDD or -YYYY-MM-DD) + */ +export function lookupCatalog(id: string): { key: string; entry: CatalogEntry } | null { + const index = getIndex() + + // Tier 1: full id lowercased + '.' -> '-' + const tier1 = id.toLowerCase().replace(/\./g, '-') + const hit1 = index.get(tier1) + if (hit1) return hit1 + + // Tier 2: normalizeModelId(id) + const tier2 = normalizeModelId(id) + const hit2 = index.get(tier2) + if (hit2) return hit2 + + // Tier 3: last segment after last slash + const lastSlashIndex = id.lastIndexOf('/') + if (lastSlashIndex !== -1) { + const lastSegment = id.slice(lastSlashIndex + 1).toLowerCase().replace(/\./g, '-') + const hit3 = index.get(lastSegment) + if (hit3) return hit3 + } + + // Tier 4: strip date suffix (-YYYYMMDD or -YYYY-MM-DD) from tier 2 + const dateStripped = tier2.replace(/-\d{8}$/, '').replace(/-\d{4}-\d{2}-\d{2}$/, '') + if (dateStripped !== tier2) { + const hit4 = index.get(dateStripped) + if (hit4) return hit4 + } + + return null +} + +/** + * Resolves contextWindow, maxInputTokens, maxOutputTokens, and reasoningEfforts + * based on the precedence: models.dev -> gateway -> fallback defaults. + */ +export function resolveModelLimits(model: ModelLimitsInput): ResolvedLimits { + const hit = lookupCatalog(model.id) + + const context = hit?.entry.c ?? model.contextWindow ?? FALLBACK_CONTEXT + const declared = hit?.entry.o ?? model.maxOutputTokens ?? FALLBACK_OUTPUT + + // Guard: only synthesize when declared value is not positive or >= context window + let out = (declared > 0 && declared < context) + ? declared + : Math.max(1, Math.floor(context * DEGENERATE_SHARE)) + out = Math.min(out, context - 1) + + const input = context - out // always >= 1 + + const reasoningEfforts = hit?.entry.e + ? [...hit.entry.e] + : [...DEFAULT_EFFORTS] + + const source: LimitSource = hit + ? 'models.dev' + : (model.contextWindow || model.maxOutputTokens) + ? 'gateway' + : 'default' + + return { + contextWindow: context, + maxInputTokens: input, + maxOutputTokens: out, + reasoningEfforts, + source, + catalogId: hit?.key ?? null, + catalogName: hit?.entry.n ?? null, + } +} diff --git a/src/data/tools.test.ts b/src/data/tools.test.ts new file mode 100644 index 0000000..344d572 --- /dev/null +++ b/src/data/tools.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect } from 'bun:test' +import { tools } from './tools.ts' +import type { ConnectionConfig, Model } from '../types.ts' + +describe('tools configuration generators', () => { + const dummyConn: ConnectionConfig = { + baseUrl: 'https://gateway.example.com/v1', + apiKey: 'sk-test-key-123', + } + + describe('copilot generator', () => { + it('produces valid JSON array grouped by provider with vendor customendpoint', () => { + const models: Model[] = [ + { id: 'cc/claude-sonnet-4.5', name: 'Claude Sonnet 4.5' }, + { id: 'openai/gpt-4o', name: 'GPT-4o' }, + ] + const jsonStr = tools.copilot.sampleTemplate(dummyConn, models) + const parsed = JSON.parse(jsonStr) + + expect(Array.isArray(parsed)).toBe(true) + expect(parsed.length).toBe(2) + for (const group of parsed) { + expect(group.vendor).toBe('customendpoint') + expect(group.apiKey).toBe('sk-test-key-123') + expect(group.apiType).toBe('chat-completions') + expect(Array.isArray(group.models)).toBe(true) + } + }) + + it('resolves limits for cc/claude-sonnet-4.5 accurately (136000 / 64000)', () => { + const models: Model[] = [{ id: 'cc/claude-sonnet-4.5', name: 'Claude Sonnet 4.5' }] + const jsonStr = tools.copilot.sampleTemplate(dummyConn, models) + const parsed = JSON.parse(jsonStr) + const model = parsed[0].models[0] + + expect(model.maxInputTokens).toBe(136000) + expect(model.maxOutputTokens).toBe(64000) + expect(model.maxInputTokens + model.maxOutputTokens).toBe(200000) + }) + + it('emits sanitized reasoning efforts for cc/claude-opus-4-7 when reasoning is true', () => { + const models: Model[] = [{ id: 'cc/claude-opus-4-7', name: 'Claude Opus 4.7', reasoning: true }] + const jsonStr = tools.copilot.sampleTemplate(dummyConn, models) + const parsed = JSON.parse(jsonStr) + const model = parsed[0].models[0] + + expect(model.thinking).toBe(true) + expect(model.reasoningEffortFormat).toBe('chat-completions') + expect(model.supportsReasoningEffort).toEqual(['low', 'medium', 'high', 'xhigh', 'max']) + }) + + it('falls back to default efforts for reasoning model without effort catalog entry', () => { + const models: Model[] = [{ id: 'cc/claude-sonnet-4.5', name: 'Claude Sonnet 4.5', reasoning: true }] + const jsonStr = tools.copilot.sampleTemplate(dummyConn, models) + const parsed = JSON.parse(jsonStr) + const model = parsed[0].models[0] + + expect(model.thinking).toBe(true) + expect(model.supportsReasoningEffort).toEqual(['low', 'medium', 'high']) + }) + + it('does not emit thinking or reasoning effort keys for non-reasoning models', () => { + const models: Model[] = [{ id: 'openai/gpt-4o', name: 'GPT-4o', reasoning: false }] + const jsonStr = tools.copilot.sampleTemplate(dummyConn, models) + const parsed = JSON.parse(jsonStr) + const model = parsed[0].models[0] + + expect(model.thinking).toBeUndefined() + expect(model.supportsReasoningEffort).toBeUndefined() + expect(model.reasoningEffortFormat).toBeUndefined() + }) + + it('does not contain minOutput in serialized output', () => { + const models: Model[] = [{ id: 'cc/claude-sonnet-4.5', name: 'Claude Sonnet 4.5' }] + const jsonStr = tools.copilot.sampleTemplate(dummyConn, models) + expect(jsonStr.includes('minOutput')).toBe(false) + }) + + it('handles empty selectedModels using fallback without throwing', () => { + const jsonStr = tools.copilot.sampleTemplate(dummyConn, []) + const parsed = JSON.parse(jsonStr) + expect(Array.isArray(parsed)).toBe(true) + expect(parsed.length).toBeGreaterThan(0) + }) + }) + + describe('claude-code and codex templates unchanged', () => { + it('claude-code produces expected JSON config with comments', () => { + const models: Model[] = [{ id: 'cc/claude-sonnet-4.5', name: 'Claude Sonnet 4.5' }] + const config = tools['claude-code'].sampleTemplate(dummyConn, models) + expect(config).toContain('"model": "cc/claude-sonnet-4.5"') + expect(config).toContain('"anthropic_api_base": "https://gateway.example.com/v1"') + expect(config).toContain('// Target: ~/.claude/config.json') + }) + + it('codex produces expected TOML config', () => { + const models: Model[] = [{ id: 'openai/gpt-4o', name: 'GPT-4o' }] + const toml = tools.codex.sampleTemplate(dummyConn, models) + expect(toml).toContain('model = "openai/gpt-4o"') + expect(toml).toContain('base_url = "https://gateway.example.com/v1"') + }) + }) +}) diff --git a/src/data/tools.ts b/src/data/tools.ts index 71bc82d..faeeeb6 100644 --- a/src/data/tools.ts +++ b/src/data/tools.ts @@ -1,4 +1,5 @@ import type { ToolId, ToolMeta, ConnectionConfig, Model } from '../types.ts' +import { resolveModelLimits } from './modelCatalog.ts' export const tools: Record = { copilot: { @@ -20,7 +21,6 @@ export const tools: Record = { name: 'Claude Sonnet 4.5', family: 'claude-sonnet-4-5', provider: 'Anthropic', - contextWindow: 200000, vision: true, toolCalling: true, } @@ -46,20 +46,23 @@ export const tools: Record = { apiKey: apiKeyVal, apiType: 'chat-completions', models: models.map((m) => { + const limits = resolveModelLimits(m) const modelObj: Record = { id: m.id, name: m.name || m.id, url: endpointUrl, toolCalling: m.toolCalling ?? true, vision: m.vision ?? true, - maxInputTokens: m.contextWindow || 128000, - // Guidance: maxInputTokens + maxOutputTokens <= context window - maxOutputTokens: 16384, + // Invariant: maxInputTokens + maxOutputTokens <= context window. + // Resolved từ snapshot models.dev (src/data/modelCatalog.generated.ts), + // rồi tới gateway, rồi hằng số. + maxInputTokens: limits.maxInputTokens, + maxOutputTokens: limits.maxOutputTokens, } if (m.reasoning) { modelObj.thinking = true - modelObj.supportsReasoningEffort = ['low', 'medium', 'high'] + modelObj.supportsReasoningEffort = limits.reasoningEfforts modelObj.reasoningEffortFormat = 'chat-completions' } diff --git a/src/services/installEndpoint.test.ts b/src/services/installEndpoint.test.ts index 021ad5b..61e8a87 100644 --- a/src/services/installEndpoint.test.ts +++ b/src/services/installEndpoint.test.ts @@ -117,6 +117,56 @@ describe('Install Link & Endpoint', () => { expect(res.body).toContain('while [[ "$CONFIG" == *"$API_KEY_PLACEHOLDER"* ]]') }) + it('ensures parity by construction between preview template and install script', () => { + const models = [ + { id: 'cc/claude-sonnet-4.5', name: 'Claude Sonnet 4.5' }, + { id: 'openai/o3-mini', name: 'o3-mini', reasoning: true }, + ] + const token = encodeInstallToken({ + v: 1, + t: 'copilot', + b: 'http://localhost:20128/v1', + m: models, + }) + + const conn = { baseUrl: 'http://localhost:20128/v1', apiKey: '__NINEROUTER_API_KEY__' } + const expectedTemplate = tools.copilot.sampleTemplate(conn, models) + + const shRes = renderInstallScript(token, 'sh') + expect(shRes.status).toBe(200) + expect(shRes.body).toContain(expectedTemplate) + + const ps1Res = renderInstallScript(token, 'ps1') + expect(ps1Res.status).toBe(200) + expect(ps1Res.body).toContain(expectedTemplate) + }) + + it('round-trips maxOutputTokens for uncataloged model through install token', () => { + const models = [ + { + id: 'custom-private/my-special-llm', + name: 'My Special LLM', + contextWindow: 65536, + maxOutputTokens: 8192, + }, + ] + const token = encodeInstallToken({ + v: 1, + t: 'copilot', + b: 'http://localhost:20128/v1', + m: models, + }) + + const decoded = decodeInstallToken(token) + expect(decoded?.m[0].maxOutputTokens).toBe(8192) + + const shRes = renderInstallScript(token, 'sh') + expect(shRes.status).toBe(200) + // Should resolve with maxOutputTokens 8192 and input 65536 - 8192 = 57344 + expect(shRes.body).toContain('"maxOutputTokens": 8192') + expect(shRes.body).toContain('"maxInputTokens": 57344') + }) + it('handles invalid token gracefully', () => { const res = renderInstallScript('invalid-token', 'sh') expect(res.status).toBe(400) diff --git a/src/services/installEndpoint.ts b/src/services/installEndpoint.ts index c9ee8b8..35996b2 100644 --- a/src/services/installEndpoint.ts +++ b/src/services/installEndpoint.ts @@ -46,6 +46,7 @@ export function renderInstallScript( name: m.name || m.id, provider: m.provider, contextWindow: m.contextWindow, + maxOutputTokens: m.maxOutputTokens, vision: m.vision, toolCalling: m.toolCalling, reasoning: m.reasoning, diff --git a/src/services/installLink.ts b/src/services/installLink.ts index 757256b..4685b33 100644 --- a/src/services/installLink.ts +++ b/src/services/installLink.ts @@ -6,6 +6,7 @@ export interface MinimalModel { name?: string provider?: string contextWindow?: number + maxOutputTokens?: number vision?: boolean toolCalling?: boolean reasoning?: boolean @@ -84,6 +85,7 @@ export function buildOneClickCommand( ...(m.name ? { name: m.name } : {}), ...(m.provider ? { provider: m.provider } : {}), ...(m.contextWindow ? { contextWindow: m.contextWindow } : {}), + ...(m.maxOutputTokens ? { maxOutputTokens: m.maxOutputTokens } : {}), ...(m.vision !== undefined ? { vision: m.vision } : {}), ...(m.toolCalling !== undefined ? { toolCalling: m.toolCalling } : {}), ...(m.reasoning !== undefined ? { reasoning: m.reasoning } : {}), diff --git a/src/services/modelService.test.ts b/src/services/modelService.test.ts index 287dbbb..6ee789e 100644 --- a/src/services/modelService.test.ts +++ b/src/services/modelService.test.ts @@ -47,4 +47,52 @@ describe('fetchRemoteModels', () => { expect(models[0].id).toBe('cx/gpt-5.6-sol') expect(callCount).toBe(2) }) + + it('maps capabilities.maxOutput to Model.maxOutputTokens', async () => { + const mockFetch = mock(() => { + return Promise.resolve(new Response(JSON.stringify({ + data: [{ + id: 'custom/model-caps', + owned_by: 'custom', + capabilities: { + contextWindow: 100000, + maxOutput: 32768, + }, + }], + }), { status: 200 })) + }) + globalThis.fetch = mockFetch as unknown as typeof fetch + + const models = await fetchRemoteModels({ + baseUrl: 'https://gateway.example.com/v1', + apiKey: 'sk-test-key', + }) + + expect(models.length).toBe(1) + expect(models[0].contextWindow).toBe(100000) + expect(models[0].maxOutputTokens).toBe(32768) + }) + + it('maps max_completion_tokens to Model.maxOutputTokens', async () => { + const mockFetch = mock(() => { + return Promise.resolve(new Response(JSON.stringify({ + data: [{ + id: 'custom/model-mct', + owned_by: 'custom', + context_length: 64000, + max_completion_tokens: 16384, + }], + }), { status: 200 })) + }) + globalThis.fetch = mockFetch as unknown as typeof fetch + + const models = await fetchRemoteModels({ + baseUrl: 'https://gateway.example.com/v1', + apiKey: 'sk-test-key', + }) + + expect(models.length).toBe(1) + expect(models[0].contextWindow).toBe(64000) + expect(models[0].maxOutputTokens).toBe(16384) + }) }) diff --git a/src/services/modelService.ts b/src/services/modelService.ts index 93925e8..b1b2a18 100644 --- a/src/services/modelService.ts +++ b/src/services/modelService.ts @@ -128,6 +128,12 @@ export async function fetchRemoteModels(connection: ConnectionConfig): Promise