From 9e13a2421a13316366cd16578ce5b3ac4c96a0de Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 22 Mar 2026 15:32:06 +0800 Subject: [PATCH 1/4] align gpt-5 model routing with current OpenAI defaults --- lib/capability-policy.ts | 4 +- lib/codex-manager.ts | 55 ++- lib/prompts/codex.ts | 46 +- lib/request/helpers/model-map.ts | 495 +++++++++++++++------ lib/request/request-transformer.ts | 264 ++--------- test/codex-manager-cli.test.ts | 66 +++ test/codex-prompts.test.ts | 31 +- test/codex.test.ts | 165 +------ test/config.test.ts | 20 +- test/model-map.test.ts | 282 +++++------- test/property/transformer.property.test.ts | 8 +- test/request-transformer.test.ts | 185 ++------ 12 files changed, 777 insertions(+), 844 deletions(-) diff --git a/lib/capability-policy.ts b/lib/capability-policy.ts index eb9a7f92..cbd51f09 100644 --- a/lib/capability-policy.ts +++ b/lib/capability-policy.ts @@ -1,4 +1,4 @@ -import { getNormalizedModel } from "./request/helpers/model-map.js"; +import { resolveNormalizedModel } from "./request/helpers/model-map.js"; export interface CapabilityPolicySnapshot { successes: number; @@ -33,7 +33,7 @@ function normalizeModel(model: string | undefined): string | null { const withoutProvider = trimmedInput.includes("/") ? (trimmedInput.split("/").pop() ?? trimmedInput) : trimmedInput; - const mapped = getNormalizedModel(withoutProvider) ?? withoutProvider; + const mapped = resolveNormalizedModel(withoutProvider); const trimmed = mapped.trim().toLowerCase(); if (!trimmed) return null; return trimmed.replace(/-(none|minimal|low|medium|high|xhigh)$/i, ""); diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index b80f1204..6e36b80d 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -39,6 +39,11 @@ import { } from "./forecast.js"; import { createLogger } from "./logger.js"; import { MODEL_FAMILIES, type ModelFamily } from "./prompts/codex.js"; +import { + getModelCapabilities, + getModelProfile, + resolveNormalizedModel, +} from "./request/helpers/model-map.js"; import { fetchCodexQuotaSnapshot, formatQuotaSnapshotLine, @@ -95,6 +100,14 @@ type TokenSuccessWithAccount = TokenSuccess & { type PromptTone = "accent" | "success" | "warning" | "danger" | "muted"; const log = createLogger("codex-manager"); +interface ModelInspection { + requested: string; + normalized: string; + remapped: boolean; + promptFamily: ModelFamily; + capabilities: ReturnType; +} + function stylePromptText(text: string, tone: PromptTone): string { if (!output.isTTY) return text; const ui = getUiRuntimeOptions(); @@ -117,6 +130,30 @@ function stylePromptText(text: string, tone: PromptTone): string { return `${legacyCode}${text}${ANSI.reset}`; } +function inspectRequestedModel(requestedModel: string): ModelInspection { + const normalized = resolveNormalizedModel(requestedModel); + const profile = getModelProfile(normalized); + return { + requested: requestedModel, + normalized, + remapped: requestedModel !== normalized, + promptFamily: profile.promptFamily, + capabilities: getModelCapabilities(normalized), + }; +} + +function formatModelInspection(model: ModelInspection): string { + const route = model.remapped + ? `${model.requested} -> ${model.normalized}` + : model.normalized; + return [ + route, + `prompt family ${model.promptFamily}`, + `tool search ${model.capabilities.toolSearch ? "yes" : "no"}`, + `computer use ${model.capabilities.computerUse ? "yes" : "no"}`, + ].join(" | "); +} + function collapseWhitespace(value: string): string { return value.replace(/\s+/g, " ").trim(); } @@ -1898,6 +1935,7 @@ async function runHealthCheck(options: HealthCheckOptions = {}): Promise { const forceRefresh = options.forceRefresh === true; const liveProbe = options.liveProbe === true; const probeModel = options.model?.trim() || "gpt-5-codex"; + const modelInspection = inspectRequestedModel(probeModel); const display = options.display ?? DEFAULT_DASHBOARD_DISPLAY_SETTINGS; const quotaCache = liveProbe ? await loadQuotaCache() : null; const workingQuotaCache = quotaCache ? cloneQuotaCacheData(quotaCache) : null; @@ -1926,6 +1964,7 @@ async function runHealthCheck(options: HealthCheckOptions = {}): Promise { : `Checking ${storage.accounts.length} account(s) with quick check${liveProbe ? " + live check" : ""}...`, "accent", )); + console.log(stylePromptText(`Model probe: ${formatModelInspection(modelInspection)}`, "muted")); for (let i = 0; i < storage.accounts.length; i += 1) { const account = storage.accounts[i]; if (!account) continue; @@ -1954,7 +1993,7 @@ async function runHealthCheck(options: HealthCheckOptions = {}): Promise { const snapshot = await fetchCodexQuotaSnapshot({ accountId: probeAccountId, accessToken: currentAccessToken, - model: probeModel, + model: modelInspection.normalized, }); if (workingQuotaCache) { quotaCacheChanged = @@ -2045,7 +2084,7 @@ async function runHealthCheck(options: HealthCheckOptions = {}): Promise { const snapshot = await fetchCodexQuotaSnapshot({ accountId: probeAccountId, accessToken: result.access, - model: probeModel, + model: modelInspection.normalized, }); if (workingQuotaCache) { quotaCacheChanged = @@ -2767,6 +2806,8 @@ async function runReport(args: string[]): Promise { return 1; } const options = parsedArgs.options; + const requestedModel = options.model?.trim() || "gpt-5-codex"; + const modelInspection = inspectRequestedModel(requestedModel); setStoragePath(null); const storagePath = getStoragePath(); @@ -2802,7 +2843,7 @@ async function runReport(args: string[]): Promise { const liveQuota = await fetchCodexQuotaSnapshot({ accountId, accessToken: refreshResult.access, - model: options.model, + model: modelInspection.normalized, }); liveQuotaByIndex.set(i, liveQuota); } catch (error) { @@ -2848,6 +2889,13 @@ async function runReport(args: string[]): Promise { generatedAt: new Date(now).toISOString(), storagePath, model: options.model, + modelSelection: { + requested: modelInspection.requested, + normalized: modelInspection.normalized, + remapped: modelInspection.remapped, + promptFamily: modelInspection.promptFamily, + capabilities: modelInspection.capabilities, + }, liveProbe: options.live, accounts: { total: accountCount, @@ -2878,6 +2926,7 @@ async function runReport(args: string[]): Promise { console.log(`Report generated at ${report.generatedAt}`); console.log(`Storage: ${report.storagePath}`); + console.log(`Model: ${formatModelInspection(modelInspection)}`); console.log( `Accounts: ${report.accounts.total} total (${report.accounts.enabled} enabled, ${report.accounts.disabled} disabled, ${report.accounts.coolingDown} cooling, ${report.accounts.rateLimited} rate-limited)`, ); diff --git a/lib/prompts/codex.ts b/lib/prompts/codex.ts index 434d0ad2..b21eab13 100644 --- a/lib/prompts/codex.ts +++ b/lib/prompts/codex.ts @@ -4,6 +4,7 @@ import { fileURLToPath } from "node:url"; import type { CacheMetadata, GitHubRelease } from "../types.js"; import { logWarn, logError, logDebug } from "../logger.js"; import { getCodexCacheDir } from "../runtime-paths.js"; +import { getModelProfile, type PromptModelFamily } from "../request/helpers/model-map.js"; const GITHUB_API_RELEASES = "https://api.github.com/repos/openai/codex/releases/latest"; @@ -44,12 +45,7 @@ function setCacheEntry(key: string, value: { content: string; timestamp: number * Model family type for prompt selection * Maps to different system prompts in the Codex CLI */ -export type ModelFamily = - | "gpt-5-codex" - | "codex-max" - | "codex" - | "gpt-5.2" - | "gpt-5.1"; +export type ModelFamily = PromptModelFamily; /** * All supported model families @@ -87,38 +83,16 @@ const CACHE_FILES: Record = { }; /** - * Determine the model family based on the normalized model name - * @param normalizedModel - The normalized model name (e.g., "gpt-5-codex", "gpt-5.1-codex-max", "gpt-5.2", "gpt-5.1") + * Determine the prompt family based on the effective model name. + * + * GPT-5.4-era general-purpose models intentionally stay on the GPT-5.2 prompt + * family until upstream Codex releases a newer general prompt file. + * + * @param normalizedModel - The normalized model name (e.g., "gpt-5-codex", "gpt-5.4", "gpt-5-mini") * @returns The model family for prompt selection */ export function getModelFamily(normalizedModel: string): ModelFamily { - if (normalizedModel.includes("codex-max")) { - return "codex-max"; - } - if ( - normalizedModel.includes("gpt-5-codex") || - normalizedModel.includes("gpt 5 codex") || - normalizedModel.includes("gpt-5.3-codex-spark") || - normalizedModel.includes("gpt 5.3 codex spark") || - normalizedModel.includes("gpt-5.3-codex") || - normalizedModel.includes("gpt 5.3 codex") || - normalizedModel.includes("gpt-5.2-codex") || - normalizedModel.includes("gpt 5.2 codex") || - normalizedModel.includes("gpt-5.1-codex") || - normalizedModel.includes("gpt 5.1 codex") - ) { - return "gpt-5-codex"; - } - if ( - normalizedModel.includes("codex") || - normalizedModel.startsWith("codex-") - ) { - return "codex"; - } - if (normalizedModel.includes("gpt-5.2")) { - return "gpt-5.2"; - } - return "gpt-5.1"; + return getModelProfile(normalizedModel).promptFamily; } async function readFileOrNull(path: string): Promise { @@ -396,7 +370,7 @@ function refreshInstructionsInBackground( * Prewarm instruction caches for the provided models/families. */ export function prewarmCodexInstructions(models: string[] = []): void { - const candidates = models.length > 0 ? models : ["gpt-5-codex", "gpt-5.2", "gpt-5.1"]; + const candidates = models.length > 0 ? models : ["gpt-5-codex", "gpt-5.4", "gpt-5.2"]; for (const model of candidates) { void getCodexInstructions(model).catch((error) => { logDebug("Codex instruction prewarm failed", { diff --git a/lib/request/helpers/model-map.ts b/lib/request/helpers/model-map.ts index e9cd9d5b..20a6832d 100644 --- a/lib/request/helpers/model-map.ts +++ b/lib/request/helpers/model-map.ts @@ -1,148 +1,395 @@ /** * Model Configuration Map * - * Maps model config IDs to their normalized API model names. - * Only includes exact config IDs that the host runtime will pass to the plugin. + * Maps host/runtime model identifiers to the effective model name we send to the + * OpenAI Responses API. The catalog also carries prompt-family, reasoning, and + * tool-surface metadata so routing logic stays consistent across the request + * transformer, prompt selection, and CLI diagnostics. */ +export type ModelReasoningEffort = + | "none" + | "minimal" + | "low" + | "medium" + | "high" + | "xhigh"; + +export type PromptModelFamily = + | "gpt-5-codex" + | "codex-max" + | "codex" + | "gpt-5.2" + | "gpt-5.1"; + +export interface ModelCapabilities { + toolSearch: boolean; + computerUse: boolean; +} + +export interface ModelProfile { + normalizedModel: string; + promptFamily: PromptModelFamily; + defaultReasoningEffort: ModelReasoningEffort; + supportedReasoningEfforts: readonly ModelReasoningEffort[]; + capabilities: ModelCapabilities; +} + +const REASONING_VARIANTS = [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", +] as const satisfies readonly ModelReasoningEffort[]; + +const TOOL_CAPABILITIES = { + full: { + toolSearch: true, + computerUse: true, + }, + computerOnly: { + toolSearch: false, + computerUse: true, + }, + basic: { + toolSearch: false, + computerUse: false, + }, +} as const satisfies Record; + +export const DEFAULT_MODEL = "gpt-5.4"; + /** - * Map of config model IDs to normalized API model names + * Effective model profiles keyed by canonical model name. * - * Key: The model ID as specified in runtime model config - * Value: The normalized model name to send to the API + * Prompt families intentionally stay on the latest prompt files currently + * shipped by upstream Codex CLI. GPT-5.4 era general-purpose models still use + * the GPT-5.2 prompt family because `gpt_5_4_prompt.md` is not present in the + * latest upstream release. */ -export const MODEL_MAP: Record = { - // ============================================================================ - // GPT-5 Codex Models (canonical stable family) - // ============================================================================ - "gpt-5-codex": "gpt-5-codex", - "gpt-5-codex-none": "gpt-5-codex", - "gpt-5-codex-minimal": "gpt-5-codex", - "gpt-5-codex-low": "gpt-5-codex", - "gpt-5-codex-medium": "gpt-5-codex", - "gpt-5-codex-high": "gpt-5-codex", - "gpt-5-codex-xhigh": "gpt-5-codex", - - // ============================================================================ - // GPT-5.3 Codex Spark Models (legacy aliases) - // ============================================================================ - "gpt-5.3-codex-spark": "gpt-5-codex", - "gpt-5.3-codex-spark-low": "gpt-5-codex", - "gpt-5.3-codex-spark-medium": "gpt-5-codex", - "gpt-5.3-codex-spark-high": "gpt-5-codex", - "gpt-5.3-codex-spark-xhigh": "gpt-5-codex", - - // ============================================================================ - // GPT-5.3 Codex Models (legacy aliases) - // ============================================================================ - "gpt-5.3-codex": "gpt-5-codex", - "gpt-5.3-codex-low": "gpt-5-codex", - "gpt-5.3-codex-medium": "gpt-5-codex", - "gpt-5.3-codex-high": "gpt-5-codex", - "gpt-5.3-codex-xhigh": "gpt-5-codex", - - // ============================================================================ - // GPT-5.1 Codex Models (legacy aliases) - // ============================================================================ - "gpt-5.1-codex": "gpt-5-codex", - "gpt-5.1-codex-low": "gpt-5-codex", - "gpt-5.1-codex-medium": "gpt-5-codex", - "gpt-5.1-codex-high": "gpt-5-codex", - - // ============================================================================ - // GPT-5.1 Codex Max Models - // ============================================================================ - "gpt-5.1-codex-max": "gpt-5.1-codex-max", - "gpt-5.1-codex-max-low": "gpt-5.1-codex-max", - "gpt-5.1-codex-max-medium": "gpt-5.1-codex-max", - "gpt-5.1-codex-max-high": "gpt-5.1-codex-max", - "gpt-5.1-codex-max-xhigh": "gpt-5.1-codex-max", - - // ============================================================================ - // GPT-5.2 Models (supports none/low/medium/high/xhigh per OpenAI API docs) - // ============================================================================ - "gpt-5.2": "gpt-5.2", - "gpt-5.2-none": "gpt-5.2", - "gpt-5.2-low": "gpt-5.2", - "gpt-5.2-medium": "gpt-5.2", - "gpt-5.2-high": "gpt-5.2", - "gpt-5.2-xhigh": "gpt-5.2", - - // ============================================================================ - // GPT-5.2 Codex Models (legacy aliases) - // ============================================================================ - "gpt-5.2-codex": "gpt-5-codex", - "gpt-5.2-codex-low": "gpt-5-codex", - "gpt-5.2-codex-medium": "gpt-5-codex", - "gpt-5.2-codex-high": "gpt-5-codex", - "gpt-5.2-codex-xhigh": "gpt-5-codex", - - // ============================================================================ - // GPT-5.1 Codex Mini Models - // ============================================================================ - "gpt-5.1-codex-mini": "gpt-5.1-codex-mini", - "gpt-5.1-codex-mini-medium": "gpt-5.1-codex-mini", - "gpt-5.1-codex-mini-high": "gpt-5.1-codex-mini", - - // ============================================================================ - // GPT-5.1 General Purpose Models (supports none/low/medium/high per OpenAI API docs) - // ============================================================================ - "gpt-5.1": "gpt-5.1", - "gpt-5.1-none": "gpt-5.1", - "gpt-5.1-low": "gpt-5.1", - "gpt-5.1-medium": "gpt-5.1", - "gpt-5.1-high": "gpt-5.1", - "gpt-5.1-chat-latest": "gpt-5.1", - - // ============================================================================ - // GPT-5 Codex alias (legacy/case variants) - // ============================================================================ - "gpt_5_codex": "gpt-5-codex", - - // ============================================================================ - // GPT-5 Codex Mini Models (LEGACY - maps to gpt-5.1-codex-mini) - // ============================================================================ - "codex-mini-latest": "gpt-5.1-codex-mini", - "gpt-5-codex-mini": "gpt-5.1-codex-mini", - "gpt-5-codex-mini-medium": "gpt-5.1-codex-mini", - "gpt-5-codex-mini-high": "gpt-5.1-codex-mini", - - // ============================================================================ - // GPT-5 General Purpose Models (LEGACY - maps to gpt-5.1 as gpt-5 is being phased out) - // ============================================================================ - "gpt-5": "gpt-5.1", - "gpt-5-mini": "gpt-5.1", - "gpt-5-nano": "gpt-5.1", -}; +export const MODEL_PROFILES: Record = { + "gpt-5-codex": { + normalizedModel: "gpt-5-codex", + promptFamily: "gpt-5-codex", + defaultReasoningEffort: "high", + supportedReasoningEfforts: ["low", "medium", "high", "xhigh"], + capabilities: TOOL_CAPABILITIES.basic, + }, + "gpt-5.1-codex-max": { + normalizedModel: "gpt-5.1-codex-max", + promptFamily: "codex-max", + defaultReasoningEffort: "high", + supportedReasoningEfforts: ["medium", "high", "xhigh"], + capabilities: TOOL_CAPABILITIES.basic, + }, + "gpt-5.1-codex-mini": { + normalizedModel: "gpt-5.1-codex-mini", + promptFamily: "gpt-5-codex", + defaultReasoningEffort: "medium", + supportedReasoningEfforts: ["medium", "high"], + capabilities: TOOL_CAPABILITIES.basic, + }, + "gpt-5.4": { + normalizedModel: "gpt-5.4", + promptFamily: "gpt-5.2", + defaultReasoningEffort: "none", + supportedReasoningEfforts: ["none", "low", "medium", "high", "xhigh"], + capabilities: TOOL_CAPABILITIES.full, + }, + "gpt-5.4-pro": { + normalizedModel: "gpt-5.4-pro", + promptFamily: "gpt-5.2", + defaultReasoningEffort: "high", + supportedReasoningEfforts: ["medium", "high", "xhigh"], + capabilities: TOOL_CAPABILITIES.computerOnly, + }, + "gpt-5.2-pro": { + normalizedModel: "gpt-5.2-pro", + promptFamily: "gpt-5.2", + defaultReasoningEffort: "high", + supportedReasoningEfforts: ["medium", "high", "xhigh"], + capabilities: TOOL_CAPABILITIES.basic, + }, + "gpt-5-pro": { + normalizedModel: "gpt-5-pro", + promptFamily: "gpt-5.2", + defaultReasoningEffort: "high", + supportedReasoningEfforts: ["high"], + capabilities: TOOL_CAPABILITIES.basic, + }, + "gpt-5.2": { + normalizedModel: "gpt-5.2", + promptFamily: "gpt-5.2", + defaultReasoningEffort: "none", + supportedReasoningEfforts: ["none", "low", "medium", "high", "xhigh"], + capabilities: TOOL_CAPABILITIES.basic, + }, + "gpt-5.1": { + normalizedModel: "gpt-5.1", + promptFamily: "gpt-5.1", + defaultReasoningEffort: "none", + supportedReasoningEfforts: ["none", "low", "medium", "high"], + capabilities: TOOL_CAPABILITIES.basic, + }, + "gpt-5": { + normalizedModel: "gpt-5", + promptFamily: "gpt-5.2", + defaultReasoningEffort: "medium", + supportedReasoningEfforts: ["minimal", "low", "medium", "high"], + capabilities: TOOL_CAPABILITIES.basic, + }, + "gpt-5-mini": { + normalizedModel: "gpt-5-mini", + promptFamily: "gpt-5.2", + defaultReasoningEffort: "medium", + supportedReasoningEfforts: ["medium"], + capabilities: TOOL_CAPABILITIES.basic, + }, + "gpt-5-nano": { + normalizedModel: "gpt-5-nano", + promptFamily: "gpt-5.2", + defaultReasoningEffort: "medium", + supportedReasoningEfforts: ["medium"], + capabilities: TOOL_CAPABILITIES.basic, + }, +} as const; + +const MODEL_MAP: Record = {}; + +function addAlias(alias: string, normalizedModel: string): void { + MODEL_MAP[alias] = normalizedModel; +} + +function addReasoningAliases(alias: string, normalizedModel: string): void { + addAlias(alias, normalizedModel); + for (const variant of REASONING_VARIANTS) { + addAlias(`${alias}-${variant}`, normalizedModel); + } +} + +function addGeneralAliases(): void { + addReasoningAliases("gpt-5.4", "gpt-5.4"); + addReasoningAliases("gpt-5.4-pro", "gpt-5.4-pro"); + addReasoningAliases("gpt-5.2-pro", "gpt-5.2-pro"); + addReasoningAliases("gpt-5-pro", "gpt-5-pro"); + addReasoningAliases("gpt-5.2", "gpt-5.2"); + addReasoningAliases("gpt-5.1", "gpt-5.1"); + addReasoningAliases("gpt-5", "gpt-5"); + addReasoningAliases("gpt-5-mini", "gpt-5-mini"); + addReasoningAliases("gpt-5-nano", "gpt-5-nano"); + + addAlias("gpt-5.1-chat-latest", "gpt-5.1"); + addAlias("gpt-5-chat-latest", "gpt-5"); + addReasoningAliases("gpt-5.4-mini", "gpt-5-mini"); + addReasoningAliases("gpt-5.4-nano", "gpt-5-nano"); +} + +function addCodexAliases(): void { + addReasoningAliases("gpt-5-codex", "gpt-5-codex"); + addReasoningAliases("gpt-5.3-codex-spark", "gpt-5-codex"); + addReasoningAliases("gpt-5.3-codex", "gpt-5-codex"); + addReasoningAliases("gpt-5.2-codex", "gpt-5-codex"); + addReasoningAliases("gpt-5.1-codex", "gpt-5-codex"); + addAlias("gpt_5_codex", "gpt-5-codex"); + + addReasoningAliases("gpt-5.1-codex-max", "gpt-5.1-codex-max"); + + addAlias("codex-mini-latest", "gpt-5.1-codex-mini"); + addReasoningAliases("gpt-5-codex-mini", "gpt-5.1-codex-mini"); + addReasoningAliases("gpt-5.1-codex-mini", "gpt-5.1-codex-mini"); +} + +addCodexAliases(); +addGeneralAliases(); + +export { MODEL_MAP }; + +function stripProviderPrefix(modelId: string): string { + return modelId.includes("/") ? (modelId.split("/").pop() ?? modelId) : modelId; +} + +function lookupMappedModel(modelId: string): string | undefined { + if (Object.hasOwn(MODEL_MAP, modelId)) { + return MODEL_MAP[modelId]; + } + + const lowerModelId = modelId.toLowerCase(); + const match = Object.keys(MODEL_MAP).find( + (key) => key.toLowerCase() === lowerModelId, + ); + + return match ? MODEL_MAP[match] : undefined; +} /** - * Get normalized model name from config ID + * Get normalized model name from a known config/runtime identifier. * - * @param modelId - Model ID from config (e.g., "gpt-5.1-codex-low") - * @returns Normalized model name (e.g., "gpt-5.1-codex") or undefined if not found + * This does exact/alias lookup only. Use `resolveNormalizedModel()` when you + * want GPT-5 family fallback behavior for unknown-but-similar names. */ export function getNormalizedModel(modelId: string): string | undefined { try { - if (Object.hasOwn(MODEL_MAP, modelId)) { - return MODEL_MAP[modelId]; - } - - const lowerModelId = modelId.toLowerCase(); - const match = Object.keys(MODEL_MAP).find( - (key) => key.toLowerCase() === lowerModelId, - ); - - return match ? MODEL_MAP[match] : undefined; + const stripped = stripProviderPrefix(modelId.trim()); + if (!stripped) return undefined; + return lookupMappedModel(stripped); } catch { return undefined; } } /** - * Check if a model ID is in the model map + * Resolve a model identifier to the effective API model. + * + * This expands exact alias lookup with GPT-5 family fallback rules so the + * plugin never silently downgrades modern GPT-5 requests to GPT-5.1-era + * routing. + */ +export function resolveNormalizedModel(model: string | undefined): string { + if (!model) return DEFAULT_MODEL; + + const modelId = stripProviderPrefix(model).trim(); + if (!modelId) return DEFAULT_MODEL; + + const mappedModel = lookupMappedModel(modelId); + if (mappedModel) { + return mappedModel; + } + + const normalized = modelId.toLowerCase(); + + if ( + normalized.includes("gpt-5.3-codex-spark") || + normalized.includes("gpt 5.3 codex spark") + ) { + return "gpt-5-codex"; + } + if ( + normalized.includes("gpt-5.3-codex") || + normalized.includes("gpt 5.3 codex") + ) { + return "gpt-5-codex"; + } + if ( + normalized.includes("gpt-5.2-codex") || + normalized.includes("gpt 5.2 codex") + ) { + return "gpt-5-codex"; + } + if ( + normalized.includes("gpt-5.1-codex-max") || + normalized.includes("gpt 5.1 codex max") + ) { + return "gpt-5.1-codex-max"; + } + if ( + normalized.includes("gpt-5.1-codex-mini") || + normalized.includes("gpt 5.1 codex mini") || + normalized.includes("codex-mini-latest") || + normalized.includes("gpt-5-codex-mini") || + normalized.includes("gpt 5 codex mini") + ) { + return "gpt-5.1-codex-mini"; + } + if ( + normalized.includes("gpt-5-codex") || + normalized.includes("gpt 5 codex") || + normalized.includes("gpt-5.1-codex") || + normalized.includes("gpt 5.1 codex") || + normalized.includes("codex") + ) { + return "gpt-5-codex"; + } + if ( + normalized.includes("gpt-5.4-pro") || + normalized.includes("gpt 5.4 pro") + ) { + return "gpt-5.4-pro"; + } + if ( + normalized.includes("gpt-5.2-pro") || + normalized.includes("gpt 5.2 pro") + ) { + return "gpt-5.2-pro"; + } + if ( + normalized.includes("gpt-5-pro") || + normalized.includes("gpt 5 pro") + ) { + return "gpt-5-pro"; + } + if ( + normalized.includes("gpt-5.4-mini") || + normalized.includes("gpt 5.4 mini") || + normalized.includes("gpt-5-mini") || + normalized.includes("gpt 5 mini") + ) { + return "gpt-5-mini"; + } + if ( + normalized.includes("gpt-5.4-nano") || + normalized.includes("gpt 5.4 nano") || + normalized.includes("gpt-5-nano") || + normalized.includes("gpt 5 nano") + ) { + return "gpt-5-nano"; + } + if ( + normalized.includes("gpt-5.4") || + normalized.includes("gpt 5.4") + ) { + return "gpt-5.4"; + } + if ( + normalized.includes("gpt-5.2") || + normalized.includes("gpt 5.2") + ) { + return "gpt-5.2"; + } + if ( + normalized.includes("gpt-5.1") || + normalized.includes("gpt 5.1") + ) { + return "gpt-5.1"; + } + if (normalized === "gpt-5" || normalized.includes("gpt-5") || normalized.includes("gpt 5")) { + return "gpt-5.4"; + } + + return DEFAULT_MODEL; +} + +/** + * Resolve the effective model profile for a requested model string. + */ +export function getModelProfile(model: string | undefined): ModelProfile { + const normalizedModel = resolveNormalizedModel(model); + const profile = MODEL_PROFILES[normalizedModel]; + if (profile) { + return profile; + } + + const fallbackProfile = MODEL_PROFILES[DEFAULT_MODEL]; + if (fallbackProfile) { + return fallbackProfile; + } + + throw new Error(`Default model profile is missing for ${DEFAULT_MODEL}`); +} + +/** + * Expose current tool-surface metadata for diagnostics and capability checks. + */ +export function getModelCapabilities(model: string | undefined): ModelCapabilities { + return getModelProfile(model).capabilities; +} + +/** + * Check if a model ID is in the explicit model map. * - * @param modelId - Model ID to check - * @returns True if model is in the map + * This only returns `true` for exact known aliases. Use + * `resolveNormalizedModel()` if you want the fallback behavior. */ export function isKnownModel(modelId: string): boolean { return getNormalizedModel(modelId) !== undefined; diff --git a/lib/request/request-transformer.ts b/lib/request/request-transformer.ts index 33a4715e..6c002476 100644 --- a/lib/request/request-transformer.ts +++ b/lib/request/request-transformer.ts @@ -2,7 +2,11 @@ import { logDebug, logWarn } from "../logger.js"; import { TOOL_REMAP_MESSAGE } from "../prompts/codex.js"; import { CODEX_HOST_BRIDGE } from "../prompts/codex-host-bridge.js"; import { getHostCodexPrompt } from "../prompts/host-codex-prompt.js"; -import { getNormalizedModel } from "./helpers/model-map.js"; +import { + getModelProfile, + resolveNormalizedModel, + type ModelReasoningEffort, +} from "./helpers/model-map.js"; import { filterHostSystemPromptsWithCachedPrompt, normalizeOrphanedToolOutputs, @@ -41,117 +45,14 @@ export { /** * Normalize model name to Codex-supported variants * - * Uses explicit model map for known models, with fallback pattern matching - * for unknown/custom model names. + * Uses the shared model catalog so request routing, prompt selection, and CLI + * diagnostics all agree on the same effective model. * * @param model - Original model name (e.g., "gpt-5-codex-low", "openai/gpt-5-codex") - * @returns Normalized model name (e.g., "gpt-5-codex", "gpt-5.1-codex-max") + * @returns Normalized model name (e.g., "gpt-5-codex", "gpt-5.4", "gpt-5.1-codex-max") */ export function normalizeModel(model: string | undefined): string { - if (!model) return "gpt-5.1"; - - // Strip provider prefix if present (e.g., "openai/gpt-5-codex" → "gpt-5-codex") - const modelId = model.includes("/") ? model.split("/").pop() ?? model : model; - - // Try explicit model map first (handles all known model variants) - const mappedModel = getNormalizedModel(modelId); - if (mappedModel) { - return mappedModel; - } - - // Fallback: Pattern-based matching for unknown/custom model names - // This preserves backwards compatibility with old verbose names - // like "GPT 5 Codex Low (ChatGPT Subscription)" - const normalized = modelId.toLowerCase(); - - // Priority order for pattern matching (most specific first): - // 1. GPT-5.3 Codex Spark (legacy alias -> canonical gpt-5-codex) - if ( - normalized.includes("gpt-5.3-codex-spark") || - normalized.includes("gpt 5.3 codex spark") - ) { - return "gpt-5-codex"; - } - - // 2. GPT-5.3 Codex (legacy alias -> canonical gpt-5-codex) - if ( - normalized.includes("gpt-5.3-codex") || - normalized.includes("gpt 5.3 codex") - ) { - return "gpt-5-codex"; - } - - // 3. GPT-5.2 Codex (legacy alias -> canonical gpt-5-codex) - if ( - normalized.includes("gpt-5.2-codex") || - normalized.includes("gpt 5.2 codex") - ) { - return "gpt-5-codex"; - } - - // 4. GPT-5.2 (general purpose) - if (normalized.includes("gpt-5.2") || normalized.includes("gpt 5.2")) { - return "gpt-5.2"; - } - - // 5. GPT-5.1 Codex Max - if ( - normalized.includes("gpt-5.1-codex-max") || - normalized.includes("gpt 5.1 codex max") - ) { - return "gpt-5.1-codex-max"; - } - - // 6. GPT-5.1 Codex Mini - if ( - normalized.includes("gpt-5.1-codex-mini") || - normalized.includes("gpt 5.1 codex mini") - ) { - return "gpt-5.1-codex-mini"; - } - - // 7. Legacy Codex Mini - if ( - normalized.includes("codex-mini-latest") || - normalized.includes("gpt-5-codex-mini") || - normalized.includes("gpt 5 codex mini") - ) { - return "gpt-5.1-codex-mini"; - } - - // 8. GPT-5 Codex canonical + GPT-5.1 Codex legacy alias - if ( - normalized.includes("gpt-5-codex") || - normalized.includes("gpt 5 codex") - ) { - return "gpt-5-codex"; - } - - // 9. GPT-5.1 Codex (legacy alias) - if ( - normalized.includes("gpt-5.1-codex") || - normalized.includes("gpt 5.1 codex") - ) { - return "gpt-5-codex"; - } - - // 10. GPT-5.1 (general-purpose) - if (normalized.includes("gpt-5.1") || normalized.includes("gpt 5.1")) { - return "gpt-5.1"; - } - - // 11. GPT-5 Codex family (any other variant with "codex") - if (normalized.includes("codex")) { - return "gpt-5-codex"; - } - - // 12. GPT-5 family (any variant) - default to 5.1 - if (normalized.includes("gpt-5") || normalized.includes("gpt 5")) { - return "gpt-5.1"; - } - - // Default fallback - return "gpt-5.1"; + return resolveNormalizedModel(model); } /** @@ -399,114 +300,14 @@ export function getReasoningConfig( modelName: string | undefined, userConfig: ConfigOptions = {}, ): ReasoningConfig { - const normalizedName = modelName?.toLowerCase() ?? ""; - - // Canonical GPT-5 Codex (stable) defaults to high and does not support "none". - const isGpt5Codex = - normalizedName.includes("gpt-5-codex") || - normalizedName.includes("gpt 5 codex"); - - // Legacy GPT-5.3 Codex alias behavior (supports xhigh, but not "none") - const isGpt53Codex = - normalizedName.includes("gpt-5.3-codex") || - normalizedName.includes("gpt 5.3 codex"); - - // Legacy GPT-5.2 Codex alias behavior (supports xhigh, but not "none") - const isGpt52Codex = - normalizedName.includes("gpt-5.2-codex") || - normalizedName.includes("gpt 5.2 codex"); - - // GPT-5.2 general purpose (not codex variant) - const isGpt52General = - (normalizedName.includes("gpt-5.2") || normalizedName.includes("gpt 5.2")) && - !isGpt52Codex; - const isCodexMax = - normalizedName.includes("codex-max") || - normalizedName.includes("codex max"); - const isCodexMini = - normalizedName.includes("codex-mini") || - normalizedName.includes("codex mini") || - normalizedName.includes("codex_mini") || - normalizedName.includes("codex-mini-latest"); - const isCodex = normalizedName.includes("codex") && !isCodexMini; - const isLightweight = - !isCodexMini && - (normalizedName.includes("nano") || - normalizedName.includes("mini")); - - // GPT-5.1 general purpose (not codex variants) - supports "none" per OpenAI API docs - const isGpt51General = - ( - normalizedName.includes("gpt-5.1") || - normalizedName.includes("gpt 5.1") || - normalizedName === "gpt-5" || - normalizedName.startsWith("gpt-5-") - ) && - !isCodex && - !isGpt52General && - !isCodexMax && - !isCodexMini; - - // GPT-5.2 general, legacy GPT-5.2/5.3 Codex aliases, and Codex Max support xhigh reasoning - const supportsXhigh = - isGpt52General || isGpt53Codex || isGpt52Codex || isCodexMax; - - // GPT 5.1 general and GPT 5.2 general support "none" reasoning per: - // - OpenAI API docs: "gpt-5.1 defaults to none, supports: none, low, medium, high" - // - Codex CLI: ReasoningEffort enum includes None variant (codex-rs/protocol/src/openai_models.rs) - // - Codex CLI: docs/config.md lists "none" as valid for model_reasoning_effort - // - gpt-5.2 (being newer) also supports: none, low, medium, high, xhigh - // - Codex models (including GPT-5 Codex and legacy GPT-5.3/5.2 Codex aliases) do NOT support "none" - const supportsNone = isGpt52General || isGpt51General; - - // Default based on model type (Codex CLI defaults + plugin opinionated tuning) - // Note: OpenAI docs say gpt-5.1 defaults to "none", but we default to "medium" - // for better coding assistance unless user explicitly requests "none". - // - Canonical GPT-5 Codex defaults to high in stable Codex. - // - Legacy GPT-5.3/5.2 Codex aliases default to xhigh for backward compatibility. - const defaultEffort: ReasoningConfig["effort"] = isCodexMini - ? "medium" - : isGpt5Codex - ? "high" - : isGpt53Codex || isGpt52Codex - ? "xhigh" - : supportsXhigh - ? "high" - : isLightweight - ? "minimal" - : "medium"; - - // Get user-requested effort - let effort = userConfig.reasoningEffort || defaultEffort; - - if (isCodexMini) { - if (effort === "minimal" || effort === "low" || effort === "none") { - effort = "medium"; - } - if (effort === "xhigh") { - effort = "high"; - } - if (effort !== "high" && effort !== "medium") { - effort = "medium"; - } - } - - // For models that don't support xhigh, downgrade to high - if (!supportsXhigh && effort === "xhigh") { - effort = "high"; - } - - // For models that don't support "none", upgrade to "low" - // (Codex models don't support "none" - only GPT-5.1 and GPT-5.2 general purpose do) - if (!supportsNone && effort === "none") { - effort = "low"; - } - - // Normalize "minimal" to "low" for Codex families - // Codex CLI presets are low/medium/high (or xhigh for Codex Max / GPT-5.3/5.2 Codex) - if (isCodex && effort === "minimal") { - effort = "low"; - } + const profile = getModelProfile(modelName); + const defaultEffort = profile.defaultReasoningEffort; + const requestedEffort = userConfig.reasoningEffort ?? defaultEffort; + const effort = coerceReasoningEffort( + requestedEffort, + profile.supportedReasoningEfforts, + defaultEffort, + ); const summary = sanitizeReasoningSummary(userConfig.reasoningSummary); @@ -516,6 +317,37 @@ export function getReasoningConfig( }; } +const REASONING_FALLBACKS: Record< + ModelReasoningEffort, + readonly ModelReasoningEffort[] +> = { + none: ["none", "low", "minimal", "medium", "high", "xhigh"], + minimal: ["minimal", "low", "none", "medium", "high", "xhigh"], + low: ["low", "minimal", "none", "medium", "high", "xhigh"], + medium: ["medium", "low", "high", "minimal", "none", "xhigh"], + high: ["high", "medium", "xhigh", "low", "minimal", "none"], + xhigh: ["xhigh", "high", "medium", "low", "minimal", "none"], +} as const; + +function coerceReasoningEffort( + effort: ModelReasoningEffort, + supportedEfforts: readonly ModelReasoningEffort[], + defaultEffort: ModelReasoningEffort, +): ReasoningConfig["effort"] { + if (supportedEfforts.includes(effort)) { + return effort; + } + + const fallbackOrder = REASONING_FALLBACKS[effort] ?? [defaultEffort]; + for (const candidate of fallbackOrder) { + if (supportedEfforts.includes(candidate)) { + return candidate; + } + } + + return defaultEffort; +} + function sanitizeReasoningSummary( summary: ConfigOptions["reasoningSummary"], ): SupportedReasoningSummary { diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 930cf8fb..613d6c93 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -5702,11 +5702,77 @@ describe("codex manager cli commands", () => { const payload = JSON.parse(String(logSpy.mock.calls[0]?.[0])) as { command: string; accounts: { total: number; enabled: number; disabled: number }; + modelSelection: { + requested: string; + normalized: string; + remapped: boolean; + promptFamily: string; + capabilities: { toolSearch: boolean; computerUse: boolean }; + }; }; expect(payload.command).toBe("report"); expect(payload.accounts.total).toBe(2); expect(payload.accounts.enabled).toBe(1); expect(payload.accounts.disabled).toBe(1); + expect(payload.modelSelection).toEqual({ + requested: "gpt-5-codex", + normalized: "gpt-5-codex", + remapped: false, + promptFamily: "gpt-5-codex", + capabilities: { + toolSearch: false, + computerUse: false, + }, + }); + }); + + it("reports normalized model routing and capabilities for remapped report probes", async () => { + const now = Date.now(); + loadAccountsMock.mockResolvedValueOnce({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "real@example.net", + refreshToken: "refresh-a", + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], + }); + + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli([ + "auth", + "report", + "--json", + "--model", + "gpt-5.4-mini", + ]); + + expect(exitCode).toBe(0); + const payload = JSON.parse(String(logSpy.mock.calls[0]?.[0])) as { + modelSelection: { + requested: string; + normalized: string; + remapped: boolean; + promptFamily: string; + capabilities: { toolSearch: boolean; computerUse: boolean }; + }; + }; + expect(payload.modelSelection).toEqual({ + requested: "gpt-5.4-mini", + normalized: "gpt-5-mini", + remapped: true, + promptFamily: "gpt-5.2", + capabilities: { + toolSearch: false, + computerUse: false, + }, + }); }); it("drives interactive settings hub across sections and persists dashboard/backend changes", async () => { diff --git a/test/codex-prompts.test.ts b/test/codex-prompts.test.ts index 17131b8f..60a190e4 100644 --- a/test/codex-prompts.test.ts +++ b/test/codex-prompts.test.ts @@ -86,9 +86,15 @@ describe("Codex Prompts Module", () => { expect(getModelFamily("gpt-5.1-codex-mini-low")).toBe("gpt-5-codex"); }); + it("should route GPT-5.4 era general models through the latest available general prompt family", () => { + expect(getModelFamily("gpt-5.4")).toBe("gpt-5.2"); + expect(getModelFamily("gpt-5.4-pro")).toBe("gpt-5.2"); + expect(getModelFamily("gpt-5-mini")).toBe("gpt-5.2"); + }); + it("should detect models starting with codex-", () => { - expect(getModelFamily("codex-mini")).toBe("codex"); - expect(getModelFamily("codex-latest")).toBe("codex"); + expect(getModelFamily("codex-mini")).toBe("gpt-5-codex"); + expect(getModelFamily("codex-latest")).toBe("gpt-5-codex"); }); }); @@ -457,6 +463,27 @@ describe("Codex Prompts Module", () => { ); expect(rawGitHubCall?.[0]).toContain("gpt_5_codex_prompt.md"); }); + + it("should map gpt-5.4 prompts to the latest available general prompt file", async () => { + mockedReadFile.mockRejectedValue(new Error("ENOENT")); + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ tag_name: "rust-v0.116.0" }), + text: () => Promise.resolve("content"), + headers: { get: () => "etag" }, + }); + mockedMkdir.mockResolvedValue(undefined); + mockedWriteFile.mockResolvedValue(undefined); + + await getCodexInstructions("gpt-5.4"); + const fetchCalls = mockFetch.mock.calls; + const rawGitHubCall = fetchCalls.find( + (call) => + typeof call[0] === "string" && + call[0].includes("raw.githubusercontent.com"), + ); + expect(rawGitHubCall?.[0]).toContain("gpt_5_2_prompt.md"); + }); }); }); }); diff --git a/test/codex.test.ts b/test/codex.test.ts index 85bc4fbf..533cb988 100644 --- a/test/codex.test.ts +++ b/test/codex.test.ts @@ -1,155 +1,36 @@ -import { describe, it, expect } from "vitest"; -import { getModelFamily } from "../lib/prompts/codex.js"; - - describe("Codex Module", () => { - describe("getModelFamily", () => { - describe("GPT-5.3 Codex Spark family", () => { - it("should return gpt-5.3-codex-spark for gpt-5.3-codex-spark", () => { - expect(getModelFamily("gpt-5.3-codex-spark")).toBe("gpt-5-codex"); - }); - - it("should return gpt-5.3-codex-spark for gpt-5.3-codex-spark-high", () => { - expect(getModelFamily("gpt-5.3-codex-spark-high")).toBe("gpt-5-codex"); - }); - }); - - describe("GPT-5.3 Codex family", () => { - it("should return gpt-5.3-codex for gpt-5.3-codex", () => { - expect(getModelFamily("gpt-5.3-codex")).toBe("gpt-5-codex"); - }); - - it("should return gpt-5.3-codex for gpt-5.3-codex-low", () => { - expect(getModelFamily("gpt-5.3-codex-low")).toBe("gpt-5-codex"); - }); - - it("should return gpt-5.3-codex for gpt-5.3-codex-high", () => { - expect(getModelFamily("gpt-5.3-codex-high")).toBe("gpt-5-codex"); - }); - - it("should return gpt-5.3-codex for gpt-5.3-codex-xhigh", () => { - expect(getModelFamily("gpt-5.3-codex-xhigh")).toBe("gpt-5-codex"); - }); - }); - - describe("GPT-5.2 Codex family", () => { - it("should return gpt-5.2-codex for gpt-5.2-codex", () => { - expect(getModelFamily("gpt-5.2-codex")).toBe("gpt-5-codex"); - }); - - it("should return gpt-5.2-codex for gpt-5.2-codex-low", () => { - expect(getModelFamily("gpt-5.2-codex-low")).toBe("gpt-5-codex"); - }); - - it("should return gpt-5.2-codex for gpt-5.2-codex-high", () => { - expect(getModelFamily("gpt-5.2-codex-high")).toBe("gpt-5-codex"); - }); - - it("should return gpt-5.2-codex for gpt-5.2-codex-xhigh", () => { - expect(getModelFamily("gpt-5.2-codex-xhigh")).toBe("gpt-5-codex"); - }); +import { describe, expect, it } from "vitest"; +import { __clearCacheForTesting, getModelFamily } from "../lib/prompts/codex.js"; + +describe("Codex Module", () => { + describe("getModelFamily", () => { + it("keeps codex variants on codex prompt families", () => { + expect(getModelFamily("gpt-5.3-codex-spark")).toBe("gpt-5-codex"); + expect(getModelFamily("gpt-5.2-codex-high")).toBe("gpt-5-codex"); + expect(getModelFamily("gpt-5.1-codex-max-high")).toBe("codex-max"); + expect(getModelFamily("gpt-5.1-codex-mini-high")).toBe("gpt-5-codex"); + expect(getModelFamily("codex-mini-latest")).toBe("gpt-5-codex"); }); - describe("Codex Max family", () => { - it("should return codex-max for gpt-5.1-codex-max", () => { - expect(getModelFamily("gpt-5.1-codex-max")).toBe("codex-max"); - }); - - it("should return codex-max for gpt-5.1-codex-max-low", () => { - expect(getModelFamily("gpt-5.1-codex-max-low")).toBe("codex-max"); - }); - - it("should return codex-max for gpt-5.1-codex-max-high", () => { - expect(getModelFamily("gpt-5.1-codex-max-high")).toBe("codex-max"); - }); - - it("should return codex-max for gpt-5.1-codex-max-xhigh", () => { - expect(getModelFamily("gpt-5.1-codex-max-xhigh")).toBe("codex-max"); - }); + it("routes GPT-5.4-era general models through the latest upstream general prompt family", () => { + expect(getModelFamily("gpt-5.4")).toBe("gpt-5.2"); + expect(getModelFamily("gpt-5.4-pro")).toBe("gpt-5.2"); + expect(getModelFamily("gpt-5")).toBe("gpt-5.2"); + expect(getModelFamily("gpt-5-mini")).toBe("gpt-5.2"); + expect(getModelFamily("gpt-5-nano")).toBe("gpt-5.2"); }); - describe("Codex family", () => { - it("should return codex for gpt-5.1-codex", () => { - expect(getModelFamily("gpt-5.1-codex")).toBe("gpt-5-codex"); - }); - - it("should return codex for gpt-5.1-codex-low", () => { - expect(getModelFamily("gpt-5.1-codex-low")).toBe("gpt-5-codex"); - }); - - it("should return codex for gpt-5.1-codex-mini", () => { - expect(getModelFamily("gpt-5.1-codex-mini")).toBe("gpt-5-codex"); - }); - - it("should return codex for gpt-5.1-codex-mini-high", () => { - expect(getModelFamily("gpt-5.1-codex-mini-high")).toBe("gpt-5-codex"); - }); - - it("should return codex for codex-mini-latest", () => { - expect(getModelFamily("codex-mini-latest")).toBe("codex"); - }); - }); - - describe("GPT-5.1 general family", () => { - it("should return gpt-5.1 for gpt-5.1", () => { - expect(getModelFamily("gpt-5.1")).toBe("gpt-5.1"); - }); - - it("should return gpt-5.1 for gpt-5.1-low", () => { - expect(getModelFamily("gpt-5.1-low")).toBe("gpt-5.1"); - }); - - it("should return gpt-5.1 for gpt-5.1-high", () => { - expect(getModelFamily("gpt-5.1-high")).toBe("gpt-5.1"); - }); - - it("should return gpt-5.1 for unknown models", () => { - expect(getModelFamily("unknown-model")).toBe("gpt-5.1"); - }); - - it("should return gpt-5.1 for empty string", () => { - expect(getModelFamily("")).toBe("gpt-5.1"); - }); + it("keeps GPT-5.1 on its own prompt family", () => { + expect(getModelFamily("gpt-5.1")).toBe("gpt-5.1"); + expect(getModelFamily("gpt-5.1-high")).toBe("gpt-5.1"); }); - describe("GPT-5.2 general family", () => { - it("should return gpt-5.2 for gpt-5.2", () => { - expect(getModelFamily("gpt-5.2")).toBe("gpt-5.2"); - }); - - it("should return gpt-5.2 for gpt-5.2-high", () => { - expect(getModelFamily("gpt-5.2-high")).toBe("gpt-5.2"); - }); - }); - - describe("Priority order", () => { - it("should prioritize gpt-5.3-codex-spark over gpt-5.3-codex detection", () => { - expect(getModelFamily("gpt-5.3-codex-spark")).toBe("gpt-5-codex"); - }); - - it("should prioritize gpt-5.3-codex over generic codex detection", () => { - expect(getModelFamily("gpt-5.3-codex")).toBe("gpt-5-codex"); - }); - - it("should prioritize gpt-5.2-codex over gpt-5.2 general", () => { - // "gpt-5.2-codex" also contains the substring "gpt-5.2" - expect(getModelFamily("gpt-5.2-codex")).toBe("gpt-5-codex"); - }); - - it("should prioritize codex-max over codex", () => { - // Model contains both "codex-max" and "codex" - expect(getModelFamily("gpt-5.1-codex-max")).toBe("codex-max"); - }); - - it("should prioritize codex over gpt-5.1", () => { - // Model contains both "codex" and potential gpt-5.1 - expect(getModelFamily("gpt-5.1-codex")).toBe("gpt-5-codex"); - }); + it("falls back to the default model profile for unknown models", () => { + expect(getModelFamily("unknown-model")).toBe("gpt-5.2"); + expect(getModelFamily("")).toBe("gpt-5.2"); }); }); }); -import { __clearCacheForTesting } from "../lib/prompts/codex.js"; - describe("Codex Cache", () => { it("should clear prompt cache without error", () => { expect(() => __clearCacheForTesting()).not.toThrow(); diff --git a/test/config.test.ts b/test/config.test.ts index 79c982ce..cec3149d 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -90,10 +90,10 @@ describe('Configuration Parsing', () => { expect(defaultReasoning.summary).toBe('auto'); }); - it('should use minimal effort for lightweight models (nano/mini)', () => { + it('should keep lightweight general models on their fixed medium reasoning tier', () => { const nanoReasoning = getReasoningConfig('gpt-5-nano', {}); - expect(nanoReasoning.effort).toBe('minimal'); + expect(nanoReasoning.effort).toBe('medium'); expect(nanoReasoning.summary).toBe('auto'); }); @@ -105,13 +105,18 @@ describe('Configuration Parsing', () => { expect(codexMinimalReasoning.summary).toBe('auto'); }); - it('should preserve "minimal" effort for non-codex models', () => { + it('should preserve "minimal" effort for GPT-5 general models that still support it', () => { const gpt5MinimalConfig = { reasoningEffort: 'minimal' as const }; const gpt5MinimalReasoning = getReasoningConfig('gpt-5', gpt5MinimalConfig); expect(gpt5MinimalReasoning.effort).toBe('minimal'); }); + it('should default GPT-5.4 general models to none reasoning', () => { + const gpt54Reasoning = getReasoningConfig('gpt-5.4', {}); + expect(gpt54Reasoning.effort).toBe('none'); + }); + it('should handle high effort setting', () => { const highConfig = { reasoningEffort: 'high' as const }; const highReasoning = getReasoningConfig('gpt-5', highConfig); @@ -169,7 +174,7 @@ describe('Configuration Parsing', () => { describe('Model-specific behavior', () => { it('should detect lightweight models correctly', () => { const miniReasoning = getReasoningConfig('gpt-5-mini', {}); - expect(miniReasoning.effort).toBe('minimal'); + expect(miniReasoning.effort).toBe('medium'); }); it('should detect codex models correctly', () => { @@ -182,5 +187,12 @@ describe('Configuration Parsing', () => { const gpt5Reasoning = getReasoningConfig('gpt-5', {}); expect(gpt5Reasoning.effort).toBe('medium'); }); + + it('should clamp unsupported low effort on GPT-5.4-pro up to medium', () => { + const gpt54ProReasoning = getReasoningConfig('gpt-5.4-pro', { + reasoningEffort: 'low', + }); + expect(gpt54ProReasoning.effort).toBe('medium'); + }); }); }); diff --git a/test/model-map.test.ts b/test/model-map.test.ts index 21f7cb5b..6ad16967 100644 --- a/test/model-map.test.ts +++ b/test/model-map.test.ts @@ -1,172 +1,112 @@ -import { describe, it, expect } from "vitest"; -import { MODEL_MAP, getNormalizedModel, isKnownModel } from "../lib/request/helpers/model-map.js"; - -describe("Model Map Module", () => { - describe("MODEL_MAP", () => { - it("contains canonical GPT-5 codex mappings", () => { - expect(MODEL_MAP["gpt-5-codex"]).toBe("gpt-5-codex"); - expect(MODEL_MAP["gpt-5-codex-low"]).toBe("gpt-5-codex"); - expect(MODEL_MAP["gpt-5-codex-medium"]).toBe("gpt-5-codex"); - expect(MODEL_MAP["gpt-5-codex-high"]).toBe("gpt-5-codex"); - }); - - it("contains GPT-5.1 codex-max models", () => { - expect(MODEL_MAP["gpt-5.1-codex-max"]).toBe("gpt-5.1-codex-max"); - expect(MODEL_MAP["gpt-5.1-codex-max-low"]).toBe("gpt-5.1-codex-max"); - expect(MODEL_MAP["gpt-5.1-codex-max-medium"]).toBe("gpt-5.1-codex-max"); - expect(MODEL_MAP["gpt-5.1-codex-max-high"]).toBe("gpt-5.1-codex-max"); - expect(MODEL_MAP["gpt-5.1-codex-max-xhigh"]).toBe("gpt-5.1-codex-max"); - }); - - it("contains GPT-5.2 models", () => { - expect(MODEL_MAP["gpt-5.2"]).toBe("gpt-5.2"); - expect(MODEL_MAP["gpt-5.2-none"]).toBe("gpt-5.2"); - expect(MODEL_MAP["gpt-5.2-low"]).toBe("gpt-5.2"); - expect(MODEL_MAP["gpt-5.2-medium"]).toBe("gpt-5.2"); - expect(MODEL_MAP["gpt-5.2-high"]).toBe("gpt-5.2"); - expect(MODEL_MAP["gpt-5.2-xhigh"]).toBe("gpt-5.2"); - }); - - it("contains GPT-5.2 codex models", () => { - expect(MODEL_MAP["gpt-5.2-codex"]).toBe("gpt-5-codex"); - expect(MODEL_MAP["gpt-5.2-codex-low"]).toBe("gpt-5-codex"); - expect(MODEL_MAP["gpt-5.2-codex-medium"]).toBe("gpt-5-codex"); - expect(MODEL_MAP["gpt-5.2-codex-high"]).toBe("gpt-5-codex"); - expect(MODEL_MAP["gpt-5.2-codex-xhigh"]).toBe("gpt-5-codex"); - }); - - it("contains GPT-5.3 codex models", () => { - expect(MODEL_MAP["gpt-5.3-codex"]).toBe("gpt-5-codex"); - expect(MODEL_MAP["gpt-5.3-codex-low"]).toBe("gpt-5-codex"); - expect(MODEL_MAP["gpt-5.3-codex-medium"]).toBe("gpt-5-codex"); - expect(MODEL_MAP["gpt-5.3-codex-high"]).toBe("gpt-5-codex"); - expect(MODEL_MAP["gpt-5.3-codex-xhigh"]).toBe("gpt-5-codex"); - }); - - it("contains GPT-5.3 codex spark models", () => { - expect(MODEL_MAP["gpt-5.3-codex-spark"]).toBe("gpt-5-codex"); - expect(MODEL_MAP["gpt-5.3-codex-spark-low"]).toBe("gpt-5-codex"); - expect(MODEL_MAP["gpt-5.3-codex-spark-medium"]).toBe("gpt-5-codex"); - expect(MODEL_MAP["gpt-5.3-codex-spark-high"]).toBe("gpt-5-codex"); - expect(MODEL_MAP["gpt-5.3-codex-spark-xhigh"]).toBe("gpt-5-codex"); - }); - - it("contains GPT-5.1 codex-mini models", () => { - expect(MODEL_MAP["gpt-5.1-codex-mini"]).toBe("gpt-5.1-codex-mini"); - expect(MODEL_MAP["gpt-5.1-codex-mini-medium"]).toBe("gpt-5.1-codex-mini"); - expect(MODEL_MAP["gpt-5.1-codex-mini-high"]).toBe("gpt-5.1-codex-mini"); - }); - - it("contains GPT-5.1 general purpose models", () => { - expect(MODEL_MAP["gpt-5.1"]).toBe("gpt-5.1"); - expect(MODEL_MAP["gpt-5.1-none"]).toBe("gpt-5.1"); - expect(MODEL_MAP["gpt-5.1-low"]).toBe("gpt-5.1"); - expect(MODEL_MAP["gpt-5.1-medium"]).toBe("gpt-5.1"); - expect(MODEL_MAP["gpt-5.1-high"]).toBe("gpt-5.1"); - expect(MODEL_MAP["gpt-5.1-chat-latest"]).toBe("gpt-5.1"); - }); - - it("keeps canonical GPT-5 codex mapping stable", () => { - expect(MODEL_MAP["gpt-5-codex"]).toBe("gpt-5-codex"); - }); - - it("maps legacy codex-mini models to GPT-5.1 codex-mini", () => { - expect(MODEL_MAP["codex-mini-latest"]).toBe("gpt-5.1-codex-mini"); - expect(MODEL_MAP["gpt-5-codex-mini"]).toBe("gpt-5.1-codex-mini"); - expect(MODEL_MAP["gpt-5-codex-mini-medium"]).toBe("gpt-5.1-codex-mini"); - expect(MODEL_MAP["gpt-5-codex-mini-high"]).toBe("gpt-5.1-codex-mini"); - }); - - it("maps legacy GPT-5 general purpose models to GPT-5.1", () => { - expect(MODEL_MAP["gpt-5"]).toBe("gpt-5.1"); - expect(MODEL_MAP["gpt-5-mini"]).toBe("gpt-5.1"); - expect(MODEL_MAP["gpt-5-nano"]).toBe("gpt-5.1"); - }); - }); - - describe("getNormalizedModel", () => { - it("returns normalized model for exact match", () => { - expect(getNormalizedModel("gpt-5.1-codex")).toBe("gpt-5-codex"); - expect(getNormalizedModel("gpt-5.1-codex-low")).toBe("gpt-5-codex"); - expect(getNormalizedModel("gpt-5.2-codex-high")).toBe("gpt-5-codex"); - expect(getNormalizedModel("gpt-5.3-codex-high")).toBe("gpt-5-codex"); - expect(getNormalizedModel("gpt-5.3-codex-spark-high")).toBe("gpt-5-codex"); - }); - - it("handles case-insensitive lookup", () => { - expect(getNormalizedModel("GPT-5.1-CODEX")).toBe("gpt-5-codex"); - expect(getNormalizedModel("Gpt-5.2-Codex-High")).toBe("gpt-5-codex"); - expect(getNormalizedModel("Gpt-5.3-Codex-High")).toBe("gpt-5-codex"); - expect(getNormalizedModel("Gpt-5.3-Codex-Spark-High")).toBe("gpt-5-codex"); - }); - - it("returns undefined for unknown models", () => { - expect(getNormalizedModel("unknown-model")).toBeUndefined(); - expect(getNormalizedModel("gpt-6")).toBeUndefined(); - expect(getNormalizedModel("")).toBeUndefined(); - }); - - it("handles legacy model mapping", () => { - expect(getNormalizedModel("gpt-5-codex")).toBe("gpt-5-codex"); - expect(getNormalizedModel("gpt-5")).toBe("gpt-5.1"); - expect(getNormalizedModel("codex-mini-latest")).toBe("gpt-5.1-codex-mini"); - }); - - it("strips reasoning effort suffix and normalizes", () => { - expect(getNormalizedModel("gpt-5.1-codex-max-xhigh")).toBe("gpt-5.1-codex-max"); - expect(getNormalizedModel("gpt-5.2-medium")).toBe("gpt-5.2"); - }); - }); - - describe("isKnownModel", () => { - it("returns true for known models", () => { - expect(isKnownModel("gpt-5.1-codex")).toBe(true); - expect(isKnownModel("gpt-5.2")).toBe(true); - expect(isKnownModel("gpt-5.3-codex")).toBe(true); - expect(isKnownModel("gpt-5.3-codex-spark")).toBe(true); - expect(isKnownModel("gpt-5.1-codex-max")).toBe(true); - expect(isKnownModel("gpt-5-codex")).toBe(true); - }); - - it("returns true for case-insensitive matches", () => { - expect(isKnownModel("GPT-5.1-CODEX")).toBe(true); - expect(isKnownModel("GPT-5.2-CODEX-HIGH")).toBe(true); - expect(isKnownModel("GPT-5.3-CODEX-HIGH")).toBe(true); - expect(isKnownModel("GPT-5.3-CODEX-SPARK-HIGH")).toBe(true); - }); - - it("returns false for unknown models", () => { - expect(isKnownModel("gpt-6")).toBe(false); - expect(isKnownModel("claude-3")).toBe(false); - expect(isKnownModel("unknown")).toBe(false); - expect(isKnownModel("")).toBe(false); - }); - }); - - describe("Model count and completeness", () => { - it("has expected number of model mappings", () => { - const modelCount = Object.keys(MODEL_MAP).length; - expect(modelCount).toBeGreaterThanOrEqual(30); - }); - - it("all values are valid normalized model names", () => { - const validNormalizedModels = new Set([ - "gpt-5-codex", - "gpt-5.1-codex-max", - "gpt-5.1-codex-mini", - "gpt-5.1", - "gpt-5.2", - ]); - - for (const [key, value] of Object.entries(MODEL_MAP)) { - expect(validNormalizedModels.has(value)).toBe(true); - } - }); - - it("no duplicate keys exist", () => { - const keys = Object.keys(MODEL_MAP); - const uniqueKeys = new Set(keys); - expect(keys.length).toBe(uniqueKeys.size); - }); - }); +import { describe, expect, it } from "vitest"; +import { + DEFAULT_MODEL, + MODEL_MAP, + getModelCapabilities, + getModelProfile, + getNormalizedModel, + isKnownModel, + resolveNormalizedModel, +} from "../lib/request/helpers/model-map.js"; + +describe("model map", () => { + describe("MODEL_MAP", () => { + it("keeps codex families canonical", () => { + expect(MODEL_MAP["gpt-5-codex"]).toBe("gpt-5-codex"); + expect(MODEL_MAP["gpt-5.3-codex-spark-high"]).toBe("gpt-5-codex"); + expect(MODEL_MAP["gpt-5.1-codex-max-xhigh"]).toBe("gpt-5.1-codex-max"); + expect(MODEL_MAP["codex-mini-latest"]).toBe("gpt-5.1-codex-mini"); + }); + + it("adds first-class GPT-5.4 era general models", () => { + expect(MODEL_MAP["gpt-5.4"]).toBe("gpt-5.4"); + expect(MODEL_MAP["gpt-5.4-pro-high"]).toBe("gpt-5.4-pro"); + expect(MODEL_MAP["gpt-5"]).toBe("gpt-5"); + expect(MODEL_MAP["gpt-5-pro-high"]).toBe("gpt-5-pro"); + }); + + it("keeps mini and nano on current non-5.1 model IDs", () => { + expect(MODEL_MAP["gpt-5-mini"]).toBe("gpt-5-mini"); + expect(MODEL_MAP["gpt-5-nano"]).toBe("gpt-5-nano"); + expect(MODEL_MAP["gpt-5.4-mini"]).toBe("gpt-5-mini"); + expect(MODEL_MAP["gpt-5.4-nano"]).toBe("gpt-5-nano"); + }); + }); + + describe("getNormalizedModel", () => { + it("returns exact aliases case-insensitively", () => { + expect(getNormalizedModel("GPT-5.4")).toBe("gpt-5.4"); + expect(getNormalizedModel("GPT-5.4-PRO-HIGH")).toBe("gpt-5.4-pro"); + expect(getNormalizedModel("gpt-5.4-mini")).toBe("gpt-5-mini"); + expect(getNormalizedModel("gpt-5.3-codex-high")).toBe("gpt-5-codex"); + }); + + it("returns undefined for unknown exact identifiers", () => { + expect(getNormalizedModel("unknown-model")).toBeUndefined(); + expect(getNormalizedModel("gpt-6")).toBeUndefined(); + expect(getNormalizedModel("")).toBeUndefined(); + }); + }); + + describe("resolveNormalizedModel", () => { + it("resolves provider-prefixed and verbose GPT-5 variants", () => { + expect(resolveNormalizedModel("openai/gpt-5.4")).toBe("gpt-5.4"); + expect(resolveNormalizedModel("openai/gpt-5.4-mini-high")).toBe("gpt-5-mini"); + expect(resolveNormalizedModel("GPT 5.4 Pro High")).toBe("gpt-5.4-pro"); + expect(resolveNormalizedModel("GPT 5 Codex Low (ChatGPT Subscription)")).toBe("gpt-5-codex"); + }); + + it("defaults unknown GPT-5-ish requests to GPT-5.4 instead of GPT-5.1", () => { + expect(resolveNormalizedModel("gpt-5-unknown-preview")).toBe("gpt-5.4"); + expect(resolveNormalizedModel("gpt 5 experimental build")).toBe("gpt-5.4"); + }); + + it("uses the current default model when the request is missing or unrelated", () => { + expect(resolveNormalizedModel(undefined)).toBe(DEFAULT_MODEL); + expect(resolveNormalizedModel("")).toBe(DEFAULT_MODEL); + expect(resolveNormalizedModel("gpt-4")).toBe(DEFAULT_MODEL); + expect(resolveNormalizedModel("unknown-model")).toBe(DEFAULT_MODEL); + }); + }); + + describe("model profiles", () => { + it("routes GPT-5.4-era general models through the latest available general prompt family", () => { + expect(getModelProfile("gpt-5.4").promptFamily).toBe("gpt-5.2"); + expect(getModelProfile("gpt-5.4-pro").promptFamily).toBe("gpt-5.2"); + expect(getModelProfile("gpt-5-mini").promptFamily).toBe("gpt-5.2"); + }); + + it("keeps GPT-5.1 on its own prompt family", () => { + expect(getModelProfile("gpt-5.1").promptFamily).toBe("gpt-5.1"); + }); + + it("exposes tool-search and computer-use capabilities", () => { + expect(getModelCapabilities("gpt-5.4")).toEqual({ + toolSearch: true, + computerUse: true, + }); + expect(getModelCapabilities("gpt-5.4-pro")).toEqual({ + toolSearch: false, + computerUse: true, + }); + expect(getModelCapabilities("gpt-5-mini")).toEqual({ + toolSearch: false, + computerUse: false, + }); + }); + }); + + describe("isKnownModel", () => { + it("returns true for explicit aliases only", () => { + expect(isKnownModel("gpt-5.4")).toBe(true); + expect(isKnownModel("gpt-5.4-mini")).toBe(true); + expect(isKnownModel("GPT-5.3-CODEX-HIGH")).toBe(true); + }); + + it("returns false for unknown names even though fallback routing exists", () => { + expect(isKnownModel("gpt-5-unknown-preview")).toBe(false); + expect(isKnownModel("claude-3")).toBe(false); + expect(isKnownModel("")).toBe(false); + }); + }); }); diff --git a/test/property/transformer.property.test.ts b/test/property/transformer.property.test.ts index 10a1454d..98fe302e 100644 --- a/test/property/transformer.property.test.ts +++ b/test/property/transformer.property.test.ts @@ -47,12 +47,12 @@ describe("normalizeModel property tests", () => { it("handles undefined gracefully", () => { const result = normalizeModel(undefined); - expect(result).toBe("gpt-5.1"); + expect(result).toBe("gpt-5.4"); }); it("handles empty string gracefully", () => { const result = normalizeModel(""); - expect(result).toBe("gpt-5.1"); + expect(result).toBe("gpt-5.4"); }); }); @@ -203,10 +203,10 @@ describe("getReasoningConfig property tests", () => { ); }); - it("non-xhigh models downgrade xhigh to high", () => { + it("models without xhigh support downgrade xhigh to high", () => { fc.assert( fc.property( - fc.constantFrom("gpt-5.1", "gpt-5.1-codex"), + fc.constantFrom("gpt-5", "gpt-5.1"), (model) => { const result = getReasoningConfig(model, { reasoningEffort: "xhigh" }); expect(result.effort).toBe("high"); diff --git a/test/request-transformer.test.ts b/test/request-transformer.test.ts index b53ca89b..0dd01959 100644 --- a/test/request-transformer.test.ts +++ b/test/request-transformer.test.ts @@ -16,143 +16,48 @@ import type { RequestBody, UserConfig, InputItem } from '../lib/types.js'; describe('Request Transformer Module', () => { describe('normalizeModel', () => { - // NOTE: All gpt-5 models now normalize to gpt-5.1 as gpt-5 is being phased out - it('should normalize gpt-5-codex to gpt-5.1-codex', async () => { + it('keeps codex families canonical', async () => { expect(normalizeModel('gpt-5-codex')).toBe('gpt-5-codex'); - }); - - it('should normalize gpt-5 to gpt-5.1', async () => { - expect(normalizeModel('gpt-5')).toBe('gpt-5.1'); - }); - - it('should normalize variants containing "codex" to gpt-5.1-codex', async () => { expect(normalizeModel('openai/gpt-5-codex')).toBe('gpt-5-codex'); - expect(normalizeModel('custom-gpt-5-codex-variant')).toBe('gpt-5-codex'); + expect(normalizeModel('gpt-5.3-codex-spark-high')).toBe('gpt-5-codex'); + expect(normalizeModel('gpt-5.1-codex-max-high')).toBe('gpt-5.1-codex-max'); + expect(normalizeModel('codex-mini-latest')).toBe('gpt-5.1-codex-mini'); }); - it('should normalize variants containing "gpt-5" to gpt-5.1', async () => { - expect(normalizeModel('gpt-5-mini')).toBe('gpt-5.1'); - expect(normalizeModel('gpt-5-nano')).toBe('gpt-5.1'); + it('keeps GPT-5.4 era general models first-class', async () => { + expect(normalizeModel('gpt-5.4')).toBe('gpt-5.4'); + expect(normalizeModel('gpt-5.4-pro-high')).toBe('gpt-5.4-pro'); + expect(normalizeModel('gpt-5')).toBe('gpt-5'); + expect(normalizeModel('gpt-5-pro-high')).toBe('gpt-5-pro'); }); - it('should return gpt-5.1 as default for unknown models', async () => { - expect(normalizeModel('unknown-model')).toBe('gpt-5.1'); - expect(normalizeModel('gpt-4')).toBe('gpt-5.1'); + it('maps GPT-5.4 mini and nano aliases onto the current small-model IDs', async () => { + expect(normalizeModel('gpt-5.4-mini')).toBe('gpt-5-mini'); + expect(normalizeModel('gpt-5.4-mini-high')).toBe('gpt-5-mini'); + expect(normalizeModel('gpt-5.4-nano')).toBe('gpt-5-nano'); + expect(normalizeModel('gpt-5.4-nano-high')).toBe('gpt-5-nano'); + expect(normalizeModel('gpt-5-mini')).toBe('gpt-5-mini'); + expect(normalizeModel('gpt-5-nano')).toBe('gpt-5-nano'); }); - it('should return gpt-5.1 for undefined', async () => { - expect(normalizeModel(undefined)).toBe('gpt-5.1'); + it('defaults unknown requests to GPT-5.4 instead of GPT-5.1', async () => { + expect(normalizeModel('unknown-model')).toBe('gpt-5.4'); + expect(normalizeModel('gpt-4')).toBe('gpt-5.4'); + expect(normalizeModel(undefined)).toBe('gpt-5.4'); + expect(normalizeModel('')).toBe('gpt-5.4'); }); - // Codex CLI preset name tests - legacy gpt-5 models now map to gpt-5.1 - describe('Codex CLI preset names', () => { - it('should normalize all gpt-5-codex presets to gpt-5.1-codex', async () => { - expect(normalizeModel('gpt-5-codex-low')).toBe('gpt-5-codex'); - expect(normalizeModel('gpt-5-codex-medium')).toBe('gpt-5-codex'); - expect(normalizeModel('gpt-5-codex-high')).toBe('gpt-5-codex'); - }); - - it('should normalize all gpt-5 presets to gpt-5.1', async () => { - expect(normalizeModel('gpt-5-minimal')).toBe('gpt-5.1'); - expect(normalizeModel('gpt-5-low')).toBe('gpt-5.1'); - expect(normalizeModel('gpt-5-medium')).toBe('gpt-5.1'); - expect(normalizeModel('gpt-5-high')).toBe('gpt-5.1'); - }); - - it('should prioritize codex over gpt-5 in model name', async () => { - // Model name contains BOTH "codex" and "gpt-5" - // Should return "gpt-5.1-codex" (codex checked first, maps to 5.1) - expect(normalizeModel('gpt-5-codex-low')).toBe('gpt-5-codex'); - expect(normalizeModel('my-gpt-5-codex-model')).toBe('gpt-5-codex'); - }); - - it('should normalize codex mini presets to gpt-5.1-codex-mini', async () => { - expect(normalizeModel('gpt-5-codex-mini')).toBe('gpt-5.1-codex-mini'); - expect(normalizeModel('gpt-5-codex-mini-low')).toBe('gpt-5.1-codex-mini'); - expect(normalizeModel('gpt-5-codex-mini-medium')).toBe('gpt-5.1-codex-mini'); - expect(normalizeModel('gpt-5-codex-mini-high')).toBe('gpt-5.1-codex-mini'); - expect(normalizeModel('openai/gpt-5-codex-mini-high')).toBe('gpt-5.1-codex-mini'); - expect(normalizeModel('codex-mini-latest')).toBe('gpt-5.1-codex-mini'); - expect(normalizeModel('openai/codex-mini-latest')).toBe('gpt-5.1-codex-mini'); - }); - - it('should normalize gpt-5.1 codex max presets', async () => { - expect(normalizeModel('gpt-5.1-codex-max')).toBe('gpt-5.1-codex-max'); - expect(normalizeModel('gpt-5.1-codex-max-high')).toBe('gpt-5.1-codex-max'); - expect(normalizeModel('gpt-5.1-codex-max-xhigh')).toBe('gpt-5.1-codex-max'); - expect(normalizeModel('openai/gpt-5.1-codex-max-medium')).toBe('gpt-5.1-codex-max'); - }); - - it('should normalize gpt-5.2 codex presets', async () => { - expect(normalizeModel('gpt-5.2-codex')).toBe('gpt-5-codex'); - expect(normalizeModel('gpt-5.2-codex-low')).toBe('gpt-5-codex'); - expect(normalizeModel('gpt-5.2-codex-medium')).toBe('gpt-5-codex'); - expect(normalizeModel('gpt-5.2-codex-high')).toBe('gpt-5-codex'); - expect(normalizeModel('gpt-5.2-codex-xhigh')).toBe('gpt-5-codex'); - expect(normalizeModel('openai/gpt-5.2-codex-xhigh')).toBe('gpt-5-codex'); - }); - - it('should normalize gpt-5.3 codex presets', async () => { - expect(normalizeModel('gpt-5.3-codex')).toBe('gpt-5-codex'); - expect(normalizeModel('gpt-5.3-codex-low')).toBe('gpt-5-codex'); - expect(normalizeModel('gpt-5.3-codex-medium')).toBe('gpt-5-codex'); - expect(normalizeModel('gpt-5.3-codex-high')).toBe('gpt-5-codex'); - expect(normalizeModel('gpt-5.3-codex-xhigh')).toBe('gpt-5-codex'); - expect(normalizeModel('openai/gpt-5.3-codex-xhigh')).toBe('gpt-5-codex'); - }); - - it('should normalize gpt-5.3 codex spark presets', async () => { - expect(normalizeModel('gpt-5.3-codex-spark')).toBe('gpt-5-codex'); - expect(normalizeModel('gpt-5.3-codex-spark-low')).toBe('gpt-5-codex'); - expect(normalizeModel('gpt-5.3-codex-spark-medium')).toBe('gpt-5-codex'); - expect(normalizeModel('gpt-5.3-codex-spark-high')).toBe('gpt-5-codex'); - expect(normalizeModel('gpt-5.3-codex-spark-xhigh')).toBe('gpt-5-codex'); - expect(normalizeModel('openai/gpt-5.3-codex-spark-xhigh')).toBe('gpt-5-codex'); - }); - - it('should normalize gpt-5.1 codex and mini slugs', async () => { - expect(normalizeModel('gpt-5.1-codex')).toBe('gpt-5-codex'); - expect(normalizeModel('openai/gpt-5.1-codex')).toBe('gpt-5-codex'); - expect(normalizeModel('gpt-5.1-codex-mini')).toBe('gpt-5.1-codex-mini'); - expect(normalizeModel('gpt-5.1-codex-mini-low')).toBe('gpt-5.1-codex-mini'); - expect(normalizeModel('gpt-5.1-codex-mini-high')).toBe('gpt-5.1-codex-mini'); - expect(normalizeModel('openai/gpt-5.1-codex-mini-medium')).toBe('gpt-5.1-codex-mini'); - }); - - it('should normalize gpt-5.1 general-purpose slugs', async () => { - expect(normalizeModel('gpt-5.1')).toBe('gpt-5.1'); - expect(normalizeModel('openai/gpt-5.1')).toBe('gpt-5.1'); - expect(normalizeModel('GPT 5.1 High')).toBe('gpt-5.1'); - }); + it('still prioritizes codex detection when model names contain both codex and GPT-5', async () => { + expect(normalizeModel('gpt-5-codex-low')).toBe('gpt-5-codex'); + expect(normalizeModel('my-gpt-5-codex-model')).toBe('gpt-5-codex'); }); - // Edge case tests - legacy gpt-5 models now map to gpt-5.1 - describe('Edge cases', () => { - it('should handle uppercase model names', async () => { - expect(normalizeModel('GPT-5-CODEX')).toBe('gpt-5-codex'); - expect(normalizeModel('GPT-5-HIGH')).toBe('gpt-5.1'); - expect(normalizeModel('CODEx-MINI-LATEST')).toBe('gpt-5.1-codex-mini'); - expect(normalizeModel('GPT-5.3-CODEX-SPARK')).toBe('gpt-5-codex'); - }); - - it('should handle mixed case', async () => { - expect(normalizeModel('Gpt-5-Codex-Low')).toBe('gpt-5-codex'); - expect(normalizeModel('GpT-5-MeDiUm')).toBe('gpt-5.1'); - }); - - it('should handle special characters', async () => { - expect(normalizeModel('my_gpt-5_codex')).toBe('gpt-5-codex'); - expect(normalizeModel('gpt.5.high')).toBe('gpt-5.1'); - }); - - it('should handle old verbose names', async () => { - expect(normalizeModel('GPT 5 Codex Low (ChatGPT Subscription)')).toBe('gpt-5-codex'); - expect(normalizeModel('GPT 5 High (ChatGPT Subscription)')).toBe('gpt-5.1'); - }); - - it('should handle empty string', async () => { - expect(normalizeModel('')).toBe('gpt-5.1'); - }); + it('handles case and formatting variations', async () => { + expect(normalizeModel('GPT-5.4')).toBe('gpt-5.4'); + expect(normalizeModel('GPT-5-HIGH')).toBe('gpt-5'); + expect(normalizeModel('Gpt-5.4-Pro')).toBe('gpt-5.4-pro'); + expect(normalizeModel('GPT 5 High (ChatGPT Subscription)')).toBe('gpt-5.4'); + expect(normalizeModel('GPT 5 Codex Low (ChatGPT Subscription)')).toBe('gpt-5-codex'); }); }); @@ -725,7 +630,7 @@ describe('Request Transformer Module', () => { input: [], }; const result = await transformRequestBody(body, codexInstructions); - expect(result.model).toBe('gpt-5.1'); // gpt-5 now maps to gpt-5.1 + expect(result.model).toBe('gpt-5-mini'); }); it('should apply default reasoning config', async () => { @@ -802,7 +707,7 @@ describe('Request Transformer Module', () => { 'hybrid', ); - expect(result.reasoning?.effort).toBe('high'); + expect(result.reasoning?.effort).toBe('xhigh'); expect(result.reasoning?.summary).toBe('detailed'); expect(result.text?.verbosity).toBe('high'); }); @@ -1066,7 +971,7 @@ describe('Request Transformer Module', () => { 'hybrid', ); - expect(result.reasoning?.effort).toBe('high'); + expect(result.reasoning?.effort).toBe('xhigh'); expect(result.reasoning?.summary).toBe('detailed'); expect(result.text?.verbosity).toBe('high'); expect(result.tools).toEqual([{ type: 'function', function: { name: 'read_file' } }]); @@ -1474,7 +1379,7 @@ describe('Request Transformer Module', () => { expect(result.reasoning?.summary).toBe('detailed'); }); - it('should downgrade requested xhigh to high for gpt-5.2-codex', async () => { + it('should preserve requested xhigh for gpt-5.2-codex', async () => { const body: RequestBody = { model: 'gpt-5.2-codex-xhigh', input: [], @@ -1489,11 +1394,11 @@ describe('Request Transformer Module', () => { }; const result = await transformRequestBody(body, codexInstructions, userConfig); expect(result.model).toBe('gpt-5-codex'); - expect(result.reasoning?.effort).toBe('high'); + expect(result.reasoning?.effort).toBe('xhigh'); expect(result.reasoning?.summary).toBe('detailed'); }); - it('should downgrade requested xhigh to high for gpt-5.3-codex', async () => { + it('should preserve requested xhigh for gpt-5.3-codex', async () => { const body: RequestBody = { model: 'gpt-5.3-codex-xhigh', input: [], @@ -1508,11 +1413,11 @@ describe('Request Transformer Module', () => { }; const result = await transformRequestBody(body, codexInstructions, userConfig); expect(result.model).toBe('gpt-5-codex'); - expect(result.reasoning?.effort).toBe('high'); + expect(result.reasoning?.effort).toBe('xhigh'); expect(result.reasoning?.summary).toBe('detailed'); }, 10_000); - it('should downgrade xhigh to high for non-max codex', async () => { + it('should preserve xhigh for non-max codex when the normalized model supports it', async () => { const body: RequestBody = { model: 'gpt-5.1-codex-high', input: [], @@ -1523,7 +1428,7 @@ describe('Request Transformer Module', () => { }; const result = await transformRequestBody(body, codexInstructions, userConfig); expect(result.model).toBe('gpt-5-codex'); - expect(result.reasoning?.effort).toBe('high'); + expect(result.reasoning?.effort).toBe('xhigh'); }); it('should downgrade xhigh to high for non-max general models', async () => { @@ -1652,7 +1557,7 @@ describe('Request Transformer Module', () => { expect(result.reasoning?.effort).toBe('low'); }); - it('should upgrade none to low for GPT-5.1-codex-max (codex max does not support none)', async () => { + it('should upgrade none to medium for GPT-5.1-codex-max (codex max does not support none)', async () => { const body: RequestBody = { model: 'gpt-5.1-codex-max', input: [], @@ -1663,7 +1568,7 @@ describe('Request Transformer Module', () => { }; const result = await transformRequestBody(body, codexInstructions, userConfig); expect(result.model).toBe('gpt-5.1-codex-max'); - expect(result.reasoning?.effort).toBe('low'); + expect(result.reasoning?.effort).toBe('medium'); }); it('should preserve minimal for non-codex models', async () => { @@ -1685,7 +1590,7 @@ describe('Request Transformer Module', () => { input: [], }; const result = await transformRequestBody(body, codexInstructions); - expect(result.reasoning?.effort).toBe('minimal'); + expect(result.reasoning?.effort).toBe('medium'); }); it('should convert orphaned function_call_output to message to preserve context', async () => { @@ -1951,7 +1856,7 @@ describe('Request Transformer Module', () => { expect(result.store).toBe(false); }); - it('should handle gpt-5-mini normalizing to gpt-5.1', async () => { + it('should handle gpt-5-mini without silently downgrading it to gpt-5.1', async () => { const body: RequestBody = { model: 'gpt-5-mini', input: [] @@ -1959,8 +1864,8 @@ describe('Request Transformer Module', () => { const result = await transformRequestBody(body, codexInstructions); - expect(result.model).toBe('gpt-5.1'); // gpt-5 now maps to gpt-5.1 - expect(result.reasoning?.effort).toBe('minimal'); // Lightweight gpt-5-mini defaults to minimal + expect(result.model).toBe('gpt-5-mini'); + expect(result.reasoning?.effort).toBe('medium'); }); }); From 16bbcc6c6e03af8c9287bca38d309ef27828341e Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 22 Mar 2026 18:21:53 +0800 Subject: [PATCH 2/4] Fix parity model normalization regressions --- lib/capability-policy.ts | 4 +- lib/codex-manager.ts | 11 +++-- lib/prompts/codex.ts | 9 +++- lib/request/helpers/model-map.ts | 5 +- test/capability-policy.test.ts | 10 ++++ test/codex-manager-cli.test.ts | 72 ++++++++++++++++++++++++++++ test/codex-prompts.test.ts | 82 +++++++++++++++++++++++++++++++- test/model-map.test.ts | 7 +++ 8 files changed, 190 insertions(+), 10 deletions(-) diff --git a/lib/capability-policy.ts b/lib/capability-policy.ts index cbd51f09..eb9a7f92 100644 --- a/lib/capability-policy.ts +++ b/lib/capability-policy.ts @@ -1,4 +1,4 @@ -import { resolveNormalizedModel } from "./request/helpers/model-map.js"; +import { getNormalizedModel } from "./request/helpers/model-map.js"; export interface CapabilityPolicySnapshot { successes: number; @@ -33,7 +33,7 @@ function normalizeModel(model: string | undefined): string | null { const withoutProvider = trimmedInput.includes("/") ? (trimmedInput.split("/").pop() ?? trimmedInput) : trimmedInput; - const mapped = resolveNormalizedModel(withoutProvider); + const mapped = getNormalizedModel(withoutProvider) ?? withoutProvider; const trimmed = mapped.trim().toLowerCase(); if (!trimmed) return null; return trimmed.replace(/-(none|minimal|low|medium|high|xhigh)$/i, ""); diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index 6e36b80d..34e82ed2 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -2609,6 +2609,7 @@ async function runForecast(args: string[]): Promise { } const options = parsedArgs.options; const display = DEFAULT_DASHBOARD_DISPLAY_SETTINGS; + const probeModel = inspectRequestedModel(options.model?.trim() || "gpt-5-codex").normalized; const quotaCache = options.live ? await loadQuotaCache() : null; const workingQuotaCache = quotaCache ? cloneQuotaCacheData(quotaCache) : null; let quotaCacheChanged = false; @@ -2659,7 +2660,7 @@ async function runForecast(args: string[]): Promise { const liveQuota = await fetchCodexQuotaSnapshot({ accountId: probeAccountId, accessToken: probeAccessToken, - model: options.model, + model: probeModel, }); liveQuotaByIndex.set(i, liveQuota); if (workingQuotaCache) { @@ -3410,6 +3411,7 @@ async function runFix(args: string[]): Promise { } const options = parsedArgs.options; const display = DEFAULT_DASHBOARD_DISPLAY_SETTINGS; + const probeModel = inspectRequestedModel(options.model?.trim() || "gpt-5-codex").normalized; const quotaCache = options.live ? await loadQuotaCache() : null; const workingQuotaCache = quotaCache ? cloneQuotaCacheData(quotaCache) : null; let quotaCacheChanged = false; @@ -3458,7 +3460,7 @@ async function runFix(args: string[]): Promise { const snapshot = await fetchCodexQuotaSnapshot({ accountId: probeAccountId, accessToken: currentAccessToken, - model: options.model, + model: probeModel, }); if (workingQuotaCache) { quotaCacheChanged = @@ -3554,7 +3556,7 @@ async function runFix(args: string[]): Promise { const snapshot = await fetchCodexQuotaSnapshot({ accountId: probeAccountId, accessToken: refreshResult.access, - model: options.model, + model: probeModel, }); if (workingQuotaCache) { quotaCacheChanged = @@ -4885,6 +4887,7 @@ async function runBest(args: string[]): Promise { } const now = Date.now(); + const probeModel = inspectRequestedModel(options.model?.trim() || "gpt-5-codex").normalized; const refreshFailures = new Map(); const liveQuotaByIndex = new Map>>(); const probeIdTokenByIndex = new Map(); @@ -4967,7 +4970,7 @@ async function runBest(args: string[]): Promise { const liveQuota = await fetchCodexQuotaSnapshot({ accountId: probeAccountId, accessToken: probeAccessToken, - model: options.model, + model: probeModel, }); liveQuotaByIndex.set(i, liveQuota); } catch (error) { diff --git a/lib/prompts/codex.ts b/lib/prompts/codex.ts index b21eab13..ceaa0d52 100644 --- a/lib/prompts/codex.ts +++ b/lib/prompts/codex.ts @@ -370,8 +370,15 @@ function refreshInstructionsInBackground( * Prewarm instruction caches for the provided models/families. */ export function prewarmCodexInstructions(models: string[] = []): void { - const candidates = models.length > 0 ? models : ["gpt-5-codex", "gpt-5.4", "gpt-5.2"]; + const candidates = models.length > 0 ? models : ["gpt-5-codex", "gpt-5.4", "gpt-5.1"]; + const prewarmTargets = new Map(); for (const model of candidates) { + const promptFamily = getModelFamily(model); + if (!prewarmTargets.has(promptFamily)) { + prewarmTargets.set(promptFamily, model); + } + } + for (const model of prewarmTargets.values()) { void getCodexInstructions(model).catch((error) => { logDebug("Codex instruction prewarm failed", { model, diff --git a/lib/request/helpers/model-map.ts b/lib/request/helpers/model-map.ts index 20a6832d..f229cc98 100644 --- a/lib/request/helpers/model-map.ts +++ b/lib/request/helpers/model-map.ts @@ -180,8 +180,8 @@ function addGeneralAliases(): void { addReasoningAliases("gpt-5-mini", "gpt-5-mini"); addReasoningAliases("gpt-5-nano", "gpt-5-nano"); - addAlias("gpt-5.1-chat-latest", "gpt-5.1"); - addAlias("gpt-5-chat-latest", "gpt-5"); + addReasoningAliases("gpt-5.1-chat-latest", "gpt-5.1"); + addReasoningAliases("gpt-5-chat-latest", "gpt-5"); addReasoningAliases("gpt-5.4-mini", "gpt-5-mini"); addReasoningAliases("gpt-5.4-nano", "gpt-5-nano"); } @@ -195,6 +195,7 @@ function addCodexAliases(): void { addAlias("gpt_5_codex", "gpt-5-codex"); addReasoningAliases("gpt-5.1-codex-max", "gpt-5.1-codex-max"); + addAlias("codex-max", "gpt-5.1-codex-max"); addAlias("codex-mini-latest", "gpt-5.1-codex-mini"); addReasoningAliases("gpt-5-codex-mini", "gpt-5.1-codex-mini"); diff --git a/test/capability-policy.test.ts b/test/capability-policy.test.ts index 9675a283..34db5fda 100644 --- a/test/capability-policy.test.ts +++ b/test/capability-policy.test.ts @@ -61,6 +61,16 @@ describe("capability policy store", () => { expect(snapshot?.successes).toBe(1); }); + it("keeps unknown model identifiers in separate capability buckets", () => { + const store = new CapabilityPolicyStore(); + store.recordSuccess("id:acc_unknown", "claude-3-sonnet-high", 1_000); + + expect(store.getSnapshot("id:acc_unknown", "claude-3-sonnet")).toMatchObject({ + successes: 1, + }); + expect(store.getSnapshot("id:acc_unknown", "gpt-5.4")).toBeNull(); + }); + it("ignores blank model and blank account writes", () => { const store = new CapabilityPolicyStore(); store.recordSuccess("", "gpt-5-codex", 1_000); diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 613d6c93..ba887689 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -5775,6 +5775,78 @@ describe("codex manager cli commands", () => { }); }); + it("uses the same normalized backend model for live probes across report, forecast, best, and fix", async () => { + const now = Date.now(); + const storageState = { + version: 3 as const, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "probe@example.com", + accountId: "acc_probe", + refreshToken: "refresh-probe", + accessToken: "access-probe", + expiresAt: now + 3_600_000, + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], + }; + loadAccountsMock.mockImplementation(async () => structuredClone(storageState)); + loadQuotaCacheMock.mockResolvedValue({ byAccountId: {}, byEmail: {} }); + saveQuotaCacheMock.mockResolvedValue(undefined); + queuedRefreshMock.mockResolvedValue({ + type: "success", + access: "access-probe-refresh", + refresh: "refresh-probe-refresh", + expires: now + 7_200_000, + idToken: "id-probe-refresh", + }); + fetchCodexQuotaSnapshotMock.mockResolvedValue({ + status: 200, + model: "gpt-5-mini", + primary: { + usedPercent: 10, + windowMinutes: 300, + resetAtMs: now + 1_000, + }, + secondary: { + usedPercent: 5, + windowMinutes: 10080, + resetAtMs: now + 2_000, + }, + }); + setCodexCliActiveSelectionMock.mockResolvedValue(true); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const commands = [ + ["auth", "report", "--live", "--json", "--model", "gpt-5.4-mini-high"], + ["auth", "forecast", "--live", "--json", "--model", "gpt-5.4-mini-high"], + ["auth", "best", "--live", "--model", "gpt-5.4-mini-high"], + ["auth", "fix", "--live", "--json", "--model", "gpt-5.4-mini-high"], + ]; + + for (const command of commands) { + fetchCodexQuotaSnapshotMock.mockClear(); + const exitCode = await runCodexMultiAuthCli(command); + + expect(exitCode).toBe(0); + expect(fetchCodexQuotaSnapshotMock).toHaveBeenCalled(); + for (const [request] of fetchCodexQuotaSnapshotMock.mock.calls) { + expect(request).toMatchObject({ model: "gpt-5-mini" }); + } + } + + expect(errorSpy).not.toHaveBeenCalled(); + errorSpy.mockRestore(); + warnSpy.mockRestore(); + logSpy.mockRestore(); + }); + it("drives interactive settings hub across sections and persists dashboard/backend changes", async () => { const now = Date.now(); setupInteractiveSettingsLogin(createSettingsStorage(now)); diff --git a/test/codex-prompts.test.ts b/test/codex-prompts.test.ts index 60a190e4..845eb5ef 100644 --- a/test/codex-prompts.test.ts +++ b/test/codex-prompts.test.ts @@ -14,7 +14,14 @@ vi.mock("node:fs", () => ({ const originalFetch = global.fetch; let mockFetch: ReturnType; -import { getModelFamily, getCodexInstructions, MODEL_FAMILIES, TOOL_REMAP_MESSAGE, __clearCacheForTesting } from "../lib/prompts/codex.js"; +import { + __clearCacheForTesting, + getCodexInstructions, + getModelFamily, + MODEL_FAMILIES, + prewarmCodexInstructions, + TOOL_REMAP_MESSAGE, +} from "../lib/prompts/codex.js"; const mockedReadFile = vi.mocked(fs.readFile); const mockedWriteFile = vi.mocked(fs.writeFile); @@ -160,6 +167,12 @@ describe("Codex Prompts Module", () => { const result = await getCodexInstructions("codex-max"); expect(result).toBe("new instructions from github"); expect(mockFetch).toHaveBeenCalledTimes(2); + const rawGitHubCall = mockFetch.mock.calls.find( + (call) => + typeof call[0] === "string" && + call[0].includes("raw.githubusercontent.com"), + ); + expect(rawGitHubCall?.[0]).toContain("gpt-5.1-codex-max_prompt.md"); }); it("should handle 304 Not Modified response", async () => { @@ -243,6 +256,12 @@ describe("Codex Prompts Module", () => { const result = await getCodexInstructions("gpt-5.2-codex"); expect(result).toBe("fallback instructions"); + const rawGitHubCall = mockFetch.mock.calls.find( + (call) => + typeof call[0] === "string" && + call[0].includes("raw.githubusercontent.com"), + ); + expect(rawGitHubCall?.[0]).toContain("gpt_5_codex_prompt.md"); }); it("should parse tag from HTML content if URL parsing fails", async () => { @@ -366,6 +385,46 @@ describe("Codex Prompts Module", () => { const result = await getCodexInstructions("gpt-5.1"); expect(result).toBe("bundled fallback instructions"); }); + + it("prewarms unique prompt families once while retaining gpt-5.1 coverage", async () => { + mockedReadFile.mockRejectedValue(new Error("ENOENT")); + mockFetch.mockImplementation((input) => { + if (typeof input === "string" && input.includes("api.github.com")) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ tag_name: "rust-v0.120.0" }), + }); + } + return Promise.resolve({ + ok: true, + text: () => Promise.resolve("prewarmed content"), + headers: { get: () => "etag" }, + }); + }); + mockedMkdir.mockResolvedValue(undefined); + mockedWriteFile.mockResolvedValue(undefined); + + prewarmCodexInstructions(); + + await vi.waitFor(() => { + const rawCalls = mockFetch.mock.calls.filter( + (call) => + typeof call[0] === "string" && + call[0].includes("raw.githubusercontent.com"), + ); + expect(rawCalls).toHaveLength(3); + }); + + const rawUrls = mockFetch.mock.calls + .map((call) => call[0]) + .filter( + (url): url is string => + typeof url === "string" && url.includes("raw.githubusercontent.com"), + ); + expect(rawUrls.filter((url) => url.includes("gpt_5_2_prompt.md"))).toHaveLength(1); + expect(rawUrls.some((url) => url.includes("gpt_5_codex_prompt.md"))).toBe(true); + expect(rawUrls.some((url) => url.includes("gpt_5_1_prompt.md"))).toBe(true); + }); }); describe("Cache size management", () => { @@ -484,6 +543,27 @@ describe("Codex Prompts Module", () => { ); expect(rawGitHubCall?.[0]).toContain("gpt_5_2_prompt.md"); }); + + it("should map gpt-5.2 prompts to the latest available general prompt file", async () => { + mockedReadFile.mockRejectedValue(new Error("ENOENT")); + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ tag_name: "rust-v0.116.0" }), + text: () => Promise.resolve("content"), + headers: { get: () => "etag" }, + }); + mockedMkdir.mockResolvedValue(undefined); + mockedWriteFile.mockResolvedValue(undefined); + + await getCodexInstructions("gpt-5.2"); + const fetchCalls = mockFetch.mock.calls; + const rawGitHubCall = fetchCalls.find( + (call) => + typeof call[0] === "string" && + call[0].includes("raw.githubusercontent.com"), + ); + expect(rawGitHubCall?.[0]).toContain("gpt_5_2_prompt.md"); + }); }); }); }); diff --git a/test/model-map.test.ts b/test/model-map.test.ts index 6ad16967..c071b372 100644 --- a/test/model-map.test.ts +++ b/test/model-map.test.ts @@ -31,6 +31,11 @@ describe("model map", () => { expect(MODEL_MAP["gpt-5.4-mini"]).toBe("gpt-5-mini"); expect(MODEL_MAP["gpt-5.4-nano"]).toBe("gpt-5-nano"); }); + + it("adds reasoning variants for legacy chat-latest aliases", () => { + expect(MODEL_MAP["gpt-5-chat-latest-high"]).toBe("gpt-5"); + expect(MODEL_MAP["gpt-5.1-chat-latest-minimal"]).toBe("gpt-5.1"); + }); }); describe("getNormalizedModel", () => { @@ -39,6 +44,8 @@ describe("model map", () => { expect(getNormalizedModel("GPT-5.4-PRO-HIGH")).toBe("gpt-5.4-pro"); expect(getNormalizedModel("gpt-5.4-mini")).toBe("gpt-5-mini"); expect(getNormalizedModel("gpt-5.3-codex-high")).toBe("gpt-5-codex"); + expect(getNormalizedModel("gpt-5-chat-latest-high")).toBe("gpt-5"); + expect(getNormalizedModel("codex-max")).toBe("gpt-5.1-codex-max"); }); it("returns undefined for unknown exact identifiers", () => { From e17a5c5e244e0584cd6613f1aaa0062b4f40ef3a Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 18:25:29 +0800 Subject: [PATCH 3/4] Harden prompt raw-path assertions --- test/codex-prompts.test.ts | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/test/codex-prompts.test.ts b/test/codex-prompts.test.ts index 845eb5ef..827c16de 100644 --- a/test/codex-prompts.test.ts +++ b/test/codex-prompts.test.ts @@ -167,12 +167,15 @@ describe("Codex Prompts Module", () => { const result = await getCodexInstructions("codex-max"); expect(result).toBe("new instructions from github"); expect(mockFetch).toHaveBeenCalledTimes(2); - const rawGitHubCall = mockFetch.mock.calls.find( - (call) => - typeof call[0] === "string" && - call[0].includes("raw.githubusercontent.com"), - ); - expect(rawGitHubCall?.[0]).toContain("gpt-5.1-codex-max_prompt.md"); + const rawGitHubUrls = mockFetch.mock.calls + .map((call) => call[0]) + .filter( + (url): url is string => + typeof url === "string" && url.includes("raw.githubusercontent.com"), + ); + expect( + rawGitHubUrls.some((url) => url.includes("gpt-5.1-codex-max_prompt.md")), + ).toBe(true); }); it("should handle 304 Not Modified response", async () => { @@ -256,12 +259,15 @@ describe("Codex Prompts Module", () => { const result = await getCodexInstructions("gpt-5.2-codex"); expect(result).toBe("fallback instructions"); - const rawGitHubCall = mockFetch.mock.calls.find( - (call) => - typeof call[0] === "string" && - call[0].includes("raw.githubusercontent.com"), + const rawGitHubUrls = mockFetch.mock.calls + .map((call) => call[0]) + .filter( + (url): url is string => + typeof url === "string" && url.includes("raw.githubusercontent.com"), + ); + expect(rawGitHubUrls.some((url) => url.includes("gpt_5_codex_prompt.md"))).toBe( + true, ); - expect(rawGitHubCall?.[0]).toContain("gpt_5_codex_prompt.md"); }); it("should parse tag from HTML content if URL parsing fails", async () => { From 163377eafb92b1f3fa2e644fb3bd9f93e8e38531 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 22 Mar 2026 19:41:48 +0800 Subject: [PATCH 4/4] warn on reasoning effort coercion --- lib/request/request-transformer.ts | 12 ++++++++++++ test/config.test.ts | 25 ++++++++++++++++++++++++- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/lib/request/request-transformer.ts b/lib/request/request-transformer.ts index 6c002476..0e198929 100644 --- a/lib/request/request-transformer.ts +++ b/lib/request/request-transformer.ts @@ -304,6 +304,7 @@ export function getReasoningConfig( const defaultEffort = profile.defaultReasoningEffort; const requestedEffort = userConfig.reasoningEffort ?? defaultEffort; const effort = coerceReasoningEffort( + profile.normalizedModel, requestedEffort, profile.supportedReasoningEfforts, defaultEffort, @@ -330,6 +331,7 @@ const REASONING_FALLBACKS: Record< } as const; function coerceReasoningEffort( + modelName: string, effort: ModelReasoningEffort, supportedEfforts: readonly ModelReasoningEffort[], defaultEffort: ModelReasoningEffort, @@ -341,10 +343,20 @@ function coerceReasoningEffort( const fallbackOrder = REASONING_FALLBACKS[effort] ?? [defaultEffort]; for (const candidate of fallbackOrder) { if (supportedEfforts.includes(candidate)) { + logWarn("Coercing unsupported reasoning effort for model", { + model: modelName, + requestedEffort: effort, + effectiveEffort: candidate, + }); return candidate; } } + logWarn("Falling back to default reasoning effort for model", { + model: modelName, + requestedEffort: effort, + effectiveEffort: defaultEffort, + }); return defaultEffort; } diff --git a/test/config.test.ts b/test/config.test.ts index cec3149d..c0e3fcbe 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -1,9 +1,10 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { applyFastSessionDefaults, getModelConfig, getReasoningConfig, } from '../lib/request/request-transformer.js'; +import * as logger from '../lib/logger.js'; import type { UserConfig } from '../lib/types.js'; describe('Configuration Parsing', () => { @@ -97,6 +98,28 @@ describe('Configuration Parsing', () => { expect(nanoReasoning.summary).toBe('auto'); }); + it('should warn when a lightweight model reasoning request is coerced', () => { + const warnSpy = vi.spyOn(logger, 'logWarn').mockImplementation(() => {}); + + try { + const miniReasoning = getReasoningConfig('gpt-5-mini', { + reasoningEffort: 'high', + }); + + expect(miniReasoning.effort).toBe('medium'); + expect(warnSpy).toHaveBeenCalledWith( + 'Coercing unsupported reasoning effort for model', + expect.objectContaining({ + model: 'gpt-5-mini', + requestedEffort: 'high', + effectiveEffort: 'medium', + }), + ); + } finally { + warnSpy.mockRestore(); + } + }); + it('should normalize "minimal" to "low" for gpt-5-codex', () => { const codexMinimalConfig = { reasoningEffort: 'minimal' as const }; const codexMinimalReasoning = getReasoningConfig('gpt-5-codex', codexMinimalConfig);