Download packages/ai/scripts/generate-models.ts from SaylorTwift/pi: direct link, hf CLI and curl.
- Browser
- Download file 109 kB
-
https://huggingface.co/SaylorTwift/pi/resolve/main/packages/ai/scripts/generate-models.ts
- Command line
-
hf download hf://SaylorTwift/pi/packages/ai/scripts/generate-models.ts
-
curl -L -o generate-models.ts https://huggingface.co/SaylorTwift/pi/resolve/main/packages/ai/scripts/generate-models.ts
109 kB
| import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "fs"; | |
| import { dirname, join, resolve } from "path"; | |
| import { fileURLToPath } from "url"; | |
| import { getEffortThinkingLevelMap, type ModelsDevReasoningOption } from "./models-dev-reasoning-options.ts"; | |
| import { | |
| getOpenRouterThinkingLevelMap, | |
| type OpenRouterReasoningMetadata, | |
| } from "./openrouter-reasoning-options.ts"; | |
| import { | |
| CLOUDFLARE_AI_GATEWAY_ANTHROPIC_BASE_URL, | |
| CLOUDFLARE_AI_GATEWAY_COMPAT_BASE_URL, | |
| CLOUDFLARE_AI_GATEWAY_OPENAI_BASE_URL, | |
| CLOUDFLARE_WORKERS_AI_BASE_URL, | |
| } from "../src/api/cloudflare.ts"; | |
| import type { | |
| AnthropicMessagesCompat, | |
| Api, | |
| KnownProvider, | |
| Model, | |
| ModelCost, | |
| OpenAICompletionsCompat, | |
| OpenAIResponsesCompat, | |
| } from "../src/types.ts"; | |
| import { | |
| assertExactModelIds, | |
| createModelDataManifest, | |
| type ModelDataStructure, | |
| MODEL_DATA_MANIFEST_FILE, | |
| readModelDataProviderIds, | |
| validateGeneratedModelData, | |
| validateModelDataDirectory, | |
| } from "./model-data.ts"; | |
| const __filename = fileURLToPath(import.meta.url); | |
| const __dirname = dirname(__filename); | |
| const packageRoot = join(__dirname, ".."); | |
| function readGeneratorOptions(args: string[]): { | |
| strict: boolean; | |
| dataOnly: boolean; | |
| jsonOnly: boolean; | |
| jsonOutputDir: string | undefined; | |
| pretty: boolean; | |
| } { | |
| let strict = false; | |
| let dataOnly = false; | |
| let jsonOnly = false; | |
| let jsonOutputDir: string | undefined; | |
| let pretty = false; | |
| for (let index = 0; index < args.length; index++) { | |
| const arg = args[index]; | |
| if (arg === "--strict") { | |
| strict = true; | |
| continue; | |
| } | |
| if (arg === "--data-only") { | |
| dataOnly = true; | |
| continue; | |
| } | |
| if (arg === "--json-only") { | |
| jsonOnly = true; | |
| continue; | |
| } | |
| if (arg === "--pretty") { | |
| pretty = true; | |
| continue; | |
| } | |
| if (arg === "--json-output") { | |
| const value = args[++index]; | |
| if (!value) throw new Error("--json-output requires a directory"); | |
| jsonOutputDir = resolve(value); | |
| continue; | |
| } | |
| throw new Error(`Unknown argument: ${arg}`); | |
| } | |
| if (jsonOnly && !jsonOutputDir) throw new Error("--json-only requires --json-output"); | |
| if (dataOnly && (jsonOnly || jsonOutputDir)) throw new Error("--data-only cannot be combined with JSON catalog output"); | |
| return { strict, dataOnly, jsonOnly, jsonOutputDir, pretty }; | |
| } | |
| const generatorOptions = readGeneratorOptions(process.argv.slice(2)); | |
| interface ModelsDevModel { | |
| id: string; | |
| name: string; | |
| tool_call?: boolean; | |
| structured_output?: boolean; | |
| reasoning?: boolean; | |
| reasoning_options?: ModelsDevReasoningOption[]; | |
| status?: string; | |
| limit?: { | |
| context?: number; | |
| output?: number; | |
| }; | |
| cost?: { | |
| input?: number; | |
| output?: number; | |
| cache_read?: number; | |
| cache_write?: number; | |
| tiers?: { | |
| input?: number; | |
| output?: number; | |
| cache_read?: number; | |
| cache_write?: number; | |
| tier?: { | |
| type?: string; | |
| size?: number; | |
| }; | |
| }[]; | |
| }; | |
| modalities?: { | |
| input?: string[]; | |
| output?: string[]; | |
| }; | |
| provider?: { | |
| npm?: string; | |
| }; | |
| } | |
| interface ModelsDevProvider { | |
| models?: Record<string, ModelsDevModel>; | |
| } | |
| type ModelsDevCatalog = Record<string, ModelsDevProvider>; | |
| interface NvidiaNimModelListItem { | |
| id: string; | |
| } | |
| interface OpenRouterModelListItem { | |
| id: string; | |
| name: string; | |
| supported_parameters?: string[]; | |
| architecture?: { modality?: string }; | |
| pricing?: { | |
| prompt?: string; | |
| completion?: string; | |
| input_cache_read?: string; | |
| input_cache_write?: string; | |
| }; | |
| top_provider?: { | |
| context_length?: number; | |
| max_completion_tokens?: number; | |
| }; | |
| context_length?: number; | |
| reasoning?: OpenRouterReasoningMetadata; | |
| } | |
| interface AiGatewayModel { | |
| id: string; | |
| name?: string; | |
| context_window?: number; | |
| max_tokens?: number; | |
| tags?: string[]; | |
| pricing?: { | |
| input?: string | number; | |
| output?: string | number; | |
| input_cache_read?: string | number; | |
| input_cache_write?: string | number; | |
| }; | |
| } | |
| const COPILOT_STATIC_HEADERS = { | |
| "User-Agent": "GitHubCopilotChat/0.35.0", | |
| "Editor-Version": "vscode/1.107.0", | |
| "Editor-Plugin-Version": "copilot-chat/0.35.0", | |
| "Copilot-Integration-Id": "vscode-chat", | |
| } as const; | |
| const TOGETHER_BASE_URL = "https://api.together.ai/v1"; | |
| const TOGETHER_BASE_COMPAT: OpenAICompletionsCompat = { | |
| supportsStore: false, | |
| supportsDeveloperRole: false, | |
| supportsReasoningEffort: false, | |
| maxTokensField: "max_tokens", | |
| supportsStrictMode: false, | |
| supportsLongCacheRetention: false, | |
| }; | |
| const TOGETHER_TOGGLE_REASONING_COMPAT: OpenAICompletionsCompat = { | |
| ...TOGETHER_BASE_COMPAT, | |
| thinkingFormat: "together", | |
| }; | |
| const TOGETHER_REASONING_EFFORT_COMPAT: OpenAICompletionsCompat = { | |
| ...TOGETHER_BASE_COMPAT, | |
| supportsReasoningEffort: true, | |
| thinkingFormat: "openai", | |
| }; | |
| const TOGETHER_TOGGLE_REASONING_EFFORT_COMPAT: OpenAICompletionsCompat = { | |
| ...TOGETHER_TOGGLE_REASONING_COMPAT, | |
| supportsReasoningEffort: true, | |
| }; | |
| const TOGETHER_REASONING_ONLY_MODELS = new Set([ | |
| "deepseek-ai/DeepSeek-R1", | |
| "MiniMaxAI/MiniMax-M2.7", | |
| ]); | |
| const TOGETHER_REASONING_EFFORT_MODELS = new Set(["openai/gpt-oss-20b", "openai/gpt-oss-120b"]); | |
| const TOGETHER_TOGGLE_REASONING_EFFORT_MODELS = new Set(["deepseek-ai/DeepSeek-V4-Pro"]); | |
| const TOGETHER_FIXED_REASONING_LEVEL_MAP = { | |
| off: null, | |
| minimal: null, | |
| low: null, | |
| medium: null, | |
| } as const; | |
| const TOGETHER_REASONING_EFFORT_LEVEL_MAP = { | |
| off: null, | |
| minimal: null, | |
| } as const; | |
| const TOGETHER_DEEPSEEK_V4_THINKING_LEVEL_MAP = { | |
| minimal: null, | |
| low: null, | |
| medium: null, | |
| high: "high", | |
| xhigh: null, | |
| } as const; | |
| const TOGETHER_TOGGLE_REASONING_LEVEL_MAP = { | |
| minimal: null, | |
| low: null, | |
| medium: null, | |
| } as const; | |
| const AI_GATEWAY_MODELS_URL = "https://ai-gateway.vercel.sh/v1"; | |
| const AI_GATEWAY_BASE_URL = "https://ai-gateway.vercel.sh"; | |
| const VERTEX_BASE_URL = "https://{location}-aiplatform.googleapis.com"; | |
| const NVIDIA_BASE_URL = "https://integrate.api.nvidia.com/v1"; | |
| const NVIDIA_HEADERS = { | |
| "NVCF-POLL-SECONDS": "3600", | |
| } as const; | |
| const NVIDIA_OPENAI_COMPAT: OpenAICompletionsCompat = { | |
| supportsStore: false, | |
| supportsDeveloperRole: false, | |
| supportsReasoningEffort: false, | |
| maxTokensField: "max_tokens", | |
| supportsStrictMode: false, | |
| supportsLongCacheRetention: false, | |
| }; | |
| const NVIDIA_NIM_UNSUPPORTED_MODELS = new Set([ | |
| "abacusai/dracarys-llama-3.1-70b-instruct", | |
| "bytedance/seed-oss-36b-instruct", | |
| "deepseek-ai/deepseek-v4-flash", | |
| "deepseek-ai/deepseek-v4-pro", | |
| "google/gemma-2-2b-it", | |
| "google/gemma-3n-e2b-it", | |
| "google/gemma-3n-e4b-it", | |
| "google/gemma-4-31b-it", | |
| "meta/llama-3.2-1b-instruct", | |
| "meta/llama-4-maverick-17b-128e-instruct", | |
| "microsoft/phi-4-mini-instruct", | |
| "minimaxai/minimax-m2.7", | |
| "mistralai/mistral-nemotron", | |
| "nvidia/nemotron-mini-4b-instruct", | |
| "qwen/qwen3-next-80b-a3b-instruct", | |
| "qwen/qwen3.5-397b-a17b", | |
| "sarvamai/sarvam-m", | |
| "upstage/solar-10.7b-instruct", | |
| ]); | |
| const ZAI_TOOL_STREAM_UNSUPPORTED_MODELS = new Set(["glm-4.5", "glm-4.5-air", "glm-4.5-flash", "glm-4.5v"]); | |
| const OPENCODE_GO_GLM52_THINKING_LEVEL_MAP = { | |
| off: null, | |
| minimal: null, | |
| low: null, | |
| medium: null, | |
| high: "high", | |
| max: "max", | |
| } as const; | |
| const EAGER_TOOL_INPUT_STREAMING_UNSUPPORTED_ANTHROPIC_MODELS = new Set([ | |
| "github-copilot:claude-haiku-4.5", | |
| "github-copilot:claude-sonnet-4", | |
| "github-copilot:claude-sonnet-4.5", | |
| ]); | |
| const ANTHROPIC_ALLOWED_FALLBACK_MODELS = { | |
| "claude-fable-5": ["claude-opus-4-8", "claude-opus-5"], | |
| "claude-opus-5": ["claude-opus-4-8"], | |
| } satisfies Record<string, string[]>; | |
| const DEEPSEEK_V4_THINKING_LEVEL_MAP = { | |
| minimal: null, | |
| low: null, | |
| medium: null, | |
| high: "high", | |
| max: "max", | |
| } as const; | |
| const DEEPSEEK_V4_FLASH_THINKING_LEVEL_MAP = { | |
| ...DEEPSEEK_V4_THINKING_LEVEL_MAP, | |
| low: "low", | |
| } as const; | |
| // Verified against Fireworks Messages raw_output on 2026-09-10 (#9323). | |
| // Fall back to verified support when models.dev omits effort metadata; this is | |
| // not an allowlist. Any Fireworks Messages model advertising effort uses adaptive thinking. | |
| const FIREWORKS_ADAPTIVE_THINKING_FALLBACK_MODELS = new Set([ | |
| "accounts/fireworks/models/deepseek-v4-flash-0731", | |
| "accounts/fireworks/models/deepseek-v4-flash-vision-exp", | |
| "accounts/fireworks/models/deepseek-v4-pro-0813", | |
| "accounts/fireworks/models/qwen3p8-max", | |
| "accounts/fireworks/models/qwen3p8-2p4t-a95b", | |
| ]); | |
| const QWEN_TOKEN_PLAN_FALLBACK_THINKING_LEVEL_MAP = { | |
| minimal: null, | |
| low: null, | |
| medium: null, | |
| high: "high", | |
| xhigh: null, | |
| max: "max", | |
| } as const; | |
| const QWEN_TOKEN_PLAN_REASONING_EFFORT_FALLBACK_MODEL_IDS = new Set(["glm-5", "glm-5.1"]); | |
| // Retired preview id — models.dev may still list it after GA ships. | |
| const QWEN_TOKEN_PLAN_EXCLUDED_MODEL_IDS = new Set(["qwen3.8-max-preview"]); | |
| const QWEN_TOKEN_PLAN_PROVIDER_IDS = new Set<string>([ | |
| "qwen-token-plan", | |
| "qwen-token-plan-cn", | |
| "qwen-token-plan-individual", | |
| ]); | |
| // QwenCloud Token Plan Individual text-model allowlist, verified 2026-09-03. | |
| // Retired models remain excluded above even if the public catalog lags. | |
| // https://docs.qwencloud.com/token-plan/personal/token-plan-personal-overview | |
| const QWEN_TOKEN_PLAN_INDIVIDUAL_MODEL_IDS = new Set<string>([ | |
| "deepseek-v4-flash-0731", | |
| "deepseek-v4-pro", | |
| "deepseek-v4-pro-0813", | |
| "glm-5.2", | |
| "qwen3.6-flash", | |
| "qwen3.7-max", | |
| "qwen3.7-plus", | |
| "qwen3.8-flash", | |
| "qwen3.8-max", | |
| ]); | |
| const KIMI_K3_MAX_TOKENS = 131072; | |
| const KIMI_K3_COST = { | |
| input: 3, | |
| output: 15, | |
| cacheRead: 0.3, | |
| cacheWrite: 0, | |
| } as const; | |
| // Kimi Coding is subscription-backed, so models.dev reports zero cost. Use the | |
| // equivalent Moonshot API rates to estimate the value of subscription usage. | |
| const KIMI_CODING_IMPLIED_COSTS: Record<string, Model<Api>["cost"]> = { | |
| k3: KIMI_K3_COST, | |
| "kimi-for-coding": { input: 0.95, output: 4, cacheRead: 0.19, cacheWrite: 0 }, | |
| "kimi-for-coding-highspeed": { input: 1.9, output: 8, cacheRead: 0.38, cacheWrite: 0 }, | |
| "kimi-k2-thinking": { input: 0.6, output: 2.5, cacheRead: 0.15, cacheWrite: 0 }, | |
| }; | |
| const OPENROUTER_KIMI_K3_MODEL_IDS = new Set(["moonshotai/kimi-k3", "~moonshotai/kimi-latest"]); | |
| const ANT_LING_RING_THINKING_LEVEL_MAP = { | |
| off: null, | |
| minimal: null, | |
| low: null, | |
| medium: null, | |
| high: "high", | |
| xhigh: "xhigh", | |
| } as const; | |
| const BEDROCK_INFERENCE_PROFILE_ONLY_MODEL_IDS = new Set(["anthropic.claude-opus-5"]); | |
| const MODELS_DEV_OPENAI_UNSUPPORTED_MODEL_IDS = new Set(["gpt-5.6"]); | |
| const OPENAI_TOOL_SEARCH_MODEL_IDS = new Set([ | |
| "gpt-5.4", | |
| "gpt-5.4-mini", | |
| "gpt-5.4-pro", | |
| "gpt-5.5", | |
| "gpt-5.6-sol", | |
| "gpt-5.6-terra", | |
| "gpt-5.6-luna", | |
| "gpt-6-astra", | |
| ]); | |
| // Public OpenAI documents additional_tools for applications that load tools | |
| // outside the normal tool-search flow. Codex currently uses the input item for | |
| // its Responses Lite GPT-5.6 models. | |
| // https://developers.openai.com/api/docs/guides/tools-tool-search#add-tools-at-a-specific-point-in-the-input | |
| const OPENAI_ADDITIONAL_TOOLS_MODEL_IDS = OPENAI_TOOL_SEARCH_MODEL_IDS; | |
| const OPENAI_CODEX_ADDITIONAL_TOOLS_MODEL_IDS = new Set([ | |
| "gpt-5.6-sol", | |
| "gpt-5.6-terra", | |
| "gpt-5.6-luna", | |
| "gpt-6-astra", | |
| ]); | |
| const OPENAI_LONG_CONTEXT_INPUT_THRESHOLD = 272000; | |
| const OPENAI_SHORT_CONTEXT_CAPPED_MODEL_IDS = new Set([ | |
| "gpt-5.4", | |
| "gpt-5.5", | |
| "gpt-5.6-sol", | |
| "gpt-5.6-terra", | |
| "gpt-5.6-luna", | |
| "gpt-6-astra", | |
| ]); | |
| const OPENAI_LONG_CONTEXT_PRICING_MODEL_IDS = new Set([ | |
| "gpt-5.4", | |
| "gpt-5.4-pro", | |
| "gpt-5.5", | |
| "gpt-5.5-pro", | |
| "gpt-5.6-sol", | |
| "gpt-5.6-terra", | |
| "gpt-5.6-luna", | |
| "gpt-6-astra", | |
| ]); | |
| function withOpenAiLongContextPricing(cost: Model<Api>["cost"]): Model<Api>["cost"] { | |
| return { | |
| ...cost, | |
| tiers: [ | |
| { | |
| inputTokensAbove: OPENAI_LONG_CONTEXT_INPUT_THRESHOLD, | |
| input: roundCost(cost.input * 2), | |
| output: roundCost(cost.output * 1.5), | |
| cacheRead: roundCost(cost.cacheRead * 2), | |
| cacheWrite: roundCost(cost.cacheWrite * 2), | |
| }, | |
| ], | |
| }; | |
| } | |
| // OpenAI reduced GPT-5.6 Terra and Luna prices on 2026-07-30. Keep these | |
| // authoritative values until models.dev and passthrough catalogs catch up. | |
| // https://developers.openai.com/api/docs/pricing | |
| const OPENAI_GPT_56_STANDARD_COSTS: Record<string, ModelCost> = { | |
| "gpt-5.6-luna": { input: 0.2, output: 1.2, cacheRead: 0.02, cacheWrite: 0.25 }, | |
| "gpt-5.6-terra": { input: 2, output: 12, cacheRead: 0.2, cacheWrite: 2.5 }, | |
| }; | |
| const OPENAI_RESPONSES_NONE_REASONING_MODELS = new Set([ | |
| "gpt-5.1", | |
| "gpt-5.2", | |
| "gpt-5.3-codex", | |
| "gpt-5.4", | |
| "gpt-5.4-mini", | |
| "gpt-5.4-nano", | |
| "gpt-5.5", | |
| "gpt-5.6-sol", | |
| "gpt-5.6-terra", | |
| "gpt-5.6-luna", | |
| ]); | |
| const XAI_BUILTIN_EXCLUDED_MODEL_IDS = new Set([ | |
| "grok-3", | |
| "grok-3-fast", | |
| "grok-4.20-0309-non-reasoning", | |
| "grok-4.20-0309-reasoning", | |
| "grok-build-0.1", | |
| "grok-code-fast-1", | |
| ]); | |
| const XAI_RESPONSES_COMPAT: OpenAIResponsesCompat = { | |
| supportsLongCacheRetention: false, | |
| }; | |
| const OPENCODE_OPENAI_COMPLETIONS_LONG_CACHE_RETENTION_UNSUPPORTED_MODELS = new Set([ | |
| "opencode:deepseek-v4-flash", | |
| "opencode:deepseek-v4-pro", | |
| "opencode:kimi-k2.5", | |
| "opencode:kimi-k2.6", | |
| "opencode:minimax-m2.7", | |
| "opencode-go:kimi-k2.6", | |
| ]); | |
| // GitHub's "Models with extended capabilities" table lists these Copilot models as supporting | |
| // the extended 1 million token context window. | |
| const GITHUB_COPILOT_EXTENDED_CONTEXT_MODELS = new Set([ | |
| "claude-fable-5", | |
| "claude-opus-4.6", | |
| "claude-opus-4.7", | |
| "claude-opus-4.8", | |
| "claude-opus-5", | |
| "claude-sonnet-4.6", | |
| "claude-sonnet-5", | |
| "gpt-5.3-codex", | |
| "gpt-5.4", | |
| "gpt-5.5", | |
| ]); | |
| // Checked manually against the authenticated GitHub Copilot /models endpoint on 2026-06-15. | |
| // Keep this to narrow corrections over models.dev metadata instead of snapshotting Copilot's catalog. | |
| const GITHUB_COPILOT_THINKING_LEVEL_OVERRIDES = { | |
| "claude-opus-4.7": { minimal: "low" }, | |
| "claude-opus-4.8": { minimal: "low" }, | |
| "claude-opus-5": { minimal: "low" }, | |
| "claude-sonnet-4.6": { minimal: "low", max: "max" }, | |
| } satisfies Record<string, NonNullable<Model<Api>["thinkingLevelMap"]>>; | |
| function mergeThinkingLevelMap(model: Model<any>, map: NonNullable<Model<any>["thinkingLevelMap"]>): void { | |
| model.thinkingLevelMap = { ...model.thinkingLevelMap, ...map }; | |
| } | |
| const modelsDevReasoningOptions = new Map<string, ModelsDevReasoningOption[]>(); | |
| function getModelKey(model: Pick<Model<Api>, "provider" | "id">): string { | |
| return `${model.provider}:${model.id}`; | |
| } | |
| function recordModelsDevReasoningOptions(provider: string, id: string, sourceModel: ModelsDevModel): void { | |
| if (sourceModel.reasoning_options !== undefined) { | |
| modelsDevReasoningOptions.set(`${provider}:${id}`, sourceModel.reasoning_options); | |
| } | |
| } | |
| function supportsDirectReasoningEffort(model: Model<Api>): boolean { | |
| if (model.api === "anthropic-messages") return model.compat?.forceAdaptiveThinking === true; | |
| if ( | |
| model.api === "openai-responses" || | |
| model.api === "azure-openai-responses" || | |
| model.api === "openai-codex-responses" | |
| ) { | |
| return true; | |
| } | |
| if (model.api !== "openai-completions") return false; | |
| const compat = { | |
| ...detectOpenAICompletionsCompat(model as Model<"openai-completions">), | |
| ...(model.compat as OpenAICompletionsCompat | undefined), | |
| }; | |
| return compat.thinkingFormat === "openai" && compat.supportsReasoningEffort; | |
| } | |
| function applyModelsDevReasoningOptionMetadata(model: Model<Api>): void { | |
| const reasoningOptions = modelsDevReasoningOptions.get(getModelKey(model)); | |
| if (!reasoningOptions || !supportsDirectReasoningEffort(model)) return; | |
| const thinkingLevelMap = getEffortThinkingLevelMap(reasoningOptions); | |
| if (thinkingLevelMap) mergeThinkingLevelMap(model, thinkingLevelMap); | |
| } | |
| function getTogetherCompat(modelId: string, reasoning: boolean): OpenAICompletionsCompat { | |
| if (!reasoning) return TOGETHER_BASE_COMPAT; | |
| if (TOGETHER_REASONING_EFFORT_MODELS.has(modelId)) return TOGETHER_REASONING_EFFORT_COMPAT; | |
| if (TOGETHER_TOGGLE_REASONING_EFFORT_MODELS.has(modelId)) return TOGETHER_TOGGLE_REASONING_EFFORT_COMPAT; | |
| if (TOGETHER_REASONING_ONLY_MODELS.has(modelId)) return TOGETHER_BASE_COMPAT; | |
| return TOGETHER_TOGGLE_REASONING_COMPAT; | |
| } | |
| function getTogetherThinkingLevelMap( | |
| modelId: string, | |
| reasoning: boolean, | |
| ): NonNullable<Model<any>["thinkingLevelMap"]> | undefined { | |
| if (!reasoning) return undefined; | |
| if (TOGETHER_REASONING_EFFORT_MODELS.has(modelId)) return { ...TOGETHER_REASONING_EFFORT_LEVEL_MAP }; | |
| if (TOGETHER_TOGGLE_REASONING_EFFORT_MODELS.has(modelId)) return { ...TOGETHER_DEEPSEEK_V4_THINKING_LEVEL_MAP }; | |
| if (TOGETHER_REASONING_ONLY_MODELS.has(modelId)) return { ...TOGETHER_FIXED_REASONING_LEVEL_MAP }; | |
| return { ...TOGETHER_TOGGLE_REASONING_LEVEL_MAP }; | |
| } | |
| function supportsOpenAiXhigh(modelId: string): boolean { | |
| return ( | |
| modelId.includes("gpt-5.2") || | |
| modelId.includes("gpt-5.3") || | |
| modelId.includes("gpt-5.4") || | |
| modelId.includes("gpt-5.5") || | |
| modelId.includes("gpt-5.6") || | |
| modelId.includes("gpt-6-astra") | |
| ); | |
| } | |
| function supportsOpenAiMax(model: Model<Api>): boolean { | |
| return ( | |
| (model.id.includes("gpt-5.6") || model.id.includes("gpt-6-astra")) && | |
| (model.api === "openai-responses" || | |
| model.api === "azure-openai-responses" || | |
| model.api === "openai-codex-responses" || | |
| model.api === "openai-completions") | |
| ); | |
| } | |
| function isGoogleThinkingApi(model: Model<any>): boolean { | |
| return model.api === "google-generative-ai" || model.api === "google-vertex"; | |
| } | |
| const VERIFIED_ANTHROPIC_MID_CONVO_EFFORT_PROVIDERS = new Set(["anthropic", "openrouter"]); | |
| function supportsAnthropicMidConvoEffort(modelId: string): boolean { | |
| const id = modelId.toLowerCase().replace(/^~?anthropic\//, ""); | |
| return ( | |
| /^claude-opus-5(?:-\d{8})?$/.test(id) || | |
| /^claude-(?:fable|mythos)-5(?:[.-]1)(?:-\d{8})?$/.test(id) | |
| ); | |
| } | |
| function isAnthropicAdaptiveThinkingModel(modelId: string): boolean { | |
| return ( | |
| modelId.includes("opus-4-6") || | |
| modelId.includes("opus-4.6") || | |
| modelId.includes("opus-4-7") || | |
| modelId.includes("opus-4.7") || | |
| modelId.includes("opus-4-8") || | |
| modelId.includes("opus-4.8") || | |
| modelId.includes("opus-5") || | |
| modelId.includes("opus.5") || | |
| modelId.includes("sonnet-4-6") || | |
| modelId.includes("sonnet-4.6") || | |
| modelId.includes("sonnet-5") || | |
| modelId.includes("sonnet.5") || | |
| modelId.includes("fable-5") || | |
| modelId.includes("mythos-5") | |
| ); | |
| } | |
| function isAnthropicTemperatureUnsupportedModel(modelId: string): boolean { | |
| const id = modelId.toLowerCase(); | |
| return ( | |
| id.includes("opus-4-7") || | |
| id.includes("opus-4.7") || | |
| id.includes("opus-4-8") || | |
| id.includes("opus-4.8") || | |
| id.includes("opus-5") || | |
| id.includes("opus.5") | |
| ); | |
| } | |
| const OPENAI_COMPLETIONS_DEFAULT_COMPAT = { | |
| supportsStore: true, | |
| supportsDeveloperRole: true, | |
| supportsReasoningEffort: true, | |
| supportsUsageInStreaming: true, | |
| supportsFinishReason: true, | |
| maxTokensField: "max_completion_tokens", | |
| requiresToolResultName: false, | |
| requiresAssistantAfterToolResult: false, | |
| requiresThinkingAsText: false, | |
| requiresReasoningContentOnAssistantMessages: false, | |
| thinkingFormat: "openai", | |
| openRouterRouting: {}, | |
| vercelGatewayRouting: {}, | |
| chatTemplateKwargs: {}, | |
| chatTemplateArgs: {}, | |
| zaiToolStream: false, | |
| supportsStrictMode: true, | |
| supportsOpenAIGrammarTools: false, | |
| sendSessionAffinityHeaders: false, | |
| supportsLongCacheRetention: true, | |
| } satisfies Required< | |
| Omit< | |
| OpenAICompletionsCompat, | |
| "cacheControlFormat" | "deferredToolsMode" | "supportsThinkingTokenBudget" | "thinkingTokenBudgetField" | |
| > | |
| > & { | |
| cacheControlFormat?: OpenAICompletionsCompat["cacheControlFormat"]; | |
| deferredToolsMode?: OpenAICompletionsCompat["deferredToolsMode"]; | |
| }; | |
| type OpenAICompletionsResolvedCompat = typeof OPENAI_COMPLETIONS_DEFAULT_COMPAT & { | |
| cacheControlFormat?: OpenAICompletionsCompat["cacheControlFormat"]; | |
| }; | |
| function mergeAnthropicMessagesCompat(model: Model<Api>, compat: AnthropicMessagesCompat): void { | |
| model.compat = { ...(model.compat as AnthropicMessagesCompat | undefined), ...compat }; | |
| } | |
| function detectOpenAICompletionsCompat(model: Model<"openai-completions">): OpenAICompletionsResolvedCompat { | |
| const provider = model.provider; | |
| const baseUrl = model.baseUrl; | |
| const isZai = | |
| provider === "zai" || | |
| provider === "zai-coding-cn" || | |
| baseUrl.includes("api.z.ai") || | |
| baseUrl.includes("open.bigmodel.cn"); | |
| const isTogether = | |
| provider === "together" || baseUrl.includes("api.together.ai") || baseUrl.includes("api.together.xyz"); | |
| const isMoonshot = provider === "moonshotai" || provider === "moonshotai-cn" || baseUrl.includes("api.moonshot."); | |
| const isOpenRouter = provider === "openrouter" || baseUrl.includes("openrouter.ai"); | |
| const isCloudflareWorkersAI = provider === "cloudflare-workers-ai" || baseUrl.includes("api.cloudflare.com"); | |
| const isCloudflareAiGateway = provider === "cloudflare-ai-gateway" || baseUrl.includes("gateway.ai.cloudflare.com"); | |
| const isNvidia = provider === "nvidia" || baseUrl.includes("integrate.api.nvidia.com"); | |
| const isAntLing = provider === "ant-ling" || baseUrl.includes("api.ant-ling.com"); | |
| const isTogetherReasoningOnly = isTogether && TOGETHER_REASONING_ONLY_MODELS.has(model.id); | |
| const isDeepSeek = provider === "deepseek" || baseUrl.toLowerCase().includes("deepseek.com"); | |
| const isNonStandard = | |
| isNvidia || | |
| provider === "cerebras" || | |
| baseUrl.includes("cerebras.ai") || | |
| provider === "xai" || | |
| baseUrl.includes("api.x.ai") || | |
| isTogether || | |
| baseUrl.includes("chutes.ai") || | |
| isDeepSeek || | |
| isZai || | |
| isMoonshot || | |
| provider === "opencode" || | |
| baseUrl.includes("opencode.ai") || | |
| isCloudflareWorkersAI || | |
| isCloudflareAiGateway || | |
| isAntLing; | |
| const useMaxTokens = | |
| baseUrl.includes("chutes.ai") || | |
| isDeepSeek || | |
| isMoonshot || | |
| isCloudflareAiGateway || | |
| isTogether || | |
| isNvidia || | |
| isAntLing || | |
| isZai; | |
| const isGrok = provider === "xai" || baseUrl.includes("api.x.ai"); | |
| const isOpenRouterDeveloperRoleModel = | |
| isOpenRouter && (model.id.startsWith("anthropic/") || model.id.startsWith("openai/")); | |
| const cacheControlFormat = | |
| provider === "openrouter" && /^~?anthropic\//.test(model.id) ? "anthropic" : undefined; | |
| return { | |
| supportsStore: !isNonStandard, | |
| supportsDeveloperRole: isOpenRouterDeveloperRoleModel || (!isNonStandard && !isOpenRouter), | |
| supportsReasoningEffort: | |
| !isGrok && !isZai && !isMoonshot && !isTogether && !isCloudflareAiGateway && !isNvidia && !isAntLing, | |
| supportsUsageInStreaming: true, | |
| supportsFinishReason: true, | |
| maxTokensField: useMaxTokens ? "max_tokens" : "max_completion_tokens", | |
| requiresToolResultName: false, | |
| requiresAssistantAfterToolResult: false, | |
| requiresThinkingAsText: false, | |
| requiresReasoningContentOnAssistantMessages: isDeepSeek, | |
| thinkingFormat: isDeepSeek | |
| ? "deepseek" | |
| : isZai | |
| ? "zai" | |
| : isTogether && !isTogetherReasoningOnly | |
| ? "together" | |
| : isAntLing | |
| ? "ant-ling" | |
| : isOpenRouter | |
| ? "openrouter" | |
| : "openai", | |
| openRouterRouting: {}, | |
| vercelGatewayRouting: {}, | |
| chatTemplateKwargs: {}, | |
| chatTemplateArgs: {}, | |
| zaiToolStream: false, | |
| supportsStrictMode: !isMoonshot && !isTogether && !isCloudflareAiGateway && !isNvidia, | |
| supportsOpenAIGrammarTools: false, | |
| ...(cacheControlFormat ? { cacheControlFormat } : {}), | |
| sendSessionAffinityHeaders: isOpenRouter, | |
| supportsLongCacheRetention: !( | |
| isTogether || | |
| isCloudflareWorkersAI || | |
| isCloudflareAiGateway || | |
| isNvidia || | |
| isAntLing | |
| ), | |
| }; | |
| } | |
| function isPlainEmptyObject(value: unknown): boolean { | |
| return typeof value === "object" && value !== null && !Array.isArray(value) && Object.keys(value).length === 0; | |
| } | |
| function openAICompletionsCompatDelta(compat: OpenAICompletionsResolvedCompat): OpenAICompletionsCompat { | |
| const delta: OpenAICompletionsCompat = {}; | |
| for (const [key, value] of Object.entries(compat)) { | |
| const defaultValue = OPENAI_COMPLETIONS_DEFAULT_COMPAT[key as keyof typeof OPENAI_COMPLETIONS_DEFAULT_COMPAT]; | |
| if (isPlainEmptyObject(value) && isPlainEmptyObject(defaultValue)) continue; | |
| if (value !== defaultValue) { | |
| (delta as Record<string, unknown>)[key] = value; | |
| } | |
| } | |
| return delta; | |
| } | |
| function mergeOpenAICompletionsCompat(model: Model<Api>, compat: OpenAICompletionsCompat): void { | |
| model.compat = { ...(model.compat as OpenAICompletionsCompat | undefined), ...compat }; | |
| } | |
| function applyOpenAICompletionsCompatMetadata(model: Model<Api>): void { | |
| if (model.api !== "openai-completions") return; | |
| const detected = openAICompletionsCompatDelta(detectOpenAICompletionsCompat(model as Model<"openai-completions">)); | |
| model.compat = { ...detected, ...(model.compat as OpenAICompletionsCompat | undefined) }; | |
| if (Object.keys(model.compat).length === 0) { | |
| delete model.compat; | |
| } | |
| } | |
| function applyAnthropicMessagesCompatMetadata(model: Model<Api>): void { | |
| if (model.api !== "anthropic-messages") return; | |
| const compat = getAnthropicMessagesCompat(model.provider, model.id); | |
| if (compat) { | |
| mergeAnthropicMessagesCompat(model, compat); | |
| if (compat.supportsMidConvoEffort) mergeThinkingLevelMap(model, { off: null }); | |
| } | |
| } | |
| function isAnthropicFallbackMetadataModel(model: Model<Api>): model is Model<"anthropic-messages"> { | |
| if (model.provider !== "anthropic" || model.api !== "anthropic-messages") return false; | |
| return ( | |
| model.id in ANTHROPIC_ALLOWED_FALLBACK_MODELS || | |
| Object.values(ANTHROPIC_ALLOWED_FALLBACK_MODELS).some((fallbackModelIds) => fallbackModelIds.includes(model.id)) | |
| ); | |
| } | |
| function applyAnthropicAllowedFallbackModelMetadata(models: readonly Model<"anthropic-messages">[]): void { | |
| const modelsById = new Map(models.map((model) => [model.id, model])); | |
| for (const [modelId, fallbackModelIds] of Object.entries(ANTHROPIC_ALLOWED_FALLBACK_MODELS)) { | |
| const model = modelsById.get(modelId); | |
| if (!model) continue; | |
| const compatibleFallbackModelIds = model.compat?.supportsMidConvoEffort | |
| ? fallbackModelIds.filter(supportsAnthropicMidConvoEffort) | |
| : fallbackModelIds; | |
| const allowedFallbackModels = compatibleFallbackModelIds.flatMap((fallbackModelId) => { | |
| const fallbackModel = modelsById.get(fallbackModelId); | |
| return fallbackModel | |
| ? [{ provider: fallbackModel.provider, model: fallbackModel.id, cost: fallbackModel.cost }] | |
| : []; | |
| }); | |
| if (allowedFallbackModels.length > 0) { | |
| mergeAnthropicMessagesCompat(model, { allowedFallbackModels }); | |
| } | |
| } | |
| } | |
| function applyStrictToolCompatMetadata(model: Model<Api>): void { | |
| if ( | |
| (model.provider === "openai" || model.provider === "cloudflare-ai-gateway") && | |
| model.api === "openai-responses" | |
| ) { | |
| model.compat = { ...(model.compat as OpenAIResponsesCompat | undefined), supportsStrictMode: true }; | |
| } else if (model.provider === "anthropic" && model.api === "anthropic-messages") { | |
| mergeAnthropicMessagesCompat(model, { supportsStrictTools: true }); | |
| } | |
| } | |
| // Responses endpoints verified (OpenAI, ChatGPT Codex backend, GitHub Copilot, | |
| // opencode zen) or documented (Azure OpenAI, Cloudflare AI Gateway) to pass | |
| // OpenAI custom grammar tools through. OpenAI rejects `type: "custom"` tools | |
| // for pre-GPT-5 models (gpt-4.x, gpt-4o, o-series). | |
| const OPENAI_GRAMMAR_TOOL_PROVIDERS = new Set([ | |
| "openai", | |
| "openai-codex", | |
| "azure-openai-responses", | |
| "github-copilot", | |
| "opencode", | |
| "cloudflare-ai-gateway", | |
| ]); | |
| const OPENAI_GRAMMAR_TOOL_APIS = new Set<Api>([ | |
| "openai-responses", | |
| "azure-openai-responses", | |
| "openai-codex-responses", | |
| ]); | |
| function applyOpenAIGrammarToolCompatMetadata(model: Model<Api>): void { | |
| if (!OPENAI_GRAMMAR_TOOL_APIS.has(model.api) || !OPENAI_GRAMMAR_TOOL_PROVIDERS.has(model.provider)) return; | |
| const match = /^gpt-(\d+)/.exec(model.id); | |
| if (!match || Number(match[1]) < 5) return; | |
| model.compat = { ...(model.compat as OpenAIResponsesCompat | undefined), supportsOpenAIGrammarTools: true }; | |
| } | |
| function applyOpenAIToolSearchMetadata(model: Model<Api>): void { | |
| const isOpenAIResponses = model.provider === "openai" && model.api === "openai-responses"; | |
| const isOpenAICodex = model.provider === "openai-codex" && model.api === "openai-codex-responses"; | |
| if (!(isOpenAIResponses || isOpenAICodex) || !OPENAI_TOOL_SEARCH_MODEL_IDS.has(model.id)) return; | |
| const supportsAdditionalTools = | |
| (isOpenAIResponses && OPENAI_ADDITIONAL_TOOLS_MODEL_IDS.has(model.id)) || | |
| (isOpenAICodex && OPENAI_CODEX_ADDITIONAL_TOOLS_MODEL_IDS.has(model.id)); | |
| model.compat = { | |
| ...(model.compat as OpenAIResponsesCompat | undefined), | |
| ...(supportsAdditionalTools ? { supportsAdditionalTools: true } : {}), | |
| supportsToolSearch: true, | |
| }; | |
| } | |
| // OpenAI charges prompt-cache writes starting with the GPT-5.6 family, and exactly | |
| // those models accept `prompt_cache_options`; older models reject the parameter. | |
| // https://developers.openai.com/api/docs/guides/prompt-caching | |
| function applyOpenAIExplicitPromptCacheMetadata(model: Model<Api>): void { | |
| if (model.provider !== "openai" || model.api !== "openai-responses") return; | |
| if (!(model.cost.cacheWrite > 0)) return; | |
| model.compat = { | |
| ...(model.compat as OpenAIResponsesCompat | undefined), | |
| supportsExplicitPromptCacheMode: true, | |
| }; | |
| } | |
| function isGemini3ProModel(modelId: string): boolean { | |
| return /gemini-3(?:\.\d+)?-pro/.test(modelId.toLowerCase()); | |
| } | |
| function isGemini3FlashModel(modelId: string): boolean { | |
| const id = modelId.toLowerCase(); | |
| return /gemini-3(?:\.\d+)?-flash/.test(id) || id === "gemini-flash-latest" || id === "gemini-flash-lite-latest"; | |
| } | |
| function isGemma4Model(modelId: string): boolean { | |
| return /gemma-?4/.test(modelId.toLowerCase()); | |
| } | |
| function applyThinkingLevelMetadata(model: Model<any>): void { | |
| if ( | |
| (model.api === "openai-responses" || model.api === "azure-openai-responses") && | |
| model.id.startsWith("gpt-5") | |
| ) { | |
| mergeThinkingLevelMap(model, { off: null }); | |
| } | |
| if ( | |
| model.id === "gpt-6-astra" && | |
| (model.api === "openai-responses" || | |
| model.api === "azure-openai-responses" || | |
| model.api === "openai-codex-responses") | |
| ) { | |
| mergeThinkingLevelMap(model, { | |
| off: null, | |
| minimal: null, | |
| low: "low", | |
| medium: "medium", | |
| high: "high", | |
| xhigh: "xhigh", | |
| max: "max", | |
| }); | |
| } | |
| if (model.provider === "github-copilot" && model.id.startsWith("gpt-5")) { | |
| mergeThinkingLevelMap(model, { minimal: "low" }); | |
| } | |
| if ( | |
| model.api === "openai-responses" && | |
| model.provider === "openai" && | |
| OPENAI_RESPONSES_NONE_REASONING_MODELS.has(model.id) | |
| ) { | |
| mergeThinkingLevelMap(model, { off: "none" }); | |
| } | |
| // xAI models without verified effort options must not send the undocumented | |
| // "none"/"minimal" efforts. | |
| if (model.provider === "xai" && model.api === "openai-responses" && model.thinkingLevelMap === undefined) { | |
| mergeThinkingLevelMap(model, { off: null, minimal: null }); | |
| } | |
| if (supportsOpenAiXhigh(model.id)) { | |
| mergeThinkingLevelMap(model, { xhigh: "xhigh" }); | |
| } | |
| if (supportsOpenAiMax(model)) { | |
| mergeThinkingLevelMap(model, { max: "max" }); | |
| } | |
| if (model.provider === "openai" && model.id === "gpt-5.5") { | |
| mergeThinkingLevelMap(model, { minimal: null }); | |
| } | |
| if (model.id.endsWith("gpt-5.5-pro")) { | |
| mergeThinkingLevelMap(model, { off: null, minimal: null, low: null }); | |
| } | |
| // Anthropic adaptive-thinking effort support (per Anthropic adaptive thinking docs): | |
| // - "max" is available on all adaptive-thinking Claude models. | |
| // - "xhigh" is only available on Opus 4.7/4.8/5, Sonnet 5, and Fable 5. | |
| if ( | |
| model.id.includes("opus-4-6") || | |
| model.id.includes("opus-4.6") || | |
| model.id.includes("sonnet-4-6") || | |
| model.id.includes("sonnet-4.6") | |
| ) { | |
| mergeThinkingLevelMap(model, { max: "max" }); | |
| } | |
| if ( | |
| model.id.includes("opus-4-7") || | |
| model.id.includes("opus-4.7") || | |
| model.id.includes("opus-4-8") || | |
| model.id.includes("opus-4.8") || | |
| model.id.includes("opus-5") || | |
| model.id.includes("opus.5") || | |
| model.id.includes("sonnet-5") || | |
| model.id.includes("sonnet.5") | |
| ) { | |
| mergeThinkingLevelMap(model, { xhigh: "xhigh", max: "max" }); | |
| } | |
| if (model.id.includes("fable-5")) { | |
| mergeThinkingLevelMap(model, { off: null, xhigh: "xhigh", max: "max" }); | |
| } | |
| if (model.api === "anthropic-messages" && isAnthropicAdaptiveThinkingModel(model.id)) { | |
| mergeAnthropicMessagesCompat(model, { forceAdaptiveThinking: true }); | |
| } | |
| if (model.api === "anthropic-messages" && isAnthropicTemperatureUnsupportedModel(model.id)) { | |
| mergeAnthropicMessagesCompat(model, { supportsTemperature: false }); | |
| } | |
| if (model.api === "openai-completions" && model.id.includes("deepseek-v4")) { | |
| mergeThinkingLevelMap( | |
| model, | |
| model.provider === "openrouter" | |
| ? { ...DEEPSEEK_V4_THINKING_LEVEL_MAP, xhigh: "xhigh", max: null } | |
| : (model.provider === "deepseek" || model.provider === "opencode" || model.provider === "opencode-go") && | |
| model.id.includes("deepseek-v4-flash") | |
| ? DEEPSEEK_V4_FLASH_THINKING_LEVEL_MAP | |
| : DEEPSEEK_V4_THINKING_LEVEL_MAP, | |
| ); | |
| } | |
| if (isGoogleThinkingApi(model) && isGemini3ProModel(model.id)) { | |
| mergeThinkingLevelMap(model, { off: null, minimal: null, low: "LOW", medium: null, high: "HIGH" }); | |
| } | |
| if (isGoogleThinkingApi(model) && isGemini3FlashModel(model.id)) { | |
| mergeThinkingLevelMap(model, { off: null }); | |
| } | |
| if (isGoogleThinkingApi(model) && isGemma4Model(model.id)) { | |
| mergeThinkingLevelMap(model, { off: null, minimal: "MINIMAL", low: null, medium: null, high: "HIGH" }); | |
| } | |
| if (model.provider === "groq" && model.id === "qwen/qwen3.6-27b") { | |
| mergeThinkingLevelMap(model, { minimal: null, low: null, medium: null, high: "default" }); | |
| } | |
| if (model.provider === "openai-codex" && supportsOpenAiXhigh(model.id)) { | |
| mergeThinkingLevelMap(model, { minimal: "low" }); | |
| } | |
| if ( | |
| (model.provider === "moonshotai" || model.provider === "moonshotai-cn") && | |
| (model.id === "kimi-k2.7-code" || model.id === "kimi-k2.7-code-highspeed") | |
| ) { | |
| // Kimi K2.7 Code is always-thinking. Official docs say | |
| // `thinking: { type: "disabled" }` is rejected, and callers can omit | |
| // the thinking parameter to use the enabled default. | |
| mergeThinkingLevelMap(model, { off: null }); | |
| } | |
| if (model.provider === "openrouter" && model.id.startsWith("inception/mercury-2")) { | |
| // Mercury 2 in instant mode (reasoning_effort: "none") disables tool calling. | |
| // Mark "off" unsupported so the openai-completions provider omits the reasoning param | |
| // instead of defaulting to {reasoning:{effort:"none"}} (see openai-completions.ts:575). | |
| // Pi's low/medium/high pass through verbatim; OpenRouter normalizes to Mercury's vocabulary. | |
| mergeThinkingLevelMap(model, { off: null }); | |
| } | |
| if (model.provider === "openrouter" && model.id === "z-ai/glm-5.2") { | |
| mergeThinkingLevelMap(model, { xhigh: "xhigh" }); | |
| } | |
| if (model.provider === "fireworks") { | |
| if (model.api === "anthropic-messages" && model.compat?.forceAdaptiveThinking) { | |
| // Qwen Max currently advertises only a toggle. Prefer upstream effort | |
| // metadata once available instead of replacing it with this fallback. | |
| if (model.id === "accounts/fireworks/models/qwen3p8-max" && !model.thinkingLevelMap) { | |
| model.thinkingLevelMap = getEffortThinkingLevelMap([ | |
| { type: "effort", values: ["low", "medium", "xhigh"] }, | |
| ]); | |
| } | |
| const reasoningOptions = modelsDevReasoningOptions.get(getModelKey(model)); | |
| if ( | |
| reasoningOptions?.some((option) => option.type === "toggle") || | |
| // The 2.4T alias omits the verified toggle in models.dev. | |
| model.id === "accounts/fireworks/models/qwen3p8-2p4t-a95b" | |
| ) { | |
| mergeThinkingLevelMap(model, { off: "none" }); | |
| } | |
| if (model.id === "accounts/fireworks/models/deepseek-v4-pro-0813") { | |
| mergeThinkingLevelMap(model, { low: "low" }); | |
| } | |
| } | |
| if (model.id.includes("glm-5p2")) { | |
| // GLM 5.2 and its fast router support off/high/max. Fireworks maps low | |
| // and medium to high, so do not expose those aliases as distinct levels. | |
| mergeThinkingLevelMap(model, { off: "none", minimal: null, low: null, medium: null, max: "max" }); | |
| } | |
| if (model.id.includes("kimi-k3")) { | |
| // Fireworks maps medium to high on both APIs; do not expose it as a distinct level. | |
| mergeThinkingLevelMap(model, { medium: null }); | |
| } | |
| } | |
| if (model.provider === "opencode-go" && model.id === "glm-5.2") { | |
| mergeThinkingLevelMap(model, OPENCODE_GO_GLM52_THINKING_LEVEL_MAP); | |
| } | |
| if (model.provider === "opencode-go" && model.id === "kimi-k2.6") { | |
| // OpenCode Go exposes Kimi K2.6 thinking as on/off, not distinct effort tiers. | |
| mergeThinkingLevelMap(model, { minimal: null, low: null, medium: null }); | |
| } | |
| if (model.provider === "opencode" && model.id === "grok-build-0.1") { | |
| // OpenCode Zen Grok Build reasons by default but rejects explicit reasoningEffort. | |
| mergeThinkingLevelMap(model, { off: null, minimal: null, low: null, medium: null }); | |
| } | |
| if (model.provider === "ant-ling" && model.reasoning) { | |
| // Ring reasons by default. Only high/xhigh have documented explicit effort controls. | |
| mergeThinkingLevelMap(model, ANT_LING_RING_THINKING_LEVEL_MAP); | |
| } | |
| if (model.provider === "github-copilot") { | |
| const override = GITHUB_COPILOT_THINKING_LEVEL_OVERRIDES[model.id]; | |
| if (override) { | |
| mergeThinkingLevelMap(model, override); | |
| } | |
| } | |
| } | |
| function getAnthropicMessagesCompat(provider: string, modelId: string): AnthropicMessagesCompat | undefined { | |
| const compat: AnthropicMessagesCompat = {}; | |
| if ( | |
| VERIFIED_ANTHROPIC_MID_CONVO_EFFORT_PROVIDERS.has(provider) && | |
| supportsAnthropicMidConvoEffort(modelId) | |
| ) { | |
| compat.supportsMidConvoEffort = true; | |
| } | |
| if (EAGER_TOOL_INPUT_STREAMING_UNSUPPORTED_ANTHROPIC_MODELS.has(`${provider}:${modelId}`)) { | |
| compat.supportsEagerToolInputStreaming = false; | |
| } | |
| if (provider === "xiaomi" || provider.startsWith("xiaomi-token-plan-")) { | |
| compat.allowEmptySignature = true; | |
| } | |
| return Object.keys(compat).length > 0 ? compat : undefined; | |
| } | |
| function getBedrockBaseUrl(modelId: string): string { | |
| return modelId.startsWith("eu.") | |
| ? "https://bedrock-runtime.eu-central-1.amazonaws.com" | |
| : "https://bedrock-runtime.us-east-1.amazonaws.com"; | |
| } | |
| function normalizeNvidiaModelId(modelId: string): string { | |
| return modelId.toLowerCase().replaceAll("_", "."); | |
| } | |
| function roundCost(value: number): number { | |
| return Number(value.toFixed(6)); | |
| } | |
| function getModelsDevCost(cost: ModelsDevModel["cost"]): ModelCost { | |
| const tiers = cost?.tiers?.flatMap((tier) => { | |
| const context = tier.tier; | |
| if (context?.type !== "context" || context.size === undefined) return []; | |
| return [ | |
| { | |
| inputTokensAbove: context.size, | |
| input: tier.input || 0, | |
| output: tier.output || 0, | |
| cacheRead: tier.cache_read || 0, | |
| cacheWrite: tier.cache_write || 0, | |
| }, | |
| ]; | |
| }); | |
| return { | |
| input: cost?.input || 0, | |
| output: cost?.output || 0, | |
| cacheRead: cost?.cache_read || 0, | |
| cacheWrite: cost?.cache_write || 0, | |
| ...(tiers && tiers.length > 0 ? { tiers } : {}), | |
| }; | |
| } | |
| async function fetchNvidiaNimModelIds(): Promise<Map<string, string>> { | |
| try { | |
| console.log("Fetching models from NVIDIA NIM API..."); | |
| const response = await fetch(`${NVIDIA_BASE_URL}/models`); | |
| if (!response.ok) throw new Error(`NVIDIA NIM API returned ${response.status}`); | |
| const data = (await response.json()) as { data?: NvidiaNimModelListItem[] }; | |
| const modelIds = new Map<string, string>(); | |
| for (const model of data.data ?? []) { | |
| modelIds.set(model.id, model.id); | |
| modelIds.set(normalizeNvidiaModelId(model.id), model.id); | |
| } | |
| console.log(`Fetched ${data.data?.length ?? 0} model IDs from NVIDIA NIM`); | |
| return modelIds; | |
| } catch (error) { | |
| console.error("Failed to fetch NVIDIA NIM models:", error); | |
| if (generatorOptions.strict) throw error; | |
| return new Map(); | |
| } | |
| } | |
| async function fetchOpenRouterModels(): Promise<Model<any>[]> { | |
| try { | |
| console.log("Fetching models from OpenRouter API..."); | |
| const response = await fetch("https://openrouter.ai/api/v1/models"); | |
| if (!response.ok) throw new Error(`OpenRouter API returned ${response.status}`); | |
| const data = (await response.json()) as { data?: OpenRouterModelListItem[] }; | |
| const models: Model<any>[] = []; | |
| for (const model of data.data ?? []) { | |
| // Only include models that support tools | |
| if (!model.supported_parameters?.includes("tools")) continue; | |
| // Parse provider from model ID | |
| let provider: KnownProvider = "openrouter"; | |
| let modelKey = model.id; | |
| modelKey = model.id; // Keep full ID for OpenRouter | |
| // Parse input modalities | |
| const input: ("text" | "image")[] = ["text"]; | |
| if (model.architecture?.modality?.includes("image")) { | |
| input.push("image"); | |
| } | |
| // Convert pricing from $/token to $/million tokens | |
| const inputCost = roundCost(parseFloat(model.pricing?.prompt || "0") * 1_000_000); | |
| const outputCost = roundCost(parseFloat(model.pricing?.completion || "0") * 1_000_000); | |
| const cacheReadCost = roundCost(parseFloat(model.pricing?.input_cache_read || "0") * 1_000_000); | |
| const cacheWriteCost = roundCost(parseFloat(model.pricing?.input_cache_write || "0") * 1_000_000); | |
| const contextWindow = model.top_provider?.context_length || model.context_length || 4096; | |
| const thinkingLevelMap = getOpenRouterThinkingLevelMap(model.reasoning); | |
| const useAnthropicMessages = /^anthropic\//.test(modelKey) && !modelKey.endsWith(":batch"); | |
| const normalizedModel: Model<any> = { | |
| id: modelKey, | |
| name: model.name, | |
| api: useAnthropicMessages ? "anthropic-messages" : "openai-completions", | |
| baseUrl: useAnthropicMessages ? "https://openrouter.ai/api" : "https://openrouter.ai/api/v1", | |
| provider, | |
| reasoning: model.supported_parameters?.includes("reasoning") || false, | |
| ...(thinkingLevelMap && { thinkingLevelMap }), | |
| input, | |
| cost: { | |
| input: inputCost, | |
| output: outputCost, | |
| cacheRead: cacheReadCost, | |
| cacheWrite: cacheWriteCost, | |
| }, | |
| contextWindow, | |
| maxTokens: model.top_provider?.max_completion_tokens || 4096, | |
| }; | |
| models.push(normalizedModel); | |
| } | |
| console.log(`Fetched ${models.length} tool-capable models from OpenRouter`); | |
| return models; | |
| } catch (error) { | |
| console.error("Failed to fetch OpenRouter models:", error); | |
| if (generatorOptions.strict) throw error; | |
| return []; | |
| } | |
| } | |
| async function fetchAiGatewayModels(): Promise<Model<any>[]> { | |
| try { | |
| console.log("Fetching models from Vercel AI Gateway API..."); | |
| const response = await fetch(`${AI_GATEWAY_MODELS_URL}/models`); | |
| if (!response.ok) throw new Error(`Vercel AI Gateway API returned ${response.status}`); | |
| const data = await response.json(); | |
| const models: Model<any>[] = []; | |
| const toNumber = (value: string | number | undefined): number => { | |
| if (typeof value === "number") { | |
| return Number.isFinite(value) ? value : 0; | |
| } | |
| const parsed = parseFloat(value ?? "0"); | |
| return Number.isFinite(parsed) ? parsed : 0; | |
| }; | |
| const items = Array.isArray(data.data) ? (data.data as AiGatewayModel[]) : []; | |
| for (const model of items) { | |
| const tags = Array.isArray(model.tags) ? model.tags : []; | |
| // Only include models that support tools | |
| if (!tags.includes("tool-use")) continue; | |
| const input: ("text" | "image")[] = ["text"]; | |
| if (tags.includes("vision")) { | |
| input.push("image"); | |
| } | |
| const inputCost = roundCost(toNumber(model.pricing?.input) * 1_000_000); | |
| const outputCost = roundCost(toNumber(model.pricing?.output) * 1_000_000); | |
| const cacheReadCost = roundCost(toNumber(model.pricing?.input_cache_read) * 1_000_000); | |
| const cacheWriteCost = roundCost(toNumber(model.pricing?.input_cache_write) * 1_000_000); | |
| models.push({ | |
| id: model.id, | |
| name: model.name || model.id, | |
| api: "anthropic-messages", | |
| baseUrl: AI_GATEWAY_BASE_URL, | |
| provider: "vercel-ai-gateway", | |
| reasoning: tags.includes("reasoning"), | |
| input, | |
| cost: { | |
| input: inputCost, | |
| output: outputCost, | |
| cacheRead: cacheReadCost, | |
| cacheWrite: cacheWriteCost, | |
| }, | |
| contextWindow: model.context_window || 4096, | |
| maxTokens: model.max_tokens || 4096, | |
| }); | |
| } | |
| console.log(`Fetched ${models.length} tool-capable models from Vercel AI Gateway`); | |
| return models; | |
| } catch (error) { | |
| console.error("Failed to fetch Vercel AI Gateway models:", error); | |
| if (generatorOptions.strict) throw error; | |
| return []; | |
| } | |
| } | |
| function processZaiModels(data: ModelsDevCatalog): Model<Api>[] { | |
| const variants = [ | |
| { | |
| source: "zai-coding-plan", | |
| provider: "zai", | |
| baseUrl: "https://api.z.ai/api/coding/paas/v4", | |
| }, | |
| { | |
| source: "zhipuai-coding-plan", | |
| provider: "zai-coding-cn", | |
| baseUrl: "https://open.bigmodel.cn/api/coding/paas/v4", | |
| }, | |
| ] as const; | |
| const models: Model<Api>[] = []; | |
| for (const { source, provider, baseUrl } of variants) { | |
| for (const [modelId, model] of Object.entries(data[source]?.models ?? {})) { | |
| const m = model as ModelsDevModel; | |
| if (m.tool_call !== true) continue; | |
| const supportsImage = m.modalities?.input?.includes("image"); | |
| const thinkingLevelMap = getEffortThinkingLevelMap(m.reasoning_options ?? []); | |
| const isGlm52 = modelId === "glm-5.2" || modelId === "glm-5.2-highspeed"; | |
| if (thinkingLevelMap && isGlm52) { | |
| thinkingLevelMap.off = "none"; | |
| } | |
| const supportsReasoningEffort = thinkingLevelMap !== undefined; | |
| const referenceCost = data.zai?.models[modelId]?.cost ?? m.cost; | |
| models.push({ | |
| id: modelId, | |
| name: m.name || modelId, | |
| api: "openai-completions", | |
| provider, | |
| baseUrl, | |
| reasoning: m.reasoning === true, | |
| ...(thinkingLevelMap ? { thinkingLevelMap } : {}), | |
| input: supportsImage ? ["text", "image"] : ["text"], | |
| cost: { | |
| input: referenceCost?.input || 0, | |
| output: referenceCost?.output || 0, | |
| cacheRead: referenceCost?.cache_read || 0, | |
| cacheWrite: referenceCost?.cache_write || 0, | |
| }, | |
| compat: { | |
| supportsDeveloperRole: false, | |
| thinkingFormat: "zai", | |
| ...(supportsReasoningEffort ? { supportsReasoningEffort: true } : {}), | |
| ...(!ZAI_TOOL_STREAM_UNSUPPORTED_MODELS.has(modelId) ? { zaiToolStream: true } : {}), | |
| }, | |
| contextWindow: m.limit?.context || 4096, | |
| maxTokens: m.limit?.output || 4096, | |
| }); | |
| recordModelsDevReasoningOptions(provider, modelId, m); | |
| } | |
| } | |
| return models; | |
| } | |
| function processBasetenModels(provider: ModelsDevProvider | undefined): Model<Api>[] { | |
| if (!provider?.models) return []; | |
| const baseUrl = "https://inference.baseten.co/v1"; | |
| const baseCompat: OpenAICompletionsCompat = { | |
| supportsStore: false, | |
| supportsDeveloperRole: false, | |
| supportsReasoningEffort: false, | |
| supportsUsageInStreaming: true, | |
| maxTokensField: "max_tokens", | |
| supportsStrictMode: true, | |
| supportsLongCacheRetention: false, | |
| }; | |
| const reasoningEffortCompat: OpenAICompletionsCompat = { | |
| ...baseCompat, | |
| supportsReasoningEffort: true, | |
| thinkingFormat: "openai", | |
| }; | |
| const toggleReasoningCompat: OpenAICompletionsCompat = { | |
| ...baseCompat, | |
| thinkingFormat: "baseten", | |
| chatTemplateArgs: { enable_thinking: { $var: "thinking.enabled" } }, | |
| }; | |
| const toggleReasoningEffortCompat: OpenAICompletionsCompat = { | |
| ...reasoningEffortCompat, | |
| thinkingFormat: "baseten", | |
| chatTemplateArgs: { enable_thinking: { $var: "thinking.enabled" } }, | |
| }; | |
| const toggleThinkingLevelMap = { | |
| off: "off", | |
| minimal: null, | |
| low: null, | |
| medium: null, | |
| high: "high", | |
| xhigh: null, | |
| max: null, | |
| } as const; | |
| const glm52ThinkingLevelMap = { | |
| off: "none", | |
| minimal: null, | |
| low: null, | |
| medium: null, | |
| high: "high", | |
| xhigh: null, | |
| max: "max", | |
| } as const; | |
| const models: Model<Api>[] = []; | |
| for (const [modelId, model] of Object.entries(provider.models)) { | |
| if (model.status === "deprecated") continue; | |
| const reasoning = model.reasoning === true; | |
| const reasoningOptions = model.reasoning_options ?? []; | |
| const isGlm52 = modelId === "zai-org/GLM-5.2" || modelId === "zai-org/GLM-5.2-Fast"; | |
| const supportsToggle = reasoningOptions.some((option) => option.type === "toggle") || isGlm52; | |
| const supportsEffort = reasoningOptions.some((option) => option.type === "effort") || isGlm52; | |
| const compat = | |
| supportsToggle && supportsEffort | |
| ? toggleReasoningEffortCompat | |
| : supportsToggle | |
| ? toggleReasoningCompat | |
| : supportsEffort | |
| ? reasoningEffortCompat | |
| : baseCompat; | |
| const thinkingLevelMap = isGlm52 | |
| ? glm52ThinkingLevelMap | |
| : supportsToggle | |
| ? toggleThinkingLevelMap | |
| : getEffortThinkingLevelMap(reasoningOptions); | |
| // Baseten's GLM-5.2 endpoints are text-only despite models.dev reporting image input. | |
| const supportsImageInput = !isGlm52 && model.modalities?.input?.includes("image"); | |
| models.push({ | |
| id: modelId, | |
| name: model.name || modelId, | |
| api: "openai-completions", | |
| provider: "baseten", | |
| baseUrl, | |
| reasoning, | |
| ...(thinkingLevelMap ? { thinkingLevelMap } : {}), | |
| input: supportsImageInput ? ["text", "image"] : ["text"], | |
| cost: { | |
| input: model.cost?.input || 0, | |
| output: model.cost?.output || 0, | |
| cacheRead: model.cost?.cache_read || 0, | |
| cacheWrite: model.cost?.cache_write || 0, | |
| }, | |
| compat, | |
| contextWindow: model.limit?.context || 4096, | |
| maxTokens: model.limit?.output || 4096, | |
| }); | |
| } | |
| return models; | |
| } | |
| function processFireworksModels(provider: ModelsDevProvider | undefined): Model<Api>[] { | |
| if (!provider?.models) return []; | |
| const anthropicCompat: AnthropicMessagesCompat = { | |
| // Arbitrary loader names work, but Fireworks only defers the prefix for ToolSearch/tool_search. | |
| supportsToolReferences: true, | |
| allowEmptySignature: true, | |
| sendSessionAffinityHeaders: true, | |
| supportsEagerToolInputStreaming: false, | |
| supportsCacheControlOnTools: false, | |
| supportsLongCacheRetention: false, | |
| }; | |
| const openAICompat: OpenAICompletionsCompat = { | |
| supportsStore: false, | |
| supportsDeveloperRole: false, | |
| sendSessionAffinityHeaders: true, | |
| supportsLongCacheRetention: false, | |
| }; | |
| const kimiK3Compat: OpenAICompletionsCompat = { | |
| ...openAICompat, | |
| requiresReasoningContentOnAssistantMessages: true, | |
| thinkingFormat: "openai", | |
| deferredToolsMode: "kimi", | |
| }; | |
| const models: Model<Api>[] = []; | |
| for (const [modelId, model] of Object.entries(provider.models)) { | |
| if (model.tool_call !== true) continue; | |
| const input: ("text" | "image")[] = model.modalities?.input?.includes("image") | |
| ? ["text", "image"] | |
| : ["text"]; | |
| const common = { | |
| id: modelId, | |
| name: model.name || modelId, | |
| provider: "fireworks", | |
| reasoning: model.reasoning === true, | |
| input, | |
| cost: { | |
| input: model.cost?.input || 0, | |
| output: model.cost?.output || 0, | |
| cacheRead: model.cost?.cache_read || 0, | |
| cacheWrite: model.cost?.cache_write || 0, | |
| }, | |
| contextWindow: model.limit?.context || 4096, | |
| maxTokens: model.limit?.output || 4096, | |
| }; | |
| if (modelId.includes("glm-")) { | |
| models.push({ | |
| ...common, | |
| api: "openai-completions", | |
| baseUrl: "https://api.fireworks.ai/inference/v1", | |
| compat: openAICompat, | |
| }); | |
| } else if (modelId.includes("kimi-k3")) { | |
| models.push({ | |
| ...common, | |
| api: "openai-completions", | |
| baseUrl: "https://api.fireworks.ai/inference/v1", | |
| compat: kimiK3Compat, | |
| }); | |
| } else { | |
| models.push({ | |
| ...common, | |
| api: "anthropic-messages", | |
| // Fireworks Anthropic-compatible API - SDK appends /v1/messages. | |
| baseUrl: "https://api.fireworks.ai/inference", | |
| // Fireworks prompt caching uses automatic prefix matching + session affinity. | |
| // x-session-affinity routes requests to the same replica for cache hits. | |
| // cache_control on tools and eager_input_streaming are not supported. | |
| // See: https://docs.fireworks.ai/tools-sdks/anthropic-compatibility | |
| // Use adaptive thinking for cataloged effort controls, with verified | |
| // fallbacks where models.dev is incomplete. New models need no allowlist entry. | |
| compat: { | |
| ...anthropicCompat, | |
| ...(model.reasoning_options?.some((option) => option.type === "effort") || | |
| FIREWORKS_ADAPTIVE_THINKING_FALLBACK_MODELS.has(modelId) | |
| ? { forceAdaptiveThinking: true } | |
| : {}), | |
| }, | |
| }); | |
| } | |
| recordModelsDevReasoningOptions("fireworks", modelId, model); | |
| } | |
| return models; | |
| } | |
| async function loadModelsDevData(): Promise<Model<any>[]> { | |
| try { | |
| console.log("Fetching models from models.dev API..."); | |
| const response = await fetch("https://models.dev/api.json"); | |
| if (!response.ok) throw new Error(`models.dev API returned ${response.status}`); | |
| const data = (await response.json()) as ModelsDevCatalog; | |
| const models: Model<any>[] = []; | |
| const nvidiaNimModelIds = data.nvidia?.models ? await fetchNvidiaNimModelIds() : new Map<string, string>(); | |
| // Process Amazon Bedrock models | |
| if (data["amazon-bedrock"]?.models) { | |
| for (const [modelId, model] of Object.entries(data["amazon-bedrock"].models)) { | |
| const m = model as ModelsDevModel; | |
| if (m.tool_call !== true) continue; | |
| if (BEDROCK_INFERENCE_PROFILE_ONLY_MODEL_IDS.has(modelId)) continue; | |
| let id = modelId; | |
| if (id.startsWith("ai21.jamba")) { | |
| // These models doesn't support tool use in streaming mode | |
| continue; | |
| } | |
| if (id.startsWith("mistral.mistral-7b-instruct-v0")) { | |
| // These models doesn't support system messages | |
| continue; | |
| } | |
| models.push({ | |
| id, | |
| name: m.name || id, | |
| api: "bedrock-converse-stream" as const, | |
| provider: "amazon-bedrock" as const, | |
| baseUrl: getBedrockBaseUrl(id), | |
| reasoning: m.reasoning === true, | |
| input: (m.modalities?.input?.includes("image") ? ["text", "image"] : ["text"]) as ("text" | "image")[], | |
| cost: { | |
| input: m.cost?.input || 0, | |
| output: m.cost?.output || 0, | |
| cacheRead: m.cost?.cache_read || 0, | |
| cacheWrite: m.cost?.cache_write || 0, | |
| }, | |
| contextWindow: m.limit?.context || 4096, | |
| maxTokens: m.limit?.output || 4096, | |
| ...(m.structured_output === true && { compat: { supportsStrictMode: true } }), | |
| }); | |
| recordModelsDevReasoningOptions("amazon-bedrock" as const, id, m); | |
| } | |
| } | |
| // Process Anthropic models | |
| if (data.anthropic?.models) { | |
| for (const [modelId, model] of Object.entries(data.anthropic.models)) { | |
| const m = model as ModelsDevModel; | |
| if (m.tool_call !== true) continue; | |
| models.push({ | |
| id: modelId, | |
| name: m.name || modelId, | |
| api: "anthropic-messages", | |
| provider: "anthropic", | |
| baseUrl: "https://api.anthropic.com", | |
| reasoning: m.reasoning === true, | |
| input: m.modalities?.input?.includes("image") ? ["text", "image"] : ["text"], | |
| cost: { | |
| input: m.cost?.input || 0, | |
| output: m.cost?.output || 0, | |
| cacheRead: m.cost?.cache_read || 0, | |
| cacheWrite: m.cost?.cache_write || 0, | |
| }, | |
| contextWindow: m.limit?.context || 4096, | |
| maxTokens: m.limit?.output || 4096, | |
| }); | |
| recordModelsDevReasoningOptions("anthropic", modelId, m); | |
| } | |
| } | |
| // Process Google models | |
| if (data.google?.models) { | |
| for (const [modelId, model] of Object.entries(data.google.models)) { | |
| const m = model as ModelsDevModel; | |
| if (m.tool_call !== true) continue; | |
| let source = m; | |
| if (modelId === "gemini-flash-latest") { | |
| source = (data.google.models["gemini-3.5-flash"] as ModelsDevModel | undefined) ?? m; | |
| } | |
| if (modelId === "gemini-flash-lite-latest") { | |
| source = (data.google.models["gemini-3.1-flash-lite"] as ModelsDevModel | undefined) ?? m; | |
| } | |
| models.push({ | |
| id: modelId, | |
| name: m.name || modelId, | |
| api: "google-generative-ai", | |
| provider: "google", | |
| baseUrl: "https://generativelanguage.googleapis.com/v1beta", | |
| reasoning: source.reasoning === true, | |
| input: source.modalities?.input?.includes("image") ? ["text", "image"] : ["text"], | |
| cost: { | |
| input: source.cost?.input || 0, | |
| output: source.cost?.output || 0, | |
| cacheRead: source.cost?.cache_read || 0, | |
| cacheWrite: source.cost?.cache_write || 0, | |
| }, | |
| contextWindow: source.limit?.context || 4096, | |
| maxTokens: source.limit?.output || 4096, | |
| }); | |
| recordModelsDevReasoningOptions("google", modelId, source); | |
| } | |
| } | |
| // Process Google Vertex Gemini models. The google-vertex models.dev catalog also includes | |
| // Claude, OpenAI, and other MaaS models that do not use the @google/genai Gemini streaming | |
| // path implemented by our google-vertex provider. | |
| if (data["google-vertex"]?.models) { | |
| for (const [modelId, model] of Object.entries(data["google-vertex"].models)) { | |
| const m = model as ModelsDevModel; | |
| if (m.tool_call !== true) continue; | |
| if (!modelId.startsWith("gemini-")) continue; | |
| if (modelId === "gemini-3.1-flash-lite-preview") continue; | |
| let source = m; | |
| if (modelId === "gemini-flash-latest") { | |
| source = (data["google-vertex"].models["gemini-3.5-flash"] as ModelsDevModel | undefined) ?? m; | |
| } | |
| if (modelId === "gemini-flash-lite-latest") { | |
| source = (data["google-vertex"].models["gemini-3.1-flash-lite"] as ModelsDevModel | undefined) ?? m; | |
| } | |
| // models.dev reports Vertex cache_read/cache_write values for Gemini 2.5 Flash that | |
| // do not match the official Gemini API standard pricing table. pi only accounts | |
| // cachedContentTokenCount as cacheRead. | |
| const cacheRead = modelId === "gemini-2.5-flash" ? 0.03 : source.cost?.cache_read || 0; | |
| models.push({ | |
| id: modelId, | |
| name: m.name || modelId, | |
| api: "google-vertex", | |
| provider: "google-vertex", | |
| baseUrl: VERTEX_BASE_URL, | |
| reasoning: source.reasoning === true, | |
| input: source.modalities?.input?.includes("image") ? ["text", "image"] : ["text"], | |
| cost: { | |
| input: source.cost?.input || 0, | |
| output: source.cost?.output || 0, | |
| cacheRead, | |
| cacheWrite: 0, | |
| }, | |
| contextWindow: source.limit?.context || 4096, | |
| maxTokens: source.limit?.output || 4096, | |
| }); | |
| recordModelsDevReasoningOptions("google-vertex", modelId, source); | |
| } | |
| } | |
| // Process OpenAI models | |
| if (data.openai?.models) { | |
| for (const [modelId, model] of Object.entries(data.openai.models)) { | |
| const m = model as ModelsDevModel; | |
| if (m.tool_call !== true) continue; | |
| // models.dev lists this alias, but it is not accepted by OpenAI APIs. | |
| if (MODELS_DEV_OPENAI_UNSUPPORTED_MODEL_IDS.has(modelId)) continue; | |
| models.push({ | |
| id: modelId, | |
| name: m.name || modelId, | |
| api: "openai-responses", | |
| provider: "openai", | |
| baseUrl: "https://api.openai.com/v1", | |
| reasoning: m.reasoning === true, | |
| input: m.modalities?.input?.includes("image") ? ["text", "image"] : ["text"], | |
| cost: { | |
| input: m.cost?.input || 0, | |
| output: m.cost?.output || 0, | |
| cacheRead: m.cost?.cache_read || 0, | |
| cacheWrite: m.cost?.cache_write || 0, | |
| }, | |
| contextWindow: m.limit?.context || 4096, | |
| maxTokens: m.limit?.output || 4096, | |
| }); | |
| recordModelsDevReasoningOptions("openai", modelId, m); | |
| } | |
| } | |
| // Process Groq models | |
| if (data.groq?.models) { | |
| for (const [modelId, model] of Object.entries(data.groq.models)) { | |
| const m = model as ModelsDevModel; | |
| if (m.tool_call !== true) continue; | |
| models.push({ | |
| id: modelId, | |
| name: m.name || modelId, | |
| api: "openai-completions", | |
| provider: "groq", | |
| baseUrl: "https://api.groq.com/openai/v1", | |
| reasoning: m.reasoning === true, | |
| input: m.modalities?.input?.includes("image") ? ["text", "image"] : ["text"], | |
| cost: { | |
| input: m.cost?.input || 0, | |
| output: m.cost?.output || 0, | |
| cacheRead: m.cost?.cache_read || 0, | |
| cacheWrite: m.cost?.cache_write || 0, | |
| }, | |
| contextWindow: m.limit?.context || 4096, | |
| maxTokens: m.limit?.output || 4096, | |
| }); | |
| recordModelsDevReasoningOptions("groq", modelId, m); | |
| } | |
| } | |
| // Process Cerebras models | |
| if (data.cerebras?.models) { | |
| for (const [modelId, model] of Object.entries(data.cerebras.models)) { | |
| const m = model as ModelsDevModel; | |
| if (m.tool_call !== true) continue; | |
| models.push({ | |
| id: modelId, | |
| name: m.name || modelId, | |
| api: "openai-completions", | |
| provider: "cerebras", | |
| baseUrl: "https://api.cerebras.ai/v1", | |
| reasoning: m.reasoning === true, | |
| input: m.modalities?.input?.includes("image") ? ["text", "image"] : ["text"], | |
| cost: { | |
| input: m.cost?.input || 0, | |
| output: m.cost?.output || 0, | |
| cacheRead: m.cost?.cache_read || 0, | |
| cacheWrite: m.cost?.cache_write || 0, | |
| }, | |
| contextWindow: m.limit?.context || 4096, | |
| maxTokens: m.limit?.output || 4096, | |
| }); | |
| recordModelsDevReasoningOptions("cerebras", modelId, m); | |
| } | |
| } | |
| // Process Cloudflare Workers AI models | |
| if (data["cloudflare-workers-ai"]?.models) { | |
| for (const [modelId, model] of Object.entries(data["cloudflare-workers-ai"].models)) { | |
| const m = model as ModelsDevModel; | |
| if (m.tool_call !== true) continue; | |
| models.push({ | |
| id: modelId, | |
| name: m.name || modelId, | |
| api: "openai-completions", | |
| provider: "cloudflare-workers-ai", | |
| baseUrl: CLOUDFLARE_WORKERS_AI_BASE_URL, | |
| reasoning: m.reasoning === true, | |
| input: m.modalities?.input?.includes("image") ? ["text", "image"] : ["text"], | |
| cost: { | |
| input: m.cost?.input || 0, | |
| output: m.cost?.output || 0, | |
| cacheRead: m.cost?.cache_read || 0, | |
| cacheWrite: m.cost?.cache_write || 0, | |
| }, | |
| contextWindow: m.limit?.context || 4096, | |
| maxTokens: m.limit?.output || 4096, | |
| compat: { sendSessionAffinityHeaders: true }, | |
| }); | |
| recordModelsDevReasoningOptions("cloudflare-workers-ai", modelId, m); | |
| } | |
| } | |
| // Process Cloudflare AI Gateway models | |
| const cloudflareAIGatewayModelIds = new Set<string>(); | |
| if (data["cloudflare-ai-gateway"]?.models) { | |
| for (const [prefixedId, model] of Object.entries(data["cloudflare-ai-gateway"].models)) { | |
| const m = model as ModelsDevModel; | |
| if (m.tool_call !== true) continue; | |
| const slashIdx = prefixedId.indexOf("/"); | |
| if (slashIdx === -1) continue; | |
| const upstream = prefixedId.slice(0, slashIdx); | |
| const nativeId = prefixedId.slice(slashIdx + 1); | |
| let api: "anthropic-messages" | "openai-completions" | "openai-responses"; | |
| let baseUrl: string; | |
| let id: string; | |
| if (upstream === "openai") { | |
| api = "openai-responses"; | |
| baseUrl = CLOUDFLARE_AI_GATEWAY_OPENAI_BASE_URL; | |
| id = nativeId; | |
| } else if (upstream === "anthropic") { | |
| api = "anthropic-messages"; | |
| baseUrl = CLOUDFLARE_AI_GATEWAY_ANTHROPIC_BASE_URL; | |
| id = nativeId; | |
| } else if (upstream === "workers-ai") { | |
| api = "openai-completions"; | |
| baseUrl = CLOUDFLARE_AI_GATEWAY_COMPAT_BASE_URL; | |
| id = prefixedId; | |
| } else { | |
| continue; | |
| } | |
| // Gateway passthroughs forward session affinity headers to upstreams that | |
| // use them for cache/routing affinity. | |
| const compat = | |
| upstream === "anthropic" || upstream === "workers-ai" ? { sendSessionAffinityHeaders: true } : undefined; | |
| cloudflareAIGatewayModelIds.add(id); | |
| models.push({ | |
| id, | |
| name: m.name || id, | |
| api, | |
| provider: "cloudflare-ai-gateway", | |
| baseUrl, | |
| reasoning: m.reasoning === true, | |
| input: m.modalities?.input?.includes("image") ? ["text", "image"] : ["text"], | |
| cost: { | |
| input: m.cost?.input || 0, | |
| output: m.cost?.output || 0, | |
| cacheRead: m.cost?.cache_read || 0, | |
| cacheWrite: m.cost?.cache_write || 0, | |
| }, | |
| contextWindow: m.limit?.context || 4096, | |
| maxTokens: m.limit?.output || 4096, | |
| ...(compat ? { compat } : {}), | |
| }); | |
| recordModelsDevReasoningOptions("cloudflare-ai-gateway", id, m); | |
| } | |
| } | |
| // The gateway proxies Workers AI through its OpenAI-compatible /compat endpoint, | |
| // but models.dev may omit or intermittently drop those `workers-ai/*` entries | |
| // from the AI Gateway catalog. Mirror the Workers AI catalog under the documented | |
| // prefix so the gateway keeps its OpenAI-compatible models stable. | |
| if (data["cloudflare-workers-ai"]?.models) { | |
| for (const [modelId, model] of Object.entries(data["cloudflare-workers-ai"].models)) { | |
| const m = model as ModelsDevModel; | |
| if (m.tool_call !== true) continue; | |
| const id = `workers-ai/${modelId}`; | |
| if (cloudflareAIGatewayModelIds.has(id)) continue; | |
| cloudflareAIGatewayModelIds.add(id); | |
| models.push({ | |
| id, | |
| name: m.name || id, | |
| api: "openai-completions", | |
| provider: "cloudflare-ai-gateway", | |
| baseUrl: CLOUDFLARE_AI_GATEWAY_COMPAT_BASE_URL, | |
| reasoning: m.reasoning === true, | |
| input: m.modalities?.input?.includes("image") ? ["text", "image"] : ["text"], | |
| cost: { | |
| input: m.cost?.input || 0, | |
| output: m.cost?.output || 0, | |
| cacheRead: m.cost?.cache_read || 0, | |
| cacheWrite: m.cost?.cache_write || 0, | |
| }, | |
| contextWindow: m.limit?.context || 4096, | |
| maxTokens: m.limit?.output || 4096, | |
| compat: { sendSessionAffinityHeaders: true }, | |
| }); | |
| recordModelsDevReasoningOptions("cloudflare-ai-gateway", id, m); | |
| } | |
| } | |
| // Process xAi models | |
| if (data.xai?.models) { | |
| for (const [modelId, model] of Object.entries(data.xai.models)) { | |
| const m = model as ModelsDevModel; | |
| if (m.tool_call !== true) continue; | |
| models.push({ | |
| id: modelId, | |
| name: m.name || modelId, | |
| api: "openai-responses", | |
| provider: "xai", | |
| baseUrl: "https://api.x.ai/v1", | |
| compat: { ...XAI_RESPONSES_COMPAT }, | |
| reasoning: m.reasoning === true, | |
| input: m.modalities?.input?.includes("image") ? ["text", "image"] : ["text"], | |
| cost: { | |
| input: m.cost?.input || 0, | |
| output: m.cost?.output || 0, | |
| cacheRead: m.cost?.cache_read || 0, | |
| cacheWrite: m.cost?.cache_write || 0, | |
| }, | |
| contextWindow: m.limit?.context || 4096, | |
| maxTokens: m.limit?.output || 4096, | |
| }); | |
| recordModelsDevReasoningOptions("xai", modelId, m); | |
| } | |
| } | |
| models.push(...processZaiModels(data)); | |
| // Process Mistral models | |
| if (data.mistral?.models) { | |
| for (const [modelId, model] of Object.entries(data.mistral.models)) { | |
| const m = model as ModelsDevModel; | |
| if (m.tool_call !== true) continue; | |
| models.push({ | |
| id: modelId, | |
| name: m.name || modelId, | |
| api: "mistral-conversations", | |
| provider: "mistral", | |
| baseUrl: "https://api.mistral.ai", | |
| reasoning: m.reasoning === true, | |
| input: m.modalities?.input?.includes("image") ? ["text", "image"] : ["text"], | |
| cost: { | |
| input: m.cost?.input || 0, | |
| output: m.cost?.output || 0, | |
| cacheRead: m.cost?.cache_read ?? (m.cost?.input ? roundCost(m.cost.input * 0.1) : 0), | |
| cacheWrite: m.cost?.cache_write || 0, | |
| }, | |
| contextWindow: m.limit?.context || 4096, | |
| maxTokens: m.limit?.output || 4096, | |
| }); | |
| recordModelsDevReasoningOptions("mistral", modelId, m); | |
| } | |
| } | |
| // Process Hugging Face models | |
| if (data.huggingface?.models) { | |
| for (const [modelId, model] of Object.entries(data.huggingface.models)) { | |
| const m = model as ModelsDevModel; | |
| if (m.tool_call !== true) continue; | |
| models.push({ | |
| id: modelId, | |
| name: m.name || modelId, | |
| api: "openai-completions", | |
| provider: "huggingface", | |
| baseUrl: "https://router.huggingface.co/v1", | |
| reasoning: m.reasoning === true, | |
| input: m.modalities?.input?.includes("image") ? ["text", "image"] : ["text"], | |
| cost: { | |
| input: m.cost?.input || 0, | |
| output: m.cost?.output || 0, | |
| cacheRead: m.cost?.cache_read || 0, | |
| cacheWrite: m.cost?.cache_write || 0, | |
| }, | |
| compat: { | |
| supportsDeveloperRole: false, | |
| }, | |
| contextWindow: m.limit?.context || 4096, | |
| maxTokens: m.limit?.output || 4096, | |
| }); | |
| recordModelsDevReasoningOptions("huggingface", modelId, m); | |
| } | |
| } | |
| models.push(...processFireworksModels(data["fireworks-ai"])); | |
| // Process NVIDIA NIM models | |
| if (data.nvidia?.models) { | |
| for (const [modelId, model] of Object.entries(data.nvidia.models)) { | |
| const m = model as ModelsDevModel; | |
| if (m.tool_call !== true) continue; | |
| if (!m.modalities?.input?.includes("text")) continue; | |
| if (!m.modalities?.output?.includes("text")) continue; | |
| const liveModelId = nvidiaNimModelIds.get(modelId) ?? nvidiaNimModelIds.get(normalizeNvidiaModelId(modelId)); | |
| if (!liveModelId) continue; | |
| if (NVIDIA_NIM_UNSUPPORTED_MODELS.has(liveModelId)) continue; | |
| models.push({ | |
| id: liveModelId, | |
| name: m.name || liveModelId, | |
| api: "openai-completions", | |
| provider: "nvidia", | |
| baseUrl: NVIDIA_BASE_URL, | |
| headers: { ...NVIDIA_HEADERS }, | |
| reasoning: m.reasoning === true, | |
| input: m.modalities.input.includes("image") ? ["text", "image"] : ["text"], | |
| cost: { | |
| input: m.cost?.input || 0, | |
| output: m.cost?.output || 0, | |
| cacheRead: m.cost?.cache_read || 0, | |
| cacheWrite: m.cost?.cache_write || 0, | |
| }, | |
| compat: NVIDIA_OPENAI_COMPAT, | |
| contextWindow: m.limit?.context || 4096, | |
| maxTokens: m.limit?.output || 4096, | |
| }); | |
| recordModelsDevReasoningOptions("nvidia", liveModelId, m); | |
| } | |
| } | |
| // Process Together AI models | |
| const togetherProvider = data.together ?? data.togetherai ?? data["together-ai"]; | |
| if (togetherProvider?.models) { | |
| for (const [modelId, model] of Object.entries(togetherProvider.models)) { | |
| const m = model as ModelsDevModel; | |
| if (m.tool_call !== true) continue; | |
| if (m.status === "deprecated") continue; | |
| const reasoning = m.reasoning === true; | |
| const thinkingLevelMap = getTogetherThinkingLevelMap(modelId, reasoning); | |
| models.push({ | |
| id: modelId, | |
| name: m.name || modelId, | |
| api: "openai-completions", | |
| provider: "together", | |
| baseUrl: TOGETHER_BASE_URL, | |
| reasoning, | |
| ...(thinkingLevelMap ? { thinkingLevelMap } : {}), | |
| input: m.modalities?.input?.includes("image") ? ["text", "image"] : ["text"], | |
| cost: { | |
| input: m.cost?.input || 0, | |
| output: m.cost?.output || 0, | |
| cacheRead: m.cost?.cache_read || 0, | |
| cacheWrite: m.cost?.cache_write || 0, | |
| }, | |
| compat: getTogetherCompat(modelId, reasoning), | |
| contextWindow: m.limit?.context || 4096, | |
| maxTokens: m.limit?.output || 4096, | |
| }); | |
| recordModelsDevReasoningOptions("together", modelId, m); | |
| } | |
| } | |
| models.push(...processBasetenModels(data.baseten)); | |
| // Process OpenCode models (Zen and Go) | |
| // API mapping based on provider.npm field: | |
| // - @ai-sdk/openai → openai-responses | |
| // - @ai-sdk/anthropic → anthropic-messages | |
| // - @ai-sdk/google → google-generative-ai | |
| // - null/undefined/@ai-sdk/openai-compatible → openai-completions | |
| const opencodeVariants = [ | |
| { key: "opencode", provider: "opencode", basePath: "https://opencode.ai/zen" }, | |
| { key: "opencode-go", provider: "opencode-go", basePath: "https://opencode.ai/zen/go" }, | |
| ] as const; | |
| for (const variant of opencodeVariants) { | |
| if (!data[variant.key]?.models) continue; | |
| for (const [modelId, model] of Object.entries(data[variant.key].models)) { | |
| const m = model as ModelsDevModel; | |
| if (m.tool_call !== true) continue; | |
| if (m.status === "deprecated") continue; | |
| const npm = m.provider?.npm; | |
| let api: Api; | |
| let baseUrl: string; | |
| let compat: OpenAICompletionsCompat | OpenAIResponsesCompat | undefined; | |
| if (npm === "@ai-sdk/openai") { | |
| api = "openai-responses"; | |
| baseUrl = `${variant.basePath}/v1`; | |
| compat = { sessionAffinityFormat: "openai-nosession" }; | |
| } else if (npm === "@ai-sdk/anthropic") { | |
| api = "anthropic-messages"; | |
| // Anthropic SDK appends /v1/messages to baseURL | |
| baseUrl = variant.basePath; | |
| } else if (npm === "@ai-sdk/google") { | |
| api = "google-generative-ai"; | |
| baseUrl = `${variant.basePath}/v1`; | |
| } else if (npm === "@ai-sdk/alibaba") { | |
| api = "openai-completions"; | |
| baseUrl = `${variant.basePath}/v1`; | |
| compat = { cacheControlFormat: "anthropic" }; | |
| } else { | |
| // null, undefined, or @ai-sdk/openai-compatible | |
| api = "openai-completions"; | |
| baseUrl = `${variant.basePath}/v1`; | |
| } | |
| if (variant.provider === "opencode" && modelId === "grok-build-0.1") { | |
| compat = { ...(compat ?? {}), supportsReasoningEffort: false }; | |
| } | |
| if ((variant.provider === "opencode" || variant.provider === "opencode-go") && modelId === "kimi-k2.6") { | |
| // OpenCode Kimi K2.6 accepts Anthropic-style thinking objects | |
| // and rejects string thinking values or combined reasoning_effort. | |
| compat = { ...(compat ?? {}), thinkingFormat: "deepseek", supportsReasoningEffort: false }; | |
| } | |
| // Fix known mismatches between models.dev npm data and actual | |
| // OpenCode Go endpoint behaviour. models.dev reports these models | |
| // as @ai-sdk/anthropic, but the OpenCode Go endpoints either don't | |
| // accept Anthropic SDK auth (MiniMax M2.7) or are served through | |
| // the OpenAI-compatible /v1/chat/completions path (Qwen 3.5/3.6). | |
| // Switch them to openai-completions so requests use Bearer auth | |
| // and the standard /v1/chat/completions endpoint. | |
| if (variant.provider === "opencode-go") { | |
| if (modelId === "minimax-m2.7") { | |
| api = "openai-completions"; | |
| baseUrl = `${variant.basePath}/v1`; | |
| } | |
| if (modelId === "qwen3.5-plus" || modelId === "qwen3.6-plus") { | |
| api = "openai-completions"; | |
| baseUrl = `${variant.basePath}/v1`; | |
| // Qwen/DashScope uses enable_thinking at the top level. | |
| compat = { ...(compat ?? {}), thinkingFormat: "qwen" }; | |
| } | |
| } | |
| if (api === "openai-completions") { | |
| compat = { ...(compat ?? {}), maxTokensField: "max_tokens" }; | |
| if ( | |
| OPENCODE_OPENAI_COMPLETIONS_LONG_CACHE_RETENTION_UNSUPPORTED_MODELS.has( | |
| `${variant.provider}:${modelId}`, | |
| ) | |
| ) { | |
| compat = { ...compat, supportsLongCacheRetention: false }; | |
| } | |
| } | |
| models.push({ | |
| id: modelId, | |
| name: m.name || modelId, | |
| api, | |
| provider: variant.provider, | |
| baseUrl, | |
| reasoning: m.reasoning === true, | |
| input: m.modalities?.input?.includes("image") ? ["text", "image"] : ["text"], | |
| cost: { | |
| input: m.cost?.input || 0, | |
| output: m.cost?.output || 0, | |
| cacheRead: m.cost?.cache_read || 0, | |
| cacheWrite: m.cost?.cache_write || 0, | |
| }, | |
| ...(compat ? { compat } : {}), | |
| contextWindow: m.limit?.context || 4096, | |
| maxTokens: m.limit?.output || 4096, | |
| }); | |
| recordModelsDevReasoningOptions(variant.provider, modelId, m); | |
| } | |
| } | |
| // Process GitHub Copilot models | |
| if (data["github-copilot"]?.models) { | |
| for (const [modelId, model] of Object.entries(data["github-copilot"].models)) { | |
| const m = model as ModelsDevModel; | |
| if (m.tool_call !== true) continue; | |
| if (m.status === "deprecated") continue; | |
| // Claude 4.x and 5.x models route to Anthropic Messages API | |
| const isCopilotClaude = /^claude-(haiku|sonnet|opus|fable)-[45]([.\-]|$)/.test(modelId); | |
| // GPT, Grok, OSWE, and MAI-Code models are only served through | |
| // the Copilot /responses endpoint. | |
| const needsResponsesApi = | |
| modelId.startsWith("gpt-") || | |
| modelId.startsWith("grok-") || | |
| modelId.startsWith("oswe") || | |
| modelId.startsWith("mai-"); | |
| const api: Api = isCopilotClaude | |
| ? "anthropic-messages" | |
| : needsResponsesApi | |
| ? "openai-responses" | |
| : "openai-completions"; | |
| const anthropicCompat = | |
| api === "anthropic-messages" ? getAnthropicMessagesCompat("github-copilot", modelId) : undefined; | |
| const copilotModel: Model<any> = { | |
| id: modelId, | |
| name: m.name || modelId, | |
| api, | |
| provider: "github-copilot", | |
| baseUrl: "https://api.individual.githubcopilot.com", | |
| reasoning: m.reasoning === true, | |
| input: m.modalities?.input?.includes("image") ? ["text", "image"] : ["text"], | |
| cost: getModelsDevCost(m.cost), | |
| contextWindow: m.limit?.context || 128000, | |
| maxTokens: m.limit?.output || 8192, | |
| headers: { ...COPILOT_STATIC_HEADERS }, | |
| ...(anthropicCompat ? { compat: anthropicCompat } : {}), | |
| // compat only applies to openai-completions | |
| ...(api === "openai-completions" ? { | |
| compat: { | |
| supportsStore: false, | |
| supportsDeveloperRole: false, | |
| supportsReasoningEffort: false, | |
| }, | |
| } : {}), | |
| }; | |
| models.push(copilotModel); | |
| recordModelsDevReasoningOptions("github-copilot", modelId, m); | |
| } | |
| } | |
| // Process MiniMax models | |
| const minimaxVariants = [ | |
| { key: "minimax", provider: "minimax", baseUrl: "https://api.minimax.io/anthropic" }, | |
| { key: "minimax-cn", provider: "minimax-cn", baseUrl: "https://api.minimaxi.com/anthropic" }, | |
| ] as const; | |
| for (const { key, provider, baseUrl } of minimaxVariants) { | |
| if (data[key]?.models) { | |
| for (const [modelId, model] of Object.entries(data[key].models)) { | |
| const m = model as ModelsDevModel; | |
| if (m.tool_call !== true) continue; | |
| models.push({ | |
| id: modelId, | |
| name: m.name || modelId, | |
| api: "anthropic-messages", | |
| provider, | |
| // MiniMax's Anthropic-compatible API - SDK appends /v1/messages | |
| baseUrl, | |
| reasoning: m.reasoning === true, | |
| input: m.modalities?.input?.includes("image") ? ["text", "image"] : ["text"], | |
| cost: { | |
| input: m.cost?.input || 0, | |
| output: m.cost?.output || 0, | |
| cacheRead: m.cost?.cache_read || 0, | |
| cacheWrite: m.cost?.cache_write || 0, | |
| }, | |
| contextWindow: m.limit?.context || 4096, | |
| maxTokens: m.limit?.output || 4096, | |
| }); | |
| recordModelsDevReasoningOptions(provider, modelId, m); | |
| } | |
| } | |
| } | |
| // Process Kimi For Coding models | |
| if (data["kimi-for-coding"]?.models) { | |
| const kimiModels = data["kimi-for-coding"].models as Record<string, ModelsDevModel>; | |
| const hasCanonicalModel = Object.prototype.hasOwnProperty.call(kimiModels, "kimi-for-coding"); | |
| const kimiAliases = new Set(["k2p5", "k2p6", "k2p7"]); | |
| for (const [modelId, model] of Object.entries(kimiModels)) { | |
| const m = model as ModelsDevModel; | |
| if (m.tool_call !== true) continue; | |
| // models.dev may expose versioned aliases (e.g. k2p5/k2p6/k2p7). | |
| // Normalize aliases to the canonical model id and drop duplicates when canonical exists. | |
| if (kimiAliases.has(modelId) && hasCanonicalModel) continue; | |
| const normalizedId = kimiAliases.has(modelId) ? "kimi-for-coding" : modelId; | |
| const normalizedName = kimiAliases.has(modelId) ? "Kimi For Coding" : m.name || normalizedId; | |
| const isKimiK3 = normalizedId === "k3"; | |
| const allowEmptySignature = isKimiK3 || normalizedId === "kimi-for-coding"; | |
| const impliedCost = KIMI_CODING_IMPLIED_COSTS[normalizedId]; | |
| models.push({ | |
| id: normalizedId, | |
| name: normalizedName, | |
| api: "anthropic-messages", | |
| provider: "kimi-coding", | |
| // Kimi For Coding's Anthropic-compatible API - SDK appends /v1/messages | |
| baseUrl: "https://api.kimi.com/coding", | |
| compat: { | |
| ...(allowEmptySignature ? { allowEmptySignature: true } : {}), | |
| forceAdaptiveThinking: true, | |
| }, | |
| reasoning: isKimiK3 || m.reasoning === true, | |
| input: m.modalities?.input?.includes("image") ? ["text", "image"] : ["text"], | |
| cost: { | |
| input: m.cost?.input || impliedCost?.input || 0, | |
| output: m.cost?.output || impliedCost?.output || 0, | |
| cacheRead: m.cost?.cache_read || impliedCost?.cacheRead || 0, | |
| cacheWrite: m.cost?.cache_write || impliedCost?.cacheWrite || 0, | |
| }, | |
| contextWindow: m.limit?.context || 4096, | |
| maxTokens: m.limit?.output || 4096, | |
| }); | |
| recordModelsDevReasoningOptions("kimi-coding", normalizedId, m); | |
| } | |
| } | |
| // Process Moonshot AI models | |
| const moonshotVariants = [ | |
| { key: "moonshotai", provider: "moonshotai", baseUrl: "https://api.moonshot.ai/v1" }, | |
| { key: "moonshotai-cn", provider: "moonshotai-cn", baseUrl: "https://api.moonshot.cn/v1" }, | |
| ] as const; | |
| const moonshotCompat: OpenAICompletionsCompat = { | |
| supportsStore: false, | |
| supportsDeveloperRole: false, | |
| supportsReasoningEffort: false, | |
| maxTokensField: "max_tokens", | |
| supportsStrictMode: false, | |
| thinkingFormat: "deepseek", | |
| }; | |
| const getMoonshotProviderModels = (key: "moonshotai" | "moonshotai-cn"): Record<string, ModelsDevModel> => { | |
| const providerModels = data[key]?.models as Record<string, ModelsDevModel> | undefined; | |
| return providerModels ? { ...providerModels } : {}; | |
| }; | |
| const moonshotModels = { | |
| moonshotai: getMoonshotProviderModels("moonshotai"), | |
| "moonshotai-cn": getMoonshotProviderModels("moonshotai-cn"), | |
| }; | |
| for (const { key, provider, baseUrl } of moonshotVariants) { | |
| for (const [modelId, m] of Object.entries(moonshotModels[key])) { | |
| if (m.tool_call !== true) continue; | |
| const isKimiK3 = modelId === "kimi-k3"; | |
| const compat = isKimiK3 ? { ...moonshotCompat } : moonshotCompat; | |
| if (isKimiK3) { | |
| compat.requiresReasoningContentOnAssistantMessages = true; | |
| compat.deferredToolsMode = "kimi"; | |
| compat.thinkingFormat = "openai"; | |
| compat.supportsReasoningEffort = true; | |
| } | |
| models.push({ | |
| id: modelId, | |
| name: m.name || modelId, | |
| api: "openai-completions", | |
| provider, | |
| baseUrl, | |
| reasoning: isKimiK3 || m.reasoning === true, | |
| input: m.modalities?.input?.includes("image") ? ["text", "image"] : ["text"], | |
| cost: { | |
| input: m.cost?.input || (isKimiK3 ? KIMI_K3_COST.input : 0), | |
| output: m.cost?.output || (isKimiK3 ? KIMI_K3_COST.output : 0), | |
| cacheRead: m.cost?.cache_read || (isKimiK3 ? KIMI_K3_COST.cacheRead : 0), | |
| cacheWrite: m.cost?.cache_write || (isKimiK3 ? KIMI_K3_COST.cacheWrite : 0), | |
| }, | |
| contextWindow: m.limit?.context || 4096, | |
| maxTokens: m.limit?.output || 4096, | |
| compat, | |
| }); | |
| recordModelsDevReasoningOptions(provider, modelId, m); | |
| } | |
| } | |
| // Process Xiaomi MiMo models | |
| // Built-in `xiaomi` targets the API billing endpoint (single stable URL, | |
| // keys from platform.xiaomimimo.com). The three `xiaomi-token-plan-*` | |
| // providers cover prepaid Token Plan endpoints in cn / ams / sgp. | |
| const xiaomiCompat: OpenAICompletionsCompat = { | |
| requiresReasoningContentOnAssistantMessages: true, | |
| thinkingFormat: "deepseek", | |
| }; | |
| const xiaomiVariants = [ | |
| { source: "xiaomi", provider: "xiaomi", baseUrl: "https://api.xiaomimimo.com/v1" }, | |
| { | |
| source: "xiaomi-token-plan-cn", | |
| provider: "xiaomi-token-plan-cn", | |
| baseUrl: "https://token-plan-cn.xiaomimimo.com/v1", | |
| }, | |
| { | |
| source: "xiaomi-token-plan-ams", | |
| provider: "xiaomi-token-plan-ams", | |
| baseUrl: "https://token-plan-ams.xiaomimimo.com/v1", | |
| }, | |
| { | |
| source: "xiaomi-token-plan-sgp", | |
| provider: "xiaomi-token-plan-sgp", | |
| baseUrl: "https://token-plan-sgp.xiaomimimo.com/v1", | |
| }, | |
| ] as const; | |
| for (const { source, provider, baseUrl } of xiaomiVariants) { | |
| const providerModels = data[source]?.models; | |
| if (!providerModels) continue; | |
| for (const [modelId, model] of Object.entries(providerModels)) { | |
| const m = model as ModelsDevModel; | |
| if (m.tool_call !== true) continue; | |
| if (m.status === "deprecated") continue; | |
| models.push({ | |
| id: modelId, | |
| name: m.name || modelId, | |
| api: "openai-completions", | |
| provider, | |
| baseUrl, | |
| compat: xiaomiCompat, | |
| reasoning: m.reasoning === true, | |
| input: m.modalities?.input?.includes("image") ? ["text", "image"] : ["text"], | |
| cost: { | |
| input: m.cost?.input || 0, | |
| output: m.cost?.output || 0, | |
| cacheRead: m.cost?.cache_read || 0, | |
| cacheWrite: m.cost?.cache_write || 0, | |
| }, | |
| contextWindow: m.limit?.context || 4096, | |
| maxTokens: m.limit?.output || 4096, | |
| }); | |
| recordModelsDevReasoningOptions(provider, modelId, m); | |
| } | |
| } | |
| // Process Alibaba Cloud Model Studio Token Plan models. International and | |
| // China use separate endpoints and API keys (sk-sp- prefix). The Individual | |
| // provider reuses the international source and endpoint with a narrower catalog. | |
| // models.dev keys are "alibaba-token-plan[-cn]"; pi exposes them as | |
| // "qwen-token-plan[-cn]" plus the Individual catalog view. | |
| const qwenTokenPlanCompat: OpenAICompletionsCompat = { | |
| thinkingFormat: "qwen", | |
| supportsDeveloperRole: false, | |
| supportsStore: false, | |
| supportsReasoningEffort: true, | |
| }; | |
| const qwenTokenPlanVariants = [ | |
| { | |
| source: "alibaba-token-plan", | |
| provider: "qwen-token-plan", | |
| baseUrl: "https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1", | |
| modelIds: undefined, | |
| }, | |
| { | |
| source: "alibaba-token-plan", | |
| provider: "qwen-token-plan-individual", | |
| baseUrl: "https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1", | |
| modelIds: QWEN_TOKEN_PLAN_INDIVIDUAL_MODEL_IDS, | |
| }, | |
| { | |
| source: "alibaba-token-plan-cn", | |
| provider: "qwen-token-plan-cn", | |
| baseUrl: "https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1", | |
| modelIds: undefined, | |
| }, | |
| ] as const; | |
| for (const { source, provider, baseUrl, modelIds } of qwenTokenPlanVariants) { | |
| const providerModels = data[source]?.models; | |
| const emittedModelIds = modelIds ? new Set<string>() : undefined; | |
| for (const [modelId, model] of Object.entries(providerModels ?? {})) { | |
| const m = model as ModelsDevModel; | |
| if (m.tool_call !== true) continue; | |
| if (QWEN_TOKEN_PLAN_EXCLUDED_MODEL_IDS.has(modelId)) continue; | |
| if (modelIds && !modelIds.has(modelId)) continue; | |
| const thinkingLevelMap = | |
| getEffortThinkingLevelMap(m.reasoning_options ?? []) ?? | |
| (QWEN_TOKEN_PLAN_REASONING_EFFORT_FALLBACK_MODEL_IDS.has(modelId) | |
| ? QWEN_TOKEN_PLAN_FALLBACK_THINKING_LEVEL_MAP | |
| : undefined); | |
| models.push({ | |
| id: modelId, | |
| name: m.name || modelId, | |
| api: "openai-completions", | |
| provider, | |
| baseUrl, | |
| compat: thinkingLevelMap | |
| ? qwenTokenPlanCompat | |
| : { ...qwenTokenPlanCompat, supportsReasoningEffort: false }, | |
| ...(thinkingLevelMap ? { thinkingLevelMap } : {}), | |
| reasoning: m.reasoning === true, | |
| input: m.modalities?.input?.includes("image") ? ["text", "image"] : ["text"], | |
| cost: { | |
| input: m.cost?.input || 0, | |
| output: m.cost?.output || 0, | |
| cacheRead: m.cost?.cache_read || 0, | |
| cacheWrite: m.cost?.cache_write || 0, | |
| }, | |
| contextWindow: m.limit?.context || 4096, | |
| maxTokens: m.limit?.output || 4096, | |
| }); | |
| emittedModelIds?.add(modelId); | |
| } | |
| if (modelIds && emittedModelIds && generatorOptions.strict) { | |
| assertExactModelIds(provider, modelIds, emittedModelIds); | |
| } | |
| } | |
| console.log(`Loaded ${models.length} tool-capable models from models.dev`); | |
| return models; | |
| } catch (error) { | |
| console.error("Failed to load models.dev data:", error); | |
| if (generatorOptions.strict) throw error; | |
| return []; | |
| } | |
| } | |
| async function generateModels() { | |
| // Fetch models from both sources | |
| // models.dev: Anthropic, Google, OpenAI, Groq, Cerebras | |
| // OpenRouter: xAI and other providers (excluding Anthropic, Google, OpenAI) | |
| // AI Gateway: OpenAI-compatible catalog with tool-capable models | |
| const modelsDevModels = await loadModelsDevData(); | |
| const openRouterModels = await fetchOpenRouterModels(); | |
| const aiGatewayModels = await fetchAiGatewayModels(); | |
| // Combine models (models.dev has priority) | |
| const allModels = [...modelsDevModels, ...openRouterModels, ...aiGatewayModels].filter( | |
| (model) => | |
| !(model.provider === "xai" && XAI_BUILTIN_EXCLUDED_MODEL_IDS.has(model.id)) && | |
| !((model.provider === "opencode" || model.provider === "opencode-go") && model.id === "gpt-5.3-codex-spark"), | |
| ); | |
| // Temporary overrides until upstream model metadata is corrected. | |
| for (const candidate of allModels) { | |
| if (candidate.provider === "github-copilot" && GITHUB_COPILOT_EXTENDED_CONTEXT_MODELS.has(candidate.id)) { | |
| candidate.contextWindow = 1000000; | |
| } | |
| if ( | |
| (candidate.provider === "anthropic" || | |
| candidate.provider === "opencode" || | |
| candidate.provider === "opencode-go") && | |
| (candidate.id === "claude-opus-4-6" || | |
| candidate.id === "claude-sonnet-4-6" || | |
| candidate.id === "claude-opus-4.6" || | |
| candidate.id === "claude-sonnet-4.6") | |
| ) { | |
| candidate.contextWindow = 1000000; | |
| } | |
| // OpenCode variants list Claude Sonnet 4/4.5 with 1M context, actual limit is 200K | |
| if ( | |
| (candidate.provider === "opencode" || candidate.provider === "opencode-go") && | |
| (candidate.id === "claude-sonnet-4-5" || candidate.id === "claude-sonnet-4") | |
| ) { | |
| candidate.contextWindow = 200000; | |
| } | |
| if ((candidate.provider === "opencode" || candidate.provider === "opencode-go") && candidate.id === "gpt-5.4") { | |
| candidate.contextWindow = 272000; | |
| candidate.maxTokens = 128000; | |
| } | |
| // Keep direct OpenAI requests in the short-context pricing tier by default. Users can opt into the | |
| // larger context through model overrides, so retain long-context cost metadata on the capped models. | |
| if (candidate.provider === "openai" && OPENAI_SHORT_CONTEXT_CAPPED_MODEL_IDS.has(candidate.id)) { | |
| candidate.contextWindow = OPENAI_LONG_CONTEXT_INPUT_THRESHOLD; | |
| candidate.maxTokens = 128000; | |
| } | |
| if (candidate.provider === "openai" && OPENAI_LONG_CONTEXT_PRICING_MODEL_IDS.has(candidate.id)) { | |
| const standardCost = OPENAI_GPT_56_STANDARD_COSTS[candidate.id]; | |
| candidate.cost = withOpenAiLongContextPricing(standardCost ?? candidate.cost); | |
| } | |
| // Cloudflare AI Gateway passes OpenAI usage through at OpenAI list prices. | |
| if (candidate.provider === "cloudflare-ai-gateway") { | |
| const standardCost = OPENAI_GPT_56_STANDARD_COSTS[candidate.id]; | |
| if (standardCost) candidate.cost = withOpenAiLongContextPricing(standardCost); | |
| } | |
| // models.dev reports gpt-5-pro output as 272000 (a duplicate of the input sub-limit); | |
| // the actual max output is 128000. Also propagates to the derived Azure clone. | |
| if (candidate.provider === "openai" && candidate.id === "gpt-5-pro") { | |
| candidate.maxTokens = 128000; | |
| } | |
| // Keep Kimi K3's canonical output limit when gateway metadata is missing or incorrect. | |
| if ( | |
| (candidate.provider === "openrouter" && OPENROUTER_KIMI_K3_MODEL_IDS.has(candidate.id)) || | |
| (candidate.provider === "vercel-ai-gateway" && candidate.id === "moonshotai/kimi-k3") | |
| ) { | |
| candidate.maxTokens = KIMI_K3_MAX_TOKENS; | |
| } | |
| // Keep selected OpenRouter model metadata stable until upstream settles. | |
| if (candidate.provider === "openrouter" && candidate.id === "moonshotai/kimi-k2.5") { | |
| candidate.cost.input = 0.41; | |
| candidate.cost.output = 2.06; | |
| candidate.cost.cacheRead = 0.07; | |
| candidate.maxTokens = 4096; | |
| } | |
| if (candidate.provider === "openrouter" && candidate.id.startsWith("moonshotai/kimi-k2.6")) { | |
| candidate.compat = { | |
| ...candidate.compat, | |
| supportsDeveloperRole: false, | |
| requiresReasoningContentOnAssistantMessages: true, | |
| }; | |
| } | |
| if (candidate.provider === "openrouter" && candidate.id === "z-ai/glm-5") { | |
| candidate.cost.input = 0.6; | |
| candidate.cost.output = 1.9; | |
| candidate.cost.cacheRead = 0.119; | |
| } | |
| } | |
| // Add missing gpt models | |
| const missingOpenAiModels: Model<"openai-responses">[] = [ | |
| { | |
| id: "gpt-6-astra", | |
| name: "GPT-6 Astra", | |
| api: "openai-responses", | |
| baseUrl: "https://api.openai.com/v1", | |
| provider: "openai", | |
| reasoning: true, | |
| input: ["text", "image"], | |
| cost: withOpenAiLongContextPricing({ input: 10, output: 50, cacheRead: 1, cacheWrite: 12.5 }), | |
| contextWindow: OPENAI_LONG_CONTEXT_INPUT_THRESHOLD, | |
| maxTokens: 128000, | |
| }, | |
| { | |
| id: "gpt-5.6-sol", | |
| name: "GPT-5.6 Sol", | |
| api: "openai-responses", | |
| baseUrl: "https://api.openai.com/v1", | |
| provider: "openai", | |
| reasoning: true, | |
| input: ["text", "image"], | |
| cost: withOpenAiLongContextPricing({ input: 5, output: 30, cacheRead: 0.5, cacheWrite: 6.25 }), | |
| contextWindow: OPENAI_LONG_CONTEXT_INPUT_THRESHOLD, | |
| maxTokens: 128000, | |
| }, | |
| { | |
| id: "gpt-5.6-terra", | |
| name: "GPT-5.6 Terra", | |
| api: "openai-responses", | |
| baseUrl: "https://api.openai.com/v1", | |
| provider: "openai", | |
| reasoning: true, | |
| input: ["text", "image"], | |
| cost: withOpenAiLongContextPricing(OPENAI_GPT_56_STANDARD_COSTS["gpt-5.6-terra"]), | |
| contextWindow: OPENAI_LONG_CONTEXT_INPUT_THRESHOLD, | |
| maxTokens: 128000, | |
| }, | |
| { | |
| id: "gpt-5.6-luna", | |
| name: "GPT-5.6 Luna", | |
| api: "openai-responses", | |
| baseUrl: "https://api.openai.com/v1", | |
| provider: "openai", | |
| reasoning: true, | |
| input: ["text", "image"], | |
| cost: withOpenAiLongContextPricing(OPENAI_GPT_56_STANDARD_COSTS["gpt-5.6-luna"]), | |
| contextWindow: OPENAI_LONG_CONTEXT_INPUT_THRESHOLD, | |
| maxTokens: 128000, | |
| }, | |
| { | |
| id: "gpt-5-chat-latest", | |
| name: "GPT-5 Chat Latest", | |
| api: "openai-responses", | |
| baseUrl: "https://api.openai.com/v1", | |
| provider: "openai", | |
| reasoning: false, | |
| input: ["text", "image"], | |
| cost: { | |
| input: 1.25, | |
| output: 10, | |
| cacheRead: 0.125, | |
| cacheWrite: 0, | |
| }, | |
| contextWindow: 128000, | |
| maxTokens: 16384, | |
| }, | |
| ]; | |
| for (const model of missingOpenAiModels) { | |
| if (!allModels.some((m) => m.provider === model.provider && m.id === model.id)) { | |
| allModels.push(model); | |
| } | |
| } | |
| const deepseekCompat: OpenAICompletionsCompat = { | |
| requiresReasoningContentOnAssistantMessages: true, | |
| thinkingFormat: "deepseek", | |
| }; | |
| const deepseekModels: Model<"openai-completions">[] = [ | |
| { | |
| id: "deepseek-flash", | |
| name: "DeepSeek V4.1 Flash", | |
| api: "openai-completions", | |
| baseUrl: "https://api.deepseek.com", | |
| provider: "deepseek", | |
| reasoning: true, | |
| thinkingLevelMap: DEEPSEEK_V4_FLASH_THINKING_LEVEL_MAP, | |
| input: ["text", "image"], | |
| cost: { | |
| // DeepSeek also offers time-based off-peak rates, which the cost schema cannot represent yet. | |
| input: 0.3, | |
| output: 1.2, | |
| cacheRead: 0.006, | |
| cacheWrite: 0, | |
| }, | |
| contextWindow: 1000000, | |
| maxTokens: 384000, | |
| compat: deepseekCompat, | |
| }, | |
| { | |
| id: "deepseek-v4-pro", | |
| name: "DeepSeek V4 Pro", | |
| api: "openai-completions", | |
| baseUrl: "https://api.deepseek.com", | |
| provider: "deepseek", | |
| reasoning: true, | |
| input: ["text"], | |
| cost: { | |
| // DeepSeek also offers time-based off-peak rates, which the cost schema cannot represent yet. | |
| input: 1.32, | |
| output: 3.96, | |
| cacheRead: 0.044, | |
| cacheWrite: 0, | |
| }, | |
| contextWindow: 1000000, | |
| maxTokens: 384000, | |
| compat: deepseekCompat, | |
| }, | |
| ]; | |
| allModels.push(...deepseekModels); | |
| const antLingCompat: OpenAICompletionsCompat = { | |
| supportsStore: false, | |
| supportsDeveloperRole: false, | |
| supportsReasoningEffort: false, | |
| maxTokensField: "max_tokens", | |
| supportsLongCacheRetention: false, | |
| }; | |
| const antLingModels: Model<"openai-completions">[] = [ | |
| { | |
| id: "Ling-2.6-flash", | |
| name: "Ling 2.6 Flash", | |
| api: "openai-completions", | |
| baseUrl: "https://api.ant-ling.com/v1", | |
| provider: "ant-ling", | |
| reasoning: false, | |
| input: ["text"], | |
| cost: { input: 0.01, output: 0.02, cacheRead: 0, cacheWrite: 0 }, | |
| contextWindow: 262144, | |
| maxTokens: 65536, | |
| compat: antLingCompat, | |
| }, | |
| { | |
| id: "Ling-2.6-1T", | |
| name: "Ling 2.6 1T", | |
| api: "openai-completions", | |
| baseUrl: "https://api.ant-ling.com/v1", | |
| provider: "ant-ling", | |
| reasoning: false, | |
| input: ["text"], | |
| cost: { input: 0.06, output: 0.25, cacheRead: 0, cacheWrite: 0 }, | |
| contextWindow: 262144, | |
| maxTokens: 65536, | |
| compat: antLingCompat, | |
| }, | |
| { | |
| id: "Ring-2.6-1T", | |
| name: "Ring 2.6 1T", | |
| api: "openai-completions", | |
| baseUrl: "https://api.ant-ling.com/v1", | |
| provider: "ant-ling", | |
| reasoning: true, | |
| input: ["text"], | |
| cost: { input: 0.06, output: 0.25, cacheRead: 0, cacheWrite: 0 }, | |
| contextWindow: 262144, | |
| maxTokens: 65536, | |
| compat: { ...antLingCompat, thinkingFormat: "ant-ling" }, | |
| }, | |
| ]; | |
| allModels.push(...antLingModels); | |
| for (const candidate of allModels) { | |
| if ( | |
| candidate.api === "openai-completions" && | |
| candidate.id.includes("deepseek-v4") && | |
| !QWEN_TOKEN_PLAN_PROVIDER_IDS.has(candidate.provider) | |
| ) { | |
| const preservesNativeReasoningEffort = candidate.provider === "openrouter" || candidate.provider === "opencode"; | |
| candidate.compat = { | |
| ...candidate.compat, | |
| ...(preservesNativeReasoningEffort | |
| ? { | |
| requiresReasoningContentOnAssistantMessages: | |
| deepseekCompat.requiresReasoningContentOnAssistantMessages, | |
| } | |
| : deepseekCompat), | |
| }; | |
| } | |
| } | |
| const minimaxDirectSupportedIds = new Set(["MiniMax-M2.7", "MiniMax-M2.7-highspeed", "MiniMax-M3"]); | |
| for (let i = allModels.length - 1; i >= 0; i--) { | |
| const candidate = allModels[i]; | |
| if ( | |
| (candidate.provider === "minimax" || candidate.provider === "minimax-cn") && | |
| !minimaxDirectSupportedIds.has(candidate.id) | |
| ) { | |
| allModels.splice(i, 1); | |
| } | |
| } | |
| // OpenAI Codex (ChatGPT OAuth) models | |
| // NOTE: These are not fetched from models.dev; we keep a small, explicit list to avoid aliases. | |
| // Older model limits are based on observed server behavior; GPT-5.6 and GPT-6 Astra use Codex's 272k default catalog limit. | |
| const CODEX_BASE_URL = "https://chatgpt.com/backend-api"; | |
| const CODEX_CONTEXT = 272000; | |
| const CODEX_GPT_56_CONTEXT = 272000; | |
| const CODEX_SPARK_CONTEXT = 128000; | |
| const CODEX_MAX_TOKENS = 128000; | |
| const codexModels: Model<"openai-codex-responses">[] = [ | |
| { | |
| id: "gpt-6-astra", | |
| name: "GPT-6 Astra", | |
| api: "openai-codex-responses", | |
| provider: "openai-codex", | |
| baseUrl: CODEX_BASE_URL, | |
| reasoning: true, | |
| input: ["text", "image"], | |
| cost: withOpenAiLongContextPricing({ input: 10, output: 50, cacheRead: 1, cacheWrite: 12.5 }), | |
| contextWindow: CODEX_CONTEXT, | |
| maxTokens: CODEX_MAX_TOKENS, | |
| }, | |
| { | |
| id: "gpt-5.3-codex-spark", | |
| name: "GPT-5.3 Codex Spark", | |
| api: "openai-codex-responses", | |
| provider: "openai-codex", | |
| baseUrl: CODEX_BASE_URL, | |
| reasoning: true, | |
| input: ["text"], | |
| cost: { input: 1.75, output: 14, cacheRead: 0.175, cacheWrite: 0 }, | |
| contextWindow: CODEX_SPARK_CONTEXT, | |
| maxTokens: CODEX_MAX_TOKENS, | |
| }, | |
| { | |
| id: "gpt-5.5", | |
| name: "GPT-5.5", | |
| api: "openai-codex-responses", | |
| provider: "openai-codex", | |
| baseUrl: CODEX_BASE_URL, | |
| reasoning: true, | |
| input: ["text", "image"], | |
| cost: withOpenAiLongContextPricing({ input: 5, output: 30, cacheRead: 0.5, cacheWrite: 0 }), | |
| contextWindow: CODEX_CONTEXT, | |
| maxTokens: CODEX_MAX_TOKENS, | |
| }, | |
| { | |
| id: "gpt-5.6-luna", | |
| name: "GPT-5.6 Luna", | |
| api: "openai-codex-responses", | |
| provider: "openai-codex", | |
| baseUrl: CODEX_BASE_URL, | |
| reasoning: true, | |
| input: ["text", "image"], | |
| cost: withOpenAiLongContextPricing(OPENAI_GPT_56_STANDARD_COSTS["gpt-5.6-luna"]), | |
| contextWindow: CODEX_GPT_56_CONTEXT, | |
| maxTokens: CODEX_MAX_TOKENS, | |
| }, | |
| { | |
| id: "gpt-5.6-sol", | |
| name: "GPT-5.6 Sol", | |
| api: "openai-codex-responses", | |
| provider: "openai-codex", | |
| baseUrl: CODEX_BASE_URL, | |
| reasoning: true, | |
| input: ["text", "image"], | |
| cost: withOpenAiLongContextPricing({ input: 5, output: 30, cacheRead: 0.5, cacheWrite: 6.25 }), | |
| contextWindow: CODEX_GPT_56_CONTEXT, | |
| maxTokens: CODEX_MAX_TOKENS, | |
| }, | |
| { | |
| id: "gpt-5.6-terra", | |
| name: "GPT-5.6 Terra", | |
| api: "openai-codex-responses", | |
| provider: "openai-codex", | |
| baseUrl: CODEX_BASE_URL, | |
| reasoning: true, | |
| input: ["text", "image"], | |
| cost: withOpenAiLongContextPricing(OPENAI_GPT_56_STANDARD_COSTS["gpt-5.6-terra"]), | |
| contextWindow: CODEX_GPT_56_CONTEXT, | |
| maxTokens: CODEX_MAX_TOKENS, | |
| }, | |
| ]; | |
| allModels.push(...codexModels); | |
| // Add missing Mistral Medium 3.5 model until models.dev includes it | |
| if (!allModels.some(m => m.provider === "mistral" && m.id === "mistral-medium-3.5")) { | |
| allModels.push({ | |
| id: "mistral-medium-3.5", | |
| name: "Mistral Medium 3.5", | |
| api: "mistral-conversations", | |
| provider: "mistral", | |
| baseUrl: "https://api.mistral.ai", | |
| reasoning: true, | |
| input: ["text", "image"], | |
| cost: { | |
| input: 1.5, | |
| output: 7.5, | |
| cacheRead: 0, | |
| cacheWrite: 0, | |
| }, | |
| contextWindow: 262144, // 256k tokens | |
| maxTokens: 262144, | |
| }); | |
| } | |
| // Add "auto" alias for openrouter/auto | |
| if (!allModels.some(m => m.provider === "openrouter" && m.id === "auto")) { | |
| allModels.push({ | |
| id: "auto", | |
| name: "Auto", | |
| api: "openai-completions", | |
| provider: "openrouter", | |
| baseUrl: "https://openrouter.ai/api/v1", | |
| reasoning: true, | |
| input: ["text", "image"], | |
| cost: { | |
| // we dont know about the costs because OpenRouter auto routes to different models | |
| // and then charges you for the underlying used model | |
| input:0, | |
| output:0, | |
| cacheRead:0, | |
| cacheWrite:0, | |
| }, | |
| contextWindow: 2000000, | |
| maxTokens: 30000, | |
| }); | |
| } | |
| // Add "fusion" alias for openrouter/fusion. OpenRouter exposes Fusion as a | |
| // router alias/plugin entry point; its model metadata does not advertise | |
| // tools, but the alias resolves to a concrete model that can invoke caller | |
| // tools and has the openrouter:fusion server tool auto-injected. | |
| if (!allModels.some(m => m.provider === "openrouter" && m.id === "openrouter/fusion")) { | |
| allModels.push({ | |
| id: "openrouter/fusion", | |
| name: "OpenRouter: Fusion", | |
| api: "openai-completions", | |
| provider: "openrouter", | |
| baseUrl: "https://openrouter.ai/api/v1", | |
| reasoning: true, | |
| input: ["text"], | |
| cost: { | |
| // we dont know about the costs because Fusion routes to multiple models | |
| // and then charges you for the underlying used models | |
| input: 0, | |
| output: 0, | |
| cacheRead: 0, | |
| cacheWrite: 0, | |
| }, | |
| contextWindow: 1000000, | |
| maxTokens: 30000, | |
| }); | |
| } | |
| // Azure Foundry deploys these with larger context windows than OpenAI's own short-tier defaults. | |
| // See models-sold-directly-by-azure docs. | |
| const AZURE_CONTEXT_WINDOW_OVERRIDES: Record<string, number> = { | |
| "gpt-5.4": 1050000, | |
| "gpt-5.5": 1050000, | |
| "gpt-5.6-luna": 1050000, | |
| "gpt-5.6-sol": 1050000, | |
| "gpt-5.6-terra": 1050000, | |
| }; | |
| const azureOpenAiModels: Model<Api>[] = allModels | |
| .filter((model) => model.provider === "openai" && model.api === "openai-responses") | |
| .map((model) => ({ | |
| ...model, | |
| api: "azure-openai-responses", | |
| provider: "azure-openai-responses", | |
| baseUrl: "", | |
| cost: { | |
| input: model.cost.input, | |
| output: model.cost.output, | |
| cacheRead: model.cost.cacheRead, | |
| cacheWrite: model.cost.cacheWrite, | |
| }, | |
| contextWindow: AZURE_CONTEXT_WINDOW_OVERRIDES[model.id] ?? model.contextWindow, | |
| })); | |
| allModels.push(...azureOpenAiModels); | |
| for (const model of allModels) { | |
| applyOpenAICompletionsCompatMetadata(model); | |
| applyAnthropicMessagesCompatMetadata(model); | |
| applyModelsDevReasoningOptionMetadata(model); | |
| applyThinkingLevelMetadata(model); | |
| applyStrictToolCompatMetadata(model); | |
| applyOpenAIGrammarToolCompatMetadata(model); | |
| applyOpenAIToolSearchMetadata(model); | |
| applyOpenAIExplicitPromptCacheMetadata(model); | |
| } | |
| applyAnthropicAllowedFallbackModelMetadata(allModels.filter(isAnthropicFallbackMetadataModel)); | |
| // Group by provider and deduplicate by model ID | |
| const providers: Record<string, Record<string, Model<any>>> = {}; | |
| for (const model of allModels) { | |
| if (!providers[model.provider]) { | |
| providers[model.provider] = {}; | |
| } | |
| // Use model ID as key to automatically deduplicate | |
| // Only add if not already present (models.dev takes priority over OpenRouter) | |
| if (!providers[model.provider][model.id]) { | |
| providers[model.provider][model.id] = model; | |
| } | |
| } | |
| const sortedProviderIds = Object.keys(providers).sort(); | |
| const jsonProviders: Record<string, Record<string, Model<any>>> = {}; | |
| for (const providerId of sortedProviderIds) { | |
| jsonProviders[providerId] = {}; | |
| for (const modelId of Object.keys(providers[providerId]).sort()) { | |
| jsonProviders[providerId][modelId] = providers[providerId][modelId]; | |
| } | |
| } | |
| const serializeJson = (value: unknown) => `${JSON.stringify(value, null, generatorOptions.pretty ? 2 : undefined)}\n`; | |
| const writeJson = (path: string, value: unknown) => writeFileSync(path, serializeJson(value)); | |
| const generatedDataProviderIds = generatorOptions.dataOnly | |
| ? readModelDataProviderIds(packageRoot) | |
| : sortedProviderIds; | |
| const missingProviderIds = generatedDataProviderIds.filter((providerId) => !jsonProviders[providerId]); | |
| if (missingProviderIds.length > 0) { | |
| throw new Error(`Cannot hydrate missing providers: ${missingProviderIds.join(", ")}`); | |
| } | |
| // Only the ignored internal data is grouped by API for type derivation. Public JSON catalog output stays flat. | |
| const generatedDataProviders: Record<string, Record<string, Record<string, Model<Api>>>> = {}; | |
| const modelDataStructure: ModelDataStructure = {}; | |
| for (const providerId of generatedDataProviderIds) { | |
| const models = jsonProviders[providerId]; | |
| generatedDataProviders[providerId] = {}; | |
| modelDataStructure[providerId] = {}; | |
| const apiIds = Array.from(new Set(Object.values(models).map((model) => model.api))).sort(); | |
| for (const api of apiIds) { | |
| generatedDataProviders[providerId][api] = {}; | |
| for (const [modelId, model] of Object.entries(models)) { | |
| if (model.api !== api) continue; | |
| generatedDataProviders[providerId][api][modelId] = model; | |
| modelDataStructure[providerId][modelId] = api; | |
| } | |
| } | |
| } | |
| const generatedAt = new Date().toISOString(); | |
| if (!generatorOptions.jsonOnly) { | |
| // Stage and validate all provider values before replacing the current generated data. | |
| const providersDir = join(packageRoot, "src/providers"); | |
| const dataDir = join(providersDir, "data"); | |
| const stagingRoot = mkdtempSync(join(providersDir, ".model-generation-")); | |
| const stagedDataDir = join(stagingRoot, "data"); | |
| const previousDataDir = join(stagingRoot, "previous-data"); | |
| let restoreGeneratedCatalog: (() => void) | undefined; | |
| try { | |
| mkdirSync(stagedDataDir, { recursive: true }); | |
| const fileContents: Record<string, string> = {}; | |
| for (const providerId of generatedDataProviderIds) { | |
| const filename = `${providerId}.json`; | |
| const content = serializeJson(generatedDataProviders[providerId]); | |
| fileContents[filename] = content; | |
| writeFileSync(join(stagedDataDir, filename), content); | |
| } | |
| writeJson( | |
| join(stagedDataDir, MODEL_DATA_MANIFEST_FILE), | |
| createModelDataManifest(modelDataStructure, fileContents, generatedAt), | |
| ); | |
| validateModelDataDirectory(modelDataStructure, stagedDataDir); | |
| if (!generatorOptions.dataOnly) { | |
| const previousShardContents = new Map( | |
| readdirSync(providersDir) | |
| .filter((entry) => entry.endsWith(".models.ts")) | |
| .map((entry) => [entry, readFileSync(join(providersDir, entry), "utf8")] as const), | |
| ); | |
| const aggregatorPath = join(packageRoot, "src/models.generated.ts"); | |
| const previousAggregator = readFileSync(aggregatorPath, "utf8"); | |
| restoreGeneratedCatalog = () => { | |
| for (const entry of readdirSync(providersDir)) { | |
| if (entry.endsWith(".models.ts")) rmSync(join(providersDir, entry)); | |
| } | |
| for (const [entry, content] of previousShardContents) { | |
| writeFileSync(join(providersDir, entry), content); | |
| } | |
| writeFileSync(aggregatorPath, previousAggregator); | |
| }; | |
| const generatedHeader = `// This file is auto-generated by scripts/generate-models.ts | |
| // Do not edit manually - run 'npm run generate-models' to update | |
| `; | |
| const catalogConstName = (providerId: string) => | |
| `${providerId.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}_MODELS`; | |
| const generatedShardFiles = new Set<string>(); | |
| for (const providerId of sortedProviderIds) { | |
| let output = generatedHeader; | |
| output += `import values from "./data/${providerId}.json" with { type: "json" };\n`; | |
| output += `import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";\n\n`; | |
| output += `export const ${catalogConstName(providerId)}: ModelCatalog<typeof values, ${JSON.stringify(providerId)}> =\n`; | |
| output += `\tflattenModelCatalog(${JSON.stringify(providerId)}, values);\n`; | |
| const filename = `${providerId}.models.ts`; | |
| generatedShardFiles.add(filename); | |
| writeFileSync(join(providersDir, filename), output); | |
| } | |
| for (const entry of readdirSync(providersDir)) { | |
| if (entry.endsWith(".models.ts") && !generatedShardFiles.has(entry)) rmSync(join(providersDir, entry)); | |
| } | |
| let output = generatedHeader; | |
| for (const providerId of sortedProviderIds) { | |
| output += `import { ${catalogConstName(providerId)} } from "./providers/${providerId}.models.ts";\n`; | |
| } | |
| output += `\nexport const MODELS: {\n`; | |
| for (const providerId of sortedProviderIds) { | |
| output += `\treadonly ${JSON.stringify(providerId)}: typeof ${catalogConstName(providerId)};\n`; | |
| } | |
| output += `} = {\n`; | |
| for (const providerId of sortedProviderIds) { | |
| output += `\t${JSON.stringify(providerId)}: ${catalogConstName(providerId)},\n`; | |
| } | |
| output += `};\n`; | |
| writeFileSync(aggregatorPath, output); | |
| console.log("Generated provider catalogs and src/models.generated.ts"); | |
| } | |
| const hadPreviousData = existsSync(dataDir); | |
| if (hadPreviousData) renameSync(dataDir, previousDataDir); | |
| try { | |
| renameSync(stagedDataDir, dataDir); | |
| validateGeneratedModelData(packageRoot); | |
| } catch (error) { | |
| rmSync(dataDir, { recursive: true, force: true }); | |
| if (hadPreviousData && existsSync(previousDataDir)) renameSync(previousDataDir, dataDir); | |
| throw error; | |
| } | |
| restoreGeneratedCatalog = undefined; | |
| console.log( | |
| generatorOptions.dataOnly | |
| ? "Hydrated JSON model values under src/providers/data/" | |
| : "Generated JSON model values under src/providers/data/", | |
| ); | |
| } catch (error) { | |
| restoreGeneratedCatalog?.(); | |
| throw error; | |
| } finally { | |
| rmSync(stagingRoot, { recursive: true, force: true }); | |
| } | |
| } | |
| if (generatorOptions.jsonOutputDir) { | |
| const providerOutputDir = join(generatorOptions.jsonOutputDir, "providers"); | |
| rmSync(generatorOptions.jsonOutputDir, { recursive: true, force: true }); | |
| mkdirSync(providerOutputDir, { recursive: true }); | |
| writeJson(join(generatorOptions.jsonOutputDir, "models.json"), jsonProviders); | |
| writeJson(join(generatorOptions.jsonOutputDir, "providers.json"), sortedProviderIds); | |
| for (const providerId of sortedProviderIds) { | |
| writeJson(join(providerOutputDir, `${providerId}.json`), jsonProviders[providerId]); | |
| } | |
| console.log(`Generated JSON model catalog under ${generatorOptions.jsonOutputDir}`); | |
| } | |
| // Print statistics | |
| const totalModels = allModels.length; | |
| const reasoningModels = allModels.filter(m => m.reasoning).length; | |
| console.log(`\nModel Statistics:`); | |
| console.log(` Total tool-capable models: ${totalModels}`); | |
| console.log(` Reasoning-capable models: ${reasoningModels}`); | |
| for (const [provider, models] of Object.entries(providers)) { | |
| console.log(` ${provider}: ${Object.keys(models).length} models`); | |
| } | |
| } | |
| // Run the generator | |
| generateModels().catch((error) => { | |
| console.error(error); | |
| process.exitCode = 1; | |
| }); | |