diff --git a/.pi/skills/add-llm-provider.md b/.pi/skills/add-llm-provider.md index 97cd9a3ec..a8492c056 100644 --- a/.pi/skills/add-llm-provider.md +++ b/.pi/skills/add-llm-provider.md @@ -31,10 +31,12 @@ Create a provider file exporting: - Register the provider in `packages/ai/src/providers/register-builtins.ts` via lazy loader wrappers; do not statically import provider implementation modules there. - Add credential detection in `packages/ai/src/env-api-keys.ts`. -## 4. Model Generation (`packages/ai/scripts/generate-models.ts`) +## 4. Model Generation (`packages/ai/scripts/model-generation/`) -- Add logic to fetch/parse models from the provider source. -- Map to the standardized `Model` interface. +- Add `providers/.ts` with a `defineProvider()` declaration: a `modelsDev()` source (or a custom source function), static `models` for entries upstream lacks, and `rules` for provider-specific corrections. Register it in `providers/index.ts`. +- Cross-provider facts (thinking levels, tool capabilities) go into the matching pack under `rules/`. +- Every `rule()` needs a `why`. Read `packages/ai/scripts/model-generation/README.md` for matcher, patch, and ordering semantics. +- Verify with `node scripts/generate-models.ts --strict --json-only --json-output /tmp/catalog --explain :` and `node scripts/diff-model-catalog.mjs ` from the repo root. ## 5. Tests (`packages/ai/test/`) diff --git a/biome.json b/biome.json index c306633ce..8ee53e4a7 100644 --- a/biome.json +++ b/biome.json @@ -27,6 +27,7 @@ "includes": [ "packages/*/src/**/*.ts", "packages/*/test/**/*.ts", + "packages/ai/scripts/**/*.ts", "packages/session-backends/*/src/**/*.ts", "packages/session-backends/*/test/**/*.ts", "packages/coding-agent/examples/**/*.ts", diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 474a52678..44b623f09 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Changed + +- Reorganized model catalog generation into declarative provider definitions and ordered rule packs under `scripts/model-generation/`, type-checked by `npm run check`. Generated model data is unchanged apart from a stable key order. `generate-models.ts` gained `--upstream-snapshot ` to record and replay upstream catalogs and `--explain :` to list the rules applied to a model. + ## [0.86.1] - 2026-09-20 ### Added diff --git a/packages/ai/README.md b/packages/ai/README.md index 3eb24c1c6..011ed92e8 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -1686,10 +1686,10 @@ Create a new API implementation file (for example `bedrock-converse-stream.ts`) Add a lazy wrapper `src/api/.lazy.ts` (`Api()` via `lazyApi()`) so providers can reference the implementation without importing its SDK. Add any root-level `export type` re-exports in `src/index.ts` that should remain available from `@earendil-works/pi-ai`. -#### 3. Model Generation (`scripts/generate-models.ts`, `scripts/generate-image-models.ts`) +#### 3. Model Generation (`scripts/model-generation/`, `scripts/generate-image-models.ts`) -- Add logic to fetch and parse models from the provider's source (e.g., models.dev API) -- Map chat/tool-capable provider model data to the standardized `Model` interface via `scripts/generate-models.ts`; hydration groups the ignored `src/providers/data/.json` values by API, while stable `src/providers/.models.ts` wrappers derive exact model/API types directly from those JSON keys +- Declare the provider in `scripts/model-generation/providers/.ts` with `defineProvider()`: a source (usually `modelsDev()`), static models upstream lacks, and `rules` for provider-specific corrections; register it in `providers/index.ts`. See `scripts/model-generation/README.md` +- Hydration groups the ignored `src/providers/data/.json` values by API, while stable `src/providers/.models.ts` wrappers derive exact model/API types directly from those JSON keys - Map image-generation provider model data to the standardized `ImagesModel` interface via `scripts/generate-image-models.ts` - Handle provider-specific quirks (pricing format, capability flags, model ID transformations) diff --git a/packages/ai/scripts/generate-image-models.ts b/packages/ai/scripts/generate-image-models.ts index 26e46e450..464f860de 100644 --- a/packages/ai/scripts/generate-image-models.ts +++ b/packages/ai/scripts/generate-image-models.ts @@ -33,10 +33,7 @@ interface OpenRouterModelRecord { }; } -export function parseOpenRouterImageModels( - payload: unknown, - strict: boolean, -): ImagesModel<"openrouter-images">[] { +export function parseOpenRouterImageModels(payload: unknown, strict: boolean): ImagesModel<"openrouter-images">[] { const data = typeof payload === "object" && payload !== null ? (payload as { data?: OpenRouterModelRecord[] }).data diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index 54a2e331b..c228dbb5c 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -1,3288 +1,126 @@ #!/usr/bin/env node -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, - ModelPromptCache, - OpenAICompletionsCompat, - OpenAIResponsesCompat, -} from "../src/types.ts"; -import { - assertExactModelIds, - createModelDataManifest, - type ModelDataStructure, - MODEL_DATA_MANIFEST_FILE, - readModelDataProviderIds, - validateGeneratedModelData, - validateModelDataDirectory, -} from "./model-data.ts"; -import { - DEFAULT_RADIUS_GATEWAY, - getRadiusModelsFromConfig, - loadRadiusGatewayConfig, -} from "../src/providers/radius-config.ts"; +// Generates pi's built-in model catalog from upstream sources plus the declarative +// provider definitions and rule packs under scripts/model-generation/. See +// scripts/model-generation/README.md for how the pieces fit together. -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); -const packageRoot = join(__dirname, ".."); +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { buildModelCatalog } from "./model-generation/engine.ts"; +import { sortCatalog, writeCatalog } from "./model-generation/output.ts"; +import { PROVIDERS } from "./model-generation/providers/index.ts"; +import { RULE_PACKS } from "./model-generation/rules/index.ts"; +import { UpstreamCatalogs } from "./model-generation/sources.ts"; -function readGeneratorOptions(args: string[]): { +const packageRoot = join(dirname(fileURLToPath(import.meta.url)), ".."); + +interface GeneratorOptions { 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; + upstreamSnapshotDir: string | undefined; + explain: { provider: string; id: string }[]; +} + +function readGeneratorOptions(args: string[]): GeneratorOptions { + const options: GeneratorOptions = { + strict: false, + dataOnly: false, + jsonOnly: false, + jsonOutputDir: undefined, + pretty: false, + upstreamSnapshotDir: undefined, + explain: [], + }; for (let index = 0; index < args.length; index++) { const arg = args[index]; if (arg === "--strict") { - strict = true; + options.strict = true; continue; } if (arg === "--data-only") { - dataOnly = true; + options.dataOnly = true; continue; } if (arg === "--json-only") { - jsonOnly = true; + options.jsonOnly = true; continue; } if (arg === "--pretty") { - pretty = true; + options.pretty = true; continue; } if (arg === "--json-output") { const value = args[++index]; if (!value) throw new Error("--json-output requires a directory"); - jsonOutputDir = resolve(value); + options.jsonOutputDir = resolve(value); + continue; + } + if (arg === "--upstream-snapshot") { + const value = args[++index]; + if (!value) throw new Error("--upstream-snapshot requires a directory"); + options.upstreamSnapshotDir = resolve(value); + continue; + } + if (arg === "--explain") { + const value = args[++index]; + const separator = value?.indexOf(":") ?? -1; + if (!value || separator <= 0) throw new Error("--explain requires :"); + options.explain.push({ provider: value.slice(0, separator), id: value.slice(separator + 1) }); 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; -} - -type ModelsDevCatalog = Record; - -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; - -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([ - "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([ - "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["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", -]); -const OPENAI_ADDITIONAL_TOOLS_MODEL_IDS = OPENAI_TOOL_SEARCH_MODEL_IDS; -const OPENAI_MID_CONVO_SYSTEM_MESSAGE_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["cost"]): Model["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 = { - "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["thinkingLevelMap"]>>; - -function mergeThinkingLevelMap(model: Model, map: NonNullable["thinkingLevelMap"]>): void { - model.thinkingLevelMap = { ...model.thinkingLevelMap, ...map }; -} - -const modelsDevReasoningOptions = new Map(); - -function getModelKey(model: Pick, "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); + if (options.jsonOnly && !options.jsonOutputDir) throw new Error("--json-only requires --json-output"); + if (options.dataOnly && (options.jsonOnly || options.jsonOutputDir)) { + throw new Error("--data-only cannot be combined with JSON catalog output"); } + return options; } -function supportsDirectReasoningEffort(model: Model): 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): 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["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): 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") - ); -} - -const VERIFIED_ANTHROPIC_MID_CONVO_EFFORT_PROVIDERS = new Set(["anthropic", "openrouter"]); -// OpenRouter rejects `configuration_update` system messages on Opus 5 ("Mid-conversation -// reasoning effort (configuration_update) is not supported on anthropic/claude-opus-5-20260723") -// while accepting them on Fable 5.1, so gate that model there. -const MID_CONVO_EFFORT_UNSUPPORTED_ANTHROPIC_MODELS = new Set(["openrouter:anthropic/claude-opus-5"]); - -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 supportsAnthropicMidConvoSystemMessages(modelId: string): boolean { - return ( - /^claude-opus-(?:4[.-]8|5)(?:-\d{8})?$/.test(modelId) || - /^claude-(?:fable|mythos)-5(?:[.-]1)?(?:-\d{8})?$/.test(modelId) - ); -} - -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, - supportsMidConvoSystemMessages: false, - supportsMidConvoToolAdditions: false, - sendSessionAffinityHeaders: false, - supportsLongCacheRetention: true, -} satisfies Required< - Omit< - OpenAICompletionsCompat, - "cacheControlFormat" | "supportsThinkingTokenBudget" | "thinkingTokenBudgetField" - > -> & { - cacheControlFormat?: OpenAICompletionsCompat["cacheControlFormat"]; -}; - -type OpenAICompletionsResolvedCompat = typeof OPENAI_COMPLETIONS_DEFAULT_COMPAT & { - cacheControlFormat?: OpenAICompletionsCompat["cacheControlFormat"]; -}; - -function mergeAnthropicMessagesCompat(model: Model, 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 isCerebras = provider === "cerebras" || baseUrl.includes("cerebras.ai"); - const isTogetherReasoningOnly = isTogether && TOGETHER_REASONING_ONLY_MODELS.has(model.id); - const isDeepSeek = provider === "deepseek" || baseUrl.toLowerCase().includes("deepseek.com"); - - const isNonStandard = - isNvidia || - isCerebras || - 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 && !isCerebras, - supportsOpenAIGrammarTools: false, - supportsMidConvoSystemMessages: false, - supportsMidConvoToolAdditions: 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)[key] = value; - } - } - return delta; -} - -function mergeOpenAICompletionsCompat(model: Model, compat: OpenAICompletionsCompat): void { - model.compat = { ...(model.compat as OpenAICompletionsCompat | undefined), ...compat }; -} - -function applyOpenAICompletionsCompatMetadata(model: Model): 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): 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): 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): 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([ - "openai-responses", - "azure-openai-responses", - "openai-codex-responses", -]); - -function applyOpenAIGrammarToolCompatMetadata(model: Model): 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): 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, - }; -} - -// Moonshot Kimi K2.6/K2.7 accept system text after the conversation starts but reject -// tool-bearing system messages. Kimi K3 accepts both forms; Fireworks and OpenCode pass -// its tool-bearing form through. GitHub Copilot forwards K3 text but silently drops its -// tool-bearing message. DeepSeek V4 Pro and OpenAI models behind OpenRouter also accept -// plain system text in place. -function applyOpenAICompletionsTranscriptMetadata(model: Model): void { - if (model.api !== "openai-completions") return; - const isKimiK3 = - (model.provider.startsWith("moonshot") && model.id === "kimi-k3") || - (model.provider === "fireworks" && model.id.includes("kimi-k3")) || - ((model.provider === "opencode" || model.provider === "opencode-go") && model.id === "kimi-k3"); - const isMoonshotKimiK2 = - model.provider.startsWith("moonshot") && - (model.id === "kimi-k2.6" || model.id === "kimi-k2.7-code" || model.id === "kimi-k2.7-code-highspeed"); - const isTextOnly = - isMoonshotKimiK2 || - (model.provider === "github-copilot" && model.id === "kimi-k3") || - (model.provider === "deepseek" && model.id === "deepseek-v4-pro") || - (model.provider === "openrouter" && - model.id.startsWith("openai/") && - OPENAI_MID_CONVO_SYSTEM_MESSAGE_MODEL_IDS.has(model.id.slice("openai/".length))); - if (!isKimiK3 && !isTextOnly) return; - model.compat = { - ...(model.compat as OpenAICompletionsCompat | undefined), - supportsMidConvoSystemMessages: true, - ...(isKimiK3 ? { supportsMidConvoToolAdditions: true } : {}), - }; -} - -// Newer OpenAI Responses models accept developer messages after the conversation has started. -// OpenCode Zen, OpenCode Go, and GitHub Copilot pass both those messages and -// `additional_tools` items through to OpenAI unchanged; tool search is not verified -// through those proxies. -const OPENAI_RESPONSES_PROXY_PROVIDERS = new Set(["opencode", "opencode-go", "github-copilot"]); - -function applyOpenAIResponsesTranscriptMetadata(model: Model): void { - const isOpenAIResponses = model.provider === "openai" && model.api === "openai-responses"; - const isOpenAICodex = model.provider === "openai-codex" && model.api === "openai-codex-responses"; - const isProxiedResponses = - OPENAI_RESPONSES_PROXY_PROVIDERS.has(model.provider) && model.api === "openai-responses"; - if ( - !(isOpenAIResponses || isOpenAICodex || isProxiedResponses) || - !OPENAI_MID_CONVO_SYSTEM_MESSAGE_MODEL_IDS.has(model.id) - ) { - return; - } - model.compat = { - ...(model.compat as OpenAIResponsesCompat | undefined), - supportsMidConvoSystemMessages: true, - ...(isProxiedResponses ? { supportsAdditionalTools: 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): 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, - }; -} - -// Anthropic ephemeral entries have a hard five-minute lifetime; `ttl: "1h"` -// extends it to one hour. Only direct Anthropic is annotated so cache warming -// does not assume equivalent behavior through proxies. -// https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching -const ANTHROPIC_PROMPT_CACHE: ModelPromptCache = { short: 300, long: 3600 }; - -function applyPromptCacheMetadata(model: Model): void { - if (model.provider === "anthropic" && model.api === "anthropic-messages") { - model.promptCache = ANTHROPIC_PROMPT_CACHE; - } - // Do not add OpenAI lifetimes yet. Before enabling warming for explicit - // OpenAI caches, re-evaluate it using observed expiry, replay, and billing - // behavior; a documented TTL alone does not establish full cache loss. -} - -function isGemma4Model(modelId: string): boolean { - return /gemma-?4/.test(modelId.toLowerCase()); -} - -function getGoogleThinkingLevelMap( - modelId: string, - reasoningOptions: readonly ModelsDevReasoningOption[], -): NonNullable["thinkingLevelMap"]> | undefined { - const effortMap = getEffortThinkingLevelMap(reasoningOptions); - if (effortMap) return effortMap; - if (isGemma4Model(modelId)) { - return { off: null, minimal: "MINIMAL", low: null, medium: null, high: "HIGH" }; - } - return undefined; -} - -function applyThinkingLevelMetadata(model: Model): 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") && - model.thinkingLevelMap === undefined - ) { - 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 (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) && - !MID_CONVO_EFFORT_UNSUPPORTED_ANTHROPIC_MODELS.has(`${provider}:${modelId}`) - ) { - compat.supportsMidConvoEffort = true; - } - if (provider === "anthropic" && supportsAnthropicMidConvoSystemMessages(modelId)) { - compat.supportsMidConvoSystemMessages = true; - compat.supportsMidConvoToolChanges = true; - } - // OpenCode Zen and GitHub Copilot forward mid-conversation system messages but reject - // `tool_addition`/`tool_removal` blocks, so tool changes stay top-level there. - if ((provider === "opencode" || provider === "github-copilot") && supportsAnthropicMidConvoSystemMessages(modelId)) { - compat.supportsMidConvoSystemMessages = 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, - }, - ]; +async function generateModels(): Promise { + const options = readGeneratorOptions(process.argv.slice(2)); + const result = await buildModelCatalog({ + strict: options.strict, + upstream: new UpstreamCatalogs({ strict: options.strict, snapshotDir: options.upstreamSnapshotDir }), + providers: PROVIDERS, + rulePacks: RULE_PACKS, }); - 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> { - 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(); - - 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[]> { - 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[] = []; - - 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 = { - 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 fetchRadiusModels(): Promise[]> { - try { - console.log("Fetching models from Radius API..."); - const config = await loadRadiusGatewayConfig(DEFAULT_RADIUS_GATEWAY); - const models = getRadiusModelsFromConfig("radius", config); - if (models.length === 0) throw new Error("Radius API returned no models"); - console.log(`Fetched ${models.length} models from Radius`); - return models; - } catch (error) { - console.error("Failed to fetch Radius models:", error); - if (generatorOptions.strict) throw error; - return []; - } -} - -async function fetchAiGatewayModels(): Promise[]> { - 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[] = []; - - 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, - compat: { allowEmptySignature: true }, - 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[] { - 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[] = []; - - 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); - } + for (const { provider, id } of options.explain) { + const hits = result.explain(provider, id); + console.log(`\nRules applied to ${provider}:${id}${hits.length === 0 ? " (none)" : ""}`); + for (const hit of hits) console.log(` [${hit.stage}] ${hit.rule.why} (${hit.rule.definedAt})`); } - return models; -} - -function processBasetenModels(provider: ModelsDevProvider | undefined): Model[] { - 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, - // Baseten automatic prompt caching needs session affinity so related - // requests land on the same replica. See: - // https://docs.baseten.co/inference/model-apis/pricing-and-limits - sendSessionAffinityHeaders: 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[] = []; - - 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, - }); + if (result.unmatchedRules.length > 0) { + console.log( + `\n${result.unmatchedRules.length} rule(s) matched no model; remove them or mark them mayMatchNothing:`, + ); + for (const { stage, rule } of result.unmatchedRules) console.log(` [${stage}] ${rule.why} (${rule.definedAt})`); } - return models; -} - -function processGoogleModels(data: ModelsDevCatalog): Model[] { - const models: Model[] = []; - const googleModels = data.google?.models; - if (googleModels) { - for (const [modelId, model] of Object.entries(googleModels)) { - if (model.tool_call !== true) continue; - const source = - modelId === "gemini-flash-latest" - ? (googleModels["gemini-3.5-flash"] ?? model) - : modelId === "gemini-flash-lite-latest" - ? (googleModels["gemini-3.1-flash-lite"] ?? model) - : model; - const thinkingLevelMap = getGoogleThinkingLevelMap(modelId, source.reasoning_options ?? []); - - models.push({ - id: modelId, - name: model.name || modelId, - api: "google-generative-ai", - provider: "google", - baseUrl: "https://generativelanguage.googleapis.com/v1beta", - reasoning: source.reasoning === true, - ...(thinkingLevelMap ? { thinkingLevelMap } : {}), - 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, - }); - } - } - - // The google-vertex models.dev catalog also includes Claude, OpenAI, and other - // MaaS models that do not use the @google/genai Gemini streaming path. - const vertexModels = data["google-vertex"]?.models; - if (vertexModels) { - for (const [modelId, model] of Object.entries(vertexModels)) { - if (model.tool_call !== true || !modelId.startsWith("gemini-")) continue; - if (modelId === "gemini-3.1-flash-lite-preview") continue; - const source = - modelId === "gemini-flash-latest" - ? (vertexModels["gemini-3.5-flash"] ?? model) - : modelId === "gemini-flash-lite-latest" - ? (vertexModels["gemini-3.1-flash-lite"] ?? model) - : model; - const thinkingLevelMap = getGoogleThinkingLevelMap(modelId, source.reasoning_options ?? []); - // 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: model.name || modelId, - api: "google-vertex", - provider: "google-vertex", - baseUrl: VERTEX_BASE_URL, - reasoning: source.reasoning === true, - ...(thinkingLevelMap ? { thinkingLevelMap } : {}), - 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, - }); - } - } - - return models; -} - -function processFireworksModels(provider: ModelsDevProvider | undefined): Model[] { - if (!provider?.models) return []; - - const anthropicCompat: AnthropicMessagesCompat = { - 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", - }; - const models: Model[] = []; - - 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[]> { - 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[] = []; - const nvidiaNimModelIds = data.nvidia?.models ? await fetchNvidiaNimModelIds() : new Map(); - - // 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); - } - } - - models.push(...processGoogleModels(data)); - - // 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(); - 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); - } - } - - // Process Meta models - if (data.meta?.models) { - for (const [modelId, model] of Object.entries(data.meta.models)) { - const m = model as ModelsDevModel; - if (m.tool_call !== true) continue; - - models.push({ - id: modelId, - name: m.name || modelId, - api: "openai-responses", - provider: "meta", - baseUrl: "https://api.meta.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("meta", 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 }; - } - } - - let thinkingLevelMap: NonNullable["thinkingLevelMap"]> | undefined; - if (api === "google-generative-ai") { - thinkingLevelMap = getGoogleThinkingLevelMap(modelId, m.reasoning_options ?? []); - } else if (variant.provider === "opencode-go" && modelId === "deepseek-v4.1-flash") { - thinkingLevelMap = getEffortThinkingLevelMap(m.reasoning_options ?? []); - } - models.push({ - id: modelId, - name: m.name || modelId, - api, - provider: variant.provider, - baseUrl, - reasoning: m.reasoning === true, - ...(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 ? { 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 = { - 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-code-plan-global"]?.models) { - const kimiModels = data["kimi-code-plan-global"].models as Record; - 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 => { - const providerModels = data[key]?.models as Record | 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.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() : 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 all upstream catalogs. - // models.dev: Anthropic, Google, OpenAI, Groq, Cerebras, and others - // OpenRouter: its tool-capable routed catalog - // AI Gateway: OpenAI-compatible catalog with tool-capable models - // Radius: its unauthenticated public catalog; authenticated clients overlay it at runtime - const modelsDevModels = await loadModelsDevData(); - const openRouterModels = await fetchOpenRouterModels(); - const aiGatewayModels = await fetchAiGatewayModels(); - const radiusModels = await fetchRadiusModels(); - - // Combine models (models.dev has priority where sources overlap). - const allModels = [...modelsDevModels, ...openRouterModels, ...aiGatewayModels, ...radiusModels].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 = { - "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[] = 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); - applyOpenAICompletionsTranscriptMetadata(model); - applyOpenAIResponsesTranscriptMetadata(model); - applyOpenAIExplicitPromptCacheMetadata(model); - applyPromptCacheMetadata(model); - } - applyAnthropicAllowedFallbackModelMetadata(allModels.filter(isAnthropicFallbackMetadataModel)); - - // Group by provider and deduplicate by model ID - const providers: Record>> = {}; - 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>> = {}; - 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>>> = {}; - 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 = {}; - 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(); - 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 =\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; - + writeCatalog(sortCatalog(result.providers), { + packageRoot, + dataOnly: options.dataOnly, + jsonOnly: options.jsonOnly, + jsonOutputDir: options.jsonOutputDir, + pretty: options.pretty, + }); + + const allModels = Array.from(result.providers.values()).flat(); 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`); + console.log(` Total tool-capable models: ${allModels.length}`); + console.log(` Reasoning-capable models: ${allModels.filter((model) => model.reasoning).length}`); + for (const [provider, models] of result.providers) { + console.log(` ${provider}: ${models.length} models`); } } -// Run the generator generateModels().catch((error) => { console.error(error); process.exitCode = 1; diff --git a/packages/ai/scripts/generate-test-image.ts b/packages/ai/scripts/generate-test-image.ts index da3df2661..baab86647 100644 --- a/packages/ai/scripts/generate-test-image.ts +++ b/packages/ai/scripts/generate-test-image.ts @@ -2,7 +2,7 @@ import { createCanvas } from "canvas"; import { mkdirSync, writeFileSync } from "fs"; -import { join, dirname } from "path"; +import { dirname, join } from "path"; import { fileURLToPath } from "url"; const __filename = fileURLToPath(import.meta.url); @@ -30,4 +30,4 @@ const outputPath = join(__dirname, "..", "test", "data", "red-circle.png"); mkdirSync(join(__dirname, "..", "test", "data"), { recursive: true }); writeFileSync(outputPath, buffer); -console.log(`Generated test image at: ${outputPath}`); \ No newline at end of file +console.log(`Generated test image at: ${outputPath}`); diff --git a/packages/ai/scripts/model-data.ts b/packages/ai/scripts/model-data.ts index f2081817a..8de13faa8 100644 --- a/packages/ai/scripts/model-data.ts +++ b/packages/ai/scripts/model-data.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; import { join } from "node:path"; export const MODEL_DATA_SCHEMA_VERSION = 3; @@ -14,8 +14,7 @@ export interface ModelDataManifest { files: Record; } -const MODEL_DATA_IMPORT_PATTERN = - /^import \{ [A-Z][A-Z0-9_]*_MODELS \} from "\.\/providers\/([^"/]+)\.models\.ts";$/gm; +const MODEL_DATA_IMPORT_PATTERN = /^import \{ [A-Z][A-Z0-9_]*_MODELS \} from "\.\/providers\/([^"/]+)\.models\.ts";$/gm; function sha256(value: string): string { return createHash("sha256").update(value).digest("hex"); @@ -34,7 +33,10 @@ function describeSetDifference(expected: readonly string[], actual: readonly str const actualSet = new Set(actual); const missing = expected.filter((value) => !actualSet.has(value)); const extra = actual.filter((value) => !expectedSet.has(value)); - return [missing.length > 0 ? `missing: ${missing.join(", ")}` : "", extra.length > 0 ? `extra: ${extra.join(", ")}` : ""] + return [ + missing.length > 0 ? `missing: ${missing.join(", ")}` : "", + extra.length > 0 ? `extra: ${extra.join(", ")}` : "", + ] .filter(Boolean) .join("; "); } @@ -149,7 +151,8 @@ function validateModelValue( errors.push(`${label} must be an object`); return; } - if (value.id !== modelId) errors.push(`${label} has id ${JSON.stringify(value.id)}, expected ${JSON.stringify(modelId)}`); + if (value.id !== modelId) + errors.push(`${label} has id ${JSON.stringify(value.id)}, expected ${JSON.stringify(modelId)}`); if (value.provider !== providerId) { errors.push(`${label} has provider ${JSON.stringify(value.provider)}, expected ${JSON.stringify(providerId)}`); } @@ -203,7 +206,9 @@ export function validateModelDataDirectory(structure: ModelDataStructure, dataDi .filter((entry) => entry.endsWith(".json") && entry !== MODEL_DATA_MANIFEST_FILE) .sort(); if (!sameStrings(expectedFiles, actualFiles)) { - errors.push(`provider data files do not match the generated catalog (${describeSetDifference(expectedFiles, actualFiles)})`); + errors.push( + `provider data files do not match the generated catalog (${describeSetDifference(expectedFiles, actualFiles)})`, + ); } const manifestPath = join(dataDir, MODEL_DATA_MANIFEST_FILE); @@ -225,7 +230,9 @@ export function validateModelDataDirectory(structure: ModelDataStructure, dataDi else { const manifestFileNames = Object.keys(manifestFiles).sort(); if (!sameStrings(expectedFiles, manifestFileNames)) { - errors.push(`manifest file hashes do not match provider data files (${describeSetDifference(expectedFiles, manifestFileNames)})`); + errors.push( + `manifest file hashes do not match provider data files (${describeSetDifference(expectedFiles, manifestFileNames)})`, + ); } } @@ -259,7 +266,9 @@ export function validateModelDataDirectory(structure: ModelDataStructure, dataDi const expectedModelIds = Object.keys(expectedModels).sort(); const actualModelIds = Array.from(actualModels.keys()).sort(); if (!sameStrings(expectedModelIds, actualModelIds)) { - errors.push(`${filename} model IDs do not match the generated catalog (${describeSetDifference(expectedModelIds, actualModelIds)})`); + errors.push( + `${filename} model IDs do not match the generated catalog (${describeSetDifference(expectedModelIds, actualModelIds)})`, + ); } for (const [modelId, expectedApi] of Object.entries(expectedModels)) { const actualApi = actualModels.get(modelId); diff --git a/packages/ai/scripts/model-generation/README.md b/packages/ai/scripts/model-generation/README.md new file mode 100644 index 000000000..4f0be09ec --- /dev/null +++ b/packages/ai/scripts/model-generation/README.md @@ -0,0 +1,166 @@ +# Model catalog generation + +`scripts/generate-models.ts` builds pi's built-in model catalog. It reads upstream +catalogs (models.dev, OpenRouter, Vercel AI Gateway, Radius, NVIDIA NIM), applies pi's +own knowledge about each model, and writes `src/providers/data/.json` plus the +typed `src/providers/.models.ts` wrappers. + +This directory holds that knowledge as declarations instead of code paths. + +## Why declarations + +The previous generator was one 3300-line script. Three kinds of logic were interleaved: + +1. **Ingestion**: ~25 near-identical loops mapping a models.dev provider onto `Model`. +2. **Hand-maintained models**: providers without an upstream catalog. +3. **Corrections**: hundreds of `if (provider === X && id === Y)` patches for context + windows, prices, thinking levels, and compat flags, spread over a dozen passes whose + order mattered and was implicit. + +Adding one fact ("this model accepts effort `xhigh`") meant finding the right pass, +matching the right id spelling, and hoping nothing later overwrote it. Nothing type-checked +the script, so a misspelled compat flag was silently dropped from the catalog. + +Now a fact is one `rule()` with a `why`, in one place, applied in a documented order, and +`npm run check` rejects unknown flags. + +## Layout + +``` +scripts/generate-models.ts CLI: options, run the engine, write outputs +scripts/model-generation/ + dsl.ts rule(), defineProvider(), matchers, patch semantics + engine.ts applies sources, provider rules, then rule packs + sources.ts upstream fetchers, response types, snapshot replay + models-dev.ts generic models.dev -> Model mapping + output.ts JSON data, manifest, generated TypeScript shards + shared.ts constants used by more than one file + providers/.ts one declaration per provider (families share a file) + providers/index.ts generation order + rules/.ts catalog-wide rule packs + rules/index.ts pack order +``` + +## Concepts + +### Provider definition + +```ts +export const xai = defineProvider({ + id: "xai", + source: modelsDev({ + provider: "xai", + key: "xai", // models.dev provider key + api: "openai-responses", + baseUrl: "https://api.x.ai/v1", + compat: { supportsLongCacheRetention: false }, + }), + models: [/* hand-maintained Model objects, added when the source lacks the id */], + rules: [/* corrections scoped to this provider */], +}); +``` + +`source` is a function `(context) => Promise`. `modelsDev()` builds one from +options; OpenRouter, Vercel, Radius, NVIDIA and Azure use small custom functions. A +provider may list several sources; the first source to produce an id wins. + +`modelsDev()` handles the repeated parts of ingestion and exposes a few knobs for the +recurring variations: `include`, `skipDeprecated`, `mapId`, `route` (per-model api, +baseUrl, compat), `aliases`, `defaults`, `costTiers`. Everything else is a rule. + +### Rules + +```ts +rule({ + why: "OpenAI rejects minimal effort on GPT-5.5.", + match: { provider: "openai", id: "gpt-5.5" }, + set: { thinkingLevelMap: { minimal: null } }, +}); +``` + +- `match` is plain data where possible: `provider`, `api`, `id`, `idPrefix`, `idIncludes`, + `idPattern`, `reasoning`, `not`. Arrays mean "any of". `when(model, context)` is the + escape hatch for computed conditions. +- `set` merges into the model. Object fields (`compat`, `cost`, `thinkingLevelMap`, + `headers`, `promptCache`) merge one level deep; other fields are assigned. +- `replace` assigns whole values. `apply(model, context)` computes a patch. `drop: true` + removes the model. +- `context` gives rules the models.dev entry the model came from (`source`, + `reasoningOptions`), the full models.dev catalog, and `lookup(provider, id)` for other + generated models. +- `why` is required. Put the date, link, or issue number there for anything that + corrects upstream data. + +### Order of application + +1. For each provider in `providers/index.ts`: run its sources, add static `models` that + the sources did not produce, then apply the provider's `rules` in order. +2. For each pack in `rules/index.ts`, for each model, apply the pack's rules in order. +3. Normalize: drop empty `compat`, give every model the same key order. + +Rules within a pack run per model in array order, so a later rule may refine an earlier +one and `when` conditions see the model's current state. Packs are ordered by dependency: +transport compat first, then models.dev effort options, then thinking-level corrections, +then tool and cache capabilities, then fallbacks that read other models' final prices. + +Where a correction lives: + +- Single provider: that provider's `rules`. +- Several providers, one concern: the matching pack (`thinking-levels`, `tool-capabilities`, + ...). `catalog-overrides` holds cross-provider data corrections that fit nowhere else. + +## Diverging from models.dev + +The generator trusts models.dev by default and records every divergence as a rule. To keep +divergences from piling up: + +- Each run prints rules that matched nothing. Delete the rule, or mark it + `mayMatchNothing: true` when it targets a model upstream may legitimately stop listing. +- `--explain :` prints every rule that touched a model, in order, with its + `why` and source location. +- `node scripts/diff-model-catalog.mjs [provider]` (repo root) diffs the generated catalog + between HEAD and the worktree. +- `--upstream-snapshot ` records upstream responses on first use and replays them + afterwards, so two runs can be compared without upstream drift. + +## Adding a field + +New per-model metadata (for example image input limits) is a change to `Model` in +`src/types.ts` plus a rule or a `modelsDev()` option. Because `set` is typed against +`Model`, the field is immediately usable in every rule: + +```ts +rule({ + why: "Anthropic documents 8000x8000 px and 5 MB per image. https://...", + match: { idPrefix: "claude-" }, + set: { imageLimits: { maxDimension: 8000, maxBytes: 5_000_000 } }, +}); +``` + +Matching on the id rather than the provider expresses "this is a property of the model, +wherever it is served", which the old per-provider passes could not do cleanly. + +## Verifying a change + +```sh +node scripts/generate-models.ts --strict --json-only --json-output /tmp/catalog --upstream-snapshot /tmp/upstream +node scripts/generate-models.ts --strict --json-only --json-output /tmp/catalog --explain openai:gpt-5.5 +node ../../scripts/diff-model-catalog.mjs openai +npm run check +``` + +Generation tests spawn the script with a mocked `fetch`; see +`test/generate-models-strict.test.ts` and `test/fireworks-model-generation.test.ts`. + +## Not done yet + +This is the first step. The generated data is unchanged except for key order. Candidates +for follow-up, each a deliberate behavior change: + +- `costTiers` is only enabled for GitHub Copilot; other providers still drop models.dev + context-size price tiers. +- `skipDeprecated` is opt-in per provider. +- `detectOpenAICompletionsCompat` (endpoint-family request defaults) is still a function + keyed on provider ids and base URLs. It could become a table of endpoint families. +- Rules whose `set` values already equal the upstream value could be reported as redundant, + so corrections retire when models.dev catches up. diff --git a/packages/ai/scripts/model-generation/dsl.ts b/packages/ai/scripts/model-generation/dsl.ts new file mode 100644 index 000000000..a60c92b67 --- /dev/null +++ b/packages/ai/scripts/model-generation/dsl.ts @@ -0,0 +1,231 @@ +import type { Api, Model, ModelCost, ModelPromptCache, ThinkingLevelMap } from "../../src/types.ts"; +import type { ModelsDevReasoningOption } from "../models-dev-reasoning-options.ts"; +import type { ModelsDevCatalog, ModelsDevModel, UpstreamCatalogs } from "./sources.ts"; + +/** + * Declarative model catalog rules. + * + * A provider declaration says where its models come from (`source`), which + * hand-maintained entries to add when the source lacks them (`models`), and + * which corrections apply to its models (`rules`). Cross-provider concerns + * (thinking levels, transport compat flags) live in ordered rule packs. + * + * Every rule carries a `why`, matches models with plain data where possible, + * and patches them with `set` (merged into the model) or `replace`. `apply` + * and `when` are escape hatches for values that must be computed. + */ + +export type GeneratedModel = Model; + +type CompatUnion = NonNullable; +type KeysOfUnion = T extends unknown ? keyof T : never; +type ValueOfUnion = T extends unknown ? (K extends keyof T ? T[K] : never) : never; + +/** + * Union of every API's compat flags. Unknown flag names fail type checking; mixing + * flags across APIs does not, so keep rules scoped to one `api` where it matters. + */ +export type CompatPatch = { [K in KeysOfUnion]?: ValueOfUnion }; + +/** Fields a rule may change. Identity fields (`id`, `provider`, `api`) are fixed by the source. */ +export interface ModelPatch { + name?: string; + baseUrl?: string; + reasoning?: boolean; + thinkingLevelMap?: ThinkingLevelMap; + input?: GeneratedModel["input"]; + cost?: Partial; + promptCache?: ModelPromptCache; + contextWindow?: number; + maxTokens?: number; + samplingParams?: Record; + headers?: Record; + compat?: CompatPatch; +} + +export interface RuleContext { + readonly strict: boolean; + /** The full models.dev catalog, for rules that read sibling providers (for example reference prices). */ + readonly modelsDev: ModelsDevCatalog; + /** models.dev key the current model was generated from, when it came from models.dev. */ + readonly sourceId: string | undefined; + /** models.dev entry the current model was generated from, when it came from models.dev. */ + readonly source: ModelsDevModel | undefined; + /** Shorthand for `source?.reasoning_options`. */ + readonly reasoningOptions: readonly ModelsDevReasoningOption[] | undefined; + /** Another generated model in its current state. Providers are generated in declaration order. */ + lookup(provider: string, id: string): GeneratedModel | undefined; +} + +export interface ModelMatch { + provider?: string | readonly string[]; + api?: Api | readonly Api[]; + /** Exact model id(s). */ + id?: string | readonly string[]; + idPrefix?: string | readonly string[]; + /** Substring(s); any match counts. */ + idIncludes?: string | readonly string[]; + idPattern?: RegExp | readonly RegExp[]; + reasoning?: boolean; + /** Excludes models matching this nested matcher. */ + not?: ModelMatch; + /** Computed condition evaluated against the model's current state. */ + when?: (model: GeneratedModel, context: RuleContext) => boolean; +} + +export interface ModelRule { + /** Why the rule exists. Include links, dates, or issue numbers for upstream corrections. */ + why: string; + match: ModelMatch; + /** Merged into the model: object fields (`compat`, `cost`, `thinkingLevelMap`, ...) merge one level deep, everything else is assigned. */ + set?: ModelPatch; + /** Assigned to the model wholesale. */ + replace?: ModelPatch; + /** Computed patch for values that depend on upstream metadata or other models. */ + apply?: (model: GeneratedModel, context: RuleContext) => void; + /** Remove matching models from the catalog. */ + drop?: boolean; + /** Suppress the unmatched-rule report, for rules targeting models upstream may stop listing. */ + mayMatchNothing?: boolean; +} + +export interface DefinedRule extends ModelRule { + /** Source location of the `rule()` call, for reports. */ + readonly definedAt: string; +} + +const DSL_FILE_PATTERN = /model-generation[\\/]dsl\.ts/; + +function captureDefinitionSite(): string { + const stack = new Error().stack?.split("\n") ?? []; + const frame = stack.slice(1).find((line) => line.includes("model-generation") && !DSL_FILE_PATTERN.test(line)); + if (!frame) return "unknown"; + const match = /\(?((?:file:\/\/)?[^()]*?):(\d+):\d+\)?\s*$/.exec(frame.trim()); + if (!match) return frame.trim(); + const path = match[1].replace(/^file:\/\//, ""); + const relative = path.slice(path.lastIndexOf("model-generation")); + return `${relative}:${match[2]}`; +} + +export function rule(definition: ModelRule): DefinedRule { + if (!definition.set && !definition.replace && !definition.apply && !definition.drop) { + throw new Error(`Rule "${definition.why}" does not change anything`); + } + return { ...definition, definedAt: captureDefinitionSite() }; +} + +function toArray(value: T | readonly T[]): readonly T[] { + return Array.isArray(value) ? (value as readonly T[]) : [value as T]; +} + +export function matchesModel(model: GeneratedModel, match: ModelMatch, context: RuleContext): boolean { + if (match.provider !== undefined && !toArray(match.provider).includes(model.provider)) return false; + if (match.api !== undefined && !toArray(match.api).includes(model.api)) return false; + if (match.id !== undefined && !toArray(match.id).includes(model.id)) return false; + if (match.idPrefix !== undefined && !toArray(match.idPrefix).some((prefix) => model.id.startsWith(prefix))) + return false; + if (match.idIncludes !== undefined && !toArray(match.idIncludes).some((part) => model.id.includes(part))) + return false; + if (match.idPattern !== undefined && !toArray(match.idPattern).some((pattern) => pattern.test(model.id))) + return false; + if (match.reasoning !== undefined && model.reasoning !== match.reasoning) return false; + if (match.not !== undefined && matchesModel(model, match.not, context)) return false; + if (match.when !== undefined && !match.when(model, context)) return false; + return true; +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** Model fields that `set` merges one level deep instead of replacing. */ +const MERGED_FIELDS = new Set([ + "compat", + "cost", + "thinkingLevelMap", + "headers", + "promptCache", + "samplingParams", +]); + +function cloneValue(value: T): T { + if (Array.isArray(value)) return [...value] as T; + if (isPlainObject(value)) return { ...value } as T; + return value; +} + +export function applyModelPatch(model: GeneratedModel, patch: ModelPatch, mode: "merge" | "replace"): void { + const target = model as unknown as Record; + for (const [key, value] of Object.entries(patch)) { + if (value === undefined) continue; + const existing = target[key]; + if ( + mode === "merge" && + MERGED_FIELDS.has(key as keyof ModelPatch) && + isPlainObject(value) && + isPlainObject(existing) + ) { + target[key] = { ...existing, ...value }; + continue; + } + target[key] = cloneValue(value); + } +} + +/** The model's compat flags read through the merged flag type, for conditions on any API's flags. */ +export function compatOf(model: GeneratedModel): CompatPatch { + return (model.compat ?? {}) as CompatPatch; +} + +/** Merges `patch` into `model.compat`. Rules use `set: { compat }`; sources use this directly. */ +export function mergeCompat(model: GeneratedModel, patch: CompatPatch): void { + applyModelPatch(model, { compat: patch }, "merge"); +} + +export function mergeThinkingLevelMap(model: GeneratedModel, map: ThinkingLevelMap): void { + applyModelPatch(model, { thinkingLevelMap: map }, "merge"); +} + +export interface SourceContext { + readonly strict: boolean; + readonly upstream: UpstreamCatalogs; + /** Models already generated for another provider, in their post-rule state. */ + generated(provider: string): readonly GeneratedModel[]; + /** Remember the models.dev entry a model came from so rules can read it. */ + attachSource(model: GeneratedModel, sourceId: string, entry: ModelsDevModel): void; +} + +export type ModelSource = (context: SourceContext) => Promise; + +export interface ProviderDefinition { + id: string; + /** Upstream source(s). Concatenated in order; the first model with a given id wins. */ + source?: ModelSource | readonly ModelSource[]; + /** Hand-maintained models, added when no source produced the same id. */ + models?: readonly GeneratedModel[]; + /** Rules scoped to this provider's models, applied after sources and static models. */ + rules?: readonly DefinedRule[]; +} + +export function defineProvider(definition: ProviderDefinition): ProviderDefinition { + for (const model of definition.models ?? []) { + if (model.provider !== definition.id) { + throw new Error(`Static model ${model.id} declares provider ${model.provider} inside ${definition.id}`); + } + } + return definition; +} + +/** A named, ordered group of rules applied to the whole catalog. */ +export interface RulePack { + name: string; + rules: readonly DefinedRule[]; +} + +export function definePack(name: string, rules: readonly DefinedRule[]): RulePack { + return { name, rules }; +} + +export function roundCost(value: number): number { + return Number(value.toFixed(6)); +} diff --git a/packages/ai/scripts/model-generation/engine.ts b/packages/ai/scripts/model-generation/engine.ts new file mode 100644 index 000000000..138c63d7a --- /dev/null +++ b/packages/ai/scripts/model-generation/engine.ts @@ -0,0 +1,219 @@ +import type { Api, Model } from "../../src/types.ts"; +import { + applyModelPatch, + type DefinedRule, + type GeneratedModel, + matchesModel, + type ProviderDefinition, + type RuleContext, + type RulePack, + type SourceContext, +} from "./dsl.ts"; +import type { ModelsDevCatalog, ModelsDevModel, UpstreamCatalogs } from "./sources.ts"; + +export interface CatalogBuildOptions { + strict: boolean; + upstream: UpstreamCatalogs; + providers: readonly ProviderDefinition[]; + rulePacks: readonly RulePack[]; +} + +export interface RuleHit { + stage: string; + rule: DefinedRule; +} + +export interface CatalogBuildResult { + /** Generated models grouped by provider, providers in declaration order, models in generation order. */ + providers: ReadonlyMap; + /** Rules that matched no model, excluding rules declared `mayMatchNothing`. */ + unmatchedRules: readonly { stage: string; rule: DefinedRule }[]; + /** Rules that touched each model, keyed by `provider:id`. */ + explain(provider: string, id: string): readonly RuleHit[]; +} + +interface ModelOrigin { + sourceId: string; + entry: ModelsDevModel; +} + +function modelKey(provider: string, id: string): string { + return `${provider}:${id}`; +} + +export async function buildModelCatalog(options: CatalogBuildOptions): Promise { + const generated = new Map(); + const origins = new WeakMap(); + const hits = new Map(); + const matchCounts = new Map(); + // Every rule context can read the models.dev catalog, so load it before any provider runs. + const modelsDev: ModelsDevCatalog = await options.upstream.modelsDev(); + + const sourceContext: SourceContext = { + strict: options.strict, + upstream: options.upstream, + generated: (provider) => generated.get(provider) ?? [], + attachSource: (model, sourceId, entry) => { + origins.set(model, { sourceId, entry }); + }, + }; + + function lookup(provider: string, id: string): GeneratedModel | undefined { + return generated.get(provider)?.find((model) => model.id === id); + } + + function contextFor(model: GeneratedModel): RuleContext { + const origin = origins.get(model); + return { + strict: options.strict, + modelsDev, + sourceId: origin?.sourceId, + source: origin?.entry, + reasoningOptions: origin?.entry.reasoning_options, + lookup, + }; + } + + function recordHit(model: GeneratedModel, stage: string, rule: DefinedRule): void { + matchCounts.set(rule, (matchCounts.get(rule) ?? 0) + 1); + const key = modelKey(model.provider, model.id); + const list = hits.get(key); + if (list) list.push({ stage, rule }); + else hits.set(key, [{ stage, rule }]); + } + + /** Applies rules in order to each model; returns the surviving models. */ + function applyRules(models: GeneratedModel[], rules: readonly DefinedRule[], stage: string): GeneratedModel[] { + const survivors: GeneratedModel[] = []; + for (const model of models) { + let dropped = false; + for (const rule of rules) { + if (!matchesModel(model, rule.match, contextFor(model))) continue; + recordHit(model, stage, rule); + if (rule.drop) { + dropped = true; + break; + } + if (rule.set) applyModelPatch(model, rule.set, "merge"); + if (rule.replace) applyModelPatch(model, rule.replace, "replace"); + if (rule.apply) rule.apply(model, contextFor(model)); + } + if (!dropped) survivors.push(model); + } + return survivors; + } + + for (const rule of [ + ...options.providers.flatMap((provider) => provider.rules ?? []), + ...options.rulePacks.flatMap((pack) => pack.rules), + ]) { + matchCounts.set(rule, 0); + } + + for (const provider of options.providers) { + if (generated.has(provider.id)) throw new Error(`Provider ${provider.id} is declared more than once`); + const sources = provider.source ? (Array.isArray(provider.source) ? provider.source : [provider.source]) : []; + const models: GeneratedModel[] = []; + const seen = new Set(); + const add = (model: GeneratedModel): void => { + if (model.provider !== provider.id) { + throw new Error(`Provider ${provider.id} produced model ${model.id} for provider ${model.provider}`); + } + // The first source to produce an id wins; static models fill remaining gaps. + if (seen.has(model.id)) return; + seen.add(model.id); + models.push(model); + }; + for (const source of sources) { + for (const model of await source(sourceContext)) add(model); + } + for (const model of provider.models ?? []) add(structuredClone(model)); + // Rules see the provider's models, so lookups within the provider work. + generated.set(provider.id, models); + generated.set(provider.id, applyRules(models, provider.rules ?? [], `provider:${provider.id}`)); + } + + for (const pack of options.rulePacks) { + for (const [providerId, models] of generated) { + generated.set(providerId, applyRules(models, pack.rules, pack.name)); + } + } + + const providers = new Map(); + for (const [providerId, models] of generated) { + providers.set(providerId, models.map(normalizeModel)); + } + + const unmatchedRules: { stage: string; rule: DefinedRule }[] = []; + for (const provider of options.providers) { + for (const rule of provider.rules ?? []) { + if (!rule.mayMatchNothing && matchCounts.get(rule) === 0) + unmatchedRules.push({ stage: `provider:${provider.id}`, rule }); + } + } + for (const pack of options.rulePacks) { + for (const rule of pack.rules) { + if (!rule.mayMatchNothing && matchCounts.get(rule) === 0) unmatchedRules.push({ stage: pack.name, rule }); + } + } + + return { + providers, + unmatchedRules, + explain: (provider, id) => hits.get(modelKey(provider, id)) ?? [], + }; +} + +const MODEL_KEY_ORDER = [ + "id", + "name", + "api", + "provider", + "baseUrl", + "reasoning", + "thinkingLevelMap", + "input", + "cost", + "promptCache", + "contextWindow", + "maxTokens", + "samplingParams", + "headers", + "compat", +] as const satisfies readonly (keyof Model)[]; +const THINKING_LEVEL_ORDER = ["off", "minimal", "low", "medium", "high", "xhigh", "max"]; +const COST_KEY_ORDER = ["input", "output", "cacheRead", "cacheWrite", "tiers"]; + +function orderKeys(value: Record, order: readonly string[]): Record { + const rank = new Map(order.map((key, index) => [key, index])); + const keys = Object.keys(value).sort((a, b) => { + const rankA = rank.get(a) ?? Number.POSITIVE_INFINITY; + const rankB = rank.get(b) ?? Number.POSITIVE_INFINITY; + return rankA - rankB || (a < b ? -1 : a > b ? 1 : 0); + }); + return Object.fromEntries(keys.map((key) => [key, value[key]])); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * Gives every generated model the same key order (fixed order for known fields, alphabetical + * for compat flags, level order for thinking maps) and removes empty compat objects, so + * generated data diffs only when values change. + */ +export function normalizeModel(model: GeneratedModel): GeneratedModel { + const record = { ...(model as unknown as Record) }; + for (const key of Object.keys(record)) { + if (record[key] === undefined) delete record[key]; + } + if (isRecord(record.compat)) { + if (Object.keys(record.compat).length === 0) delete record.compat; + else record.compat = orderKeys(record.compat, []); + } + if (isRecord(record.thinkingLevelMap)) + record.thinkingLevelMap = orderKeys(record.thinkingLevelMap, THINKING_LEVEL_ORDER); + if (isRecord(record.cost)) record.cost = orderKeys(record.cost, COST_KEY_ORDER); + return orderKeys(record, MODEL_KEY_ORDER) as unknown as GeneratedModel; +} diff --git a/packages/ai/scripts/model-generation/models-dev.ts b/packages/ai/scripts/model-generation/models-dev.ts new file mode 100644 index 000000000..558938cba --- /dev/null +++ b/packages/ai/scripts/model-generation/models-dev.ts @@ -0,0 +1,115 @@ +import type { Api, ModelCost } from "../../src/types.ts"; +import type { CompatPatch, GeneratedModel, ModelSource } from "./dsl.ts"; +import type { ModelsDevModel } from "./sources.ts"; + +/** Per-model transport overrides resolved from the upstream entry. */ +export interface ModelsDevRoute { + api?: Api; + baseUrl?: string; + compat?: CompatPatch; + headers?: Record; +} + +export interface ModelsDevSourceOptions { + /** Provider id in the generated catalog. */ + provider: string; + /** models.dev provider key(s); the first key present in the catalog is used. */ + key: string | readonly string[]; + api: Api; + baseUrl: string | ((id: string, entry: ModelsDevModel) => string); + compat?: CompatPatch; + headers?: Record; + /** Skip entries with `status: "deprecated"`. */ + skipDeprecated?: boolean; + /** Extra filter beyond the always-applied `tool_call` check. Receives the models.dev id. */ + include?: (id: string, entry: ModelsDevModel, models: Readonly>) => boolean; + /** Rename an entry. Return `undefined` to skip it. Receives the models.dev id. */ + mapId?: (id: string, entry: ModelsDevModel) => string | undefined; + /** Per-model api, baseUrl, compat, or headers. Receives the models.dev id. */ + route?: (id: string, entry: ModelsDevModel) => ModelsDevRoute | undefined; + /** + * Entries whose capabilities, limits, and prices come from another entry of the same + * provider (`{ alias: target }`). The alias keeps its own id and name. + */ + aliases?: Readonly>; + /** Fallbacks when models.dev omits limits. Default: 4096 for both. */ + defaults?: { contextWindow?: number; maxTokens?: number }; + /** Import models.dev context-size price tiers as `cost.tiers`. */ + costTiers?: boolean; +} + +export function getModelsDevCost(cost: ModelsDevModel["cost"], includeTiers = false): ModelCost { + const tiers = includeTiers + ? 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, + }, + ]; + }) + : undefined; + + return { + input: cost?.input || 0, + output: cost?.output || 0, + cacheRead: cost?.cache_read || 0, + cacheWrite: cost?.cache_write || 0, + ...(tiers && tiers.length > 0 ? { tiers } : {}), + }; +} + +export function getModelsDevInput(entry: ModelsDevModel): GeneratedModel["input"] { + return entry.modalities?.input?.includes("image") ? ["text", "image"] : ["text"]; +} + +/** Maps tool-capable models.dev entries of one provider onto pi's `Model` shape. */ +export function modelsDev(options: ModelsDevSourceOptions): ModelSource { + return async (context) => { + const catalog = await context.upstream.modelsDev(); + const keys = Array.isArray(options.key) ? (options.key as readonly string[]) : [options.key as string]; + const key = keys.find((candidate) => catalog[candidate]?.models); + const models = key ? (catalog[key]?.models ?? {}) : {}; + const generated: GeneratedModel[] = []; + + for (const [sourceId, entry] of Object.entries(models)) { + if (entry.tool_call !== true) continue; + if (options.skipDeprecated && entry.status === "deprecated") continue; + if (options.include && !options.include(sourceId, entry, models)) continue; + const id = options.mapId ? options.mapId(sourceId, entry) : sourceId; + if (id === undefined) continue; + + const aliasTarget = options.aliases?.[sourceId]; + const data = aliasTarget ? (models[aliasTarget] ?? entry) : entry; + const route = options.route?.(sourceId, entry); + const compat = route?.compat ?? options.compat; + const headers = route?.headers ?? options.headers; + const baseUrl = + route?.baseUrl ?? (typeof options.baseUrl === "string" ? options.baseUrl : options.baseUrl(id, entry)); + + const model: GeneratedModel = { + id, + name: entry.name || id, + api: route?.api ?? options.api, + provider: options.provider, + baseUrl, + reasoning: data.reasoning === true, + input: getModelsDevInput(data), + cost: getModelsDevCost(data.cost, options.costTiers === true), + contextWindow: data.limit?.context || options.defaults?.contextWindow || 4096, + maxTokens: data.limit?.output || options.defaults?.maxTokens || 4096, + ...(headers ? { headers: { ...headers } } : {}), + ...(compat ? { compat: { ...compat } as GeneratedModel["compat"] } : {}), + }; + context.attachSource(model, sourceId, data); + generated.push(model); + } + + return generated; + }; +} diff --git a/packages/ai/scripts/model-generation/output.ts b/packages/ai/scripts/model-generation/output.ts new file mode 100644 index 000000000..aeee949f5 --- /dev/null +++ b/packages/ai/scripts/model-generation/output.ts @@ -0,0 +1,197 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { join } from "node:path"; +import type { Api, Model } from "../../src/types.ts"; +import { + createModelDataManifest, + MODEL_DATA_MANIFEST_FILE, + type ModelDataStructure, + readModelDataProviderIds, + validateGeneratedModelData, + validateModelDataDirectory, +} from "../model-data.ts"; +import type { GeneratedModel } from "./dsl.ts"; + +export interface OutputOptions { + packageRoot: string; + /** Only refresh the ignored JSON data for providers already present in the committed catalog. */ + dataOnly: boolean; + /** Skip the package sources and only write the public JSON catalog. */ + jsonOnly: boolean; + jsonOutputDir: string | undefined; + pretty: boolean; +} + +export type SortedCatalog = Record>; + +/** Providers and models sorted by id, for deterministic output. */ +export function sortCatalog(providers: ReadonlyMap): SortedCatalog { + const catalog: SortedCatalog = {}; + for (const providerId of Array.from(providers.keys()).sort()) { + const models = providers.get(providerId) ?? []; + catalog[providerId] = {}; + for (const model of [...models].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))) { + catalog[providerId][model.id] = model; + } + } + return catalog; +} + +const GENERATED_HEADER = `// This file is auto-generated by scripts/generate-models.ts +// Do not edit manually - run 'npm run generate-models' to update + +`; + +function catalogConstName(providerId: string): string { + return `${providerId.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}_MODELS`; +} + +export function writeCatalog(catalog: SortedCatalog, options: OutputOptions): void { + const sortedProviderIds = Object.keys(catalog); + const serializeJson = (value: unknown) => `${JSON.stringify(value, null, options.pretty ? 2 : undefined)}\n`; + const writeJson = (path: string, value: unknown) => writeFileSync(path, serializeJson(value)); + const generatedDataProviderIds = options.dataOnly + ? readModelDataProviderIds(options.packageRoot) + : sortedProviderIds; + const missingProviderIds = generatedDataProviderIds.filter((providerId) => !catalog[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>>> = {}; + const modelDataStructure: ModelDataStructure = {}; + for (const providerId of generatedDataProviderIds) { + const models = catalog[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 (!options.jsonOnly) { + // Stage and validate all provider values before replacing the current generated data. + const providersDir = join(options.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 = {}; + 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 (!options.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(options.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 generatedShardFiles = new Set(); + for (const providerId of sortedProviderIds) { + let output = GENERATED_HEADER; + 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 =\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 = GENERATED_HEADER; + 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(options.packageRoot); + } catch (error) { + rmSync(dataDir, { recursive: true, force: true }); + if (hadPreviousData && existsSync(previousDataDir)) renameSync(previousDataDir, dataDir); + throw error; + } + restoreGeneratedCatalog = undefined; + console.log( + options.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 (options.jsonOutputDir) { + const providerOutputDir = join(options.jsonOutputDir, "providers"); + rmSync(options.jsonOutputDir, { recursive: true, force: true }); + mkdirSync(providerOutputDir, { recursive: true }); + writeJson(join(options.jsonOutputDir, "models.json"), catalog); + writeJson(join(options.jsonOutputDir, "providers.json"), sortedProviderIds); + for (const providerId of sortedProviderIds) { + writeJson(join(providerOutputDir, `${providerId}.json`), catalog[providerId]); + } + console.log(`Generated JSON model catalog under ${options.jsonOutputDir}`); + } +} diff --git a/packages/ai/scripts/model-generation/providers/amazon-bedrock.ts b/packages/ai/scripts/model-generation/providers/amazon-bedrock.ts new file mode 100644 index 000000000..e33724f04 --- /dev/null +++ b/packages/ai/scripts/model-generation/providers/amazon-bedrock.ts @@ -0,0 +1,25 @@ +import { defineProvider } from "../dsl.ts"; +import { modelsDev } from "../models-dev.ts"; + +const BEDROCK_INFERENCE_PROFILE_ONLY_MODEL_IDS = new Set(["anthropic.claude-opus-5"]); + +export const amazonBedrock = defineProvider({ + id: "amazon-bedrock", + source: modelsDev({ + provider: "amazon-bedrock", + key: "amazon-bedrock", + api: "bedrock-converse-stream", + baseUrl: (id) => + id.startsWith("eu.") + ? "https://bedrock-runtime.eu-central-1.amazonaws.com" + : "https://bedrock-runtime.us-east-1.amazonaws.com", + include: (id) => + // Only reachable through inference profiles. + !BEDROCK_INFERENCE_PROFILE_ONLY_MODEL_IDS.has(id) && + // Jamba does not support tool use in streaming mode. + !id.startsWith("ai21.jamba") && + // Mistral 7B does not support system messages. + !id.startsWith("mistral.mistral-7b-instruct-v0"), + route: (_id, entry) => (entry.structured_output === true ? { compat: { supportsStrictMode: true } } : undefined), + }), +}); diff --git a/packages/ai/scripts/model-generation/providers/anthropic.ts b/packages/ai/scripts/model-generation/providers/anthropic.ts new file mode 100644 index 000000000..21fa67f88 --- /dev/null +++ b/packages/ai/scripts/model-generation/providers/anthropic.ts @@ -0,0 +1,12 @@ +import { defineProvider } from "../dsl.ts"; +import { modelsDev } from "../models-dev.ts"; + +export const anthropic = defineProvider({ + id: "anthropic", + source: modelsDev({ + provider: "anthropic", + key: "anthropic", + api: "anthropic-messages", + baseUrl: "https://api.anthropic.com", + }), +}); diff --git a/packages/ai/scripts/model-generation/providers/azure.ts b/packages/ai/scripts/model-generation/providers/azure.ts new file mode 100644 index 000000000..f2cd3c7c2 --- /dev/null +++ b/packages/ai/scripts/model-generation/providers/azure.ts @@ -0,0 +1,42 @@ +import { defineProvider, rule } from "../dsl.ts"; + +// 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 = { + "gpt-5.4": 1050000, + "gpt-5.5": 1050000, + "gpt-5.6-luna": 1050000, + "gpt-5.6-sol": 1050000, + "gpt-5.6-terra": 1050000, +}; + +export const azureOpenaiResponses = defineProvider({ + id: "azure-openai-responses", + // Mirrors the OpenAI Responses catalog after OpenAI's own corrections. The base URL is + // per deployment and long-context price tiers do not apply to Azure billing. + source: async (context) => + context + .generated("openai") + .filter((model) => 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, + }, + })), + rules: [ + rule({ + why: "Azure Foundry deploys these models with larger context windows than OpenAI's short-tier defaults.", + match: { id: Object.keys(AZURE_CONTEXT_WINDOW_OVERRIDES) }, + apply: (model) => { + model.contextWindow = AZURE_CONTEXT_WINDOW_OVERRIDES[model.id]; + }, + }), + ], +}); diff --git a/packages/ai/scripts/model-generation/providers/baseten.ts b/packages/ai/scripts/model-generation/providers/baseten.ts new file mode 100644 index 000000000..d058c6b8e --- /dev/null +++ b/packages/ai/scripts/model-generation/providers/baseten.ts @@ -0,0 +1,104 @@ +import type { OpenAICompletionsCompat, ThinkingLevelMap } from "../../../src/types.ts"; +import { getEffortThinkingLevelMap } from "../../models-dev-reasoning-options.ts"; +import { defineProvider, rule } from "../dsl.ts"; +import { modelsDev } from "../models-dev.ts"; +import type { ModelsDevModel } from "../sources.ts"; + +const BASETEN_BASE_COMPAT: OpenAICompletionsCompat = { + supportsStore: false, + supportsDeveloperRole: false, + supportsReasoningEffort: false, + supportsUsageInStreaming: true, + maxTokensField: "max_tokens", + supportsStrictMode: true, + // Baseten automatic prompt caching needs session affinity so related + // requests land on the same replica. See: + // https://docs.baseten.co/inference/model-apis/pricing-and-limits + sendSessionAffinityHeaders: true, + supportsLongCacheRetention: false, +}; +const BASETEN_EFFORT_COMPAT: OpenAICompletionsCompat = { + ...BASETEN_BASE_COMPAT, + supportsReasoningEffort: true, + thinkingFormat: "openai", +}; +const BASETEN_TOGGLE_COMPAT: OpenAICompletionsCompat = { + ...BASETEN_BASE_COMPAT, + thinkingFormat: "baseten", + chatTemplateArgs: { enable_thinking: { $var: "thinking.enabled" } }, +}; +const BASETEN_TOGGLE_EFFORT_COMPAT: OpenAICompletionsCompat = { + ...BASETEN_EFFORT_COMPAT, + thinkingFormat: "baseten", + chatTemplateArgs: { enable_thinking: { $var: "thinking.enabled" } }, +}; +const BASETEN_TOGGLE_THINKING_LEVEL_MAP: ThinkingLevelMap = { + off: "off", + minimal: null, + low: null, + medium: null, + high: "high", + xhigh: null, + max: null, +}; +const BASETEN_GLM52_THINKING_LEVEL_MAP: ThinkingLevelMap = { + off: "none", + minimal: null, + low: null, + medium: null, + high: "high", + xhigh: null, + max: "max", +}; + +// GLM-5.2 supports toggle and effort controls that models.dev does not catalog. +const BASETEN_GLM52_MODEL_IDS = new Set(["zai-org/GLM-5.2", "zai-org/GLM-5.2-Fast"]); + +function getBasetenReasoningControls(id: string, entry: ModelsDevModel) { + const reasoningOptions = entry.reasoning_options ?? []; + const isGlm52 = BASETEN_GLM52_MODEL_IDS.has(id); + const supportsToggle = isGlm52 || reasoningOptions.some((option) => option.type === "toggle"); + const supportsEffort = isGlm52 || reasoningOptions.some((option) => option.type === "effort"); + const compat = + supportsToggle && supportsEffort + ? BASETEN_TOGGLE_EFFORT_COMPAT + : supportsToggle + ? BASETEN_TOGGLE_COMPAT + : supportsEffort + ? BASETEN_EFFORT_COMPAT + : BASETEN_BASE_COMPAT; + const thinkingLevelMap = isGlm52 + ? BASETEN_GLM52_THINKING_LEVEL_MAP + : supportsToggle + ? BASETEN_TOGGLE_THINKING_LEVEL_MAP + : getEffortThinkingLevelMap(reasoningOptions); + return { compat, thinkingLevelMap }; +} + +export const baseten = defineProvider({ + id: "baseten", + source: modelsDev({ + provider: "baseten", + key: "baseten", + api: "openai-completions", + baseUrl: "https://inference.baseten.co/v1", + skipDeprecated: true, + route: (id, entry) => ({ compat: getBasetenReasoningControls(id, entry).compat }), + }), + rules: [ + rule({ + why: "Baseten reasoning controls follow the cataloged toggle/effort options, with GLM-5.2's undocumented controls filled in.", + match: {}, + apply: (model, context) => { + if (!context.source) return; + const { thinkingLevelMap } = getBasetenReasoningControls(model.id, context.source); + if (thinkingLevelMap) model.thinkingLevelMap = { ...thinkingLevelMap }; + }, + }), + rule({ + why: "Baseten's GLM-5.2 endpoints are text-only despite models.dev reporting image input.", + match: { id: Array.from(BASETEN_GLM52_MODEL_IDS) }, + set: { input: ["text"] }, + }), + ], +}); diff --git a/packages/ai/scripts/model-generation/providers/cloudflare.ts b/packages/ai/scripts/model-generation/providers/cloudflare.ts new file mode 100644 index 000000000..0a2fefc85 --- /dev/null +++ b/packages/ai/scripts/model-generation/providers/cloudflare.ts @@ -0,0 +1,85 @@ +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 { defineProvider, rule } from "../dsl.ts"; +import { type ModelsDevRoute, modelsDev } from "../models-dev.ts"; +import { OPENAI_GPT_56_STANDARD_COSTS, withOpenAiLongContextPricing } from "../shared.ts"; + +export const cloudflareWorkersAi = defineProvider({ + id: "cloudflare-workers-ai", + source: modelsDev({ + provider: "cloudflare-workers-ai", + key: "cloudflare-workers-ai", + api: "openai-completions", + baseUrl: CLOUDFLARE_WORKERS_AI_BASE_URL, + compat: { sendSessionAffinityHeaders: true }, + }), +}); + +// Gateway ids are `/`. OpenAI and Anthropic passthroughs use the +// native id; Workers AI keeps the prefix because it is served through the /compat endpoint. +const GATEWAY_ROUTES: Record ModelsDevRoute & { id: string }> = { + openai: (nativeId) => ({ id: nativeId, api: "openai-responses", baseUrl: CLOUDFLARE_AI_GATEWAY_OPENAI_BASE_URL }), + anthropic: (nativeId) => ({ + id: nativeId, + api: "anthropic-messages", + baseUrl: CLOUDFLARE_AI_GATEWAY_ANTHROPIC_BASE_URL, + // Gateway passthroughs forward session affinity headers to upstreams that use them. + compat: { sendSessionAffinityHeaders: true }, + }), + "workers-ai": (nativeId) => ({ + id: `workers-ai/${nativeId}`, + api: "openai-completions", + baseUrl: CLOUDFLARE_AI_GATEWAY_COMPAT_BASE_URL, + compat: { sendSessionAffinityHeaders: true }, + }), +}; + +function splitGatewayId(prefixedId: string): { upstream: string; nativeId: string } | undefined { + const slashIndex = prefixedId.indexOf("/"); + if (slashIndex === -1) return undefined; + return { upstream: prefixedId.slice(0, slashIndex), nativeId: prefixedId.slice(slashIndex + 1) }; +} + +function resolveGatewayRoute(prefixedId: string): (ModelsDevRoute & { id: string }) | undefined { + const parts = splitGatewayId(prefixedId); + return parts ? GATEWAY_ROUTES[parts.upstream]?.(parts.nativeId) : undefined; +} + +export const cloudflareAiGateway = defineProvider({ + id: "cloudflare-ai-gateway", + source: [ + modelsDev({ + provider: "cloudflare-ai-gateway", + key: "cloudflare-ai-gateway", + api: "openai-completions", + baseUrl: CLOUDFLARE_AI_GATEWAY_COMPAT_BASE_URL, + mapId: (id) => resolveGatewayRoute(id)?.id, + route: (id) => resolveGatewayRoute(id), + }), + // 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. Ids already produced above win. + modelsDev({ + provider: "cloudflare-ai-gateway", + key: "cloudflare-workers-ai", + api: "openai-completions", + baseUrl: CLOUDFLARE_AI_GATEWAY_COMPAT_BASE_URL, + compat: { sendSessionAffinityHeaders: true }, + mapId: (id) => `workers-ai/${id}`, + }), + ], + rules: [ + rule({ + why: "Cloudflare AI Gateway passes OpenAI usage through at OpenAI list prices, including the GPT-5.6 price cut and long-context tiers.", + match: { id: Object.keys(OPENAI_GPT_56_STANDARD_COSTS) }, + apply: (model) => { + model.cost = withOpenAiLongContextPricing(OPENAI_GPT_56_STANDARD_COSTS[model.id]); + }, + }), + ], +}); diff --git a/packages/ai/scripts/model-generation/providers/fireworks.ts b/packages/ai/scripts/model-generation/providers/fireworks.ts new file mode 100644 index 000000000..23d332016 --- /dev/null +++ b/packages/ai/scripts/model-generation/providers/fireworks.ts @@ -0,0 +1,71 @@ +import type { AnthropicMessagesCompat, OpenAICompletionsCompat } from "../../../src/types.ts"; +import { defineProvider } from "../dsl.ts"; +import { modelsDev } from "../models-dev.ts"; + +// 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 +const FIREWORKS_MESSAGES_COMPAT: AnthropicMessagesCompat = { + allowEmptySignature: true, + sendSessionAffinityHeaders: true, + supportsEagerToolInputStreaming: false, + supportsCacheControlOnTools: false, + supportsLongCacheRetention: false, +}; +const FIREWORKS_COMPLETIONS_COMPAT: OpenAICompletionsCompat = { + supportsStore: false, + supportsDeveloperRole: false, + sendSessionAffinityHeaders: true, + supportsLongCacheRetention: false, +}; +const FIREWORKS_KIMI_K3_COMPAT: OpenAICompletionsCompat = { + ...FIREWORKS_COMPLETIONS_COMPAT, + requiresReasoningContentOnAssistantMessages: true, + thinkingFormat: "openai", +}; + +// 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_MODEL_IDS = 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", +]); + +export const fireworks = defineProvider({ + id: "fireworks", + source: modelsDev({ + provider: "fireworks", + key: "fireworks-ai", + // Fireworks Anthropic-compatible API - SDK appends /v1/messages. + api: "anthropic-messages", + baseUrl: "https://api.fireworks.ai/inference", + route: (id, entry) => { + // GLM and Kimi K3 are served through the OpenAI-compatible endpoint. + if (id.includes("glm-")) { + return { + api: "openai-completions", + baseUrl: "https://api.fireworks.ai/inference/v1", + compat: FIREWORKS_COMPLETIONS_COMPAT, + }; + } + if (id.includes("kimi-k3")) { + return { + api: "openai-completions", + baseUrl: "https://api.fireworks.ai/inference/v1", + compat: FIREWORKS_KIMI_K3_COMPAT, + }; + } + // Use adaptive thinking for cataloged effort controls, with verified fallbacks where + // models.dev is incomplete. New models need no allowlist entry. + const adaptive = + entry.reasoning_options?.some((option) => option.type === "effort") || + FIREWORKS_ADAPTIVE_THINKING_FALLBACK_MODEL_IDS.has(id); + return { compat: { ...FIREWORKS_MESSAGES_COMPAT, ...(adaptive ? { forceAdaptiveThinking: true } : {}) } }; + }, + }), +}); diff --git a/packages/ai/scripts/model-generation/providers/github-copilot.ts b/packages/ai/scripts/model-generation/providers/github-copilot.ts new file mode 100644 index 000000000..53d05d792 --- /dev/null +++ b/packages/ai/scripts/model-generation/providers/github-copilot.ts @@ -0,0 +1,67 @@ +import type { Api } from "../../../src/types.ts"; +import { defineProvider, rule } from "../dsl.ts"; +import { modelsDev } from "../models-dev.ts"; + +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; + +// 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_MODEL_IDS = [ + "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", +]; + +function copilotApi(modelId: string): Api { + // Claude 4.x and 5.x models route to Anthropic Messages API + if (/^claude-(haiku|sonnet|opus|fable)-[45]([.-]|$)/.test(modelId)) return "anthropic-messages"; + // GPT, Grok, OSWE, and MAI-Code models are only served through the Copilot /responses endpoint. + if ( + modelId.startsWith("gpt-") || + modelId.startsWith("grok-") || + modelId.startsWith("oswe") || + modelId.startsWith("mai-") + ) { + return "openai-responses"; + } + return "openai-completions"; +} + +export const githubCopilot = defineProvider({ + id: "github-copilot", + source: modelsDev({ + provider: "github-copilot", + key: "github-copilot", + api: "openai-completions", + baseUrl: "https://api.individual.githubcopilot.com", + headers: COPILOT_STATIC_HEADERS, + skipDeprecated: true, + defaults: { contextWindow: 128000, maxTokens: 8192 }, + costTiers: true, + route: (id) => { + const api = copilotApi(id); + return api === "openai-completions" + ? { api, compat: { supportsStore: false, supportsDeveloperRole: false, supportsReasoningEffort: false } } + : { api }; + }, + }), + rules: [ + rule({ + why: "GitHub's extended-capabilities table lists these Copilot models with the 1M token context window.", + match: { id: GITHUB_COPILOT_EXTENDED_CONTEXT_MODEL_IDS }, + set: { contextWindow: 1000000 }, + }), + ], +}); diff --git a/packages/ai/scripts/model-generation/providers/google.ts b/packages/ai/scripts/model-generation/providers/google.ts new file mode 100644 index 000000000..a0a730bf6 --- /dev/null +++ b/packages/ai/scripts/model-generation/providers/google.ts @@ -0,0 +1,74 @@ +import type { ThinkingLevelMap } from "../../../src/types.ts"; +import type { ModelsDevReasoningOption } from "../../models-dev-reasoning-options.ts"; +import { getEffortThinkingLevelMap } from "../../models-dev-reasoning-options.ts"; +import { defineProvider, rule } from "../dsl.ts"; +import { modelsDev } from "../models-dev.ts"; +import { isGemma4Model } from "../shared.ts"; + +export const VERTEX_BASE_URL = "https://{location}-aiplatform.googleapis.com"; + +// models.dev lists the rolling `-latest` aliases without capability metadata. +const GEMINI_LATEST_ALIASES = { + "gemini-flash-latest": "gemini-3.5-flash", + "gemini-flash-lite-latest": "gemini-3.1-flash-lite", +} as const; + +export function getGoogleThinkingLevelMap( + modelId: string, + reasoningOptions: readonly ModelsDevReasoningOption[], +): ThinkingLevelMap | undefined { + const effortMap = getEffortThinkingLevelMap(reasoningOptions); + if (effortMap) return effortMap; + if (isGemma4Model(modelId)) { + return { off: null, minimal: "MINIMAL", low: null, medium: null, high: "HIGH" }; + } + return undefined; +} + +const geminiThinkingLevels = rule({ + why: "Gemini exposes cataloged effort levels; Gemma 4 only documents MINIMAL and HIGH.", + match: {}, + apply: (model, context) => { + const map = getGoogleThinkingLevelMap(model.id, context.reasoningOptions ?? []); + if (map) model.thinkingLevelMap = map; + }, +}); + +export const google = defineProvider({ + id: "google", + source: modelsDev({ + provider: "google", + key: "google", + api: "google-generative-ai", + baseUrl: "https://generativelanguage.googleapis.com/v1beta", + aliases: GEMINI_LATEST_ALIASES, + }), + rules: [geminiThinkingLevels], +}); + +export const googleVertex = defineProvider({ + id: "google-vertex", + source: modelsDev({ + provider: "google-vertex", + key: "google-vertex", + api: "google-vertex", + baseUrl: VERTEX_BASE_URL, + // The google-vertex models.dev catalog also includes Claude, OpenAI, and other + // MaaS models that do not use the @google/genai Gemini streaming path. + include: (id) => id.startsWith("gemini-") && id !== "gemini-3.1-flash-lite-preview", + aliases: GEMINI_LATEST_ALIASES, + }), + rules: [ + geminiThinkingLevels, + rule({ + why: "pi only accounts cachedContentTokenCount as cacheRead on Vertex; models.dev cache_write values do not apply.", + match: {}, + set: { cost: { cacheWrite: 0 } }, + }), + rule({ + why: "models.dev reports Vertex cache_read for Gemini 2.5 Flash that does not match the official Gemini API pricing table.", + match: { id: "gemini-2.5-flash" }, + set: { cost: { cacheRead: 0.03 } }, + }), + ], +}); diff --git a/packages/ai/scripts/model-generation/providers/index.ts b/packages/ai/scripts/model-generation/providers/index.ts new file mode 100644 index 000000000..1d937f4a9 --- /dev/null +++ b/packages/ai/scripts/model-generation/providers/index.ts @@ -0,0 +1,73 @@ +import type { ProviderDefinition } from "../dsl.ts"; +import { amazonBedrock } from "./amazon-bedrock.ts"; +import { anthropic } from "./anthropic.ts"; +import { azureOpenaiResponses } from "./azure.ts"; +import { baseten } from "./baseten.ts"; +import { cloudflareAiGateway, cloudflareWorkersAi } from "./cloudflare.ts"; +import { fireworks } from "./fireworks.ts"; +import { githubCopilot } from "./github-copilot.ts"; +import { google, googleVertex } from "./google.ts"; +import { kimiCoding } from "./kimi-coding.ts"; +import { minimax, minimaxCn } from "./minimax.ts"; +import { mistral } from "./mistral.ts"; +import { moonshotai, moonshotaiCn } from "./moonshot.ts"; +import { nvidia } from "./nvidia.ts"; +import { openai } from "./openai.ts"; +import { opencode, opencodeGo } from "./opencode.ts"; +import { openrouter } from "./openrouter.ts"; +import { qwenTokenPlan, qwenTokenPlanCn, qwenTokenPlanIndividual } from "./qwen-token-plan.ts"; +import { radius } from "./radius.ts"; +import { cerebras, groq, huggingface, meta, xai } from "./simple-openai-compatible.ts"; +import { antLing, deepseek, openaiCodex } from "./static.ts"; +import { together } from "./together.ts"; +import { vercelAiGateway } from "./vercel-ai-gateway.ts"; +import { xiaomi, xiaomiTokenPlanAms, xiaomiTokenPlanCn, xiaomiTokenPlanSgp } from "./xiaomi.ts"; +import { zai, zaiCodingCn } from "./zai.ts"; + +/** + * Providers in generation order. Derived providers (Azure mirrors OpenAI) must follow + * the provider they read from. + */ +export const PROVIDERS: readonly ProviderDefinition[] = [ + amazonBedrock, + anthropic, + google, + googleVertex, + openai, + groq, + cerebras, + cloudflareWorkersAi, + cloudflareAiGateway, + xai, + meta, + zai, + zaiCodingCn, + mistral, + huggingface, + fireworks, + nvidia, + together, + baseten, + opencode, + opencodeGo, + githubCopilot, + minimax, + minimaxCn, + kimiCoding, + moonshotai, + moonshotaiCn, + xiaomi, + xiaomiTokenPlanCn, + xiaomiTokenPlanAms, + xiaomiTokenPlanSgp, + qwenTokenPlan, + qwenTokenPlanIndividual, + qwenTokenPlanCn, + openrouter, + vercelAiGateway, + radius, + deepseek, + antLing, + openaiCodex, + azureOpenaiResponses, +]; diff --git a/packages/ai/scripts/model-generation/providers/kimi-coding.ts b/packages/ai/scripts/model-generation/providers/kimi-coding.ts new file mode 100644 index 000000000..42701c201 --- /dev/null +++ b/packages/ai/scripts/model-generation/providers/kimi-coding.ts @@ -0,0 +1,62 @@ +import type { ModelCost } from "../../../src/types.ts"; +import { defineProvider, rule } from "../dsl.ts"; +import { modelsDev } from "../models-dev.ts"; +import { KIMI_K3_COST } from "../shared.ts"; + +// models.dev may expose versioned aliases of the canonical Kimi For Coding model. +const KIMI_CODING_ALIASES = new Set(["k2p5", "k2p6", "k2p7"]); + +// 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 = { + 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 }, +}; + +export const kimiCoding = defineProvider({ + id: "kimi-coding", + source: modelsDev({ + provider: "kimi-coding", + key: "kimi-code-plan-global", + api: "anthropic-messages", + // Kimi For Coding's Anthropic-compatible API - SDK appends /v1/messages + baseUrl: "https://api.kimi.com/coding", + compat: { forceAdaptiveThinking: true }, + // Drop aliases when the canonical model exists; otherwise emit them under the canonical id. + include: (id, _entry, models) => !(KIMI_CODING_ALIASES.has(id) && Object.hasOwn(models, "kimi-for-coding")), + mapId: (id) => (KIMI_CODING_ALIASES.has(id) ? "kimi-for-coding" : id), + }), + rules: [ + rule({ + why: "Aliases normalized to the canonical id also take the canonical name.", + match: { id: "kimi-for-coding", when: (_model, context) => KIMI_CODING_ALIASES.has(context.sourceId ?? "") }, + set: { name: "Kimi For Coding" }, + mayMatchNothing: true, + }), + rule({ + why: "Kimi K3 and Kimi For Coding replay thinking without signatures.", + match: { id: ["k3", "kimi-for-coding"] }, + set: { compat: { allowEmptySignature: true } }, + }), + rule({ + why: "Kimi K3 always reasons even when the catalog omits the flag.", + match: { id: "k3" }, + set: { reasoning: true }, + }), + rule({ + why: "Subscription pricing is reported as zero; fill in the equivalent Moonshot API rates per missing field.", + match: { id: Object.keys(KIMI_CODING_IMPLIED_COSTS) }, + apply: (model) => { + const implied = KIMI_CODING_IMPLIED_COSTS[model.id]; + model.cost = { + input: model.cost.input || implied.input, + output: model.cost.output || implied.output, + cacheRead: model.cost.cacheRead || implied.cacheRead, + cacheWrite: model.cost.cacheWrite || implied.cacheWrite, + }; + }, + }), + ], +}); diff --git a/packages/ai/scripts/model-generation/providers/minimax.ts b/packages/ai/scripts/model-generation/providers/minimax.ts new file mode 100644 index 000000000..1e6f2cf80 --- /dev/null +++ b/packages/ai/scripts/model-generation/providers/minimax.ts @@ -0,0 +1,35 @@ +import { defineProvider, rule } from "../dsl.ts"; +import { modelsDev } from "../models-dev.ts"; + +// Only these models are served directly by MiniMax's Anthropic-compatible API. +const MINIMAX_DIRECT_SUPPORTED_MODEL_IDS = ["MiniMax-M2.7", "MiniMax-M2.7-highspeed", "MiniMax-M3"]; + +const dropUnsupported = rule({ + why: "MiniMax's Anthropic-compatible endpoint only serves the M2.7 and M3 models.", + match: { not: { id: MINIMAX_DIRECT_SUPPORTED_MODEL_IDS } }, + drop: true, + mayMatchNothing: true, +}); + +export const minimax = defineProvider({ + id: "minimax", + source: modelsDev({ + provider: "minimax", + key: "minimax", + api: "anthropic-messages", + // MiniMax's Anthropic-compatible API - SDK appends /v1/messages + baseUrl: "https://api.minimax.io/anthropic", + }), + rules: [dropUnsupported], +}); + +export const minimaxCn = defineProvider({ + id: "minimax-cn", + source: modelsDev({ + provider: "minimax-cn", + key: "minimax-cn", + api: "anthropic-messages", + baseUrl: "https://api.minimaxi.com/anthropic", + }), + rules: [dropUnsupported], +}); diff --git a/packages/ai/scripts/model-generation/providers/mistral.ts b/packages/ai/scripts/model-generation/providers/mistral.ts new file mode 100644 index 000000000..e7275aa2a --- /dev/null +++ b/packages/ai/scripts/model-generation/providers/mistral.ts @@ -0,0 +1,41 @@ +import { defineProvider, roundCost, rule } from "../dsl.ts"; +import { modelsDev } from "../models-dev.ts"; + +const MISTRAL_BASE_URL = "https://api.mistral.ai"; + +export const mistral = defineProvider({ + id: "mistral", + source: modelsDev({ + provider: "mistral", + key: "mistral", + api: "mistral-conversations", + baseUrl: MISTRAL_BASE_URL, + }), + models: [ + { + // Missing from models.dev. + id: "mistral-medium-3.5", + name: "Mistral Medium 3.5", + api: "mistral-conversations", + provider: "mistral", + baseUrl: MISTRAL_BASE_URL, + reasoning: true, + input: ["text", "image"], + cost: { input: 1.5, output: 7.5, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 262144, + maxTokens: 262144, + }, + ], + rules: [ + rule({ + why: "Mistral bills cached input at 10% of the input price when models.dev omits cache_read.", + match: { + when: (_model, context) => context.source !== undefined && context.source.cost?.cache_read === undefined, + }, + apply: (model, context) => { + const input = context.source?.cost?.input; + model.cost = { ...model.cost, cacheRead: input ? roundCost(input * 0.1) : 0 }; + }, + }), + ], +}); diff --git a/packages/ai/scripts/model-generation/providers/moonshot.ts b/packages/ai/scripts/model-generation/providers/moonshot.ts new file mode 100644 index 000000000..a9fffcd90 --- /dev/null +++ b/packages/ai/scripts/model-generation/providers/moonshot.ts @@ -0,0 +1,64 @@ +import type { OpenAICompletionsCompat } from "../../../src/types.ts"; +import { type DefinedRule, defineProvider, rule } from "../dsl.ts"; +import { modelsDev } from "../models-dev.ts"; +import { KIMI_K3_COST } from "../shared.ts"; + +const MOONSHOT_COMPAT: OpenAICompletionsCompat = { + supportsStore: false, + supportsDeveloperRole: false, + supportsReasoningEffort: false, + maxTokensField: "max_tokens", + supportsStrictMode: false, + thinkingFormat: "deepseek", +}; + +const kimiK3Rules: DefinedRule[] = [ + rule({ + why: "Kimi K3 always reasons, takes OpenAI reasoning_effort, and needs reasoning_content replayed on assistant messages.", + match: { id: "kimi-k3" }, + set: { + reasoning: true, + compat: { + requiresReasoningContentOnAssistantMessages: true, + thinkingFormat: "openai", + supportsReasoningEffort: true, + }, + }, + }), + rule({ + why: "models.dev has no Kimi K3 prices yet; fill in the published Moonshot rates per missing field.", + match: { id: "kimi-k3" }, + apply: (model) => { + model.cost = { + input: model.cost.input || KIMI_K3_COST.input, + output: model.cost.output || KIMI_K3_COST.output, + cacheRead: model.cost.cacheRead || KIMI_K3_COST.cacheRead, + cacheWrite: model.cost.cacheWrite || KIMI_K3_COST.cacheWrite, + }; + }, + }), +]; + +export const moonshotai = defineProvider({ + id: "moonshotai", + source: modelsDev({ + provider: "moonshotai", + key: "moonshotai", + api: "openai-completions", + baseUrl: "https://api.moonshot.ai/v1", + compat: MOONSHOT_COMPAT, + }), + rules: kimiK3Rules, +}); + +export const moonshotaiCn = defineProvider({ + id: "moonshotai-cn", + source: modelsDev({ + provider: "moonshotai-cn", + key: "moonshotai-cn", + api: "openai-completions", + baseUrl: "https://api.moonshot.cn/v1", + compat: MOONSHOT_COMPAT, + }), + rules: kimiK3Rules, +}); diff --git a/packages/ai/scripts/model-generation/providers/nvidia.ts b/packages/ai/scripts/model-generation/providers/nvidia.ts new file mode 100644 index 000000000..579ee79af --- /dev/null +++ b/packages/ai/scripts/model-generation/providers/nvidia.ts @@ -0,0 +1,69 @@ +import type { OpenAICompletionsCompat } from "../../../src/types.ts"; +import { defineProvider } from "../dsl.ts"; +import { modelsDev } from "../models-dev.ts"; + +export const NVIDIA_BASE_URL = "https://integrate.api.nvidia.com/v1"; + +const NVIDIA_OPENAI_COMPAT: OpenAICompletionsCompat = { + supportsStore: false, + supportsDeveloperRole: false, + supportsReasoningEffort: false, + maxTokensField: "max_tokens", + supportsStrictMode: false, + supportsLongCacheRetention: false, +}; + +// Listed by NIM but rejecting tool calls or the chat completions request shape. +const NVIDIA_NIM_UNSUPPORTED_MODEL_IDS = 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", +]); + +function normalizeNvidiaModelId(modelId: string): string { + return modelId.toLowerCase().replaceAll("_", "."); +} + +export const nvidia = defineProvider({ + id: "nvidia", + // models.dev ids do not always match the ids NIM serves; only emit models NIM lists, + // under the live id. + source: async (context) => { + const catalog = await context.upstream.modelsDev(); + if (!catalog.nvidia?.models) return []; + const liveModelIds = new Map(); + for (const model of await context.upstream.nvidiaNim()) { + liveModelIds.set(model.id, model.id); + liveModelIds.set(normalizeNvidiaModelId(model.id), model.id); + } + return modelsDev({ + provider: "nvidia", + key: "nvidia", + api: "openai-completions", + baseUrl: NVIDIA_BASE_URL, + headers: { "NVCF-POLL-SECONDS": "3600" }, + compat: NVIDIA_OPENAI_COMPAT, + include: (_id, entry) => + entry.modalities?.input?.includes("text") === true && entry.modalities?.output?.includes("text") === true, + mapId: (id) => { + const liveModelId = liveModelIds.get(id) ?? liveModelIds.get(normalizeNvidiaModelId(id)); + return liveModelId && !NVIDIA_NIM_UNSUPPORTED_MODEL_IDS.has(liveModelId) ? liveModelId : undefined; + }, + })(context); + }, +}); diff --git a/packages/ai/scripts/model-generation/providers/openai.ts b/packages/ai/scripts/model-generation/providers/openai.ts new file mode 100644 index 000000000..90ebe6d21 --- /dev/null +++ b/packages/ai/scripts/model-generation/providers/openai.ts @@ -0,0 +1,97 @@ +import type { Model } from "../../../src/types.ts"; +import { defineProvider, rule } from "../dsl.ts"; +import { modelsDev } from "../models-dev.ts"; +import { + OPENAI_BASE_URL, + OPENAI_GPT_6_ASTRA_COST, + OPENAI_GPT_56_SOL_COST, + OPENAI_GPT_56_STANDARD_COSTS, + OPENAI_LONG_CONTEXT_INPUT_THRESHOLD, + OPENAI_LONG_CONTEXT_PRICING_MODEL_IDS, + withOpenAiLongContextPricing, +} from "../shared.ts"; + +// models.dev lists this alias, but it is not accepted by OpenAI APIs. +const MODELS_DEV_OPENAI_UNSUPPORTED_MODEL_IDS = new Set(["gpt-5.6"]); + +// 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. +const OPENAI_SHORT_CONTEXT_CAPPED_MODEL_IDS = [ + "gpt-5.4", + "gpt-5.5", + "gpt-5.6-sol", + "gpt-5.6-terra", + "gpt-5.6-luna", + "gpt-6-astra", +]; + +function openAiResponsesModel( + id: string, + name: string, + model: Pick, "reasoning" | "input" | "cost" | "contextWindow" | "maxTokens">, +): Model<"openai-responses"> { + return { id, name, api: "openai-responses", provider: "openai", baseUrl: OPENAI_BASE_URL, ...model }; +} + +const shortContextFrontier: Pick, "reasoning" | "input" | "contextWindow" | "maxTokens"> = { + reasoning: true, + input: ["text", "image"], + contextWindow: OPENAI_LONG_CONTEXT_INPUT_THRESHOLD, + maxTokens: 128000, +}; + +export const openai = defineProvider({ + id: "openai", + source: modelsDev({ + provider: "openai", + key: "openai", + api: "openai-responses", + baseUrl: OPENAI_BASE_URL, + include: (id) => !MODELS_DEV_OPENAI_UNSUPPORTED_MODEL_IDS.has(id), + }), + // Models missing from models.dev. + models: [ + openAiResponsesModel("gpt-6-astra", "GPT-6 Astra", { + ...shortContextFrontier, + cost: withOpenAiLongContextPricing(OPENAI_GPT_6_ASTRA_COST), + }), + openAiResponsesModel("gpt-5.6-sol", "GPT-5.6 Sol", { + ...shortContextFrontier, + cost: withOpenAiLongContextPricing(OPENAI_GPT_56_SOL_COST), + }), + openAiResponsesModel("gpt-5.6-terra", "GPT-5.6 Terra", { + ...shortContextFrontier, + cost: withOpenAiLongContextPricing(OPENAI_GPT_56_STANDARD_COSTS["gpt-5.6-terra"]), + }), + openAiResponsesModel("gpt-5.6-luna", "GPT-5.6 Luna", { + ...shortContextFrontier, + cost: withOpenAiLongContextPricing(OPENAI_GPT_56_STANDARD_COSTS["gpt-5.6-luna"]), + }), + openAiResponsesModel("gpt-5-chat-latest", "GPT-5 Chat Latest", { + reasoning: false, + input: ["text", "image"], + cost: { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 16384, + }), + ], + rules: [ + rule({ + why: "Keep direct OpenAI requests in the short-context pricing tier by default; users opt into the larger context via model overrides.", + match: { id: OPENAI_SHORT_CONTEXT_CAPPED_MODEL_IDS }, + set: { contextWindow: OPENAI_LONG_CONTEXT_INPUT_THRESHOLD, maxTokens: 128000 }, + }), + rule({ + why: "Long-context requests are billed at higher rates; models.dev only carries the short-context price. GPT-5.6 Terra/Luna use the 2026-07-30 price cut.", + match: { id: OPENAI_LONG_CONTEXT_PRICING_MODEL_IDS }, + apply: (model) => { + model.cost = withOpenAiLongContextPricing(OPENAI_GPT_56_STANDARD_COSTS[model.id] ?? model.cost); + }, + }), + rule({ + why: "models.dev reports gpt-5-pro output as 272000 (a duplicate of the input sub-limit); the actual max output is 128000.", + match: { id: "gpt-5-pro" }, + set: { maxTokens: 128000 }, + }), + ], +}); diff --git a/packages/ai/scripts/model-generation/providers/opencode.ts b/packages/ai/scripts/model-generation/providers/opencode.ts new file mode 100644 index 000000000..9e80ef23b --- /dev/null +++ b/packages/ai/scripts/model-generation/providers/opencode.ts @@ -0,0 +1,153 @@ +import type { Api, OpenAICompletionsCompat, OpenAIResponsesCompat } from "../../../src/types.ts"; +import { getEffortThinkingLevelMap } from "../../models-dev-reasoning-options.ts"; +import { type DefinedRule, defineProvider, rule } from "../dsl.ts"; +import { type ModelsDevRoute, modelsDev } from "../models-dev.ts"; +import { getGoogleThinkingLevelMap } from "./google.ts"; + +// OpenCode Zen and Go serve several upstream APIs behind one base path. models.dev's +// provider.npm field says which SDK the model expects: +// - @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 OPENCODE_LONG_CACHE_RETENTION_UNSUPPORTED_MODEL_IDS = 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", +]); + +function opencodeRoute(provider: "opencode" | "opencode-go", basePath: string) { + return (id: string, entry: { provider?: { npm?: string } }): ModelsDevRoute => { + const npm = entry.provider?.npm; + let api: Api; + let baseUrl: string; + let compat: OpenAICompletionsCompat | OpenAIResponsesCompat | undefined; + + if (npm === "@ai-sdk/openai") { + api = "openai-responses"; + baseUrl = `${basePath}/v1`; + compat = { sessionAffinityFormat: "openai-nosession" }; + } else if (npm === "@ai-sdk/anthropic") { + api = "anthropic-messages"; + // Anthropic SDK appends /v1/messages to baseURL + baseUrl = basePath; + } else if (npm === "@ai-sdk/google") { + api = "google-generative-ai"; + baseUrl = `${basePath}/v1`; + } else if (npm === "@ai-sdk/alibaba") { + api = "openai-completions"; + baseUrl = `${basePath}/v1`; + compat = { cacheControlFormat: "anthropic" }; + } else { + api = "openai-completions"; + baseUrl = `${basePath}/v1`; + } + + if (provider === "opencode" && id === "grok-build-0.1") { + compat = { ...(compat ?? {}), supportsReasoningEffort: false }; + } + if (id === "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 endpoint. + if (provider === "opencode-go") { + if (id === "minimax-m2.7") { + api = "openai-completions"; + baseUrl = `${basePath}/v1`; + } + if (id === "qwen3.5-plus" || id === "qwen3.6-plus") { + api = "openai-completions"; + baseUrl = `${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_LONG_CACHE_RETENTION_UNSUPPORTED_MODEL_IDS.has(`${provider}:${id}`)) { + compat = { ...compat, supportsLongCacheRetention: false }; + } + } + + return { api, baseUrl, ...(compat ? { compat } : {}) }; + }; +} + +const geminiThinkingLevels = rule({ + why: "Gemini models keep their cataloged effort levels through OpenCode.", + match: { api: "google-generative-ai" }, + apply: (model, context) => { + const map = getGoogleThinkingLevelMap(model.id, context.reasoningOptions ?? []); + if (map) model.thinkingLevelMap = map; + }, +}); + +const sharedRules: DefinedRule[] = [ + geminiThinkingLevels, + rule({ + why: "OpenCode only serves Codex Spark through the ChatGPT backend, not its Zen/Go endpoints.", + match: { id: "gpt-5.3-codex-spark" }, + drop: true, + mayMatchNothing: true, + }), + rule({ + why: "OpenCode lists Claude Sonnet 4/4.5 with 1M context; the actual limit is 200K.", + match: { id: ["claude-sonnet-4-5", "claude-sonnet-4"] }, + set: { contextWindow: 200000 }, + mayMatchNothing: true, + }), + rule({ + why: "OpenCode serves GPT-5.4 with OpenAI's short-context limits.", + match: { id: "gpt-5.4" }, + set: { contextWindow: 272000, maxTokens: 128000 }, + mayMatchNothing: true, + }), +]; + +export const opencode = defineProvider({ + id: "opencode", + source: modelsDev({ + provider: "opencode", + key: "opencode", + api: "openai-completions", + baseUrl: "https://opencode.ai/zen/v1", + skipDeprecated: true, + route: opencodeRoute("opencode", "https://opencode.ai/zen"), + }), + rules: sharedRules, +}); + +export const opencodeGo = defineProvider({ + id: "opencode-go", + source: modelsDev({ + provider: "opencode-go", + key: "opencode-go", + api: "openai-completions", + baseUrl: "https://opencode.ai/zen/go/v1", + skipDeprecated: true, + route: opencodeRoute("opencode-go", "https://opencode.ai/zen/go"), + }), + rules: [ + ...sharedRules, + rule({ + why: "OpenCode Go passes DeepSeek V4.1 Flash effort levels through as cataloged (#9485).", + match: { id: "deepseek-v4.1-flash" }, + apply: (model, context) => { + const map = getEffortThinkingLevelMap(context.reasoningOptions ?? []); + if (map) model.thinkingLevelMap = map; + }, + }), + ], +}); diff --git a/packages/ai/scripts/model-generation/providers/openrouter.ts b/packages/ai/scripts/model-generation/providers/openrouter.ts new file mode 100644 index 000000000..ce3947b95 --- /dev/null +++ b/packages/ai/scripts/model-generation/providers/openrouter.ts @@ -0,0 +1,91 @@ +import type { Model } from "../../../src/types.ts"; +import { getOpenRouterThinkingLevelMap } from "../../openrouter-reasoning-options.ts"; +import { defineProvider, type GeneratedModel, roundCost, rule } from "../dsl.ts"; + +const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"; + +function perMillion(value: string | undefined): number { + return roundCost(parseFloat(value || "0") * 1_000_000); +} + +function openRouterRouterModel( + id: string, + name: string, + model: Pick, "input" | "contextWindow" | "maxTokens">, +): GeneratedModel { + return { + id, + name, + api: "openai-completions", + provider: "openrouter", + baseUrl: OPENROUTER_BASE_URL, + reasoning: true, + // Routers charge for the underlying model, so no price is known up front. + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + ...model, + }; +} + +export const openrouter = defineProvider({ + id: "openrouter", + source: async (context) => { + const models: GeneratedModel[] = []; + for (const model of await context.upstream.openRouter()) { + // Only include models that support tools + if (!model.supported_parameters?.includes("tools")) continue; + const thinkingLevelMap = getOpenRouterThinkingLevelMap(model.reasoning); + const useAnthropicMessages = /^anthropic\//.test(model.id) && !model.id.endsWith(":batch"); + models.push({ + id: model.id, + name: model.name, + api: useAnthropicMessages ? "anthropic-messages" : "openai-completions", + baseUrl: useAnthropicMessages ? "https://openrouter.ai/api" : OPENROUTER_BASE_URL, + provider: "openrouter", + reasoning: model.supported_parameters?.includes("reasoning") || false, + ...(thinkingLevelMap && { thinkingLevelMap }), + input: model.architecture?.modality?.includes("image") ? ["text", "image"] : ["text"], + cost: { + input: perMillion(model.pricing?.prompt), + output: perMillion(model.pricing?.completion), + cacheRead: perMillion(model.pricing?.input_cache_read), + cacheWrite: perMillion(model.pricing?.input_cache_write), + }, + contextWindow: model.top_provider?.context_length || model.context_length || 4096, + maxTokens: model.top_provider?.max_completion_tokens || 4096, + }); + } + console.log(`Fetched ${models.length} tool-capable models from OpenRouter`); + return models; + }, + models: [ + openRouterRouterModel("auto", "Auto", { input: ["text", "image"], contextWindow: 2000000, maxTokens: 30000 }), + // 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. + openRouterRouterModel("openrouter/fusion", "OpenRouter: Fusion", { + input: ["text"], + contextWindow: 1000000, + maxTokens: 30000, + }), + ], + rules: [ + rule({ + why: "Keep Kimi K2.5 metadata stable until OpenRouter's listing settles.", + match: { id: "moonshotai/kimi-k2.5" }, + set: { cost: { input: 0.41, output: 2.06, cacheRead: 0.07 }, maxTokens: 4096 }, + mayMatchNothing: true, + }), + rule({ + why: "OpenRouter Kimi K2.6 rejects the developer role and needs reasoning_content replayed on assistant messages.", + match: { idPrefix: "moonshotai/kimi-k2.6" }, + set: { compat: { supportsDeveloperRole: false, requiresReasoningContentOnAssistantMessages: true } }, + mayMatchNothing: true, + }), + rule({ + why: "Keep GLM-5 prices stable until OpenRouter's listing settles.", + match: { id: "z-ai/glm-5" }, + set: { cost: { input: 0.6, output: 1.9, cacheRead: 0.119 } }, + mayMatchNothing: true, + }), + ], +}); diff --git a/packages/ai/scripts/model-generation/providers/qwen-token-plan.ts b/packages/ai/scripts/model-generation/providers/qwen-token-plan.ts new file mode 100644 index 000000000..cc25ea31f --- /dev/null +++ b/packages/ai/scripts/model-generation/providers/qwen-token-plan.ts @@ -0,0 +1,118 @@ +import type { OpenAICompletionsCompat, ThinkingLevelMap } from "../../../src/types.ts"; +import { assertExactModelIds } from "../../model-data.ts"; +import { getEffortThinkingLevelMap } from "../../models-dev-reasoning-options.ts"; +import { type DefinedRule, defineProvider, type ProviderDefinition, rule } from "../dsl.ts"; +import { modelsDev } from "../models-dev.ts"; +import type { ModelsDevModel } from "../sources.ts"; + +// Alibaba Cloud Model Studio Token Plan. 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 QWEN_TOKEN_PLAN_COMPAT: OpenAICompletionsCompat = { + thinkingFormat: "qwen", + supportsDeveloperRole: false, + supportsStore: false, + supportsReasoningEffort: true, +}; +const QWEN_TOKEN_PLAN_FALLBACK_THINKING_LEVEL_MAP: ThinkingLevelMap = { + minimal: null, + low: null, + medium: null, + high: "high", + xhigh: null, + max: "max", +}; +// GLM 5.x accept reasoning_effort even when models.dev omits the options. +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"]); +// 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([ + "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", +]); + +function getQwenThinkingLevelMap(id: string, entry: ModelsDevModel): ThinkingLevelMap | undefined { + return ( + getEffortThinkingLevelMap(entry.reasoning_options ?? []) ?? + (QWEN_TOKEN_PLAN_REASONING_EFFORT_FALLBACK_MODEL_IDS.has(id) + ? QWEN_TOKEN_PLAN_FALLBACK_THINKING_LEVEL_MAP + : undefined) + ); +} + +const thinkingLevels: DefinedRule = rule({ + why: "Cataloged effort levels (or the GLM 5 fallback) enable reasoning_effort; other models cannot take it.", + match: {}, + apply: (model, context) => { + if (!context.source) return; + const map = getQwenThinkingLevelMap(model.id, context.source); + if (map) { + model.thinkingLevelMap = { ...map }; + } else { + model.compat = { ...model.compat, supportsReasoningEffort: false }; + } + }, +}); + +function qwenTokenPlanProvider(options: { + id: string; + key: string; + baseUrl: string; + modelIds?: ReadonlySet; +}): ProviderDefinition { + const source = modelsDev({ + provider: options.id, + key: options.key, + api: "openai-completions", + baseUrl: options.baseUrl, + compat: QWEN_TOKEN_PLAN_COMPAT, + include: (id) => !QWEN_TOKEN_PLAN_EXCLUDED_MODEL_IDS.has(id) && (options.modelIds?.has(id) ?? true), + }); + return defineProvider({ + id: options.id, + source: async (context) => { + const models = await source(context); + // The allowlist is verified by hand; a missing model means the plan changed and needs review. + if (options.modelIds && context.strict) { + assertExactModelIds( + options.id, + options.modelIds, + models.map((model) => model.id), + ); + } + return models; + }, + rules: [thinkingLevels], + }); +} + +const INTERNATIONAL_BASE_URL = "https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1"; + +export const qwenTokenPlan = qwenTokenPlanProvider({ + id: "qwen-token-plan", + key: "alibaba-token-plan", + baseUrl: INTERNATIONAL_BASE_URL, +}); +export const qwenTokenPlanIndividual = qwenTokenPlanProvider({ + id: "qwen-token-plan-individual", + key: "alibaba-token-plan", + baseUrl: INTERNATIONAL_BASE_URL, + modelIds: QWEN_TOKEN_PLAN_INDIVIDUAL_MODEL_IDS, +}); +export const qwenTokenPlanCn = qwenTokenPlanProvider({ + id: "qwen-token-plan-cn", + key: "alibaba-token-plan-cn", + baseUrl: "https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1", +}); diff --git a/packages/ai/scripts/model-generation/providers/radius.ts b/packages/ai/scripts/model-generation/providers/radius.ts new file mode 100644 index 000000000..f40748ba0 --- /dev/null +++ b/packages/ai/scripts/model-generation/providers/radius.ts @@ -0,0 +1,14 @@ +import { getRadiusModelsFromConfig } from "../../../src/providers/radius-config.ts"; +import { defineProvider } from "../dsl.ts"; + +// Radius ships its unauthenticated public catalog; authenticated clients overlay it at runtime. +export const radius = defineProvider({ + id: "radius", + source: async (context) => { + const config = await context.upstream.radius(); + if (!config) return []; + const models = getRadiusModelsFromConfig("radius", config); + console.log(`Fetched ${models.length} models from Radius`); + return models; + }, +}); diff --git a/packages/ai/scripts/model-generation/providers/simple-openai-compatible.ts b/packages/ai/scripts/model-generation/providers/simple-openai-compatible.ts new file mode 100644 index 000000000..6b61b9f2b --- /dev/null +++ b/packages/ai/scripts/model-generation/providers/simple-openai-compatible.ts @@ -0,0 +1,68 @@ +import { defineProvider, rule } from "../dsl.ts"; +import { modelsDev } from "../models-dev.ts"; + +// Providers whose models.dev catalog maps onto one API with no per-model corrections. + +export const groq = defineProvider({ + id: "groq", + source: modelsDev({ + provider: "groq", + key: "groq", + api: "openai-completions", + baseUrl: "https://api.groq.com/openai/v1", + }), +}); + +export const cerebras = defineProvider({ + id: "cerebras", + source: modelsDev({ + provider: "cerebras", + key: "cerebras", + api: "openai-completions", + baseUrl: "https://api.cerebras.ai/v1", + }), +}); + +export const huggingface = defineProvider({ + id: "huggingface", + source: modelsDev({ + provider: "huggingface", + key: "huggingface", + api: "openai-completions", + baseUrl: "https://router.huggingface.co/v1", + compat: { supportsDeveloperRole: false }, + }), +}); + +export const meta = defineProvider({ + id: "meta", + source: modelsDev({ provider: "meta", key: "meta", api: "openai-responses", baseUrl: "https://api.meta.ai/v1" }), +}); + +export const xai = defineProvider({ + id: "xai", + source: modelsDev({ + provider: "xai", + key: "xai", + api: "openai-responses", + baseUrl: "https://api.x.ai/v1", + compat: { supportsLongCacheRetention: false }, + }), + rules: [ + rule({ + why: "Retired or non-tool xAI ids that models.dev still lists.", + match: { + id: [ + "grok-3", + "grok-3-fast", + "grok-4.20-0309-non-reasoning", + "grok-4.20-0309-reasoning", + "grok-build-0.1", + "grok-code-fast-1", + ], + }, + drop: true, + mayMatchNothing: true, + }), + ], +}); diff --git a/packages/ai/scripts/model-generation/providers/static.ts b/packages/ai/scripts/model-generation/providers/static.ts new file mode 100644 index 000000000..c3c9a4e0b --- /dev/null +++ b/packages/ai/scripts/model-generation/providers/static.ts @@ -0,0 +1,154 @@ +import type { Model, OpenAICompletionsCompat } from "../../../src/types.ts"; +import { defineProvider } from "../dsl.ts"; +import { + DEEPSEEK_COMPAT, + DEEPSEEK_V4_FLASH_THINKING_LEVEL_MAP, + OPENAI_GPT_6_ASTRA_COST, + OPENAI_GPT_56_SOL_COST, + OPENAI_GPT_56_STANDARD_COSTS, + withOpenAiLongContextPricing, +} from "../shared.ts"; + +// Providers without an upstream catalog. Their models are maintained by hand. + +const DEEPSEEK_BASE_URL = "https://api.deepseek.com"; + +export const deepseek = defineProvider({ + id: "deepseek", + models: [ + { + id: "deepseek-flash", + name: "DeepSeek V4.1 Flash", + api: "openai-completions", + baseUrl: DEEPSEEK_BASE_URL, + provider: "deepseek", + reasoning: true, + thinkingLevelMap: DEEPSEEK_V4_FLASH_THINKING_LEVEL_MAP, + input: ["text", "image"], + // DeepSeek also offers time-based off-peak rates, which the cost schema cannot represent yet. + cost: { input: 0.3, output: 1.2, cacheRead: 0.006, cacheWrite: 0 }, + contextWindow: 1000000, + maxTokens: 384000, + compat: DEEPSEEK_COMPAT, + }, + { + id: "deepseek-v4-pro", + name: "DeepSeek V4 Pro", + api: "openai-completions", + baseUrl: DEEPSEEK_BASE_URL, + provider: "deepseek", + reasoning: true, + input: ["text"], + // DeepSeek also offers time-based off-peak rates, which the cost schema cannot represent yet. + cost: { input: 1.32, output: 3.96, cacheRead: 0.044, cacheWrite: 0 }, + contextWindow: 1000000, + maxTokens: 384000, + compat: DEEPSEEK_COMPAT, + }, + ], +}); + +const ANT_LING_BASE_URL = "https://api.ant-ling.com/v1"; +const ANT_LING_COMPAT: OpenAICompletionsCompat = { + supportsStore: false, + supportsDeveloperRole: false, + supportsReasoningEffort: false, + maxTokensField: "max_tokens", + supportsLongCacheRetention: false, +}; + +function antLingModel( + id: string, + name: string, + model: Pick, "reasoning" | "cost" | "compat">, +): Model<"openai-completions"> { + return { + id, + name, + api: "openai-completions", + baseUrl: ANT_LING_BASE_URL, + provider: "ant-ling", + input: ["text"], + contextWindow: 262144, + maxTokens: 65536, + ...model, + }; +} + +export const antLing = defineProvider({ + id: "ant-ling", + models: [ + antLingModel("Ling-2.6-flash", "Ling 2.6 Flash", { + reasoning: false, + cost: { input: 0.01, output: 0.02, cacheRead: 0, cacheWrite: 0 }, + compat: ANT_LING_COMPAT, + }), + antLingModel("Ling-2.6-1T", "Ling 2.6 1T", { + reasoning: false, + cost: { input: 0.06, output: 0.25, cacheRead: 0, cacheWrite: 0 }, + compat: ANT_LING_COMPAT, + }), + antLingModel("Ring-2.6-1T", "Ring 2.6 1T", { + reasoning: true, + cost: { input: 0.06, output: 0.25, cacheRead: 0, cacheWrite: 0 }, + compat: { ...ANT_LING_COMPAT, thinkingFormat: "ant-ling" }, + }), + ], +}); + +// OpenAI Codex (ChatGPT OAuth) models are not fetched from models.dev; 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_SPARK_CONTEXT = 128000; +const CODEX_MAX_TOKENS = 128000; + +function codexModel( + id: string, + name: string, + model: Pick, "input" | "cost"> & { contextWindow?: number }, +): Model<"openai-codex-responses"> { + return { + id, + name, + api: "openai-codex-responses", + provider: "openai-codex", + baseUrl: CODEX_BASE_URL, + reasoning: true, + contextWindow: CODEX_CONTEXT, + maxTokens: CODEX_MAX_TOKENS, + ...model, + }; +} + +export const openaiCodex = defineProvider({ + id: "openai-codex", + models: [ + codexModel("gpt-6-astra", "GPT-6 Astra", { + input: ["text", "image"], + cost: withOpenAiLongContextPricing(OPENAI_GPT_6_ASTRA_COST), + }), + codexModel("gpt-5.3-codex-spark", "GPT-5.3 Codex Spark", { + input: ["text"], + cost: { input: 1.75, output: 14, cacheRead: 0.175, cacheWrite: 0 }, + contextWindow: CODEX_SPARK_CONTEXT, + }), + codexModel("gpt-5.5", "GPT-5.5", { + input: ["text", "image"], + cost: withOpenAiLongContextPricing({ input: 5, output: 30, cacheRead: 0.5, cacheWrite: 0 }), + }), + codexModel("gpt-5.6-luna", "GPT-5.6 Luna", { + input: ["text", "image"], + cost: withOpenAiLongContextPricing(OPENAI_GPT_56_STANDARD_COSTS["gpt-5.6-luna"]), + }), + codexModel("gpt-5.6-sol", "GPT-5.6 Sol", { + input: ["text", "image"], + cost: withOpenAiLongContextPricing(OPENAI_GPT_56_SOL_COST), + }), + codexModel("gpt-5.6-terra", "GPT-5.6 Terra", { + input: ["text", "image"], + cost: withOpenAiLongContextPricing(OPENAI_GPT_56_STANDARD_COSTS["gpt-5.6-terra"]), + }), + ], +}); diff --git a/packages/ai/scripts/model-generation/providers/together.ts b/packages/ai/scripts/model-generation/providers/together.ts new file mode 100644 index 000000000..63edec703 --- /dev/null +++ b/packages/ai/scripts/model-generation/providers/together.ts @@ -0,0 +1,72 @@ +import type { OpenAICompletionsCompat, ThinkingLevelMap } from "../../../src/types.ts"; +import { defineProvider, rule } from "../dsl.ts"; +import { modelsDev } from "../models-dev.ts"; +import { TOGETHER_BASE_URL, TOGETHER_REASONING_ONLY_MODEL_IDS } from "../shared.ts"; + +const TOGETHER_BASE_COMPAT: OpenAICompletionsCompat = { + supportsStore: false, + supportsDeveloperRole: false, + supportsReasoningEffort: false, + maxTokensField: "max_tokens", + supportsStrictMode: false, + supportsLongCacheRetention: false, +}; + +// Together exposes reasoning three ways: a toggle (`reasoning: { enabled }`), OpenAI +// `reasoning_effort`, or both. Models not listed below use the toggle. +const TOGETHER_REASONING_EFFORT_MODEL_IDS = new Set(["openai/gpt-oss-20b", "openai/gpt-oss-120b"]); +const TOGETHER_TOGGLE_REASONING_EFFORT_MODEL_IDS = new Set(["deepseek-ai/DeepSeek-V4-Pro"]); +const TOGETHER_REASONING_ONLY = new Set(TOGETHER_REASONING_ONLY_MODEL_IDS); + +interface TogetherReasoningMode { + compat: OpenAICompletionsCompat; + thinkingLevelMap: ThinkingLevelMap; +} + +const TOGETHER_REASONING_MODES = { + effort: { + compat: { ...TOGETHER_BASE_COMPAT, supportsReasoningEffort: true, thinkingFormat: "openai" }, + thinkingLevelMap: { off: null, minimal: null }, + }, + toggleAndEffort: { + compat: { ...TOGETHER_BASE_COMPAT, thinkingFormat: "together", supportsReasoningEffort: true }, + thinkingLevelMap: { minimal: null, low: null, medium: null, high: "high", xhigh: null }, + }, + reasoningOnly: { + compat: TOGETHER_BASE_COMPAT, + thinkingLevelMap: { off: null, minimal: null, low: null, medium: null }, + }, + toggle: { + compat: { ...TOGETHER_BASE_COMPAT, thinkingFormat: "together" }, + thinkingLevelMap: { minimal: null, low: null, medium: null }, + }, +} satisfies Record; + +function getTogetherReasoningMode(modelId: string): TogetherReasoningMode { + if (TOGETHER_REASONING_EFFORT_MODEL_IDS.has(modelId)) return TOGETHER_REASONING_MODES.effort; + if (TOGETHER_TOGGLE_REASONING_EFFORT_MODEL_IDS.has(modelId)) return TOGETHER_REASONING_MODES.toggleAndEffort; + if (TOGETHER_REASONING_ONLY.has(modelId)) return TOGETHER_REASONING_MODES.reasoningOnly; + return TOGETHER_REASONING_MODES.toggle; +} + +export const together = defineProvider({ + id: "together", + source: modelsDev({ + provider: "together", + key: ["together", "togetherai", "together-ai"], + api: "openai-completions", + baseUrl: TOGETHER_BASE_URL, + compat: TOGETHER_BASE_COMPAT, + skipDeprecated: true, + route: (id, entry) => (entry.reasoning === true ? { compat: getTogetherReasoningMode(id).compat } : undefined), + }), + rules: [ + rule({ + why: "Together reasoning controls differ per model family; expose only the levels each mode can honor.", + match: { reasoning: true }, + apply: (model) => { + model.thinkingLevelMap = { ...getTogetherReasoningMode(model.id).thinkingLevelMap }; + }, + }), + ], +}); diff --git a/packages/ai/scripts/model-generation/providers/vercel-ai-gateway.ts b/packages/ai/scripts/model-generation/providers/vercel-ai-gateway.ts new file mode 100644 index 000000000..8fc22f682 --- /dev/null +++ b/packages/ai/scripts/model-generation/providers/vercel-ai-gateway.ts @@ -0,0 +1,40 @@ +import { defineProvider, type GeneratedModel, roundCost } from "../dsl.ts"; + +const AI_GATEWAY_BASE_URL = "https://ai-gateway.vercel.sh"; + +function perMillion(value: string | number | undefined): number { + const parsed = typeof value === "number" ? value : parseFloat(value ?? "0"); + return roundCost((Number.isFinite(parsed) ? parsed : 0) * 1_000_000); +} + +export const vercelAiGateway = defineProvider({ + id: "vercel-ai-gateway", + source: async (context) => { + const models: GeneratedModel[] = []; + for (const model of await context.upstream.vercelAiGateway()) { + const tags = Array.isArray(model.tags) ? model.tags : []; + // Only include models that support tools + if (!tags.includes("tool-use")) continue; + 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: tags.includes("vision") ? ["text", "image"] : ["text"], + compat: { allowEmptySignature: true }, + cost: { + input: perMillion(model.pricing?.input), + output: perMillion(model.pricing?.output), + cacheRead: perMillion(model.pricing?.input_cache_read), + cacheWrite: perMillion(model.pricing?.input_cache_write), + }, + contextWindow: model.context_window || 4096, + maxTokens: model.max_tokens || 4096, + }); + } + console.log(`Fetched ${models.length} tool-capable models from Vercel AI Gateway`); + return models; + }, +}); diff --git a/packages/ai/scripts/model-generation/providers/xiaomi.ts b/packages/ai/scripts/model-generation/providers/xiaomi.ts new file mode 100644 index 000000000..ad30664be --- /dev/null +++ b/packages/ai/scripts/model-generation/providers/xiaomi.ts @@ -0,0 +1,30 @@ +import type { OpenAICompletionsCompat } from "../../../src/types.ts"; +import { defineProvider, type ProviderDefinition } from "../dsl.ts"; +import { modelsDev } from "../models-dev.ts"; + +// 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 XIAOMI_COMPAT: OpenAICompletionsCompat = { + requiresReasoningContentOnAssistantMessages: true, + thinkingFormat: "deepseek", +}; + +function xiaomiProvider(id: string, baseUrl: string): ProviderDefinition { + return defineProvider({ + id, + source: modelsDev({ + provider: id, + key: id, + api: "openai-completions", + baseUrl, + compat: XIAOMI_COMPAT, + skipDeprecated: true, + }), + }); +} + +export const xiaomi = xiaomiProvider("xiaomi", "https://api.xiaomimimo.com/v1"); +export const xiaomiTokenPlanCn = xiaomiProvider("xiaomi-token-plan-cn", "https://token-plan-cn.xiaomimimo.com/v1"); +export const xiaomiTokenPlanAms = xiaomiProvider("xiaomi-token-plan-ams", "https://token-plan-ams.xiaomimimo.com/v1"); +export const xiaomiTokenPlanSgp = xiaomiProvider("xiaomi-token-plan-sgp", "https://token-plan-sgp.xiaomimimo.com/v1"); diff --git a/packages/ai/scripts/model-generation/providers/zai.ts b/packages/ai/scripts/model-generation/providers/zai.ts new file mode 100644 index 000000000..1518b023f --- /dev/null +++ b/packages/ai/scripts/model-generation/providers/zai.ts @@ -0,0 +1,55 @@ +import { getEffortThinkingLevelMap } from "../../models-dev-reasoning-options.ts"; +import { type DefinedRule, defineProvider, rule } from "../dsl.ts"; +import { getModelsDevCost, modelsDev } from "../models-dev.ts"; + +const ZAI_TOOL_STREAM_UNSUPPORTED_MODEL_IDS = ["glm-4.5", "glm-4.5-air", "glm-4.5-flash", "glm-4.5v"]; + +const zaiRules: DefinedRule[] = [ + rule({ + why: "z.ai streams tool call deltas with top-level tool_stream on every model after GLM 4.5.", + match: { not: { id: ZAI_TOOL_STREAM_UNSUPPORTED_MODEL_IDS } }, + set: { compat: { zaiToolStream: true } }, + }), + rule({ + why: "Cataloged effort levels enable reasoning_effort; GLM 5.2 additionally accepts `none` to disable thinking.", + match: { when: (_model, context) => getEffortThinkingLevelMap(context.reasoningOptions ?? []) !== undefined }, + apply: (model, context) => { + const map = getEffortThinkingLevelMap(context.reasoningOptions ?? []); + if (!map) return; + if (model.id === "glm-5.2" || model.id === "glm-5.2-highspeed") map.off = "none"; + model.thinkingLevelMap = map; + model.compat = { ...model.compat, supportsReasoningEffort: true }; + }, + }), + rule({ + why: "Coding plans are subscription-priced; report the pay-as-you-go z.ai rates so usage has an estimated value.", + match: { when: (model, context) => context.modelsDev.zai?.models?.[model.id]?.cost !== undefined }, + apply: (model, context) => { + model.cost = getModelsDevCost(context.modelsDev.zai?.models?.[model.id]?.cost); + }, + }), +]; + +export const zai = defineProvider({ + id: "zai", + source: modelsDev({ + provider: "zai", + key: "zai-coding-plan", + api: "openai-completions", + baseUrl: "https://api.z.ai/api/coding/paas/v4", + compat: { supportsDeveloperRole: false, thinkingFormat: "zai" }, + }), + rules: zaiRules, +}); + +export const zaiCodingCn = defineProvider({ + id: "zai-coding-cn", + source: modelsDev({ + provider: "zai-coding-cn", + key: "zhipuai-coding-plan", + api: "openai-completions", + baseUrl: "https://open.bigmodel.cn/api/coding/paas/v4", + compat: { supportsDeveloperRole: false, thinkingFormat: "zai" }, + }), + rules: zaiRules, +}); diff --git a/packages/ai/scripts/model-generation/rules/anthropic-compat.ts b/packages/ai/scripts/model-generation/rules/anthropic-compat.ts new file mode 100644 index 000000000..5a1c9c8f8 --- /dev/null +++ b/packages/ai/scripts/model-generation/rules/anthropic-compat.ts @@ -0,0 +1,54 @@ +import { definePack, rule } from "../dsl.ts"; +import { supportsAnthropicMidConvoEffort, supportsAnthropicMidConvoSystemMessages } from "../shared.ts"; + +// Anthropic Messages transport capabilities that depend on the exact model and relay. + +export const anthropicCompatPack = definePack("anthropic-compat", [ + rule({ + why: "Verified effort-only system messages and thinking binding on direct Anthropic and OpenRouter for Opus 5 and Fable/Mythos 5.1. OpenRouter rejects configuration_update on Opus 5 while accepting it on Fable 5.1.", + match: { + api: "anthropic-messages", + provider: ["anthropic", "openrouter"], + when: (model) => supportsAnthropicMidConvoEffort(model.id), + not: { provider: "openrouter", id: "anthropic/claude-opus-5" }, + }, + set: { compat: { supportsMidConvoEffort: true }, thinkingLevelMap: { off: null } }, + }), + rule({ + why: "Direct Anthropic accepts system messages and tool_addition/tool_removal blocks mid-conversation on Opus 4.8+ and Fable/Mythos 5.", + match: { + api: "anthropic-messages", + provider: "anthropic", + when: (model) => supportsAnthropicMidConvoSystemMessages(model.id), + }, + set: { compat: { supportsMidConvoSystemMessages: true, supportsMidConvoToolChanges: true } }, + }), + rule({ + why: "OpenCode Zen and GitHub Copilot forward mid-conversation system messages but reject tool_addition/tool_removal blocks, so tool changes stay top-level there.", + match: { + api: "anthropic-messages", + provider: ["opencode", "github-copilot"], + when: (model) => supportsAnthropicMidConvoSystemMessages(model.id), + }, + set: { compat: { supportsMidConvoSystemMessages: true } }, + }), + rule({ + why: "GitHub Copilot rejects per-tool eager_input_streaming on these Claude models.", + match: { + api: "anthropic-messages", + provider: "github-copilot", + id: ["claude-haiku-4.5", "claude-sonnet-4", "claude-sonnet-4.5"], + }, + set: { compat: { supportsEagerToolInputStreaming: false } }, + mayMatchNothing: true, + }), + rule({ + why: "Xiaomi's Anthropic-compatible endpoints replay thinking without signatures.", + match: { + api: "anthropic-messages", + when: (model) => model.provider === "xiaomi" || model.provider.startsWith("xiaomi-token-plan-"), + }, + set: { compat: { allowEmptySignature: true } }, + mayMatchNothing: true, + }), +]); diff --git a/packages/ai/scripts/model-generation/rules/anthropic-fallbacks.ts b/packages/ai/scripts/model-generation/rules/anthropic-fallbacks.ts new file mode 100644 index 000000000..e92ec2cde --- /dev/null +++ b/packages/ai/scripts/model-generation/rules/anthropic-fallbacks.ts @@ -0,0 +1,32 @@ +import { compatOf, definePack, mergeCompat, rule } from "../dsl.ts"; +import { supportsAnthropicMidConvoEffort } from "../shared.ts"; + +// Models direct Anthropic may transparently serve in place of the requested one. +const ANTHROPIC_ALLOWED_FALLBACK_MODEL_IDS: Record = { + "claude-fable-5": ["claude-opus-4-8", "claude-opus-5"], + "claude-opus-5": ["claude-opus-4-8"], +}; + +export const anthropicFallbacksPack = definePack("anthropic-fallbacks", [ + rule({ + why: "Record the fallback models Anthropic may serve, with their prices, so responses from a fallback are priced correctly. Effort-bound models only fall back to models that also support mid-conversation effort.", + match: { + api: "anthropic-messages", + provider: "anthropic", + id: Object.keys(ANTHROPIC_ALLOWED_FALLBACK_MODEL_IDS), + }, + apply: (model, context) => { + const fallbackModelIds = ANTHROPIC_ALLOWED_FALLBACK_MODEL_IDS[model.id]; + const compatibleFallbackModelIds = compatOf(model).supportsMidConvoEffort + ? fallbackModelIds.filter(supportsAnthropicMidConvoEffort) + : fallbackModelIds; + const allowedFallbackModels = compatibleFallbackModelIds.flatMap((fallbackModelId) => { + const fallbackModel = context.lookup("anthropic", fallbackModelId); + return fallbackModel && fallbackModel.api === "anthropic-messages" + ? [{ provider: fallbackModel.provider, model: fallbackModel.id, cost: fallbackModel.cost }] + : []; + }); + if (allowedFallbackModels.length > 0) mergeCompat(model, { allowedFallbackModels }); + }, + }), +]); diff --git a/packages/ai/scripts/model-generation/rules/catalog-overrides.ts b/packages/ai/scripts/model-generation/rules/catalog-overrides.ts new file mode 100644 index 000000000..ef8553952 --- /dev/null +++ b/packages/ai/scripts/model-generation/rules/catalog-overrides.ts @@ -0,0 +1,42 @@ +import { definePack, rule } from "../dsl.ts"; +import { DEEPSEEK_COMPAT, KIMI_K3_MAX_TOKENS, QWEN_TOKEN_PLAN_PROVIDER_IDS } from "../shared.ts"; + +// Corrections that span providers. Single-provider corrections live with their provider. + +export const catalogOverridesPack = definePack("catalog-overrides", [ + rule({ + why: "Claude Opus/Sonnet 4.6 have a 1M context window; upstream catalogs still report 200K.", + match: { + provider: ["anthropic", "opencode", "opencode-go"], + id: ["claude-opus-4-6", "claude-sonnet-4-6", "claude-opus-4.6", "claude-sonnet-4.6"], + }, + set: { contextWindow: 1000000 }, + }), + rule({ + why: "Keep Kimi K3's canonical output limit when gateway metadata is missing or incorrect.", + match: { + when: (model) => + (model.provider === "openrouter" && + (model.id === "moonshotai/kimi-k3" || model.id === "~moonshotai/kimi-latest")) || + (model.provider === "vercel-ai-gateway" && model.id === "moonshotai/kimi-k3"), + }, + set: { maxTokens: KIMI_K3_MAX_TOKENS }, + mayMatchNothing: true, + }), + rule({ + why: "DeepSeek V4 needs reasoning_content replayed on assistant messages everywhere; direct-style endpoints also take DeepSeek's thinking format, while OpenRouter and OpenCode keep native reasoning_effort (#9485).", + match: { api: "openai-completions", idIncludes: "deepseek-v4", not: { provider: QWEN_TOKEN_PLAN_PROVIDER_IDS } }, + apply: (model) => { + const preservesNativeReasoningEffort = model.provider === "openrouter" || model.provider === "opencode"; + model.compat = { + ...model.compat, + ...(preservesNativeReasoningEffort + ? { + requiresReasoningContentOnAssistantMessages: + DEEPSEEK_COMPAT.requiresReasoningContentOnAssistantMessages, + } + : DEEPSEEK_COMPAT), + }; + }, + }), +]); diff --git a/packages/ai/scripts/model-generation/rules/index.ts b/packages/ai/scripts/model-generation/rules/index.ts new file mode 100644 index 000000000..ceef22f33 --- /dev/null +++ b/packages/ai/scripts/model-generation/rules/index.ts @@ -0,0 +1,24 @@ +import type { RulePack } from "../dsl.ts"; +import { anthropicCompatPack } from "./anthropic-compat.ts"; +import { anthropicFallbacksPack } from "./anthropic-fallbacks.ts"; +import { catalogOverridesPack } from "./catalog-overrides.ts"; +import { openAiCompletionsCompatPack } from "./openai-completions-compat.ts"; +import { promptCachePack } from "./prompt-cache.ts"; +import { reasoningOptionsPack } from "./reasoning-options.ts"; +import { thinkingLevelsPack } from "./thinking-levels.ts"; +import { toolCapabilitiesPack } from "./tool-capabilities.ts"; + +/** + * Catalog-wide rule packs, applied in this order after every provider has been + * generated. Later packs see the effects of earlier ones. + */ +export const RULE_PACKS: readonly RulePack[] = [ + catalogOverridesPack, + openAiCompletionsCompatPack, + anthropicCompatPack, + reasoningOptionsPack, + thinkingLevelsPack, + toolCapabilitiesPack, + promptCachePack, + anthropicFallbacksPack, +]; diff --git a/packages/ai/scripts/model-generation/rules/openai-completions-compat.ts b/packages/ai/scripts/model-generation/rules/openai-completions-compat.ts new file mode 100644 index 000000000..c96ce3311 --- /dev/null +++ b/packages/ai/scripts/model-generation/rules/openai-completions-compat.ts @@ -0,0 +1,170 @@ +import type { Model, OpenAICompletionsCompat } from "../../../src/types.ts"; +import { definePack, type GeneratedModel, rule } from "../dsl.ts"; +import { TOGETHER_REASONING_ONLY_MODEL_IDS } from "../shared.ts"; + +// Request-shape defaults for OpenAI-compatible chat completions endpoints, keyed off the +// provider id or base URL. This is computed rather than declared because it describes +// endpoint families; per-model exceptions belong in provider rules, which win over it. + +export 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, + supportsMidConvoSystemMessages: false, + supportsMidConvoToolAdditions: false, + sendSessionAffinityHeaders: false, + supportsLongCacheRetention: true, +} satisfies OpenAICompletionsCompat; + +export type OpenAICompletionsResolvedCompat = OpenAICompletionsCompat & + Record; + +const TOGETHER_REASONING_ONLY = new Set(TOGETHER_REASONING_ONLY_MODEL_IDS); + +export 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 isCerebras = provider === "cerebras" || baseUrl.includes("cerebras.ai"); + const isTogetherReasoningOnly = isTogether && TOGETHER_REASONING_ONLY.has(model.id); + const isDeepSeek = provider === "deepseek" || baseUrl.toLowerCase().includes("deepseek.com"); + + const isNonStandard = + isNvidia || + isCerebras || + 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 && !isCerebras, + supportsOpenAIGrammarTools: false, + supportsMidConvoSystemMessages: false, + supportsMidConvoToolAdditions: 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; +} + +/** The detected flags that differ from the runtime defaults, so generated data stays small. */ +function openAICompletionsCompatDelta(compat: OpenAICompletionsResolvedCompat): OpenAICompletionsCompat { + const delta: Record = {}; + const defaults = OPENAI_COMPLETIONS_DEFAULT_COMPAT as Record; + for (const [key, value] of Object.entries(compat)) { + const defaultValue = defaults[key]; + if (isPlainEmptyObject(value) && isPlainEmptyObject(defaultValue)) continue; + if (value !== defaultValue) delta[key] = value; + } + return delta as OpenAICompletionsCompat; +} + +/** Effective completions compat for a model: detected defaults under its declared flags. */ +export function resolveOpenAICompletionsCompat(model: GeneratedModel): OpenAICompletionsResolvedCompat { + return { + ...detectOpenAICompletionsCompat(model as Model<"openai-completions">), + ...(model.compat as OpenAICompletionsCompat | undefined), + }; +} + +export const openAiCompletionsCompatPack = definePack("openai-completions-compat", [ + rule({ + why: "Record endpoint-family request defaults that differ from the runtime defaults; declared flags win.", + match: { api: "openai-completions" }, + apply: (model) => { + const detected = openAICompletionsCompatDelta( + detectOpenAICompletionsCompat(model as Model<"openai-completions">), + ); + model.compat = { ...detected, ...(model.compat as OpenAICompletionsCompat | undefined) }; + }, + }), +]); diff --git a/packages/ai/scripts/model-generation/rules/prompt-cache.ts b/packages/ai/scripts/model-generation/rules/prompt-cache.ts new file mode 100644 index 000000000..7dc4fd744 --- /dev/null +++ b/packages/ai/scripts/model-generation/rules/prompt-cache.ts @@ -0,0 +1,17 @@ +import { definePack, rule } from "../dsl.ts"; + +export const promptCachePack = definePack("prompt-cache", [ + rule({ + why: "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", + match: { api: "openai-responses", provider: "openai", when: (model) => model.cost.cacheWrite > 0 }, + set: { compat: { supportsExplicitPromptCacheMode: true } }, + }), + // Do not add OpenAI lifetimes yet. Before enabling warming for explicit OpenAI caches, + // re-evaluate it using observed expiry, replay, and billing behavior; a documented TTL + // alone does not establish full cache loss. + rule({ + why: "Anthropic ephemeral entries have a hard five-minute lifetime; ttl 1h extends it to one hour. Only direct Anthropic is annotated so cache warming does not assume equivalent behavior through proxies. https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching", + match: { api: "anthropic-messages", provider: "anthropic" }, + set: { promptCache: { short: 300, long: 3600 } }, + }), +]); diff --git a/packages/ai/scripts/model-generation/rules/reasoning-options.ts b/packages/ai/scripts/model-generation/rules/reasoning-options.ts new file mode 100644 index 000000000..612836603 --- /dev/null +++ b/packages/ai/scripts/model-generation/rules/reasoning-options.ts @@ -0,0 +1,31 @@ +import { getEffortThinkingLevelMap } from "../../models-dev-reasoning-options.ts"; +import { compatOf, definePack, type GeneratedModel, mergeThinkingLevelMap, rule } from "../dsl.ts"; +import { resolveOpenAICompletionsCompat } from "./openai-completions-compat.ts"; + +/** Whether pi sends effort values straight through for this model, so cataloged efforts apply verbatim. */ +export function supportsDirectReasoningEffort(model: GeneratedModel): boolean { + if (model.api === "anthropic-messages") return compatOf(model).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 = resolveOpenAICompletionsCompat(model); + return compat.thinkingFormat === "openai" && compat.supportsReasoningEffort === true; +} + +export const reasoningOptionsPack = definePack("models-dev-reasoning-options", [ + rule({ + why: "Expose the effort levels models.dev verified for models whose transport passes effort through unchanged.", + match: { + when: (model, context) => context.reasoningOptions !== undefined && supportsDirectReasoningEffort(model), + }, + apply: (model, context) => { + const map = getEffortThinkingLevelMap(context.reasoningOptions ?? []); + if (map) mergeThinkingLevelMap(model, map); + }, + }), +]); diff --git a/packages/ai/scripts/model-generation/rules/thinking-levels.ts b/packages/ai/scripts/model-generation/rules/thinking-levels.ts new file mode 100644 index 000000000..265ef71f6 --- /dev/null +++ b/packages/ai/scripts/model-generation/rules/thinking-levels.ts @@ -0,0 +1,256 @@ +import type { ThinkingLevelMap } from "../../../src/types.ts"; +import { getEffortThinkingLevelMap } from "../../models-dev-reasoning-options.ts"; +import { compatOf, definePack, mergeThinkingLevelMap, rule } from "../dsl.ts"; +import { + ANTHROPIC_ADAPTIVE_THINKING_ID_PARTS, + DEEPSEEK_V4_FLASH_THINKING_LEVEL_MAP, + DEEPSEEK_V4_THINKING_LEVEL_MAP, + OPENAI_MAX_MODEL_ID_PARTS, + OPENAI_XHIGH_MODEL_ID_PARTS, + supportsOpenAiXhigh, +} from "../shared.ts"; + +// Which pi thinking levels each model accepts and how they spell them. Rules apply in +// order and merge, so later rules refine earlier ones for the same model. + +const OPENAI_RESPONSES_APIS = ["openai-responses", "azure-openai-responses", "openai-codex-responses"] as const; + +const OPENAI_RESPONSES_NONE_REASONING_MODEL_IDS = [ + "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", +]; + +// 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: Record = { + "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" }, +}; + +const ANTHROPIC_TEMPERATURE_UNSUPPORTED_ID_PARTS = ["opus-4-7", "opus-4.7", "opus-4-8", "opus-4.8", "opus-5", "opus.5"]; + +export const thinkingLevelsPack = definePack("thinking-levels", [ + rule({ + why: "GPT-5 Responses models reason by default; pi has no way to switch reasoning off.", + match: { api: ["openai-responses", "azure-openai-responses"], idPrefix: "gpt-5" }, + set: { thinkingLevelMap: { off: null } }, + }), + rule({ + why: "GPT-6 Astra documents low through max and no minimal.", + match: { api: OPENAI_RESPONSES_APIS, id: "gpt-6-astra" }, + set: { + thinkingLevelMap: { + off: null, + minimal: null, + low: "low", + medium: "medium", + high: "high", + xhigh: "xhigh", + max: "max", + }, + }, + }), + rule({ + why: "GitHub Copilot maps minimal GPT-5 effort to low.", + match: { provider: "github-copilot", idPrefix: "gpt-5" }, + set: { thinkingLevelMap: { minimal: "low" } }, + }), + rule({ + why: "These OpenAI Responses models accept reasoning effort `none`.", + match: { provider: "openai", api: "openai-responses", id: OPENAI_RESPONSES_NONE_REASONING_MODEL_IDS }, + set: { thinkingLevelMap: { off: "none" } }, + }), + rule({ + why: "xAI models without verified effort options must not send the undocumented none/minimal efforts.", + match: { provider: "xai", api: "openai-responses", when: (model) => model.thinkingLevelMap === undefined }, + set: { thinkingLevelMap: { off: null, minimal: null } }, + mayMatchNothing: true, + }), + rule({ + why: "GPT-5.2+ accept reasoning effort xhigh everywhere they are served.", + match: { idIncludes: OPENAI_XHIGH_MODEL_ID_PARTS }, + set: { thinkingLevelMap: { xhigh: "xhigh" } }, + }), + rule({ + why: "GPT-5.6 and GPT-6 Astra accept reasoning effort max on OpenAI-style APIs.", + match: { idIncludes: OPENAI_MAX_MODEL_ID_PARTS, api: [...OPENAI_RESPONSES_APIS, "openai-completions"] }, + set: { thinkingLevelMap: { max: "max" } }, + }), + rule({ + why: "OpenAI rejects minimal effort on GPT-5.5.", + match: { provider: "openai", id: "gpt-5.5" }, + set: { thinkingLevelMap: { minimal: null } }, + }), + rule({ + why: "GPT-5.5 Pro only runs at high effort and above.", + match: { idPattern: /gpt-5\.5-pro$/ }, + set: { thinkingLevelMap: { 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. + rule({ + why: "Claude Opus/Sonnet 4.6 accept effort max.", + match: { idIncludes: ["opus-4-6", "opus-4.6", "sonnet-4-6", "sonnet-4.6"] }, + set: { thinkingLevelMap: { max: "max" } }, + }), + rule({ + why: "Claude Opus 4.7+/5 and Sonnet 5 accept effort xhigh and max.", + match: { + idIncludes: ["opus-4-7", "opus-4.7", "opus-4-8", "opus-4.8", "opus-5", "opus.5", "sonnet-5", "sonnet.5"], + }, + set: { thinkingLevelMap: { xhigh: "xhigh", max: "max" } }, + }), + rule({ + why: "Claude Fable 5 always thinks and accepts effort xhigh and max.", + match: { idIncludes: "fable-5" }, + set: { thinkingLevelMap: { off: null, xhigh: "xhigh", max: "max" } }, + }), + rule({ + why: "Claude 4.6+ models require the adaptive thinking request format.", + match: { api: "anthropic-messages", idIncludes: ANTHROPIC_ADAPTIVE_THINKING_ID_PARTS }, + set: { compat: { forceAdaptiveThinking: true } }, + }), + rule({ + why: "Claude Opus 4.7+ rejects non-default temperature values.", + match: { + api: "anthropic-messages", + when: (model) => + ANTHROPIC_TEMPERATURE_UNSUPPORTED_ID_PARTS.some((part) => model.id.toLowerCase().includes(part)), + }, + set: { compat: { supportsTemperature: false } }, + }), + rule({ + why: "DeepSeek V4 documents high/max efforts (Flash also low); OpenRouter adds xhigh and drops max. Only fills in models without cataloged efforts.", + match: { + api: "openai-completions", + idIncludes: "deepseek-v4", + when: (model) => model.thinkingLevelMap === undefined, + }, + apply: (model) => { + 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, + ); + }, + }), + rule({ + why: "Groq's Qwen 3.6 27B only accepts the `default` reasoning effort.", + match: { provider: "groq", id: "qwen/qwen3.6-27b" }, + set: { thinkingLevelMap: { minimal: null, low: null, medium: null, high: "default" } }, + mayMatchNothing: true, + }), + rule({ + why: "The ChatGPT Codex backend maps minimal effort to low on xhigh-capable models.", + match: { provider: "openai-codex", when: (model) => supportsOpenAiXhigh(model.id) }, + set: { thinkingLevelMap: { minimal: "low" } }, + }), + rule({ + why: '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.', + match: { provider: ["moonshotai", "moonshotai-cn"], id: ["kimi-k2.7-code", "kimi-k2.7-code-highspeed"] }, + set: { thinkingLevelMap: { off: null } }, + }), + rule({ + why: "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 sending effort none. Pi's low/medium/high pass through verbatim; OpenRouter normalizes to Mercury's vocabulary.", + match: { provider: "openrouter", idPrefix: "inception/mercury-2" }, + set: { thinkingLevelMap: { off: null } }, + mayMatchNothing: true, + }), + rule({ + why: "OpenRouter accepts xhigh for GLM-5.2.", + match: { provider: "openrouter", id: "z-ai/glm-5.2" }, + set: { thinkingLevelMap: { xhigh: "xhigh" } }, + mayMatchNothing: true, + }), + rule({ + why: "Fireworks Qwen Max currently advertises only a toggle. Prefer upstream effort metadata once available instead of replacing it with this fallback.", + match: { + provider: "fireworks", + api: "anthropic-messages", + id: "accounts/fireworks/models/qwen3p8-max", + when: (model) => compatOf(model).forceAdaptiveThinking === true && !model.thinkingLevelMap, + }, + apply: (model) => { + model.thinkingLevelMap = getEffortThinkingLevelMap([{ type: "effort", values: ["low", "medium", "xhigh"] }]); + }, + mayMatchNothing: true, + }), + rule({ + why: "Fireworks Messages models with a cataloged toggle accept effort none; the Qwen 2.4T alias omits its verified toggle in models.dev.", + match: { + provider: "fireworks", + api: "anthropic-messages", + when: (model, context) => + compatOf(model).forceAdaptiveThinking === true && + (context.reasoningOptions?.some((option) => option.type === "toggle") === true || + model.id === "accounts/fireworks/models/qwen3p8-2p4t-a95b"), + }, + set: { thinkingLevelMap: { off: "none" } }, + }), + rule({ + why: "Fireworks DeepSeek V4 Pro accepts effort low despite models.dev omitting it (#9323).", + match: { + provider: "fireworks", + api: "anthropic-messages", + id: "accounts/fireworks/models/deepseek-v4-pro-0813", + when: (model) => compatOf(model).forceAdaptiveThinking === true, + }, + set: { thinkingLevelMap: { low: "low" } }, + mayMatchNothing: true, + }), + rule({ + why: "Fireworks 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.", + match: { provider: "fireworks", idIncludes: "glm-5p2" }, + set: { thinkingLevelMap: { off: "none", minimal: null, low: null, medium: null, max: "max" } }, + mayMatchNothing: true, + }), + rule({ + why: "Fireworks maps Kimi K3 medium to high on both APIs; do not expose it as a distinct level.", + match: { provider: "fireworks", idIncludes: "kimi-k3" }, + set: { thinkingLevelMap: { medium: null } }, + mayMatchNothing: true, + }), + rule({ + why: "OpenCode Go GLM-5.2 exposes only high and max.", + match: { provider: "opencode-go", id: "glm-5.2" }, + set: { thinkingLevelMap: { off: null, minimal: null, low: null, medium: null, high: "high", max: "max" } }, + mayMatchNothing: true, + }), + rule({ + why: "OpenCode Go exposes Kimi K2.6 thinking as on/off, not distinct effort tiers.", + match: { provider: "opencode-go", id: "kimi-k2.6" }, + set: { thinkingLevelMap: { minimal: null, low: null, medium: null } }, + mayMatchNothing: true, + }), + rule({ + why: "OpenCode Zen Grok Build reasons by default but rejects explicit reasoningEffort.", + match: { provider: "opencode", id: "grok-build-0.1" }, + set: { thinkingLevelMap: { off: null, minimal: null, low: null, medium: null } }, + mayMatchNothing: true, + }), + rule({ + why: "Ring reasons by default. Only high/xhigh have documented explicit effort controls.", + match: { provider: "ant-ling", reasoning: true }, + set: { thinkingLevelMap: { off: null, minimal: null, low: null, medium: null, high: "high", xhigh: "xhigh" } }, + }), + rule({ + why: "Narrow corrections over models.dev metadata, checked against the authenticated GitHub Copilot /models endpoint on 2026-06-15.", + match: { provider: "github-copilot", id: Object.keys(GITHUB_COPILOT_THINKING_LEVEL_OVERRIDES) }, + apply: (model) => mergeThinkingLevelMap(model, GITHUB_COPILOT_THINKING_LEVEL_OVERRIDES[model.id]), + }), +]); diff --git a/packages/ai/scripts/model-generation/rules/tool-capabilities.ts b/packages/ai/scripts/model-generation/rules/tool-capabilities.ts new file mode 100644 index 000000000..6550eebc7 --- /dev/null +++ b/packages/ai/scripts/model-generation/rules/tool-capabilities.ts @@ -0,0 +1,117 @@ +import { definePack, rule } from "../dsl.ts"; +import { OPENAI_MID_CONVO_SYSTEM_MESSAGE_MODEL_IDS, OPENAI_TOOL_SEARCH_MODEL_IDS } from "../shared.ts"; + +// Tool-definition and transcript features that depend on the exact model and relay. + +// 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 = [ + "openai", + "openai-codex", + "azure-openai-responses", + "github-copilot", + "opencode", + "cloudflare-ai-gateway", +]; +const OPENAI_GRAMMAR_TOOL_APIS = ["openai-responses", "azure-openai-responses", "openai-codex-responses"] as const; +const GPT_5_OR_LATER = /^gpt-(?:[5-9]|[1-9]\d+)/; + +const OPENAI_CODEX_ADDITIONAL_TOOLS_MODEL_IDS = ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-6-astra"]; + +// Newer OpenAI Responses models accept developer messages after the conversation has started. +// OpenCode Zen, OpenCode Go, and GitHub Copilot pass both those messages and +// `additional_tools` items through to OpenAI unchanged; tool search is not verified +// through those proxies. +const OPENAI_RESPONSES_PROXY_PROVIDERS = ["opencode", "opencode-go", "github-copilot"]; + +// Moonshot Kimi K2.6/K2.7 accept system text after the conversation starts but reject +// tool-bearing system messages. Kimi K3 accepts both forms; Fireworks and OpenCode pass +// its tool-bearing form through. GitHub Copilot forwards K3 text but silently drops its +// tool-bearing message. DeepSeek V4 Pro and OpenAI models behind OpenRouter also accept +// plain system text in place. +const KIMI_K3_FULL_MID_CONVO = { + api: "openai-completions", + when: (model: { provider: string; id: string }) => + (model.provider.startsWith("moonshot") && model.id === "kimi-k3") || + (model.provider === "fireworks" && model.id.includes("kimi-k3")) || + ((model.provider === "opencode" || model.provider === "opencode-go") && model.id === "kimi-k3"), +} as const; + +export const toolCapabilitiesPack = definePack("tool-capabilities", [ + rule({ + why: "OpenAI and Cloudflare AI Gateway Responses accept strict JSON-schema function tools.", + match: { api: "openai-responses", provider: ["openai", "cloudflare-ai-gateway"] }, + set: { compat: { supportsStrictMode: true } }, + }), + rule({ + why: "Direct Anthropic accepts strict tool schemas.", + match: { api: "anthropic-messages", provider: "anthropic" }, + set: { compat: { supportsStrictTools: true } }, + }), + rule({ + why: "Responses endpoints verified or documented to pass OpenAI custom grammar tools through; OpenAI rejects them for pre-GPT-5 models.", + match: { api: OPENAI_GRAMMAR_TOOL_APIS, provider: OPENAI_GRAMMAR_TOOL_PROVIDERS, idPattern: GPT_5_OR_LATER }, + set: { compat: { supportsOpenAIGrammarTools: true } }, + }), + rule({ + why: "These OpenAI Responses models support client-executed tool search and message-anchored additional_tools.", + match: { api: "openai-responses", provider: "openai", id: OPENAI_TOOL_SEARCH_MODEL_IDS }, + set: { compat: { supportsToolSearch: true, supportsAdditionalTools: true } }, + }), + rule({ + why: "The ChatGPT Codex backend supports tool search on these models; additional_tools only on GPT-5.6+.", + match: { api: "openai-codex-responses", provider: "openai-codex", id: OPENAI_TOOL_SEARCH_MODEL_IDS }, + apply: (model) => { + model.compat = { + ...model.compat, + ...(OPENAI_CODEX_ADDITIONAL_TOOLS_MODEL_IDS.includes(model.id) ? { supportsAdditionalTools: true } : {}), + supportsToolSearch: true, + }; + }, + }), + rule({ + why: "Kimi K3 accepts system messages and tool additions mid-conversation on Moonshot, Fireworks, and OpenCode.", + match: KIMI_K3_FULL_MID_CONVO, + set: { compat: { supportsMidConvoSystemMessages: true, supportsMidConvoToolAdditions: true } }, + }), + rule({ + why: "These chat completions models accept plain system text mid-conversation but not tool-bearing system messages.", + match: { + api: "openai-completions", + when: (model) => + (model.provider.startsWith("moonshot") && + (model.id === "kimi-k2.6" || + model.id === "kimi-k2.7-code" || + model.id === "kimi-k2.7-code-highspeed")) || + (model.provider === "github-copilot" && model.id === "kimi-k3") || + (model.provider === "deepseek" && model.id === "deepseek-v4-pro") || + (model.provider === "openrouter" && + model.id.startsWith("openai/") && + (OPENAI_MID_CONVO_SYSTEM_MESSAGE_MODEL_IDS as readonly string[]).includes( + model.id.slice("openai/".length), + )), + }, + set: { compat: { supportsMidConvoSystemMessages: true } }, + }), + rule({ + why: "Newer OpenAI Responses models accept developer messages after the conversation has started.", + match: { + id: OPENAI_MID_CONVO_SYSTEM_MESSAGE_MODEL_IDS, + when: (model) => + (model.provider === "openai" && model.api === "openai-responses") || + (model.provider === "openai-codex" && model.api === "openai-codex-responses"), + }, + set: { compat: { supportsMidConvoSystemMessages: true } }, + }), + rule({ + why: "OpenCode Zen, OpenCode Go, and GitHub Copilot pass mid-conversation developer messages and additional_tools through to OpenAI unchanged.", + match: { + api: "openai-responses", + provider: OPENAI_RESPONSES_PROXY_PROVIDERS, + id: OPENAI_MID_CONVO_SYSTEM_MESSAGE_MODEL_IDS, + }, + set: { compat: { supportsMidConvoSystemMessages: true, supportsAdditionalTools: true } }, + }), +]); diff --git a/packages/ai/scripts/model-generation/shared.ts b/packages/ai/scripts/model-generation/shared.ts new file mode 100644 index 000000000..bb6844251 --- /dev/null +++ b/packages/ai/scripts/model-generation/shared.ts @@ -0,0 +1,136 @@ +import type { ModelCost, OpenAICompletionsCompat, ThinkingLevelMap } from "../../src/types.ts"; +import { roundCost } from "./dsl.ts"; + +// Values shared by more than one provider declaration or rule pack. Anything used in a +// single place belongs next to its provider or rule instead. + +export const OPENAI_BASE_URL = "https://api.openai.com/v1"; +export const OPENAI_LONG_CONTEXT_INPUT_THRESHOLD = 272000; + +/** OpenAI models whose long-context requests are billed at higher rates. */ +export const OPENAI_LONG_CONTEXT_PRICING_MODEL_IDS = [ + "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", +] as const; + +export function withOpenAiLongContextPricing(cost: ModelCost): ModelCost { + 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 +export const OPENAI_GPT_56_STANDARD_COSTS: Record = { + "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 }, +}; +export const OPENAI_GPT_56_SOL_COST: ModelCost = { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 6.25 }; +export const OPENAI_GPT_6_ASTRA_COST: ModelCost = { input: 10, output: 50, cacheRead: 1, cacheWrite: 12.5 }; + +/** OpenAI Responses models with client-executed tool search. */ +export const OPENAI_TOOL_SEARCH_MODEL_IDS = [ + "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", +] as const; +/** OpenAI models that accept developer messages after the conversation has started. */ +export const OPENAI_MID_CONVO_SYSTEM_MESSAGE_MODEL_IDS = OPENAI_TOOL_SEARCH_MODEL_IDS; + +export const OPENAI_XHIGH_MODEL_ID_PARTS = [ + "gpt-5.2", + "gpt-5.3", + "gpt-5.4", + "gpt-5.5", + "gpt-5.6", + "gpt-6-astra", +] as const; +export const OPENAI_MAX_MODEL_ID_PARTS = ["gpt-5.6", "gpt-6-astra"] as const; + +export function supportsOpenAiXhigh(modelId: string): boolean { + return OPENAI_XHIGH_MODEL_ID_PARTS.some((part) => modelId.includes(part)); +} + +export const KIMI_K3_MAX_TOKENS = 131072; +export const KIMI_K3_COST: ModelCost = { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 0 }; + +export const DEEPSEEK_COMPAT: OpenAICompletionsCompat = { + requiresReasoningContentOnAssistantMessages: true, + thinkingFormat: "deepseek", +}; +export const DEEPSEEK_V4_THINKING_LEVEL_MAP: ThinkingLevelMap = { + minimal: null, + low: null, + medium: null, + high: "high", + max: "max", +}; +export const DEEPSEEK_V4_FLASH_THINKING_LEVEL_MAP: ThinkingLevelMap = { + ...DEEPSEEK_V4_THINKING_LEVEL_MAP, + low: "low", +}; + +export const QWEN_TOKEN_PLAN_PROVIDER_IDS = [ + "qwen-token-plan", + "qwen-token-plan-cn", + "qwen-token-plan-individual", +] as const; + +export const TOGETHER_BASE_URL = "https://api.together.ai/v1"; +/** Together models that always reason and reject the reasoning toggle. */ +export const TOGETHER_REASONING_ONLY_MODEL_IDS = ["deepseek-ai/DeepSeek-R1", "MiniMaxAI/MiniMax-M2.7"] as const; + +/** Claude id fragments across providers; providers spell versions with `-` or `.`. */ +export const ANTHROPIC_ADAPTIVE_THINKING_ID_PARTS = [ + "opus-4-6", + "opus-4.6", + "opus-4-7", + "opus-4.7", + "opus-4-8", + "opus-4.8", + "opus-5", + "opus.5", + "sonnet-4-6", + "sonnet-4.6", + "sonnet-5", + "sonnet.5", + "fable-5", + "mythos-5", +] as const; + +export 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); +} + +export function supportsAnthropicMidConvoSystemMessages(modelId: string): boolean { + return ( + /^claude-opus-(?:4[.-]8|5)(?:-\d{8})?$/.test(modelId) || + /^claude-(?:fable|mythos)-5(?:[.-]1)?(?:-\d{8})?$/.test(modelId) + ); +} + +export function isGemma4Model(modelId: string): boolean { + return /gemma-?4/.test(modelId.toLowerCase()); +} diff --git a/packages/ai/scripts/model-generation/sources.ts b/packages/ai/scripts/model-generation/sources.ts new file mode 100644 index 000000000..d131e067c --- /dev/null +++ b/packages/ai/scripts/model-generation/sources.ts @@ -0,0 +1,236 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { + DEFAULT_RADIUS_GATEWAY, + parseRadiusGatewayConfig, + type RadiusGatewayConfig, +} from "../../src/providers/radius-config.ts"; +import type { ModelsDevReasoningOption } from "../models-dev-reasoning-options.ts"; +import type { OpenRouterReasoningMetadata } from "../openrouter-reasoning-options.ts"; + +/** One model entry from https://models.dev/api.json. */ +export 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; + }; +} + +export interface ModelsDevProvider { + models?: Record; +} + +export type ModelsDevCatalog = Record; + +export interface NvidiaNimModelListItem { + id: string; +} + +export 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; +} + +export 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; + }; +} + +export const MODELS_DEV_URL = "https://models.dev/api.json"; +export const OPENROUTER_MODELS_URL = "https://openrouter.ai/api/v1/models"; +export const AI_GATEWAY_MODELS_URL = "https://ai-gateway.vercel.sh/v1/models"; +export const NVIDIA_NIM_MODELS_URL = "https://integrate.api.nvidia.com/v1/models"; +export const RADIUS_CONFIG_URL = new URL("/v1/config", DEFAULT_RADIUS_GATEWAY).href; + +export interface UpstreamOptions { + /** Fail instead of continuing with an empty catalog when an upstream source cannot be loaded. */ + strict: boolean; + /** + * Directory of recorded upstream responses. Responses present in the directory are + * read instead of fetched; fetched responses are written there for later replays. + */ + snapshotDir?: string; +} + +interface UpstreamSource { + name: string; + label: string; + url: string; + parse: (payload: unknown) => T; + empty: () => T; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function parseListPayload(payload: unknown): T[] { + return isRecord(payload) && Array.isArray(payload.data) ? (payload.data as T[]) : []; +} + +const MODELS_DEV_SOURCE: UpstreamSource = { + name: "models-dev", + label: "models.dev", + url: MODELS_DEV_URL, + parse: (payload) => (isRecord(payload) ? (payload as ModelsDevCatalog) : {}), + empty: () => ({}), +}; + +const OPENROUTER_SOURCE: UpstreamSource = { + name: "openrouter", + label: "OpenRouter", + url: OPENROUTER_MODELS_URL, + parse: parseListPayload, + empty: () => [], +}; + +const AI_GATEWAY_SOURCE: UpstreamSource = { + name: "vercel-ai-gateway", + label: "Vercel AI Gateway", + url: AI_GATEWAY_MODELS_URL, + parse: parseListPayload, + empty: () => [], +}; + +const NVIDIA_NIM_SOURCE: UpstreamSource = { + name: "nvidia-nim", + label: "NVIDIA NIM", + url: NVIDIA_NIM_MODELS_URL, + parse: parseListPayload, + empty: () => [], +}; + +const RADIUS_SOURCE: UpstreamSource = { + name: "radius", + label: "Radius", + url: RADIUS_CONFIG_URL, + parse: (payload) => { + const config = parseRadiusGatewayConfig(payload, DEFAULT_RADIUS_GATEWAY); + if (config.models.length === 0) throw new Error("Radius API returned no models"); + return config; + }, + empty: () => undefined, +}; + +/** + * Lazily loaded, memoized upstream catalogs. Each source is fetched at most once per + * generator run and only when a provider declaration asks for it. + */ +export class UpstreamCatalogs { + readonly options: UpstreamOptions; + private readonly loaded = new Map>(); + + constructor(options: UpstreamOptions) { + this.options = options; + } + + modelsDev(): Promise { + return this.load(MODELS_DEV_SOURCE); + } + + openRouter(): Promise { + return this.load(OPENROUTER_SOURCE); + } + + vercelAiGateway(): Promise { + return this.load(AI_GATEWAY_SOURCE); + } + + nvidiaNim(): Promise { + return this.load(NVIDIA_NIM_SOURCE); + } + + radius(): Promise { + return this.load(RADIUS_SOURCE); + } + + private load(source: UpstreamSource): Promise { + let pending = this.loaded.get(source.name) as Promise | undefined; + if (!pending) { + pending = this.loadUncached(source); + this.loaded.set(source.name, pending); + } + return pending; + } + + private async loadUncached(source: UpstreamSource): Promise { + try { + return source.parse(await this.readPayload(source)); + } catch (error) { + console.error(`Failed to fetch ${source.label} models:`, error); + if (this.options.strict) throw error; + return source.empty(); + } + } + + private async readPayload(source: UpstreamSource): Promise { + const snapshotPath = this.options.snapshotDir ? join(this.options.snapshotDir, `${source.name}.json`) : undefined; + if (snapshotPath && existsSync(snapshotPath)) { + console.log(`Reading ${source.label} models from ${snapshotPath}...`); + return JSON.parse(readFileSync(snapshotPath, "utf8")); + } + console.log(`Fetching models from ${source.label} API...`); + const response = await fetch(source.url, { headers: { accept: "application/json" } }); + if (!response.ok) throw new Error(`${source.label} API returned ${response.status}`); + const text = await response.text(); + const payload: unknown = JSON.parse(text); + if (snapshotPath) { + mkdirSync(this.options.snapshotDir as string, { recursive: true }); + writeFileSync(snapshotPath, text); + } + return payload; + } +} diff --git a/packages/ai/src/providers/radius-config.ts b/packages/ai/src/providers/radius-config.ts index a2715aaec..f02d18dd3 100644 --- a/packages/ai/src/providers/radius-config.ts +++ b/packages/ai/src/providers/radius-config.ts @@ -49,6 +49,13 @@ function sanitizeRadiusGatewayConfig(config: unknown): RadiusGatewayConfig | und }; } +/** Validates a raw `/v1/config` payload from a Radius gateway. */ +export function parseRadiusGatewayConfig(payload: unknown, gateway: string): RadiusGatewayConfig { + const config = sanitizeRadiusGatewayConfig(payload); + if (!config) throw new Error(`Invalid Radius config from ${gateway}`); + return config; +} + export function normalizeRadiusGatewayUrl(value: string): string { const withScheme = /^https?:\/\//iu.test(value) ? value : `https://${value}`; return withScheme.replace(/\/+$/u, ""); @@ -90,7 +97,5 @@ export async function loadRadiusGatewayConfig( `Could not load Radius config from ${gateway}: ${response.status}: ${truncateHttpBody(await response.text())}`, ); } - const config = sanitizeRadiusGatewayConfig(await response.json()); - if (!config) throw new Error(`Invalid Radius config from ${gateway}`); - return config; + return parseRadiusGatewayConfig(await response.json(), gateway); } diff --git a/tsconfig.json b/tsconfig.json index d86e580ce..b027461af 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -54,6 +54,6 @@ "@earendil-works/pi-agent-old/*": ["./packages/agent-old/src/*"] } }, - "include": ["packages/*/src/**/*", "packages/*/test/**/*", "packages/session-backends/*/src/**/*", "packages/session-backends/*/test/**/*", "packages/coding-agent/examples/**/*"], + "include": ["packages/*/src/**/*", "packages/*/test/**/*", "packages/ai/scripts/**/*", "packages/session-backends/*/src/**/*", "packages/session-backends/*/test/**/*", "packages/coding-agent/examples/**/*"], "exclude": ["**/dist/**", "packages/coding-agent/examples/extensions/gondolin/**"] }