pi-mono/packages/ai/scripts/generate-models.ts
David Brailovsky 44f6851b7f fix(ai): use Anthropic Messages for OpenRouter ~anthropic latest aliases
The ~anthropic/claude-*-latest aliases used Chat Completions, where pi
dropped the thinking signature and OpenRouter ignored the unsigned
thinking on replay. Route them through Anthropic Messages with adaptive
thinking, like the versioned Claude models.

refs #9534
2026-10-28 09:45:28 +01:00

3688 lines
131 KiB
JavaScript

#!/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 { type AiGatewayPricing, getAiGatewayCost } from "./ai-gateway-pricing.ts";
import { buildOpenRouterCatalog, type OpenRouterCatalog, type OpenRouterModelListItem } from "./openrouter-catalog.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,
CLOUDFLARE_WORKERS_AI_REST_BASE_URL,
} from "../src/api/cloudflare.ts";
import type {
AnthropicMessagesCompat,
AnyModel,
Api,
ClassifierApi,
ClassifierModel,
ImageApi,
ImageModel,
KnownProvider,
Model,
ModelCost,
ModelPromptCache,
OpenAICompletionsCompat,
OpenAIResponsesCompat,
} from "../src/types.ts";
import {
assertExactModelIds,
createModelDataManifest,
groupProviderModelData,
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";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const packageRoot = join(__dirname, "..");
function readGeneratorOptions(args: string[]): {
strict: boolean;
dataOnly: boolean;
jsonOnly: boolean;
jsonOutputDir: string | undefined;
pretty: boolean;
} {
let strict = false;
let dataOnly = false;
let jsonOnly = false;
let jsonOutputDir: string | undefined;
let pretty = false;
for (let index = 0; index < args.length; index++) {
const arg = args[index];
if (arg === "--strict") {
strict = true;
continue;
}
if (arg === "--data-only") {
dataOnly = true;
continue;
}
if (arg === "--json-only") {
jsonOnly = true;
continue;
}
if (arg === "--pretty") {
pretty = true;
continue;
}
if (arg === "--json-output") {
const value = args[++index];
if (!value) throw new Error("--json-output requires a directory");
jsonOutputDir = resolve(value);
continue;
}
throw new Error(`Unknown argument: ${arg}`);
}
if (jsonOnly && !jsonOutputDir) throw new Error("--json-only requires --json-output");
if (dataOnly && (jsonOnly || jsonOutputDir)) throw new Error("--data-only cannot be combined with JSON catalog output");
return { strict, dataOnly, jsonOnly, jsonOutputDir, pretty };
}
const generatorOptions = readGeneratorOptions(process.argv.slice(2));
interface ModelsDevModel {
id: string;
name: string;
tool_call?: boolean;
structured_output?: boolean;
reasoning?: boolean;
reasoning_options?: ModelsDevReasoningOption[];
status?: string;
limit?: {
context?: number;
output?: number;
};
cost?: {
input?: number;
output?: number;
cache_read?: number;
cache_write?: number;
tiers?: {
input?: number;
output?: number;
cache_read?: number;
cache_write?: number;
tier?: {
type?: string;
size?: number;
};
}[];
};
modalities?: {
input?: string[];
output?: string[];
};
provider?: {
npm?: string;
};
}
interface ModelsDevProvider {
models?: Record<string, ModelsDevModel>;
}
interface ModelsDevMetadata {
id: string;
type?: string;
name: string;
limit?: { context?: number };
modalities?: { input?: string[] };
}
type ModelsDevCatalog = Record<string, ModelsDevProvider>;
interface NvidiaNimModelListItem {
id: string;
}
interface AiGatewayModel {
id: string;
name?: string;
type?: string;
context_window?: number;
max_tokens?: number;
tags?: string[];
pricing?: AiGatewayPricing;
}
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_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-0813"]);
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";
// TypeSafe-compatible System One endpoint for evaluation models.
// https://vercel.com/docs/ai-gateway/sdks-and-apis/typesafe
const AI_GATEWAY_TYPESAFE_BASE_URL = "https://ai-gateway.vercel.sh/typesafe/v1";
const VERTEX_BASE_URL = "https://{location}-aiplatform.googleapis.com";
const NVIDIA_BASE_URL = "https://integrate.api.nvidia.com/v1";
const NVIDIA_HEADERS = {
"NVCF-POLL-SECONDS": "3600",
} as const;
const NVIDIA_OPENAI_COMPAT: OpenAICompletionsCompat = {
supportsStore: false,
supportsDeveloperRole: false,
supportsReasoningEffort: false,
maxTokensField: "max_tokens",
supportsStrictMode: false,
supportsLongCacheRetention: false,
};
const NVIDIA_NIM_UNSUPPORTED_MODELS = new Set([
"abacusai/dracarys-llama-3.1-70b-instruct",
"bytedance/seed-oss-36b-instruct",
"deepseek-ai/deepseek-v4-flash",
"deepseek-ai/deepseek-v4-pro",
"google/gemma-2-2b-it",
"google/gemma-3n-e2b-it",
"google/gemma-3n-e4b-it",
"google/gemma-4-31b-it",
"meta/llama-3.2-1b-instruct",
"meta/llama-4-maverick-17b-128e-instruct",
"microsoft/phi-4-mini-instruct",
"minimaxai/minimax-m2.7",
"mistralai/mistral-nemotron",
"nvidia/nemotron-mini-4b-instruct",
"qwen/qwen3-next-80b-a3b-instruct",
"qwen/qwen3.5-397b-a17b",
"sarvamai/sarvam-m",
"upstage/solar-10.7b-instruct",
]);
const ZAI_TOOL_STREAM_UNSUPPORTED_MODELS = new Set(["glm-4.5", "glm-4.5-air", "glm-4.5-flash", "glm-4.5v"]);
const OPENCODE_GO_GLM52_THINKING_LEVEL_MAP = {
off: null,
minimal: null,
low: null,
medium: null,
high: "high",
max: "max",
} as const;
const EAGER_TOOL_INPUT_STREAMING_UNSUPPORTED_ANTHROPIC_MODELS = new Set([
"github-copilot:claude-haiku-4.5",
"github-copilot:claude-sonnet-4",
"github-copilot:claude-sonnet-4.5",
]);
const ANTHROPIC_ALLOWED_FALLBACK_MODELS = {
"claude-fable-5": ["claude-opus-4-8", "claude-opus-5"],
"claude-opus-5": ["claude-opus-4-8"],
} satisfies Record<string, string[]>;
const DEEPSEEK_V4_THINKING_LEVEL_MAP = {
minimal: null,
low: null,
medium: null,
high: "high",
max: "max",
} as const;
const DEEPSEEK_V4_FLASH_THINKING_LEVEL_MAP = {
...DEEPSEEK_V4_THINKING_LEVEL_MAP,
low: "low",
} as const;
// Azure Foundry rejects DeepSeek's own max effort.
const AZURE_DEEPSEEK_V4_THINKING_LEVEL_MAP = {
minimal: null,
low: "low",
medium: "medium",
high: "high",
xhigh: null,
max: null,
} as const;
// Verified against Fireworks Messages raw_output on 2026-09-10 (#9323).
// Fall back to verified support when models.dev omits effort metadata; this is
// not an allowlist. Any Fireworks Messages model advertising effort uses adaptive thinking.
const FIREWORKS_ADAPTIVE_THINKING_FALLBACK_MODELS = new Set([
"accounts/fireworks/models/deepseek-v4-flash-0731",
"accounts/fireworks/models/deepseek-v4-flash-vision-exp",
"accounts/fireworks/models/deepseek-v4-pro-0813",
"accounts/fireworks/models/qwen3p8-max",
"accounts/fireworks/models/qwen3p8-2p4t-a95b",
]);
const QWEN_TOKEN_PLAN_FALLBACK_THINKING_LEVEL_MAP = {
minimal: null,
low: null,
medium: null,
high: "high",
xhigh: null,
max: "max",
} as const;
const QWEN_TOKEN_PLAN_REASONING_EFFORT_FALLBACK_MODEL_IDS = new Set(["glm-5", "glm-5.1"]);
// Retired preview id — models.dev may still list it after GA ships.
const QWEN_TOKEN_PLAN_EXCLUDED_MODEL_IDS = new Set(["qwen3.8-max-preview"]);
const QWEN_TOKEN_PLAN_PROVIDER_IDS = new Set<string>([
"qwen-token-plan",
"qwen-token-plan-cn",
"qwen-token-plan-individual",
]);
// QwenCloud Token Plan Individual text-model allowlist, verified 2026-09-03.
// Retired models remain excluded above even if the public catalog lags.
// https://docs.qwencloud.com/token-plan/personal/token-plan-personal-overview
const QWEN_TOKEN_PLAN_INDIVIDUAL_MODEL_IDS = new Set<string>([
"deepseek-v4-flash-0731",
"deepseek-v4-pro",
"deepseek-v4-pro-0813",
"glm-5.2",
"qwen3.6-flash",
"qwen3.7-max",
"qwen3.7-plus",
"qwen3.8-flash",
"qwen3.8-max",
]);
const KIMI_K3_MAX_TOKENS = 131072;
const KIMI_K3_COST = {
input: 3,
output: 15,
cacheRead: 0.3,
cacheWrite: 0,
} as const;
// Kimi Chat Completions settings shared by Moonshot and Kimi Coding.
const KIMI_OPENAI_COMPAT: OpenAICompletionsCompat = {
supportsStore: false,
supportsDeveloperRole: false,
supportsReasoningEffort: false,
maxTokensField: "max_tokens",
supportsStrictMode: false,
thinkingFormat: "deepseek",
};
// Kimi K3-style models take reasoning_effort and need earlier reasoning sent back.
const KIMI_K3_OPENAI_COMPAT: OpenAICompletionsCompat = {
...KIMI_OPENAI_COMPAT,
requiresReasoningContentOnAssistantMessages: true,
thinkingFormat: "openai",
supportsReasoningEffort: true,
};
// Kimi K3 and its point releases (kimi-k3.1-pro, kimi-k3.1-pro-highspeed) share K3's request
// format. https://api.moonshot.ai/v1/models reports effort levels and dynamic tool loading for all.
function isKimiK3FamilyId(modelId: string): boolean {
return /^kimi-k3(?:\.\d+)?(?:-|$)/.test(modelId);
}
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"]);
// GPT-5.5 and older put tools loaded through tool search or additional_tools in a namespace named
// after the tool and reject replayed calls without it, so history from other models fails there
// (#11415). Like Codex, dynamic tool loading therefore starts with GPT-5.6, which does not do this.
const OPENAI_TOOL_SEARCH_MODEL_IDS = new Set([
"gpt-5.6-sol",
"gpt-5.6-terra",
"gpt-5.6-luna",
"gpt-6-astra",
"gpt-6-sol",
"gpt-6-luna",
"gpt-6.1-sol",
"gpt-6.1-terra",
]);
const OPENAI_ADDITIONAL_TOOLS_MODEL_IDS = OPENAI_TOOL_SEARCH_MODEL_IDS;
const OPENAI_MID_CONVO_SYSTEM_MESSAGE_MODEL_IDS = new Set([
"gpt-5.4",
"gpt-5.4-mini",
"gpt-5.4-pro",
"gpt-5.5",
...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",
"gpt-6-sol",
"gpt-6-luna",
"gpt-6.1-sol",
"gpt-6.1-terra",
]);
// OpenAI documents configuration_update for the GPT-6 family.
// https://developers.openai.com/api/docs/guides/reasoning
const OPENAI_MID_CONVO_EFFORT_MODEL_IDS = new Set([
"gpt-6-astra",
"gpt-6-sol",
"gpt-6-luna",
"gpt-6.1-sol",
"gpt-6.1-terra",
]);
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",
"gpt-6-sol",
"gpt-6-luna",
"gpt-6.1-sol",
"gpt-6.1-terra",
]);
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",
"gpt-6-sol",
"gpt-6-luna",
"gpt-6.1-sol",
"gpt-6.1-terra",
]);
// Keep the generated default no less restrictive than coding-agent's historical
// image preprocessing. Provider limits can narrow this profile, but unknown
// providers retain the cache-safe 2000px / 4.5 MiB behavior.
const DEFAULT_IMAGE_RESIZE = {
maxWidth: 2000,
maxHeight: 2000,
maxBytes: 4.5 * 1024 * 1024,
jpegQuality: 80,
} as const;
function withOpenAiLongContextPricing(cost: Model<Api>["cost"]): Model<Api>["cost"] {
return {
...cost,
tiers: [
{
inputTokensAbove: OPENAI_LONG_CONTEXT_INPUT_THRESHOLD,
input: roundCost(cost.input * 2),
output: roundCost(cost.output * 1.5),
cacheRead: roundCost(cost.cacheRead * 2),
cacheWrite: roundCost(cost.cacheWrite * 2),
},
],
};
}
// Keep current OpenAI prices authoritative until models.dev and passthrough
// catalogs catch up.
// https://developers.openai.com/api/docs/pricing
const OPENAI_STANDARD_COSTS: Record<string, ModelCost> = {
"gpt-5.6-luna": { input: 0.2, output: 1.2, cacheRead: 0.02, cacheWrite: 0.25 },
"gpt-5.6-sol": { input: 4, output: 20, cacheRead: 0.4, cacheWrite: 5 },
"gpt-5.6-terra": { input: 2, output: 12, cacheRead: 0.2, cacheWrite: 2.5 },
"gpt-6-astra": { input: 10, output: 50, cacheRead: 1, cacheWrite: 12.5 },
"gpt-6-luna": { input: 0.1, output: 0.5, cacheRead: 0.01, cacheWrite: 0.125 },
"gpt-6-sol": { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 },
"gpt-6.1-sol": { input: 2, output: 10, cacheRead: 0.1, cacheWrite: 2.5 },
"gpt-6.1-terra": { input: 1, output: 5, cacheRead: 0.05, cacheWrite: 1.25 },
};
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",
"gpt-6-sol",
"gpt-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.1-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.8",
"claude-opus-5",
"claude-opus-5.5",
"claude-sonnet-4.6",
"claude-sonnet-5",
"gpt-5.3-codex",
"gpt-6-astra",
"gpt-6-luna",
"gpt-6-sol",
]);
// 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.8": { minimal: "low" },
"claude-opus-5": { minimal: "low" },
"claude-sonnet-4.6": { minimal: "low", max: "max" },
} satisfies Record<string, NonNullable<Model<Api>["thinkingLevelMap"]>>;
function mergeThinkingLevelMap(model: Model<any>, map: NonNullable<Model<any>["thinkingLevelMap"]>): void {
model.thinkingLevelMap = { ...model.thinkingLevelMap, ...map };
}
const modelsDevReasoningOptions = new Map<string, ModelsDevReasoningOption[]>();
function getModelKey(model: Pick<Model<Api>, "provider" | "id">): string {
return `${model.provider}:${model.id}`;
}
function recordModelsDevReasoningOptions(provider: string, id: string, sourceModel: ModelsDevModel): void {
if (sourceModel.reasoning_options !== undefined) {
modelsDevReasoningOptions.set(`${provider}:${id}`, sourceModel.reasoning_options);
}
}
function supportsDirectReasoningEffort(model: Model<Api>): boolean {
if (model.api === "anthropic-messages") return model.compat?.forceAdaptiveThinking === true;
if (
model.api === "openai-responses" ||
model.api === "azure-openai-responses" ||
model.api === "openai-codex-responses"
) {
return true;
}
if (model.api !== "openai-completions") return false;
const compat = {
...detectOpenAICompletionsCompat(model as Model<"openai-completions">),
...(model.compat as OpenAICompletionsCompat | undefined),
};
return compat.thinkingFormat === "openai" && compat.supportsReasoningEffort;
}
function applyModelsDevReasoningOptionMetadata(model: Model<Api>): void {
const reasoningOptions = modelsDevReasoningOptions.get(getModelKey(model));
if (!reasoningOptions || !supportsDirectReasoningEffort(model)) return;
const thinkingLevelMap = getEffortThinkingLevelMap(reasoningOptions);
if (thinkingLevelMap) mergeThinkingLevelMap(model, thinkingLevelMap);
}
function getTogetherCompat(modelId: string, reasoning: boolean): OpenAICompletionsCompat {
if (!reasoning) return TOGETHER_BASE_COMPAT;
if (TOGETHER_REASONING_EFFORT_MODELS.has(modelId)) return TOGETHER_REASONING_EFFORT_COMPAT;
if (TOGETHER_TOGGLE_REASONING_EFFORT_MODELS.has(modelId)) return TOGETHER_TOGGLE_REASONING_EFFORT_COMPAT;
return TOGETHER_TOGGLE_REASONING_COMPAT;
}
function getTogetherThinkingLevelMap(
modelId: string,
reasoning: boolean,
): NonNullable<Model<any>["thinkingLevelMap"]> | undefined {
if (!reasoning) return undefined;
if (TOGETHER_REASONING_EFFORT_MODELS.has(modelId)) return { ...TOGETHER_REASONING_EFFORT_LEVEL_MAP };
if (TOGETHER_TOGGLE_REASONING_EFFORT_MODELS.has(modelId)) return { ...TOGETHER_DEEPSEEK_V4_THINKING_LEVEL_MAP };
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")
);
}
function supportsOpenAiMax(model: Model<Api>): boolean {
return (
(model.id.includes("gpt-5.6") || model.id.includes("gpt-6")) &&
(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|5[.-]5)(?:-\d{8})?$/.test(id) ||
/^claude-(?:sonnet|haiku)-5[.-]5(?:-\d{8})?$/.test(id) ||
/^claude-(?:fable|mythos)-5(?:[.-]1)(?:-\d{8})?$/.test(id)
);
}
// Claude models that accept `thinking: {type: "disabled"}` alongside per-message effort.
// Opus 5.5, Sonnet 5.5, Fable 5.1, and Mythos 5.1 reject it.
// https://platform.claude.com/docs/en/build-with-claude/thinking#configuring-thinking
const ANTHROPIC_DISABLED_THINKING_MODELS = new Set(["claude-opus-5", "claude-haiku-5-5", "claude-haiku-5.5"]);
function supportsAnthropicDisabledThinkingWithMidConvoEffort(modelId: string): boolean {
// Strip OpenRouter's "anthropic/" or "~anthropic/" prefix and any dated snapshot suffix.
const id = modelId
.toLowerCase()
.replace(/^~?anthropic\//, "")
.replace(/-\d{8}$/, "");
return ANTHROPIC_DISABLED_THINKING_MODELS.has(id);
}
function supportsAnthropicMidConvoSystemMessages(modelId: string): boolean {
return (
/^claude-opus-(?:4[.-]8|5(?:[.-]5)?)(?:-\d{8})?$/.test(modelId) ||
/^claude-(?:sonnet|haiku)-5[.-]5(?:-\d{8})?$/.test(modelId) ||
/^claude-(?:fable|mythos)-5(?:[.-]1)?(?:-\d{8})?$/.test(modelId)
);
}
function isAnthropicAdaptiveThinkingModel(modelId: string): boolean {
return (
// OpenRouter's `~anthropic/claude-*-latest` aliases follow the newest model of each family,
// all of which use adaptive thinking.
/^~anthropic\/claude-[a-z]+-latest$/.test(modelId) ||
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("haiku-5") ||
modelId.includes("haiku.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") ||
id.includes("sonnet-5-5") ||
id.includes("sonnet-5.5") ||
id.includes("haiku-5-5") ||
id.includes("haiku-5.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: false,
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<Api>, compat: AnthropicMessagesCompat): void {
model.compat = { ...(model.compat as AnthropicMessagesCompat | undefined), ...compat };
}
function detectOpenAICompletionsCompat(model: Model<"openai-completions">): OpenAICompletionsResolvedCompat {
const provider = model.provider;
const baseUrl = model.baseUrl;
const isZai =
provider === "zai" ||
provider === "zai-coding-cn" ||
baseUrl.includes("api.z.ai") ||
baseUrl.includes("open.bigmodel.cn");
const isTogether =
provider === "together" || baseUrl.includes("api.together.ai") || baseUrl.includes("api.together.xyz");
const isMoonshot = provider === "moonshotai" || provider === "moonshotai-cn" || baseUrl.includes("api.moonshot.");
const isOpenRouter = provider === "openrouter" || baseUrl.includes("openrouter.ai");
const isCloudflareWorkersAI = provider === "cloudflare-workers-ai" || baseUrl.includes("api.cloudflare.com");
const isCloudflareAiGateway = provider === "cloudflare-ai-gateway" || baseUrl.includes("gateway.ai.cloudflare.com");
const isNvidia = provider === "nvidia" || baseUrl.includes("integrate.api.nvidia.com");
const isAntLing = provider === "ant-ling" || baseUrl.includes("api.ant-ling.com");
const isCerebras = provider === "cerebras" || baseUrl.includes("cerebras.ai");
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
? "together"
: isAntLing
? "ant-ling"
: isOpenRouter
? "openrouter"
: "openai",
openRouterRouting: {},
vercelGatewayRouting: {},
chatTemplateKwargs: {},
chatTemplateArgs: {},
zaiToolStream: false,
// Preserve built-in behavior as explicit metadata against the conservative runtime default.
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<string, unknown>)[key] = value;
}
}
return delta;
}
function mergeOpenAICompletionsCompat(model: Model<Api>, compat: OpenAICompletionsCompat): void {
model.compat = { ...(model.compat as OpenAICompletionsCompat | undefined), ...compat };
}
function applyOpenAICompletionsCompatMetadata(model: Model<Api>): void {
if (model.api !== "openai-completions") return;
const detected = openAICompletionsCompatDelta(detectOpenAICompletionsCompat(model as Model<"openai-completions">));
model.compat = { ...detected, ...(model.compat as OpenAICompletionsCompat | undefined) };
if (Object.keys(model.compat).length === 0) {
delete model.compat;
}
}
function applyAnthropicMessagesCompatMetadata(model: Model<Api>): void {
if (model.api !== "anthropic-messages") return;
const compat = getAnthropicMessagesCompat(model.provider, model.id);
if (compat) {
mergeAnthropicMessagesCompat(model, compat);
if (compat.supportsMidConvoEffort && !supportsAnthropicDisabledThinkingWithMidConvoEffort(model.id)) {
mergeThinkingLevelMap(model, { off: null });
}
}
}
function isAnthropicFallbackMetadataModel(model: Model<Api>): model is Model<"anthropic-messages"> {
if (model.provider !== "anthropic" || model.api !== "anthropic-messages") return false;
return (
model.id in ANTHROPIC_ALLOWED_FALLBACK_MODELS ||
Object.values(ANTHROPIC_ALLOWED_FALLBACK_MODELS).some((fallbackModelIds) => fallbackModelIds.includes(model.id))
);
}
function applyAnthropicAllowedFallbackModelMetadata(models: readonly Model<"anthropic-messages">[]): void {
const modelsById = new Map(models.map((model) => [model.id, model]));
for (const [modelId, fallbackModelIds] of Object.entries(ANTHROPIC_ALLOWED_FALLBACK_MODELS)) {
const model = modelsById.get(modelId);
if (!model) continue;
const compatibleFallbackModelIds = model.compat?.supportsMidConvoEffort
? fallbackModelIds.filter(supportsAnthropicMidConvoEffort)
: fallbackModelIds;
const allowedFallbackModels = compatibleFallbackModelIds.flatMap((fallbackModelId) => {
const fallbackModel = modelsById.get(fallbackModelId);
return fallbackModel
? [{ provider: fallbackModel.provider, model: fallbackModel.id, cost: fallbackModel.cost }]
: [];
});
if (allowedFallbackModels.length > 0) {
mergeAnthropicMessagesCompat(model, { allowedFallbackModels });
}
}
}
function applyStrictToolCompatMetadata(model: Model<Api>): void {
if (
(model.provider === "openai" || model.provider === "cloudflare-ai-gateway") &&
model.api === "openai-responses"
) {
model.compat = { ...(model.compat as OpenAIResponsesCompat | undefined), supportsStrictMode: true };
} else if (model.provider === "anthropic" && model.api === "anthropic-messages") {
mergeAnthropicMessagesCompat(model, { supportsStrictTools: true });
}
}
// Responses endpoints verified (OpenAI, ChatGPT Codex backend, GitHub Copilot,
// opencode zen) or documented (Azure OpenAI, Cloudflare AI Gateway) to pass
// OpenAI custom grammar tools through. OpenAI rejects `type: "custom"` tools
// for pre-GPT-5 models (gpt-4.x, gpt-4o, o-series).
const OPENAI_GRAMMAR_TOOL_PROVIDERS = new Set([
"openai",
"openai-codex",
"azure",
"github-copilot",
"opencode",
"cloudflare-ai-gateway",
]);
const OPENAI_GRAMMAR_TOOL_APIS = new Set<Api>([
"openai-responses",
"azure-openai-responses",
"openai-codex-responses",
]);
function applyOpenAIGrammarToolCompatMetadata(model: Model<Api>): void {
if (!OPENAI_GRAMMAR_TOOL_APIS.has(model.api) || !OPENAI_GRAMMAR_TOOL_PROVIDERS.has(model.provider)) return;
const match = /^gpt-(\d+)/.exec(model.id);
if (!match || Number(match[1]) < 5) return;
model.compat = { ...(model.compat as OpenAIResponsesCompat | undefined), supportsOpenAIGrammarTools: true };
}
function applyOpenAIToolSearchMetadata(model: Model<Api>): void {
const isOpenAIResponses = model.provider === "openai" && model.api === "openai-responses";
const isOpenAICodex = model.provider === "openai-codex" && model.api === "openai-codex-responses";
if (!(isOpenAIResponses || isOpenAICodex) || !OPENAI_TOOL_SEARCH_MODEL_IDS.has(model.id)) return;
const supportsAdditionalTools =
(isOpenAIResponses && OPENAI_ADDITIONAL_TOOLS_MODEL_IDS.has(model.id)) ||
(isOpenAICodex && OPENAI_CODEX_ADDITIONAL_TOOLS_MODEL_IDS.has(model.id));
model.compat = {
...(model.compat as OpenAIResponsesCompat | undefined),
...(supportsAdditionalTools ? { supportsAdditionalTools: true } : {}),
supportsToolSearch: true,
};
}
// 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. Kimi Coding sets these flags in createKimiCodingModels().
function applyOpenAICompletionsTranscriptMetadata(model: Model<Api>): void {
if (model.api !== "openai-completions") return;
const isKimiK3 =
(model.provider.startsWith("moonshot") && isKimiK3FamilyId(model.id)) ||
(model.provider === "fireworks" && model.id.includes("kimi-k3")) ||
((model.provider === "opencode" || model.provider === "opencode-go") && isKimiK3FamilyId(model.id));
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<Api>): 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 && OPENAI_ADDITIONAL_TOOLS_MODEL_IDS.has(model.id) ? { 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<Api>): void {
if (model.provider !== "openai" || model.api !== "openai-responses") return;
if (!(model.cost.cacheWrite > 0)) return;
model.compat = {
...(model.compat as OpenAIResponsesCompat | undefined),
supportsExplicitPromptCacheMode: true,
};
}
// GPT-6 models accept `configuration_update` input items that change the reasoning effort
// without changing the cached prompt prefix. Not enabled for openai-codex (Codex CLI keeps it
// behind a flag that is off by default) or proxies.
function applyOpenAIMidConvoEffortMetadata(model: Model<Api>): void {
if (model.provider !== "openai" || model.api !== "openai-responses") return;
if (!OPENAI_MID_CONVO_EFFORT_MODEL_IDS.has(model.id)) return;
model.compat = {
...(model.compat as OpenAIResponsesCompat | undefined),
supportsMidConvoEffort: 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<Api>): 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 applyImageInputMetadata(model: AnyModel): void {
if (!model.input.includes("image")) return;
const providerLimits: AnyModel["inputLimits"] =
model.provider === "anthropic"
? {
maxRequestBytes: 32 * 1024 * 1024,
images: { maxPerRequest: model.type !== "image" && model.contextWindow === 200000 ? 100 : 600 },
}
: model.provider === "amazon-bedrock"
? { images: { maxPerMessage: 20 } }
: model.provider === "openai"
? { maxRequestBytes: 512 * 1024 * 1024, images: { maxPerRequest: 1500 } }
: model.provider === "google"
? { maxRequestBytes: 20 * 1024 * 1024, images: { maxPerRequest: 3600 } }
: undefined;
const configuredImages = model.inputLimits?.images;
model.inputLimits = {
...providerLimits,
...model.inputLimits,
images: {
...providerLimits?.images,
...configuredImages,
resize: { ...DEFAULT_IMAGE_RESIZE, ...configuredImages?.resize },
},
};
}
function isGemma4Model(modelId: string): boolean {
return /gemma-?4/.test(modelId.toLowerCase());
}
function getGoogleThinkingLevelMap(
modelId: string,
reasoningOptions: readonly ModelsDevReasoningOption[],
): NonNullable<Model<Api>["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<any>): void {
if (
(model.api === "openai-responses" || model.api === "azure-openai-responses") &&
model.id.startsWith("gpt-5")
) {
mergeThinkingLevelMap(model, { off: null });
}
if (
(model.id === "gpt-6-astra" ||
model.id === "gpt-6-sol" ||
model.id === "gpt-6-luna" ||
model.id === "gpt-6.1-sol" ||
model.id === "gpt-6.1-terra") &&
(model.api === "openai-responses" ||
model.api === "azure-openai-responses" ||
model.api === "openai-codex-responses")
) {
mergeThinkingLevelMap(model, {
// GPT-6 Astra and GPT-6.1 Sol reject reasoning.effort "none"; GPT-6.1 Terra accepts it. All reject
// "minimal" (verified on Codex, October 2026).
off: model.id === "gpt-6-astra" || model.id === "gpt-6.1-sol" ? null : "none",
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, Haiku 5.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") ||
model.id.includes("haiku-5") ||
model.id.includes("haiku.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 === "azure"
? AZURE_DEEPSEEK_V4_THINKING_LEVEL_MAP
: (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;
}
// OpenCode Qwen 3.8 Flash emits and accepts thinking blocks with empty signatures.
if ((provider === "opencode" || provider === "opencode-go") && modelId === "qwen3.8-flash") {
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 base = {
input: cost?.input || 0,
output: cost?.output || 0,
cacheRead: cost?.cache_read || 0,
cacheWrite: cost?.cache_write || 0,
};
// Rates a tier does not list keep the base price.
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 ?? base.input,
output: tier.output ?? base.output,
cacheRead: tier.cache_read ?? base.cacheRead,
cacheWrite: tier.cache_write ?? base.cacheWrite,
},
];
});
return tiers && tiers.length > 0 ? { ...base, tiers } : base;
}
async function fetchNvidiaNimModelIds(): Promise<Map<string, string>> {
try {
console.log("Fetching models from NVIDIA NIM API...");
const response = await fetch(`${NVIDIA_BASE_URL}/models`);
if (!response.ok) throw new Error(`NVIDIA NIM API returned ${response.status}`);
const data = (await response.json()) as { data?: NvidiaNimModelListItem[] };
const modelIds = new Map<string, string>();
for (const model of data.data ?? []) {
modelIds.set(model.id, model.id);
modelIds.set(normalizeNvidiaModelId(model.id), model.id);
}
console.log(`Fetched ${data.data?.length ?? 0} model IDs from NVIDIA NIM`);
return modelIds;
} catch (error) {
console.error("Failed to fetch NVIDIA NIM models:", error);
if (generatorOptions.strict) throw error;
return new Map();
}
}
async function fetchOpenRouterList(query: string): Promise<OpenRouterModelListItem[]> {
const response = await fetch(`https://openrouter.ai/api/v1/models${query}`);
if (!response.ok) throw new Error(`OpenRouter API returned ${response.status}`);
const data = (await response.json()) as { data?: OpenRouterModelListItem[] };
return data.data ?? [];
}
async function fetchOpenRouterModels(): Promise<OpenRouterCatalog> {
try {
console.log("Fetching models from OpenRouter API...");
const [listed, imageListed, decisionListed] = await Promise.all([
fetchOpenRouterList(""),
fetchOpenRouterList("?output_modalities=image"),
fetchOpenRouterList("?output_modalities=decisions"),
]);
const catalog = buildOpenRouterCatalog(listed, imageListed, decisionListed);
console.log(
`Fetched ${catalog.chat.length} tool-capable, ${catalog.images.length} image, and ${catalog.classifiers.length} classifier models from OpenRouter`,
);
if (generatorOptions.strict && catalog.images.length === 0) {
throw new Error("OpenRouter API returned no usable image models");
}
return catalog;
} catch (error) {
console.error("Failed to fetch OpenRouter models:", error);
if (generatorOptions.strict) throw error;
return { chat: [], images: [], classifiers: [] };
}
}
async function fetchRadiusModels(): Promise<Model<"pi-messages">[]> {
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<{
chat: Model<any>[];
classifiers: ClassifierModel<"typesafe-system-one">[];
}> {
try {
console.log("Fetching models from Vercel AI Gateway API...");
const response = await fetch(`${AI_GATEWAY_MODELS_URL}/models`);
if (!response.ok) throw new Error(`Vercel AI Gateway API returned ${response.status}`);
const data = await response.json();
const models: Model<any>[] = [];
const classifiers: ClassifierModel<"typesafe-system-one">[] = [];
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) {
// Evaluation models such as TypeSafe's Jev are served through the
// TypeSafe-compatible System One endpoint.
if (model.type === "evaluation") {
classifiers.push({
type: "classifier",
id: model.id,
name: model.name || model.id,
api: "typesafe-system-one",
provider: "vercel-ai-gateway",
baseUrl: AI_GATEWAY_TYPESAFE_BASE_URL,
input: ["text"],
cost: {
input: roundCost(toNumber(model.pricing?.input) * 1_000_000),
output: roundCost(toNumber(model.pricing?.output) * 1_000_000),
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: model.context_window || 4096,
});
continue;
}
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");
}
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: getAiGatewayCost(model.pricing),
contextWindow: model.context_window || 4096,
maxTokens: model.max_tokens || 4096,
});
}
console.log(
`Fetched ${models.length} tool-capable and ${classifiers.length} classifier models from Vercel AI Gateway`,
);
return { chat: models, classifiers };
} catch (error) {
console.error("Failed to fetch Vercel AI Gateway models:", error);
if (generatorOptions.strict) throw error;
return { chat: [], classifiers: [] };
}
}
function processZaiModels(data: ModelsDevCatalog): Model<Api>[] {
const variants = [
{
source: "zai-coding-plan",
provider: "zai",
baseUrl: "https://api.z.ai/api/coding/paas/v4",
},
{
source: "zhipuai-coding-plan",
provider: "zai-coding-cn",
baseUrl: "https://open.bigmodel.cn/api/coding/paas/v4",
},
] as const;
const models: Model<Api>[] = [];
for (const { source, provider, baseUrl } of variants) {
for (const [modelId, model] of Object.entries(data[source]?.models ?? {})) {
const m = model as ModelsDevModel;
if (m.tool_call !== true) continue;
const supportsImage = m.modalities?.input?.includes("image");
const thinkingLevelMap = getEffortThinkingLevelMap(m.reasoning_options ?? []);
const isGlm52 = modelId === "glm-5.2" || modelId === "glm-5.2-highspeed";
if (thinkingLevelMap && isGlm52) {
thinkingLevelMap.off = "none";
}
const supportsReasoningEffort = thinkingLevelMap !== undefined;
const referenceCost = data.zai?.models[modelId]?.cost ?? m.cost;
models.push({
id: modelId,
name: m.name || modelId,
api: "openai-completions",
provider,
baseUrl,
reasoning: m.reasoning === true,
...(thinkingLevelMap ? { thinkingLevelMap } : {}),
input: supportsImage ? ["text", "image"] : ["text"],
cost: getModelsDevCost(referenceCost),
compat: {
supportsDeveloperRole: false,
thinkingFormat: "zai",
...(supportsReasoningEffort ? { supportsReasoningEffort: true } : {}),
...(!ZAI_TOOL_STREAM_UNSUPPORTED_MODELS.has(modelId) ? { zaiToolStream: true } : {}),
},
contextWindow: m.limit?.context || 4096,
maxTokens: m.limit?.output || 4096,
});
recordModelsDevReasoningOptions(provider, modelId, m);
}
}
return models;
}
function processBasetenModels(provider: ModelsDevProvider | undefined): Model<Api>[] {
if (!provider?.models) return [];
const baseUrl = "https://inference.baseten.co/v1";
const baseCompat: OpenAICompletionsCompat = {
supportsStore: false,
supportsDeveloperRole: false,
supportsReasoningEffort: false,
supportsUsageInStreaming: true,
maxTokensField: "max_tokens",
supportsStrictMode: true,
// 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<Api>[] = [];
for (const [modelId, model] of Object.entries(provider.models)) {
if (model.status === "deprecated") continue;
const reasoning = model.reasoning === true;
const reasoningOptions = model.reasoning_options ?? [];
const isGlm52 = modelId === "zai-org/GLM-5.2" || modelId === "zai-org/GLM-5.2-Fast";
const supportsToggle = reasoningOptions.some((option) => option.type === "toggle") || isGlm52;
const supportsEffort = reasoningOptions.some((option) => option.type === "effort") || isGlm52;
const compat =
supportsToggle && supportsEffort
? toggleReasoningEffortCompat
: supportsToggle
? toggleReasoningCompat
: supportsEffort
? reasoningEffortCompat
: baseCompat;
const thinkingLevelMap = isGlm52
? glm52ThinkingLevelMap
: supportsToggle
? toggleThinkingLevelMap
: getEffortThinkingLevelMap(reasoningOptions);
// Baseten's GLM-5.2 endpoints are text-only despite models.dev reporting image input.
const supportsImageInput = !isGlm52 && model.modalities?.input?.includes("image");
models.push({
id: modelId,
name: model.name || modelId,
api: "openai-completions",
provider: "baseten",
baseUrl,
reasoning,
...(thinkingLevelMap ? { thinkingLevelMap } : {}),
input: supportsImageInput ? ["text", "image"] : ["text"],
cost: getModelsDevCost(model.cost),
compat,
contextWindow: model.limit?.context || 4096,
maxTokens: model.limit?.output || 4096,
});
}
return models;
}
function processGoogleModels(data: ModelsDevCatalog): Model<Api>[] {
const models: Model<Api>[] = [];
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: getModelsDevCost(source.cost),
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<Api>[] {
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<Api>[] = [];
for (const [modelId, model] of Object.entries(provider.models)) {
if (model.tool_call !== true) continue;
const input: ("text" | "image")[] = model.modalities?.input?.includes("image")
? ["text", "image"]
: ["text"];
const common = {
id: modelId,
name: model.name || modelId,
provider: "fireworks",
reasoning: model.reasoning === true,
input,
cost: getModelsDevCost(model.cost),
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;
}
// Kimi Coding is subscription-backed, so models.dev reports zero cost. Use the
// equivalent Moonshot API rates to estimate the value of subscription usage.
const KIMI_CODING_IMPLIED_COSTS: Record<string, Model<Api>["cost"]> = {
k3: KIMI_K3_COST,
"k3-256k": KIMI_K3_COST,
"k3.1-pro": { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 0 },
"k3.1-pro-highspeed": { input: 4, output: 20, cacheRead: 0.2, cacheWrite: 0 },
"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 Coding uses Chat Completions, the same protocol as the official Kimi Code client.
function createKimiCodingModels(kimiModels: Record<string, ModelsDevModel>): Model<"openai-completions">[] {
const models: Model<"openai-completions">[] = [];
for (const [modelId, m] of Object.entries(kimiModels)) {
if (m.tool_call !== true) continue;
// K3-style models report effort levels and dynamic tool loading (tools declared in
// later system messages) in https://api.kimi.com/coding/v1/models; models.dev mirrors
// the effort levels. kimi-for-coding-highspeed reports neither and rejects
// tool-bearing system messages with a 400.
const isK3Style = m.reasoning_options?.some((option) => option.type === "effort") === true;
const impliedCost = KIMI_CODING_IMPLIED_COSTS[modelId];
// K3-style models send reasoning_effort, so expose the effort values models.dev lists.
const effortLevels = isK3Style && m.reasoning_options ? getEffortThinkingLevelMap(m.reasoning_options) : undefined;
models.push({
id: modelId,
name: m.name || modelId,
api: "openai-completions",
provider: "kimi-coding",
baseUrl: "https://api.kimi.com/coding/v1",
compat: {
...(isK3Style ? KIMI_K3_OPENAI_COMPAT : KIMI_OPENAI_COMPAT),
// All models accept system text after the conversation starts.
supportsMidConvoSystemMessages: true,
...(isK3Style ? { supportsMidConvoToolAdditions: true } : {}),
},
reasoning: 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,
// Kimi Code reports every model as always-thinking.
thinkingLevelMap: { ...effortLevels, off: null },
});
}
return models;
}
async function loadModelsDevData(): Promise<Model<any>[]> {
try {
console.log("Fetching models from models.dev API...");
const response = await fetch("https://models.dev/api.json");
if (!response.ok) throw new Error(`models.dev API returned ${response.status}`);
const data = (await response.json()) as ModelsDevCatalog;
const models: Model<any>[] = [];
const nvidiaNimModelIds = data.nvidia?.models ? await fetchNvidiaNimModelIds() : new Map<string, string>();
// Process Amazon Bedrock models
if (data["amazon-bedrock"]?.models) {
for (const [modelId, model] of Object.entries(data["amazon-bedrock"].models)) {
const m = model as ModelsDevModel;
if (m.tool_call !== true) continue;
if (BEDROCK_INFERENCE_PROFILE_ONLY_MODEL_IDS.has(modelId)) continue;
let id = modelId;
if (id.startsWith("ai21.jamba")) {
// These models doesn't support tool use in streaming mode
continue;
}
if (id.startsWith("mistral.mistral-7b-instruct-v0")) {
// These models doesn't support system messages
continue;
}
models.push({
id,
name: m.name || id,
api: "bedrock-converse-stream" as const,
provider: "amazon-bedrock" as const,
baseUrl: getBedrockBaseUrl(id),
reasoning: m.reasoning === true,
input: (m.modalities?.input?.includes("image") ? ["text", "image"] : ["text"]) as ("text" | "image")[],
// Includes models.dev pricing tiers, e.g. the long-context tier for OpenAI models (#10326).
cost: getModelsDevCost(m.cost),
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: getModelsDevCost(m.cost),
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: getModelsDevCost(m.cost),
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: getModelsDevCost(m.cost),
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: getModelsDevCost(m.cost),
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: getModelsDevCost(m.cost),
contextWindow: m.limit?.context || 4096,
maxTokens: m.limit?.output || 4096,
compat: { sendSessionAffinityHeaders: true },
});
recordModelsDevReasoningOptions("cloudflare-workers-ai", modelId, m);
}
}
// Process Cloudflare AI Gateway models
const cloudflareAIGatewayModelIds = new Set<string>();
if (data["cloudflare-ai-gateway"]?.models) {
for (const [prefixedId, model] of Object.entries(data["cloudflare-ai-gateway"].models)) {
const m = model as ModelsDevModel;
if (m.tool_call !== true) continue;
const slashIdx = prefixedId.indexOf("/");
if (slashIdx === -1) continue;
const upstream = prefixedId.slice(0, slashIdx);
const nativeId = prefixedId.slice(slashIdx + 1);
let api: "anthropic-messages" | "openai-completions" | "openai-responses";
let baseUrl: string;
let id: string;
if (upstream === "openai") {
api = "openai-responses";
baseUrl = CLOUDFLARE_AI_GATEWAY_OPENAI_BASE_URL;
id = nativeId;
} else if (upstream === "anthropic") {
api = "anthropic-messages";
baseUrl = CLOUDFLARE_AI_GATEWAY_ANTHROPIC_BASE_URL;
// The /anthropic passthrough forwards the model ID to Anthropic unchanged.
// models.dev lists dotted versions (claude-opus-5.5), but Anthropic only
// accepts dashed IDs (claude-opus-5-5).
id = nativeId.replaceAll(".", "-");
} 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: getModelsDevCost(m.cost),
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: getModelsDevCost(m.cost),
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: getModelsDevCost(m.cost),
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: getModelsDevCost(m.cost),
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 with effort values use `reasoning_effort` with these levels.
// Reasoning models without them (Magistral) use `prompt_mode`.
const thinkingLevelMap = getEffortThinkingLevelMap(m.reasoning_options ?? []);
models.push({
id: modelId,
name: m.name || modelId,
api: "mistral-conversations",
provider: "mistral",
baseUrl: "https://api.mistral.ai",
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 ?? (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,
});
}
}
// 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: getModelsDevCost(m.cost),
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: getModelsDevCost(m.cost),
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: getModelsDevCost(m.cost),
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/inference" },
{ key: "opencode-go", provider: "opencode-go", basePath: "https://opencode.ai/inference/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}/openai/v1`;
compat = { sessionAffinityFormat: "openai-nosession" };
} else if (npm === "@ai-sdk/anthropic") {
api = "anthropic-messages";
// Anthropic SDK appends /v1/messages to baseURL
baseUrl = `${variant.basePath}/anthropic`;
} else if (npm === "@ai-sdk/google") {
api = "google-generative-ai";
baseUrl = `${variant.basePath}/google/v1beta`;
} else if (npm === "@ai-sdk/alibaba") {
api = "openai-completions";
baseUrl = `${variant.basePath}/openai/v1`;
compat = { cacheControlFormat: "anthropic" };
} else {
// null, undefined, or @ai-sdk/openai-compatible
api = "openai-completions";
baseUrl = `${variant.basePath}/openai/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 OpenCode Go serves them through the
// OpenAI-compatible /openai/v1/chat/completions path.
if (variant.provider === "opencode-go") {
if (modelId === "qwen3.5-plus" || modelId === "qwen3.6-plus") {
api = "openai-completions";
baseUrl = `${variant.basePath}/openai/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<Model<Api>["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: getModelsDevCost(m.cost),
...(compat ? { compat } : {}),
contextWindow: m.limit?.context || 4096,
maxTokens: m.limit?.output || 4096,
});
recordModelsDevReasoningOptions(variant.provider, modelId, m);
}
}
// Process GitHub Copilot models
if (data["github-copilot"]?.models) {
for (const [modelId, model] of Object.entries(data["github-copilot"].models)) {
const m = model as ModelsDevModel;
if (m.tool_call !== true) continue;
if (m.status === "deprecated") continue;
// Claude 4.x and 5.x models route to Anthropic Messages API
const isCopilotClaude = /^claude-(haiku|sonnet|opus|fable)-[45]([.\-]|$)/.test(modelId);
// GPT, Grok, OSWE, and MAI-Code models are only served through
// the Copilot /responses endpoint.
const needsResponsesApi =
modelId.startsWith("gpt-") ||
modelId.startsWith("grok-") ||
modelId.startsWith("oswe") ||
modelId.startsWith("mai-");
const api: Api = isCopilotClaude
? "anthropic-messages"
: needsResponsesApi
? "openai-responses"
: "openai-completions";
const anthropicCompat =
api === "anthropic-messages" ? getAnthropicMessagesCompat("github-copilot", modelId) : undefined;
const copilotModel: Model<any> = {
id: modelId,
name: m.name || modelId,
api,
provider: "github-copilot",
baseUrl: "https://api.individual.githubcopilot.com",
reasoning: m.reasoning === true,
input: m.modalities?.input?.includes("image") ? ["text", "image"] : ["text"],
cost: getModelsDevCost(m.cost),
contextWindow: m.limit?.context || 128000,
maxTokens: m.limit?.output || 8192,
headers: { ...COPILOT_STATIC_HEADERS },
...(anthropicCompat ? { compat: anthropicCompat } : {}),
// compat only applies to openai-completions
...(api === "openai-completions" ? {
compat: {
supportsStore: false,
supportsDeveloperRole: false,
supportsReasoningEffort: false,
},
} : {}),
};
models.push(copilotModel);
recordModelsDevReasoningOptions("github-copilot", modelId, m);
}
}
// Process MiniMax models
const minimaxVariants = [
{ key: "minimax", provider: "minimax", baseUrl: "https://api.minimax.io/anthropic" },
{ key: "minimax-cn", provider: "minimax-cn", baseUrl: "https://api.minimaxi.com/anthropic" },
] as const;
// MiniMax asked to hide older models from its direct providers (#2596).
const minimaxHiddenIds = new Set(["MiniMax-M2", "MiniMax-M2.1", "MiniMax-M2.5", "MiniMax-M2.5-highspeed"]);
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 || minimaxHiddenIds.has(modelId)) continue;
// Models with effort levels (e.g. MiniMax-M3.1-Flash) take adaptive thinking with
// output_config.effort and reject thinking.type="disabled" (no "none" effort, so off is null).
const thinkingLevelMap = getEffortThinkingLevelMap(m.reasoning_options ?? []);
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: getModelsDevCost(m.cost),
contextWindow: m.limit?.context || 4096,
maxTokens: m.limit?.output || 4096,
...(thinkingLevelMap ? { thinkingLevelMap, compat: { forceAdaptiveThinking: true } } : {}),
});
}
}
}
// Process Kimi For Coding models
if (data["kimi-code-plan-global"]?.models) {
models.push(...createKimiCodingModels(data["kimi-code-plan-global"].models as Record<string, ModelsDevModel>));
}
// 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 getMoonshotProviderModels = (key: "moonshotai" | "moonshotai-cn"): Record<string, ModelsDevModel> => {
const providerModels = data[key]?.models as Record<string, ModelsDevModel> | undefined;
return providerModels ? { ...providerModels } : {};
};
const moonshotModels = {
moonshotai: getMoonshotProviderModels("moonshotai"),
"moonshotai-cn": getMoonshotProviderModels("moonshotai-cn"),
};
for (const { key, provider, baseUrl } of moonshotVariants) {
for (const [modelId, m] of Object.entries(moonshotModels[key])) {
if (m.tool_call !== true) continue;
const isKimiK3 = isKimiK3FamilyId(modelId);
const compat = { ...(isKimiK3 ? KIMI_K3_OPENAI_COMPAT : KIMI_OPENAI_COMPAT) };
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"],
// Moonshot does not bill cache writes; models.dev lists the input rate as cache_write for Kimi K3.
cost:
modelId === "kimi-k3"
? { ...KIMI_K3_COST }
: {
input: m.cost?.input || 0,
output: m.cost?.output || 0,
cacheRead: m.cost?.cache_read || 0,
cacheWrite: isKimiK3 ? 0 : m.cost?.cache_write || 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.
// MiMo Chat Completions only documents thinking.type enabled/disabled and
// rejects some reasoning_effort values (e.g. "minimal"). Xiaomi documents
// all effort levels as identical, so expose only off and high.
const xiaomiCompat: OpenAICompletionsCompat = {
supportsReasoningEffort: false,
requiresReasoningContentOnAssistantMessages: true,
thinkingFormat: "deepseek",
};
const xiaomiThinkingLevelMap = {
off: "off",
minimal: null,
low: null,
medium: null,
high: "high",
xhigh: null,
max: null,
} as const;
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,
...(m.reasoning === true ? { thinkingLevelMap: xiaomiThinkingLevelMap } : {}),
input: m.modalities?.input?.includes("image") ? ["text", "image"] : ["text"],
cost: getModelsDevCost(m.cost),
contextWindow: m.limit?.context || 4096,
maxTokens: m.limit?.output || 4096,
});
}
}
// Process Alibaba Cloud Model Studio Token Plan models. International and
// China use separate endpoints and API keys (sk-sp- prefix). The Individual
// provider reuses the international source and endpoint with a narrower catalog.
// models.dev keys are "alibaba-token-plan[-cn]"; pi exposes them as
// "qwen-token-plan[-cn]" plus the Individual catalog view.
const qwenTokenPlanCompat: OpenAICompletionsCompat = {
thinkingFormat: "qwen",
supportsDeveloperRole: false,
supportsStore: false,
supportsReasoningEffort: true,
};
const qwenTokenPlanVariants = [
{
source: "alibaba-token-plan",
provider: "qwen-token-plan",
baseUrl: "https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1",
modelIds: undefined,
},
{
source: "alibaba-token-plan",
provider: "qwen-token-plan-individual",
baseUrl: "https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1",
modelIds: QWEN_TOKEN_PLAN_INDIVIDUAL_MODEL_IDS,
},
{
source: "alibaba-token-plan-cn",
provider: "qwen-token-plan-cn",
baseUrl: "https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1",
modelIds: undefined,
},
] as const;
for (const { source, provider, baseUrl, modelIds } of qwenTokenPlanVariants) {
const providerModels = data[source]?.models;
const emittedModelIds = modelIds ? new Set<string>() : undefined;
for (const [modelId, model] of Object.entries(providerModels ?? {})) {
const m = model as ModelsDevModel;
if (m.tool_call !== true) continue;
if (QWEN_TOKEN_PLAN_EXCLUDED_MODEL_IDS.has(modelId)) continue;
if (modelIds && !modelIds.has(modelId)) continue;
const thinkingLevelMap =
getEffortThinkingLevelMap(m.reasoning_options ?? []) ??
(QWEN_TOKEN_PLAN_REASONING_EFFORT_FALLBACK_MODEL_IDS.has(modelId)
? QWEN_TOKEN_PLAN_FALLBACK_THINKING_LEVEL_MAP
: undefined);
models.push({
id: modelId,
name: m.name || modelId,
api: "openai-completions",
provider,
baseUrl,
compat: thinkingLevelMap
? qwenTokenPlanCompat
: { ...qwenTokenPlanCompat, supportsReasoningEffort: false },
...(thinkingLevelMap ? { thinkingLevelMap } : {}),
reasoning: m.reasoning === true,
input: m.modalities?.input?.includes("image") ? ["text", "image"] : ["text"],
cost: getModelsDevCost(m.cost),
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 loadModelsDevClassifierModels(): Promise<ClassifierModel<"typesafe-system-one">[]> {
try {
console.log("Fetching classifier models from models.dev API...");
const response = await fetch("https://models.dev/models.json?type=decision");
if (!response.ok) throw new Error(`models.dev classifier API returned ${response.status}`);
const data = (await response.json()) as Record<string, ModelsDevMetadata>;
const metadata = data["typesafe/jev-latest"];
if (!metadata || metadata.type !== "decision") {
throw new Error("models.dev did not return decision model typesafe/jev-latest");
}
return [
{
type: "classifier",
id: "jev-latest",
name: metadata.name,
api: "typesafe-system-one",
provider: "typesafe",
baseUrl: "https://api.typesafe.ai/v1/",
// models.dev lists text input only, but the API accepts image assets as an experimental
// feature behind an opt-in header, which the typesafe-system-one API sends.
// https://github.com/typesafe-ai/typesafe-sdk-js (v0.7.0 changelog)
input: ["text", "image"],
// The canonical models.dev entry has no direct-provider pricing. System One reports token usage,
// so classify() results carry token counts but price them at zero.
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: metadata.limit?.context || 64000,
compat: { imageInput: "assets", sendImageOptInHeader: true },
},
];
} catch (error) {
console.error("Failed to load models.dev classifier data:", error);
if (generatorOptions.strict) throw error;
return [];
}
}
// OpenCode Zen serves Jev through its TypeSafe-compatible System One endpoint.
// Neither its /inference/v1/models listing nor models.dev carries metadata for it.
// https://opencode.ai/docs/zen
const OPENCODE_CLASSIFIER_MODELS: ClassifierModel<"typesafe-system-one">[] = [
{
type: "classifier",
id: "jev-1.13",
name: "Jev 1.13",
api: "typesafe-system-one",
provider: "opencode",
baseUrl: "https://opencode.ai/inference/systemone/v1",
input: ["text"],
cost: { input: 0.042, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 32000,
},
{
type: "classifier",
id: "jev-1.13-free",
name: "Jev 1.13 Free",
api: "typesafe-system-one",
provider: "opencode",
baseUrl: "https://opencode.ai/inference/systemone/v1",
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 32000,
},
];
// Workers AI has no unauthenticated catalog, so its System One models are listed here from
// Cloudflare's model pages.
const CLOUDFLARE_WORKERS_AI_CLASSIFIER_MODELS: ClassifierModel<"cloudflare-workers-ai-system-one">[] = [
// Cloudflare-hosted Clef decision models. They take up to four PNG, JPEG, or WebP images as data URLs.
// Clef Omni also takes audio and video, which classifier contexts do not carry.
// Pricing and context: https://developers.cloudflare.com/workers-ai/models/clef/,
// https://developers.cloudflare.com/workers-ai/models/clef-flash/,
// and https://developers.cloudflare.com/workers-ai/models/clef-omni/
{
type: "classifier",
id: "@cf/cloudflare/clef",
name: "Clef",
api: "cloudflare-workers-ai-system-one",
provider: "cloudflare-workers-ai",
baseUrl: CLOUDFLARE_WORKERS_AI_REST_BASE_URL,
input: ["text", "image"],
cost: { input: 0.24, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 65536,
compat: { imageInput: "images" },
},
{
type: "classifier",
id: "@cf/cloudflare/clef-flash",
name: "Clef Flash",
api: "cloudflare-workers-ai-system-one",
provider: "cloudflare-workers-ai",
baseUrl: CLOUDFLARE_WORKERS_AI_REST_BASE_URL,
input: ["text", "image"],
cost: { input: 0.038, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 24576,
compat: { imageInput: "images" },
},
{
type: "classifier",
id: "@cf/cloudflare/clef-omni",
name: "Clef Omni",
api: "cloudflare-workers-ai-system-one",
provider: "cloudflare-workers-ai",
baseUrl: CLOUDFLARE_WORKERS_AI_REST_BASE_URL,
input: ["text", "image"],
cost: { input: 0.15, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 64000,
compat: { imageInput: "images" },
},
// TypeSafe's Jev models take PNG and JPEG images as System One assets.
// https://developers.cloudflare.com/ai/models/typesafe/jev/ (jev-latest)
// and https://developers.cloudflare.com/ai/models/typesafe/jev-1.14.0/
{
type: "classifier",
id: "typesafe/jev",
name: "Jev",
api: "cloudflare-workers-ai-system-one",
provider: "cloudflare-workers-ai",
baseUrl: CLOUDFLARE_WORKERS_AI_REST_BASE_URL,
input: ["text", "image"],
cost: { input: 0.042, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 32000,
compat: { imageInput: "assets" },
},
{
type: "classifier",
id: "typesafe/jev-1.14.0",
name: "Jev 1.14",
api: "cloudflare-workers-ai-system-one",
provider: "cloudflare-workers-ai",
baseUrl: CLOUDFLARE_WORKERS_AI_REST_BASE_URL,
input: ["text", "image"],
cost: { input: 0.042, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 32000,
compat: { imageInput: "assets" },
},
];
// OpenAI Decisions API (public beta): gpt-6-luna is its only model. It bills input tokens only,
// with the same long-context multiplier as chat requests. Only API keys work: Sign in with ChatGPT
// tokens are rejected on /v1/decisions, and the Codex backend has no Decisions route.
// The endpoint rejects inputs above 922K tokens (the model's documented maximum input), but
// requests running longer than about five seconds, currently above roughly 600K input tokens,
// fail with a gateway timeout.
// https://developers.openai.com/api/docs/guides/decisions
const OPENAI_CLASSIFIER_MODELS: ClassifierModel<"openai-decisions">[] = [
{
type: "classifier",
id: "gpt-6-luna",
name: "GPT-6 Luna",
api: "openai-decisions",
provider: "openai",
baseUrl: "https://api.openai.com/v1",
input: ["text", "image"],
cost: withOpenAiLongContextPricing({ input: 0.1, output: 0, cacheRead: 0, cacheWrite: 0 }),
contextWindow: 922000,
},
];
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 modelsDevClassifierModels = await loadModelsDevClassifierModels();
const openRouterCatalog = await fetchOpenRouterModels();
const aiGatewayCatalog = await fetchAiGatewayModels();
const radiusModels = await fetchRadiusModels();
// Combine chat models (models.dev has priority where sources overlap).
const allModels = [...modelsDevModels, ...openRouterCatalog.chat, ...aiGatewayCatalog.chat, ...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"),
);
// The authenticated Copilot catalog advertised these models on 2026-09-22,
// but models.dev did not include them yet.
const missingCopilotModels: Model<Api>[] = [
{
id: "claude-opus-5.5",
name: "Claude Opus 5.5",
api: "anthropic-messages",
provider: "github-copilot",
baseUrl: "https://api.individual.githubcopilot.com",
reasoning: true,
thinkingLevelMap: {
off: null,
minimal: null,
low: "low",
medium: "medium",
high: "high",
xhigh: "xhigh",
max: "max",
},
input: ["text", "image"],
cost: { input: 4, output: 20, cacheRead: 0.2, cacheWrite: 5 },
contextWindow: 1000000,
maxTokens: 128000,
headers: { ...COPILOT_STATIC_HEADERS },
},
...(["gpt-6-sol", "gpt-6-luna"] as const).map((id) => ({
id,
name: id === "gpt-6-sol" ? "GPT-6 Sol" : "GPT-6 Luna",
api: "openai-responses" as const,
provider: "github-copilot" as const,
baseUrl: "https://api.individual.githubcopilot.com",
reasoning: true,
input: ["text", "image"] as ("text" | "image")[],
cost: withOpenAiLongContextPricing(OPENAI_STANDARD_COSTS[id]),
contextWindow: 1000000,
maxTokens: 128000,
headers: { ...COPILOT_STATIC_HEADERS },
})),
];
for (const model of missingCopilotModels) {
if (!allModels.some((candidate) => candidate.provider === model.provider && candidate.id === model.id)) {
allModels.push(model);
}
}
// 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;
}
// models.dev may list Opus 5.5, Sonnet 5.5, and Haiku 5.5 before their effort metadata is complete.
if (
(candidate.provider === "anthropic" &&
(candidate.id === "claude-opus-5-5" ||
candidate.id === "claude-sonnet-5-5" ||
candidate.id === "claude-haiku-5-5")) ||
(candidate.provider === "github-copilot" && candidate.id === "claude-opus-5.5")
) {
mergeThinkingLevelMap(candidate, {
// Haiku 5.5 accepts disabled thinking; Opus 5.5 and Sonnet 5.5 reject it.
...(candidate.id === "claude-haiku-5-5" ? {} : { off: null }),
minimal: null,
low: "low",
medium: "medium",
high: "high",
xhigh: "xhigh",
max: "max",
});
}
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_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_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" && candidate.id === "~moonshotai/kimi-latest") ||
((candidate.provider === "openrouter" || candidate.provider === "vercel-ai-gateway") &&
candidate.id.startsWith("moonshotai/") &&
isKimiK3FamilyId(candidate.id.slice("moonshotai/".length)))
) {
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.1-sol",
name: "GPT-6.1 Sol",
api: "openai-responses",
baseUrl: "https://api.openai.com/v1",
provider: "openai",
reasoning: true,
input: ["text", "image"],
cost: withOpenAiLongContextPricing(OPENAI_STANDARD_COSTS["gpt-6.1-sol"]),
contextWindow: OPENAI_LONG_CONTEXT_INPUT_THRESHOLD,
maxTokens: 128000,
},
{
id: "gpt-6.1-terra",
name: "GPT-6.1 Terra",
api: "openai-responses",
baseUrl: "https://api.openai.com/v1",
provider: "openai",
reasoning: true,
input: ["text", "image"],
cost: withOpenAiLongContextPricing(OPENAI_STANDARD_COSTS["gpt-6.1-terra"]),
contextWindow: OPENAI_LONG_CONTEXT_INPUT_THRESHOLD,
maxTokens: 128000,
},
{
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(OPENAI_STANDARD_COSTS["gpt-6-astra"]),
contextWindow: OPENAI_LONG_CONTEXT_INPUT_THRESHOLD,
maxTokens: 128000,
},
{
id: "gpt-6-sol",
name: "GPT-6 Sol",
api: "openai-responses",
baseUrl: "https://api.openai.com/v1",
provider: "openai",
reasoning: true,
input: ["text", "image"],
cost: withOpenAiLongContextPricing(OPENAI_STANDARD_COSTS["gpt-6-sol"]),
contextWindow: OPENAI_LONG_CONTEXT_INPUT_THRESHOLD,
maxTokens: 128000,
},
{
id: "gpt-6-luna",
name: "GPT-6 Luna",
api: "openai-responses",
baseUrl: "https://api.openai.com/v1",
provider: "openai",
reasoning: true,
input: ["text", "image"],
cost: withOpenAiLongContextPricing(OPENAI_STANDARD_COSTS["gpt-6-luna"]),
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(OPENAI_STANDARD_COSTS["gpt-5.6-sol"]),
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_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_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),
};
}
}
// OpenAI Codex (ChatGPT OAuth) models
// NOTE: These are not fetched from models.dev; we keep a small, explicit list to avoid aliases.
// Only list models the Codex backend accepts for ChatGPT accounts. GPT-5.5 and GPT-5.3 Codex Spark are rejected
// with "not supported when using Codex with a ChatGPT account".
// GPT-5.6 and GPT-6 use Codex's 272k default catalog limit.
// GPT-6.1 Sol uses the 500k default returned by /backend-api/codex/models.
const CODEX_BASE_URL = "https://chatgpt.com/backend-api";
const CODEX_CONTEXT = 272000;
const CODEX_GPT_61_CONTEXT = 500000;
const CODEX_GPT_56_CONTEXT = 272000;
const CODEX_MAX_TOKENS = 128000;
const codexModels: Model<"openai-codex-responses">[] = [
{
id: "gpt-6.1-sol",
name: "GPT-6.1 Sol",
api: "openai-codex-responses",
provider: "openai-codex",
baseUrl: CODEX_BASE_URL,
reasoning: true,
input: ["text", "image"],
cost: withOpenAiLongContextPricing(OPENAI_STANDARD_COSTS["gpt-6.1-sol"]),
contextWindow: CODEX_GPT_61_CONTEXT,
maxTokens: CODEX_MAX_TOKENS,
},
{
id: "gpt-6.1-terra",
name: "GPT-6.1 Terra",
api: "openai-codex-responses",
provider: "openai-codex",
baseUrl: CODEX_BASE_URL,
reasoning: true,
input: ["text", "image"],
cost: withOpenAiLongContextPricing(OPENAI_STANDARD_COSTS["gpt-6.1-terra"]),
contextWindow: CODEX_GPT_61_CONTEXT,
maxTokens: CODEX_MAX_TOKENS,
},
{
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(OPENAI_STANDARD_COSTS["gpt-6-astra"]),
contextWindow: CODEX_CONTEXT,
maxTokens: CODEX_MAX_TOKENS,
},
{
id: "gpt-6-sol",
name: "GPT-6 Sol",
api: "openai-codex-responses",
provider: "openai-codex",
baseUrl: CODEX_BASE_URL,
reasoning: true,
input: ["text", "image"],
cost: withOpenAiLongContextPricing(OPENAI_STANDARD_COSTS["gpt-6-sol"]),
contextWindow: CODEX_CONTEXT,
maxTokens: CODEX_MAX_TOKENS,
},
{
id: "gpt-6-luna",
name: "GPT-6 Luna",
api: "openai-codex-responses",
provider: "openai-codex",
baseUrl: CODEX_BASE_URL,
reasoning: true,
input: ["text", "image"],
cost: withOpenAiLongContextPricing(OPENAI_STANDARD_COSTS["gpt-6-luna"]),
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_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(OPENAI_STANDARD_COSTS["gpt-5.6-sol"]),
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_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,
thinkingLevelMap: getEffortThinkingLevelMap([{ type: "effort", values: ["none", "high"] }]),
input: ["text", "image"],
cost: {
input: 1.5,
output: 7.5,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 262144, // 256k tokens
maxTokens: 262144,
});
}
// Add "auto" alias for openrouter/auto
if (!allModels.some(m => m.provider === "openrouter" && m.id === "auto")) {
allModels.push({
id: "auto",
name: "Auto",
api: "openai-completions",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
reasoning: true,
input: ["text", "image"],
cost: {
// we dont know about the costs because OpenRouter auto routes to different models
// and then charges you for the underlying used model
input:0,
output:0,
cacheRead:0,
cacheWrite:0,
},
contextWindow: 2000000,
maxTokens: 30000,
});
}
// Add "fusion" alias for openrouter/fusion. OpenRouter exposes Fusion as a
// router alias/plugin entry point; its model metadata does not advertise
// tools, but the alias resolves to a concrete model that can invoke caller
// tools and has the openrouter:fusion server tool auto-injected.
if (!allModels.some(m => m.provider === "openrouter" && m.id === "openrouter/fusion")) {
allModels.push({
id: "openrouter/fusion",
name: "OpenRouter: Fusion",
api: "openai-completions",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
reasoning: true,
input: ["text"],
cost: {
// we dont know about the costs because Fusion routes to multiple models
// and then charges you for the underlying used models
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 1000000,
maxTokens: 30000,
});
}
// Azure Foundry deploys these with larger context windows than OpenAI's own short-tier defaults.
// See models-sold-directly-by-azure docs.
const AZURE_CONTEXT_WINDOW_OVERRIDES: Record<string, number> = {
"gpt-5.4": 1050000,
"gpt-5.5": 1050000,
"gpt-5.6-luna": 1050000,
"gpt-5.6-sol": 1050000,
"gpt-5.6-terra": 1050000,
};
const azureOpenAiModels: Model<Api>[] = allModels
.filter((model) => model.provider === "openai" && model.api === "openai-responses")
.map((model) => ({
...model,
api: "azure-openai-responses",
provider: "azure",
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);
// Azure resells DeepSeek at its own rates. US data zone, checked 2026-09-16.
// https://azure.microsoft.com/en-us/pricing/details/ai-foundry-models/deepseek/
const AZURE_DEEPSEEK_V4_PRO_COST: ModelCost = { input: 1.925, output: 3.828, cacheRead: 0.165, cacheWrite: 0 };
// Azure 400s on DeepSeek's `thinking` field and on every prompt cache parameter, discards a
// `developer` system message unbilled once reasoning_effort is set, and honours mid-convo ones (#9645).
const azureDeepSeekModels: Model<Api>[] = allModels
.filter((model) => model.provider === "deepseek" && model.id === "deepseek-v4-pro")
.map((model) => ({
...model,
provider: "azure",
baseUrl: "",
cost: AZURE_DEEPSEEK_V4_PRO_COST,
compat: {
...(model.compat as OpenAICompletionsCompat),
supportsDeveloperRole: false,
supportsMidConvoSystemMessages: true,
thinkingFormat: "openai",
supportsLongCacheRetention: false,
},
}));
allModels.push(...azureDeepSeekModels);
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);
applyOpenAIMidConvoEffortMetadata(model);
applyPromptCacheMetadata(model);
applyImageInputMetadata(model);
}
applyAnthropicAllowedFallbackModelMetadata(allModels.filter(isAnthropicFallbackMetadataModel));
// Keep chat and image catalogs separate so one upstream ID can expose both
// operations with different API implementations.
type ProviderCatalog = {
chat: Record<string, Model<Api>>;
image: Record<string, ImageModel<ImageApi>>;
classifier: Record<string, ClassifierModel<ClassifierApi>>;
};
const providers: Record<string, ProviderCatalog> = {};
for (const model of allModels) {
providers[model.provider] ??= { chat: {}, image: {}, classifier: {} };
// Only add if not already present (models.dev takes priority over OpenRouter).
providers[model.provider].chat[model.id] ??= { ...model, type: "chat" };
}
for (const model of openRouterCatalog.images) {
applyImageInputMetadata(model);
providers[model.provider] ??= { chat: {}, image: {}, classifier: {} };
providers[model.provider].image[model.id] ??= model;
}
const classifierModels: ClassifierModel<ClassifierApi>[] = [
...modelsDevClassifierModels,
...openRouterCatalog.classifiers,
...aiGatewayCatalog.classifiers,
...OPENCODE_CLASSIFIER_MODELS,
...CLOUDFLARE_WORKERS_AI_CLASSIFIER_MODELS,
...OPENAI_CLASSIFIER_MODELS,
];
for (const model of classifierModels) {
providers[model.provider] ??= { chat: {}, image: {}, classifier: {} };
providers[model.provider].classifier[model.id] ??= model;
}
const sortedProviderIds = Object.keys(providers).sort();
const jsonChatProviders: Record<string, Record<string, Model<Api>>> = {};
const jsonImageProviders: Record<string, Record<string, ImageModel<ImageApi>>> = {};
const jsonClassifierProviders: Record<string, Record<string, ClassifierModel<ClassifierApi>>> = {};
const jsonAllProviders: Record<string, AnyModel[]> = {};
for (const providerId of sortedProviderIds) {
jsonChatProviders[providerId] = Object.fromEntries(
Object.entries(providers[providerId].chat).sort(([left], [right]) => left.localeCompare(right)),
);
jsonImageProviders[providerId] = Object.fromEntries(
Object.entries(providers[providerId].image).sort(([left], [right]) => left.localeCompare(right)),
);
jsonClassifierProviders[providerId] = Object.fromEntries(
Object.entries(providers[providerId].classifier).sort(([left], [right]) => left.localeCompare(right)),
);
jsonAllProviders[providerId] = [
...Object.values(jsonChatProviders[providerId]),
...Object.values(jsonImageProviders[providerId]),
...Object.values(jsonClassifierProviders[providerId]),
];
}
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) => !jsonAllProviders[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.
const generatedDataProviders: Record<string, Record<string, Record<string, AnyModel>>> = {};
const modelDataStructure: ModelDataStructure = {};
for (const providerId of generatedDataProviderIds) {
const { groups, structure } = groupProviderModelData(providerId, jsonAllProviders[providerId]);
generatedDataProviders[providerId] = groups;
modelDataStructure[providerId] = structure;
}
const generatedAt = new Date().toISOString();
if (!generatorOptions.jsonOnly) {
// Stage and validate all provider values before replacing the current generated data.
const providersDir = join(packageRoot, "src/providers");
const dataDir = join(providersDir, "data");
const stagingRoot = mkdtempSync(join(providersDir, ".model-generation-"));
const stagedDataDir = join(stagingRoot, "data");
const previousDataDir = join(stagingRoot, "previous-data");
let restoreGeneratedCatalog: (() => void) | undefined;
try {
mkdirSync(stagedDataDir, { recursive: true });
const fileContents: Record<string, string> = {};
for (const providerId of generatedDataProviderIds) {
const filename = `${providerId}.json`;
const content = serializeJson(generatedDataProviders[providerId]);
fileContents[filename] = content;
writeFileSync(join(stagedDataDir, filename), content);
}
writeJson(
join(stagedDataDir, MODEL_DATA_MANIFEST_FILE),
createModelDataManifest(modelDataStructure, fileContents, generatedAt),
);
validateModelDataDirectory(modelDataStructure, stagedDataDir);
if (!generatorOptions.dataOnly) {
const previousShardContents = new Map(
readdirSync(providersDir)
.filter((entry) => entry.endsWith(".models.ts"))
.map((entry) => [entry, readFileSync(join(providersDir, entry), "utf8")] as const),
);
const aggregatorPath = join(packageRoot, "src/models.generated.ts");
const previousAggregator = readFileSync(aggregatorPath, "utf8");
restoreGeneratedCatalog = () => {
for (const entry of readdirSync(providersDir)) {
if (entry.endsWith(".models.ts")) rmSync(join(providersDir, entry));
}
for (const [entry, content] of previousShardContents) {
writeFileSync(join(providersDir, entry), content);
}
writeFileSync(aggregatorPath, previousAggregator);
};
const generatedHeader = `// This file is auto-generated by scripts/generate-models.ts
// Do not edit manually - run 'npm run generate-models' to update
`;
const catalogConstName = (providerId: string) =>
`${providerId.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}_MODELS`;
const imageCatalogConstName = (providerId: string) =>
`${providerId.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}_IMAGE_MODELS`;
const classifierCatalogConstName = (providerId: string) =>
`${providerId.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}_CLASSIFIER_MODELS`;
const generatedShardFiles = new Set<string>();
for (const providerId of sortedProviderIds) {
let output = generatedHeader;
output += `import values from "./data/${providerId}.json" with { type: "json" };\n`;
output += `import { flattenChatModelCatalog, flattenClassifierModelCatalog, flattenImageModelCatalog, type ChatModelCatalog, type ClassifierModelCatalog, type ImageModelCatalog } from "../model-catalog.ts";\n\n`;
output += `export const ${catalogConstName(providerId)}: ChatModelCatalog<typeof values, ${JSON.stringify(providerId)}> =\n`;
output += `\tflattenChatModelCatalog(${JSON.stringify(providerId)}, values);\n\n`;
output += `export const ${imageCatalogConstName(providerId)}: ImageModelCatalog<typeof values, ${JSON.stringify(providerId)}> =\n`;
output += `\tflattenImageModelCatalog(${JSON.stringify(providerId)}, values);\n\n`;
output += `export const ${classifierCatalogConstName(providerId)}: ClassifierModelCatalog<typeof values, ${JSON.stringify(providerId)}> =\n`;
output += `\tflattenClassifierModelCatalog(${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 { ${classifierCatalogConstName(providerId)}, ${imageCatalogConstName(providerId)}, ${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\nexport const IMAGE_MODELS: {\n`;
for (const providerId of sortedProviderIds) {
output += `\treadonly ${JSON.stringify(providerId)}: typeof ${imageCatalogConstName(providerId)};\n`;
}
output += `} = {\n`;
for (const providerId of sortedProviderIds) {
output += `\t${JSON.stringify(providerId)}: ${imageCatalogConstName(providerId)},\n`;
}
output += `};\n\nexport const CLASSIFIER_MODELS: {\n`;
for (const providerId of sortedProviderIds) {
output += `\treadonly ${JSON.stringify(providerId)}: typeof ${classifierCatalogConstName(providerId)};\n`;
}
output += `} = {\n`;
for (const providerId of sortedProviderIds) {
output += `\t${JSON.stringify(providerId)}: ${classifierCatalogConstName(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) {
// `models.json` and `providers/{id}.json` retain the legacy keyed chat catalog.
// The `.all` variants are arrays so the same upstream id can appear once per type.
const providerOutputDir = join(generatorOptions.jsonOutputDir, "providers");
rmSync(generatorOptions.jsonOutputDir, { recursive: true, force: true });
mkdirSync(providerOutputDir, { recursive: true });
writeJson(join(generatorOptions.jsonOutputDir, "models.json"), jsonChatProviders);
writeJson(join(generatorOptions.jsonOutputDir, "models.all.json"), jsonAllProviders);
writeJson(join(generatorOptions.jsonOutputDir, "providers.json"), sortedProviderIds);
for (const providerId of sortedProviderIds) {
writeJson(join(providerOutputDir, `${providerId}.json`), jsonChatProviders[providerId]);
writeJson(join(providerOutputDir, `${providerId}.all.json`), jsonAllProviders[providerId]);
}
console.log(`Generated JSON model catalog under ${generatorOptions.jsonOutputDir}`);
}
// Print statistics
const totalModels = allModels.length;
const reasoningModels = allModels.filter((model) => model.reasoning).length;
console.log(`\nModel Statistics:`);
console.log(` Total tool-capable models: ${totalModels}`);
console.log(` Reasoning-capable models: ${reasoningModels}`);
for (const [provider, models] of Object.entries(providers)) {
console.log(
` ${provider}: ${Object.keys(models.chat).length} chat models, ${Object.keys(models.image).length} image models, ${Object.keys(models.classifier).length} classifier models`,
);
}
}
// Run the generator
generateModels().catch((error) => {
console.error(error);
process.exitCode = 1;
});