Files
oh-my-pi/packages/ai/src/model-thinking.ts
T
can1357 f8fa44c327 fix(packages/ai): resolved Anthropic tool-call parallel-use suppression
- Removed Opus-specific `tool_choice.disable_parallel_tool_use` injection for Anthropic tool calls.
- Removed the obsolete `disablesParallelToolUse` helper from model capability checks.
2026-06-07 08:44:19 +02:00

729 lines
24 KiB
TypeScript

import { Effort, THINKING_EFFORTS } from "./effort";
import { resolveOpenAICompat } from "./providers/openai-completions-compat";
import type { Api, Model as ApiModel, ThinkingConfig } from "./types";
const DEFAULT_REASONING_EFFORTS: readonly Effort[] = [Effort.Minimal, Effort.Low, Effort.Medium, Effort.High];
const DEFAULT_REASONING_EFFORTS_WITH_XHIGH: readonly Effort[] = [
Effort.Minimal,
Effort.Low,
Effort.Medium,
Effort.High,
Effort.XHigh,
];
const GEMINI_3_PRO_EFFORTS: readonly Effort[] = [Effort.Low, Effort.High];
const GEMINI_3_FLASH_EFFORTS: readonly Effort[] = [Effort.Minimal, Effort.Low, Effort.Medium, Effort.High];
const GPT_5_2_PLUS_EFFORTS: readonly Effort[] = [Effort.Low, Effort.Medium, Effort.High, Effort.XHigh];
const GPT_5_1_CODEX_MINI_EFFORTS: readonly Effort[] = [Effort.Medium, Effort.High];
const CLOUDFLARE_AI_GATEWAY_BASE_URL = "https://gateway.ai.cloudflare.com/v1/<account>/<gateway>/anthropic";
type SemVer = {
major: number;
minor: number;
patch: number;
};
type GeminiKind = "pro" | "flash";
type AnthropicKind = "opus" | "sonnet";
type OpenAIVariant = "base" | "codex" | "codex-max" | "codex-mini" | "codex-spark" | "mini" | "max" | "nano";
const CODEX_GPT_5_4_PRIORITY_BY_VARIANT: Partial<Record<OpenAIVariant, number>> = {
base: 0,
mini: 1,
nano: 2,
};
const COPILOT_GENERATED_LIMITS: Record<string, { contextWindow: number; maxTokens: number }> = {
"claude-opus-4.6": { contextWindow: 168000, maxTokens: 32000 },
"gpt-5.2": { contextWindow: 272000, maxTokens: 128000 },
"gpt-5.4": { contextWindow: 272000, maxTokens: 128000 },
"gpt-5.4-mini": { contextWindow: 272000, maxTokens: 128000 },
"grok-code-fast-1": { contextWindow: 192000, maxTokens: 64000 },
};
interface GeminiModel {
family: "gemini";
kind: GeminiKind;
version: SemVer;
}
interface AnthropicModel {
family: "anthropic";
kind: AnthropicKind;
version: SemVer;
}
interface OpenAIModel {
family: "openai";
variant: OpenAIVariant;
version: SemVer;
}
interface UnknownModel {
family: "unknown";
id: string;
}
type ParsedModel = GeminiModel | AnthropicModel | OpenAIModel | UnknownModel;
/**
* Static fallback model injected when Cloudflare AI Gateway discovery
* returns no results. Ensures the provider always has at least one usable
* model entry in the catalog.
*/
export const CLOUDFLARE_FALLBACK_MODEL: ApiModel<"anthropic-messages"> = {
id: "claude-sonnet-4-5",
name: "Claude Sonnet 4.5",
api: "anthropic-messages",
provider: "cloudflare-ai-gateway",
baseUrl: CLOUDFLARE_AI_GATEWAY_BASE_URL,
reasoning: true,
input: ["text", "image"],
cost: {
input: 3,
output: 15,
cacheRead: 0.3,
cacheWrite: 3.75,
},
contextWindow: 200000,
maxTokens: 64000,
};
const kEnrichedModel = Symbol("model-thinking.enrichedModel");
type ModelWithEnriched = ApiModel<Api> & { [kEnrichedModel]?: ApiModel<Api> };
/**
* Returns a copy of the model with canonical thinking metadata attached.
*
* This helper belongs to catalog enrichment only. Runtime consumers should
* trust `model.thinking` and avoid inferring capabilities on demand.
*/
export function enrichModelThinking<TApi extends Api>(model: ApiModel<TApi>): ApiModel<TApi> {
const tagged = model as ModelWithEnriched;
const cached = tagged[kEnrichedModel];
if (cached !== undefined) {
return cached as ApiModel<TApi>;
}
const normalizedThinking = normalizeThinkingConfig(model.thinking);
let result: ApiModel<TApi>;
if (!model.reasoning) {
result =
normalizedThinking === undefined && model.thinking === undefined ? model : { ...model, thinking: undefined };
} else {
const thinking = normalizedThinking ?? inferModelThinking(model);
result = thinkingsEqual(normalizedThinking, thinking) ? model : { ...model, thinking };
}
// Stash the enriched copy on a non-enumerable slot so callers that hand us
// the same reference twice skip the work. `enumerable: false` is critical:
// many call sites build derived models via `{ ...model, ...overrides }`,
// which would otherwise copy this cache slot and trick us into returning
// the *original* enriched model — silently discarding the overrides.
Object.defineProperty(tagged, kEnrichedModel, {
value: result,
enumerable: false,
configurable: true,
writable: true,
});
return result;
}
/**
* Returns a copy of the model with thinking metadata recomputed from the
* canonical rules, replacing any existing `thinking`.
*/
export function refreshModelThinking<TApi extends Api>(model: ApiModel<TApi>): ApiModel<TApi> {
if (!model.reasoning) {
const normalizedThinking = normalizeThinkingConfig(model.thinking);
return normalizedThinking === undefined && model.thinking === undefined
? model
: { ...model, thinking: undefined };
}
return { ...model, thinking: inferModelThinking(model) };
}
/**
* Apply upstream metadata corrections to a mutable array of models.
*
* Each model is first normalized through `refreshModelThinking()` so generated
* catalogs keep canonical thinking metadata and policy fixes in one pass.
*/
export function applyGeneratedModelPolicies(models: ApiModel<Api>[]): void {
for (let index = 0; index < models.length; index++) {
const model = refreshModelThinking(models[index]!);
applyGeneratedModelPolicy(model);
models[index] = model;
}
}
/**
* Link OpenAI model variants to their context promotion targets.
*
* When a model's context is exhausted, the agent can promote to a sibling
* model with a larger context window on the same provider:
* - `codex-spark` variants promote to `gpt-5.5`.
* - `gpt-5.5` (270K input) promotes to `gpt-5.4` (1M input).
*/
export function linkOpenAIPromotionTargets(models: ApiModel<Api>[]): void {
for (const candidate of models) {
const parsedCandidate = parseKnownModel(candidate.id);
if (parsedCandidate.family !== "openai") continue;
let targetId: string | undefined;
if (parsedCandidate.variant === "codex-spark") {
targetId = "gpt-5.5";
} else if (parsedCandidate.variant === "base" && semverEqual(parsedCandidate.version, "5.5")) {
targetId = "gpt-5.4";
} else {
continue;
}
const fallback = models.find(
model => model.provider === candidate.provider && model.api === candidate.api && model.id === targetId,
);
if (!fallback) continue;
candidate.contextPromotionTarget = `${fallback.provider}/${fallback.id}`;
}
}
/**
* True when the model reasons natively but rejects the wire `reasoning.effort`
* param (compat.supportsReasoningEffort: false on openai-responses*). Callers
* are expected to omit the effort field; the wire-side omitReasoningEffort
* gate (providers/xai-responses.ts:78) is the actual strip, and this
* predicate is the upstream check that prevents a redundant
* requireSupportedEffort throw from defeating that gate.
*
* Scoped to openai-responses* because that's the only API surface where
* `compat.supportsReasoningEffort: false` is meaningful today. The
* `in`-narrowed access is necessary because Model.compat is
* `AnthropicCompat | OpenAICompat` and the api gate doesn't narrow the
* union for TS.
*/
export function modelOmitsReasoningEffort<TApi extends Api>(model: ApiModel<TApi>): boolean {
if (model.api !== "openai-responses" && model.api !== "openai-codex-responses") {
return false;
}
const compat = model.compat;
return Boolean(compat && "supportsReasoningEffort" in compat && compat.supportsReasoningEffort === false);
}
/**
* Returns the supported thinking efforts declared on the model metadata.
*
* Catalog enrichment is responsible for normalizing bundled model metadata up front.
* Runtime callers must treat explicit `model.thinking` on custom models as authoritative
* so proxy-specific overrides from `models.yml` survive request construction.
*
* @throws Error when a reasoning-capable model is missing thinking metadata
*/
export function getSupportedEfforts<TApi extends Api>(model: ApiModel<TApi>): readonly Effort[] {
if (!model.reasoning) {
return [];
}
// Models that reason natively but reject the `reasoning.effort` wire param
// (xAI Grok off the GROK_EFFORT_CAPABLE_PREFIXES allowlist in
// providers/xai-responses.ts: grok-build, grok-4.20-0309-reasoning) hide the
// picker's effort dial. Scoped to openai-responses* by
// `modelOmitsReasoningEffort` — openai-completions has its own
// supportsReasoningEffort consultation at inferFallbackEfforts L536 and
// changing that path's semantics is out-of-scope.
if (modelOmitsReasoningEffort(model)) {
return [];
}
if (!model.thinking) {
throw new Error(`Model ${model.provider}/${model.id} is missing thinking metadata`);
}
return expandEffortRange(model.thinking);
}
/**
* Clamps a requested thinking level against explicit model metadata.
*
* Non-reasoning models always resolve to `undefined`.
*/
export function clampThinkingLevelForModel<TApi extends Api>(
model: ApiModel<TApi> | undefined,
requested: Effort | undefined,
): Effort | undefined {
if (!model) {
return requested;
}
if (!model.reasoning || requested === undefined) {
return undefined;
}
const levels = getSupportedEfforts(model);
if (levels.includes(requested)) {
return requested;
}
const requestedIndex = THINKING_EFFORTS.indexOf(requested);
if (requestedIndex === -1) {
return undefined;
}
let clamped: Effort | undefined;
for (const effort of levels) {
if (THINKING_EFFORTS.indexOf(effort) > requestedIndex) {
break;
}
clamped = effort;
}
return clamped ?? levels[0];
}
export function requireSupportedEffort<TApi extends Api>(model: ApiModel<TApi>, effort: Effort): Effort {
if (!model.reasoning) {
throw new Error(`Model ${model.provider}/${model.id} does not support thinking`);
}
const levels = getSupportedEfforts(model);
if (!levels.includes(effort)) {
throw new Error(
`Thinking effort ${effort} is not supported by ${model.provider}/${model.id}. Supported efforts: ${levels.join(", ")}`,
);
}
return effort;
}
/** Maps a normalized thinking effort to Google's `thinkingLevel` enum values. */
export function mapEffortToGoogleThinkingLevel<TApi extends Api>(
model: ApiModel<TApi>,
effort: Effort,
): "MINIMAL" | "LOW" | "MEDIUM" | "HIGH" {
switch (requireSupportedEffort(model, effort)) {
case Effort.Minimal:
return "MINIMAL";
case Effort.Low:
return "LOW";
case Effort.Medium:
return "MEDIUM";
case Effort.High:
case Effort.XHigh:
return "HIGH";
}
}
/** Maps a normalized thinking effort to Anthropic adaptive effort values. */
export function mapEffortToAnthropicAdaptiveEffort<TApi extends Api>(
model: ApiModel<TApi>,
effort: Effort,
): "low" | "medium" | "high" | "xhigh" | "max" {
const supported = requireSupportedEffort(model, effort);
if (anthropicModelHasRealXHighEffort(model)) {
// Opus 4.7+ on the Messages API exposes the full five-tier adaptive scale
// (low/medium/high/xhigh/max). Shift our user-facing efforts up one notch so
// the top tier reaches the genuine "max" and "high" lands on Anthropic's
// recommended "xhigh" coding/agentic default.
switch (supported) {
case Effort.Minimal:
return "low";
case Effort.Low:
return "medium";
case Effort.Medium:
return "high";
case Effort.High:
return "xhigh";
case Effort.XHigh:
return "max";
}
}
// Older adaptive models (Opus 4.6) and Bedrock Converse expose only four tiers
// with no real "xhigh"; XHigh is a legacy alias for the top "max" tier there.
switch (supported) {
case Effort.Minimal:
case Effort.Low:
return "low";
case Effort.Medium:
return "medium";
case Effort.High:
return "high";
case Effort.XHigh:
return "max";
}
}
/**
* Returns true for Anthropic models with Opus 4.7 API restrictions:
* - Sampling parameters (temperature/top_p/top_k) return 400 error
* - Thinking content is omitted by default (needs display: "summarized")
*/
export function hasOpus47ApiRestrictions(modelId: string): boolean {
const parsed = parseAnthropicModel(getCanonicalModelId(modelId));
if (!parsed) return false;
return semverGte(parsed.version, "4.7") && parsed.kind === "opus";
}
/**
* Mid-conversation `role: "system"` messages (system instructions appended at
* non-first positions in the `messages` array) are supported starting with
* Claude Opus 4.8. Earlier Claude models reject the role.
* @see https://platform.claude.com/docs/en/build-with-claude/mid-conversation-system-messages
*/
export function supportsMidConversationSystemMessages(modelId: string): boolean {
const parsed = parseAnthropicModel(getCanonicalModelId(modelId));
if (!parsed) return false;
return parsed.kind === "opus" && semverGte(parsed.version, "4.8");
}
function anthropicModelHasRealXHighEffort<TApi extends Api>(model: ApiModel<TApi>): boolean {
if (model.api !== "anthropic-messages") return false;
const parsedModel = parseKnownModel(model.id);
if (parsedModel.family !== "anthropic" || parsedModel.kind !== "opus") return false;
return semverGte(parsedModel.version, "4.7");
}
function applyGeneratedModelPolicy(model: ApiModel<Api>): void {
const copilotLimits = model.provider === "github-copilot" ? COPILOT_GENERATED_LIMITS[model.id] : undefined;
if (copilotLimits) {
model.contextWindow = copilotLimits.contextWindow;
model.maxTokens = copilotLimits.maxTokens;
}
if (
model.api === "openai-completions" &&
(model.provider === "minimax-code" || model.provider === "minimax-code-cn")
) {
model.compat = {
...(model.compat ?? {}),
supportsStore: false,
supportsDeveloperRole: false,
supportsReasoningEffort: false,
reasoningContentField: "reasoning_content",
};
delete model.compat.thinkingFormat;
}
if (
model.api === "openai-completions" &&
model.provider === "opencode-go" &&
(model.id === "deepseek-v4-flash" || model.id === "deepseek-v4-pro")
) {
model.compat = {
...(model.compat ?? {}),
supportsToolChoice: false,
reasoningContentField: "reasoning_content",
requiresReasoningContentForToolCalls: true,
};
}
const parsedModel = parseKnownModel(model.id);
const applyPatchToolType = inferGeneratedApplyPatchToolType(model, parsedModel);
if (applyPatchToolType) {
model.applyPatchToolType = applyPatchToolType;
} else {
delete model.applyPatchToolType;
}
if (parsedModel.family === "anthropic") {
applyAnthropicCatalogPolicy(model, parsedModel);
}
if (parsedModel.family === "openai") {
applyOpenAICatalogPolicy(model, parsedModel);
}
}
function applyAnthropicCatalogPolicy(model: ApiModel<Api>, parsedModel: AnthropicModel): void {
// Claude Opus 4.5: models.dev reports 3x the correct cache pricing.
if (model.provider === "anthropic" && parsedModel.kind === "opus" && semverEqual(parsedModel.version, "4.5")) {
model.cost.cacheRead = 0.5;
model.cost.cacheWrite = 6.25;
}
// Bedrock Opus 4.6: upstream metadata is stale for cache pricing and context.
if (model.provider === "amazon-bedrock" && parsedModel.kind === "opus" && semverEqual(parsedModel.version, "4.6")) {
model.cost.cacheRead = 0.5;
model.cost.cacheWrite = 6.25;
model.contextWindow = 1000000;
model.maxTokens = 128000;
}
}
function inferGeneratedApplyPatchToolType(
model: ApiModel<Api>,
parsedModel: ParsedModel,
): ApiModel<Api>["applyPatchToolType"] {
if (parsedModel.family !== "openai" || parsedModel.version.major !== 5) {
return undefined;
}
if (model.provider === "openai" && model.api === "openai-responses") {
return "freeform";
}
if (model.provider === "openai-codex" && model.api === "openai-codex-responses") {
return "freeform";
}
return undefined;
}
function applyOpenAICatalogPolicy(model: ApiModel<Api>, parsedModel: OpenAIModel): void {
// Codex models: 400K figure includes output budget; input window is 272K.
if (parsedModel.variant.startsWith("codex") && parsedModel.variant !== "codex-spark") {
model.contextWindow = 272000;
return;
}
// GPT-5.4 mini/nano use plain OpenAI IDs on the Codex transport, but Codex still
// enforces the lower prompt budget for these variants. Codex discovery can also
// report inconsistent priorities for the GPT-5.4 family, so normalize by parsed
// variant instead of special-casing raw model ids.
if (model.api === "openai-codex-responses" && semverEqual(parsedModel.version, "5.4")) {
const normalizedPriority = CODEX_GPT_5_4_PRIORITY_BY_VARIANT[parsedModel.variant];
if (normalizedPriority !== undefined) {
model.priority = normalizedPriority;
}
if (parsedModel.variant === "mini" || parsedModel.variant === "nano") {
model.contextWindow = 272000;
}
}
}
function inferModelThinking<TApi extends Api>(model: ApiModel<TApi>): ThinkingConfig {
const parsedModel = parseKnownModel(model.id);
const efforts = inferSupportedEfforts(parsedModel, model);
const minLevel = efforts[0];
const maxLevel = efforts.at(-1);
if (!minLevel || !maxLevel) {
throw new Error(`Model ${model.provider}/${model.id} resolved to an empty thinking range`);
}
const config: ThinkingConfig = {
mode: inferThinkingControlMode(model, parsedModel),
minLevel,
maxLevel,
};
// Encode explicit levels only when the inferred set has gaps the min..max range cannot represent.
const minIndex = THINKING_EFFORTS.indexOf(minLevel);
const maxIndex = THINKING_EFFORTS.indexOf(maxLevel);
const expandedRange = THINKING_EFFORTS.slice(minIndex, maxIndex + 1);
if (expandedRange.length !== efforts.length) {
config.levels = efforts;
}
return config;
}
function normalizeThinkingConfig(thinking: ThinkingConfig | undefined): ThinkingConfig | undefined {
if (!thinking || expandEffortRange(thinking).length === 0) {
return undefined;
}
return thinking;
}
function thinkingsEqual(left: ThinkingConfig | undefined, right: ThinkingConfig | undefined): boolean {
if (left === right) return true;
if (!left || !right) return false;
if (left.mode !== right.mode || left.minLevel !== right.minLevel || left.maxLevel !== right.maxLevel) return false;
const leftLevels = left.levels;
const rightLevels = right.levels;
if (leftLevels === rightLevels) return true;
if (!leftLevels || !rightLevels) return false;
if (leftLevels.length !== rightLevels.length) return false;
return leftLevels.every((level, index) => level === rightLevels[index]);
}
function expandEffortRange(thinking: ThinkingConfig): readonly Effort[] {
if (thinking.levels && thinking.levels.length > 0) {
return thinking.levels;
}
const minIndex = THINKING_EFFORTS.indexOf(thinking.minLevel);
const maxIndex = THINKING_EFFORTS.indexOf(thinking.maxLevel);
if (minIndex === -1 || maxIndex === -1 || minIndex > maxIndex) {
return [];
}
return THINKING_EFFORTS.slice(minIndex, maxIndex + 1);
}
function inferSupportedEfforts<TApi extends Api>(parsedModel: ParsedModel, model: ApiModel<TApi>): readonly Effort[] {
switch (parsedModel.family) {
case "openai":
return inferOpenAISupportedEfforts(parsedModel);
case "gemini":
return inferGeminiSupportedEfforts(parsedModel);
case "anthropic":
return inferAnthropicSupportedEfforts(parsedModel, model);
case "unknown":
return inferFallbackEfforts(model);
}
}
function inferOpenAISupportedEfforts(model: OpenAIModel): readonly Effort[] {
if (model.variant === "codex-mini" && semverEqual(model.version, "5.1")) {
return GPT_5_1_CODEX_MINI_EFFORTS;
}
if (semverGte(model.version, "5.2")) {
return GPT_5_2_PLUS_EFFORTS;
}
return DEFAULT_REASONING_EFFORTS;
}
function inferGeminiSupportedEfforts(model: GeminiModel): readonly Effort[] {
if (!semverGte(model.version, "3.0")) {
return DEFAULT_REASONING_EFFORTS;
}
return model.kind === "pro" ? GEMINI_3_PRO_EFFORTS : GEMINI_3_FLASH_EFFORTS;
}
function inferAnthropicSupportedEfforts<TApi extends Api>(
parsedModel: AnthropicModel,
model: ApiModel<TApi>,
): readonly Effort[] {
if (
(model.api === "anthropic-messages" || model.api === "bedrock-converse-stream") &&
semverGte(parsedModel.version, "4.6")
) {
return parsedModel.kind === "opus" ? DEFAULT_REASONING_EFFORTS_WITH_XHIGH : DEFAULT_REASONING_EFFORTS;
}
return inferFallbackEfforts(model);
}
function inferFallbackEfforts<TApi extends Api>(model: ApiModel<TApi>): readonly Effort[] {
if (model.api === "anthropic-messages") {
return DEFAULT_REASONING_EFFORTS_WITH_XHIGH;
}
if (model.name.includes("deepseek-v4")) {
return DEFAULT_REASONING_EFFORTS_WITH_XHIGH;
}
if (model.api === "bedrock-converse-stream") {
return DEFAULT_REASONING_EFFORTS;
}
if (model.api === "openai-completions") {
const compat = resolveOpenAICompat(model as ApiModel<"openai-completions">);
if (compat.thinkingFormat === "openai" && compat.supportsReasoningEffort) {
return DEFAULT_REASONING_EFFORTS_WITH_XHIGH;
}
return DEFAULT_REASONING_EFFORTS;
}
// OpenAI Responses APIs encode discrete effort levels, including xhigh.
if (model.api === "openai-responses" || model.api === "openai-codex-responses") {
return DEFAULT_REASONING_EFFORTS_WITH_XHIGH;
}
return DEFAULT_REASONING_EFFORTS;
}
function inferThinkingControlMode<TApi extends Api>(
model: ApiModel<TApi>,
parsedModel: ParsedModel,
): ThinkingConfig["mode"] {
switch (model.api) {
case "google-generative-ai":
case "google-gemini-cli":
case "google-vertex":
return parsedModel.family === "gemini" &&
semverGte(parsedModel.version, "3.0") &&
parsedModel.version.major === 3
? "google-level"
: "budget";
case "anthropic-messages":
if (parsedModel.family === "anthropic") {
if (semverGte(parsedModel.version, "4.6")) {
return "anthropic-adaptive";
}
if (semverGte(parsedModel.version, "4.5")) {
return "anthropic-budget-effort";
}
}
return "budget";
case "bedrock-converse-stream":
if (parsedModel.family === "anthropic") {
if (semverGte(parsedModel.version, "4.6") && parsedModel.kind === "opus") {
return "anthropic-adaptive";
}
if (semverGte(parsedModel.version, "4.5")) {
return "anthropic-budget-effort";
}
}
return "budget";
default:
return "effort";
}
}
function parseKnownModel(modelId: string): ParsedModel {
const canonicalId = getCanonicalModelId(modelId);
return (
parseGeminiModel(canonicalId) ??
parseAnthropicModel(canonicalId) ??
parseOpenAIModel(canonicalId) ?? { family: "unknown", id: canonicalId }
);
}
const GEMINI_SUFFIX = "-preview";
function parseGeminiModel(modelId: string): GeminiModel | null {
if (modelId.endsWith(GEMINI_SUFFIX)) {
modelId = modelId.slice(0, -GEMINI_SUFFIX.length);
}
const match = /gemini-(\d+(?:\.\d+){0,2})-(pro|flash)\b/.exec(modelId);
if (!match) {
return null;
}
const version = parseSemVer(match[1]);
if (!version) {
return null;
}
return { family: "gemini", kind: match[2] as GeminiKind, version };
}
function parseAnthropicModel(modelId: string): AnthropicModel | null {
const match = /claude-(opus|sonnet)-(\d{1,2}(?:[.-]\d{1,2}){0,2})\b/.exec(modelId);
if (!match) {
return null;
}
const version = parseSemVer(match[2]);
if (!version) {
return null;
}
return { family: "anthropic", kind: match[1] as AnthropicKind, version };
}
function parseOpenAIModel(modelId: string): OpenAIModel | null {
const match = /gpt-(\d+(?:\.\d+){0,2})(?:-(codex-spark|codex-mini|codex-max|codex|mini|max|nano))?\b/.exec(modelId);
if (!match) {
return null;
}
const version = parseSemVer(match[1]);
if (!version) {
return null;
}
return { family: "openai", variant: (match[2] as OpenAIVariant | undefined) ?? "base", version };
}
function createSemVer(major: number, minor: number, patch = 0): SemVer {
return { major, minor, patch };
}
// extend this table if we need anything more than 9.10
const precomputeTable: Record<string, SemVer> = {};
for (let major = 0; major <= 9; major++) {
for (let minor = 0; minor <= 10; minor++) {
const version = createSemVer(major, minor, 0);
precomputeTable[`${major}.${minor}`] = version;
precomputeTable[`${major}-${minor}`] = version;
}
precomputeTable[`${major}`] = createSemVer(major, 0, 0);
}
function parseSemVer(version: string): SemVer | null {
return precomputeTable[version] ?? null;
}
function semverGte(left: SemVer | string, right: SemVer | string): boolean {
return compareSemVer(left, right) >= 0;
}
function semverEqual(left: SemVer | string, right: SemVer | string): boolean {
return compareSemVer(left, right) === 0;
}
function compareSemVer(left: SemVer | string | null, right: SemVer | string | null): number {
left = typeof left === "string" ? parseSemVer(left) : left;
right = typeof right === "string" ? parseSemVer(right) : right;
if (!left || !right) return (left ? 1 : 0) - (right ? 1 : 0);
if (left.major !== right.major) {
return left.major - right.major;
}
if (left.minor !== right.minor) {
return left.minor - right.minor;
}
return left.patch - right.patch;
}
function getCanonicalModelId(modelId: string): string {
const p = modelId.lastIndexOf("/");
return p !== -1 ? modelId.slice(p + 1) : modelId;
}