diff --git a/docs-site/src/content/docs/guides/pi.md b/docs-site/src/content/docs/guides/pi.md index fa44d2754..c44b97f12 100644 --- a/docs-site/src/content/docs/guides/pi.md +++ b/docs-site/src/content/docs/guides/pi.md @@ -103,9 +103,26 @@ small-context model is never given more output than context. It is not a claim a model's true maximum. Two fields are deliberately absent. `cost` requires all four price fields and opencodex has no -price data for routed models — emitting zeros would assert that every model is free. `reasoning` is -a boolean in Pi while the catalog carries an effort ladder, and mapping one onto the other would be -a guess. +price data for routed models — emitting zeros would assert that every model is free. + +`reasoning` is the one field that used to be absent and now is not: Pi stores a boolean while the +catalog carries an effort ladder, and mapping one onto the other used to be a guess. Since the +catalog's ladder is the proxy's own statement about whether a model accepts reasoning parameters +(adapters honor `reasoning_effort`), an export row with a **non-empty** ladder now emits +`"reasoning": true`, and a row without one (or with an explicitly empty ladder) stays +reasoning-free. Pi then offers its effort control for exactly the models opencodex will accept it +on. The export also emits a `thinkingLevelMap` that hides every pi level with no declared target +(`null`), so pi never offers — and never sends — an effort the ladder does not contain. One +fallback keeps the model usable: when `ultra` is declared without `max`, pi's `max` level maps +to `ultra` (still a ladder member). +If you need a different mapping, hand-edit `thinkingLevelMap` afterward as documented by Pi. + +Treat `reasoning` as Pi-UI metadata: it is derived from the catalog ladder, not proof that the +upstream natively supports a reasoning parameter. What the proxy actually sends for a given +`reasoning_effort` value depends on the provider's adapter and model — it may pass the value +through, translate it (wire aliases), clamp it to the configured ladder, emulate it, or omit it +entirely (e.g. `noReasoningModels`). The boolean only controls whether Pi offers the control at +all. ## Schema status diff --git a/docs/screenshots/custom-model-reasoning-dialog.png b/docs/screenshots/custom-model-reasoning-dialog.png new file mode 100644 index 000000000..633c2159a Binary files /dev/null and b/docs/screenshots/custom-model-reasoning-dialog.png differ diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index f4e25636e..a1af60c71 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -539,6 +539,15 @@ export const de: Record = { "models.customFieldDisplayNamePlaceholder": "z. B. Qwen 4 Max Preview", "models.customFieldContext": "Kontextfenster", "models.customFieldModalities": "Eingabemodalitäten", + "models.customFieldReasoning": "Reasoning-Aufwand", + "models.customFieldReasoningOverride": "Reasoning-Aufwand überschreiben", + "models.reasoningEffort.none": "Keine", + "models.reasoningEffort.minimal": "Minimal", + "models.reasoningEffort.low": "Niedrig", + "models.reasoningEffort.medium": "Mittel", + "models.reasoningEffort.high": "Hoch", + "models.reasoningEffort.xhigh": "Sehr hoch", + "models.reasoningEffort.max": "Maximal", "models.tipProvider": "Anbieter", "models.tipContext": "Kontext", "models.tipModalities": "Modalitäten", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 11338b919..48b5e4e7d 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -564,6 +564,15 @@ export const en = { "models.customFieldDisplayNamePlaceholder": "e.g. Qwen 4 Max Preview", "models.customFieldContext": "Context window", "models.customFieldModalities": "Input modalities", + "models.customFieldReasoning": "Reasoning effort", + "models.customFieldReasoningOverride": "Override reasoning effort", + "models.reasoningEffort.none": "None", + "models.reasoningEffort.minimal": "Minimal", + "models.reasoningEffort.low": "Low", + "models.reasoningEffort.medium": "Medium", + "models.reasoningEffort.high": "High", + "models.reasoningEffort.xhigh": "Extra high", + "models.reasoningEffort.max": "Maximum", "models.tipProvider": "Provider", "models.tipContext": "Context", "models.tipModalities": "Modalities", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index c4bc0321c..791f1cb22 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1957,6 +1957,15 @@ export const ja: Record = { "models.customFieldDisplayNamePlaceholder": "e.g. Qwen 4 Max Preview", "models.customFieldContext": "Context window", "models.customFieldModalities": "Input modalities", + "models.customFieldReasoning": "推論努力", + "models.customFieldReasoningOverride": "推論努力を上書き", + "models.reasoningEffort.none": "なし", + "models.reasoningEffort.minimal": "最小", + "models.reasoningEffort.low": "低", + "models.reasoningEffort.medium": "中", + "models.reasoningEffort.high": "高", + "models.reasoningEffort.xhigh": "非常に高", + "models.reasoningEffort.max": "最大", "models.tipProvider": "Provider", "models.tipContext": "Context", "models.tipModalities": "Modalities", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index e717e7a5c..ed0637bf6 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -550,6 +550,15 @@ export const ko: Record = { "models.customFieldDisplayNamePlaceholder": "예: Qwen 4 Max Preview", "models.customFieldContext": "컨텍스트 윈도우", "models.customFieldModalities": "입력 모달리티", + "models.customFieldReasoning": "추론 노력", + "models.customFieldReasoningOverride": "추론 노력 재정의", + "models.reasoningEffort.none": "없음", + "models.reasoningEffort.minimal": "최소", + "models.reasoningEffort.low": "낮음", + "models.reasoningEffort.medium": "중간", + "models.reasoningEffort.high": "높음", + "models.reasoningEffort.xhigh": "매우 높음", + "models.reasoningEffort.max": "최대", "models.tipProvider": "프로바이더", "models.tipContext": "컨텍스트", "models.tipModalities": "모달리티", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index b6f8c390d..4880fec78 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -552,6 +552,15 @@ export const ru: Record = { "models.customFieldDisplayNamePlaceholder": "например, Qwen 4 Max Preview", "models.customFieldContext": "Контекстное окно", "models.customFieldModalities": "Входные модальности", + "models.customFieldReasoning": "Уровень рассуждений", + "models.customFieldReasoningOverride": "Переопределить уровень рассуждений", + "models.reasoningEffort.none": "Нет", + "models.reasoningEffort.minimal": "Минимальный", + "models.reasoningEffort.low": "Низкий", + "models.reasoningEffort.medium": "Средний", + "models.reasoningEffort.high": "Высокий", + "models.reasoningEffort.xhigh": "Очень высокий", + "models.reasoningEffort.max": "Максимальный", "models.tipProvider": "Провайдер", "models.tipContext": "Контекст", "models.tipModalities": "Модальности", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 434805749..f74c53f0d 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -555,6 +555,15 @@ export const tr: Record = { "models.customFieldDisplayNamePlaceholder": "örn. Qwen 4 Max Preview", "models.customFieldContext": "Bağlam penceresi", "models.customFieldModalities": "Girdi türleri", + "models.customFieldReasoning": "Akıl yürütme çabası", + "models.customFieldReasoningOverride": "Akıl yürütme çabasını geçersiz kıl", + "models.reasoningEffort.none": "Yok", + "models.reasoningEffort.minimal": "Minimal", + "models.reasoningEffort.low": "Düşük", + "models.reasoningEffort.medium": "Orta", + "models.reasoningEffort.high": "Yüksek", + "models.reasoningEffort.xhigh": "Çok yüksek", + "models.reasoningEffort.max": "Maksimum", "models.tipProvider": "Sağlayıcı", "models.tipContext": "Bağlam", "models.tipModalities": "Girdi Türleri", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index e194a06e2..8d008686d 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -420,6 +420,15 @@ export const zhTW: Record = { "models.customFieldDisplayNamePlaceholder": "例如 Qwen 4 Max Preview", "models.customFieldContext": "上下文視窗", "models.customFieldModalities": "輸入模態", + "models.customFieldReasoning": "推理強度", + "models.customFieldReasoningOverride": "覆寫推理強度", + "models.reasoningEffort.none": "無", + "models.reasoningEffort.minimal": "最低", + "models.reasoningEffort.low": "低", + "models.reasoningEffort.medium": "中", + "models.reasoningEffort.high": "高", + "models.reasoningEffort.xhigh": "極高", + "models.reasoningEffort.max": "最高", "models.tipProvider": "供應商", "models.tipContext": "上下文", "models.tipModalities": "模態", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 5d198f5be..1b169d645 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -547,6 +547,15 @@ export const zh: Record = { "models.customFieldDisplayNamePlaceholder": "例如 Qwen 4 Max Preview", "models.customFieldContext": "上下文窗口", "models.customFieldModalities": "输入模态", + "models.customFieldReasoning": "推理强度", + "models.customFieldReasoningOverride": "覆盖推理强度", + "models.reasoningEffort.none": "无", + "models.reasoningEffort.minimal": "最低", + "models.reasoningEffort.low": "低", + "models.reasoningEffort.medium": "中", + "models.reasoningEffort.high": "高", + "models.reasoningEffort.xhigh": "极高", + "models.reasoningEffort.max": "最高", "models.tipProvider": "提供方", "models.tipContext": "上下文", "models.tipModalities": "模态", diff --git a/gui/src/pages/Models.tsx b/gui/src/pages/Models.tsx index bf63718c2..3a9567d00 100644 --- a/gui/src/pages/Models.tsx +++ b/gui/src/pages/Models.tsx @@ -49,6 +49,7 @@ import { THREAD_OPTIONS, writeCollapsedProviders, discoveryFailureLabel, + REASONING_EFFORT_LEVELS, type ModelRow, type ProviderContextCapsResponse, type ShadowCallData, @@ -193,6 +194,13 @@ export default function Models({ apiBase }: { apiBase: string }) { const [customFormContextWindow, setCustomFormContextWindow] = useState(""); const [customFormShowCustomCtx, setCustomFormShowCustomCtx] = useState(false); const [customFormModalities, setCustomFormModalities] = useState(["text"]); + const [customFormReasoning, setCustomFormReasoning] = useState(false); + const [customFormReasoningEfforts, setCustomFormReasoningEfforts] = useState([]); + // Whether the ladder has been seeded at least once. `[]` is a MEANINGFUL explicit + // no-reasoning override, so initialization is tracked separately from the array contents: + // once seeded (an edit's stored ladder — including an explicit empty one — or a new form's + // first enable), re-enabling the override preserves the current array even when empty. + const customFormReasoningInitializedRef = useRef(false); const [customSaving, setCustomSaving] = useState(false); const [customError, setCustomError] = useState(""); const [contextModalProvider, setContextModalProvider] = useState(null); @@ -831,6 +839,7 @@ export default function Models({ apiBase }: { apiBase: string }) { displayName?: string, contextWindow?: number, inputModalities?: string[], + reasoningEfforts?: string[], ) => { setCustomSaving(true); setCustomError(""); @@ -838,7 +847,7 @@ export default function Models({ apiBase }: { apiBase: string }) { const r = await fetch(`${apiBase}/api/custom-models`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ provider, modelId, displayName, contextWindow, inputModalities }), + body: JSON.stringify({ provider, modelId, displayName, contextWindow, inputModalities, reasoningEfforts }), }); try { await readJsonOrThrow(r, t("models.customSaveFailed")); @@ -1000,6 +1009,9 @@ export default function Models({ apiBase }: { apiBase: string }) { setCustomFormContextWindow(""); setCustomFormShowCustomCtx(false); setCustomFormModalities(["text"]); + setCustomFormReasoning(false); + setCustomFormReasoningEfforts([]); + customFormReasoningInitializedRef.current = false; setCustomError(""); setCustomModalOpen(true); }} @@ -1146,6 +1158,14 @@ export default function Models({ apiBase }: { apiBase: string }) { setCustomFormContextWindow(m.contextWindow ? String(m.contextWindow) : ""); setCustomFormShowCustomCtx(false); setCustomFormModalities(m.inputModalities ?? ["text"]); + // Only a STORED ladder counts as "configured": an inherited one + // would show a phantom override that saves "inherit" over the + // provider row's current metadata. + setCustomFormReasoning(Array.isArray(m.reasoningEfforts)); + setCustomFormReasoningEfforts(m.reasoningEfforts ?? []); + // A stored ladder — even an explicit empty one — is a real + // configuration: re-enabling must preserve it, not reseed. + customFormReasoningInitializedRef.current = Array.isArray(m.reasoningEfforts); setCustomError(""); setCustomModalOpen(true); setHoveredModel(null); @@ -1595,6 +1615,56 @@ export default function Models({ apiBase }: { apiBase: string }) { ))} + +
+ {t("models.customFieldReasoning")} +
+ +
+ {customFormReasoning && ( +
+ {REASONING_EFFORT_LEVELS.map(effort => ( + + ))} +
+ )} +
@@ -1611,19 +1681,24 @@ export default function Models({ apiBase }: { apiBase: string }) { const ctxVal = customFormContextWindow ? Number(customFormContextWindow.replace(/[_,\s]/g, "")) : undefined; const contextWindow = ctxVal && ctxVal > 0 ? Math.floor(ctxVal) : undefined; if (customModalMode === "add") { + const reasoningEfforts = customFormReasoning ? customFormReasoningEfforts : undefined; void addCustomModel( customModalProvider, modelId, displayName || undefined, contextWindow, customFormModalities.length > 0 ? customFormModalities : undefined, + reasoningEfforts, ); } else { + // `null` clears a stored override back to "inherit from the provider row"; + // an explicit empty ladder stays stored as "no reasoning". void updateCustomModel(customModalId, { modelId, displayName, contextWindow: contextWindow ?? null, inputModalities: customFormModalities, + reasoningEfforts: customFormReasoning ? customFormReasoningEfforts : null, }); } }} diff --git a/gui/src/pages/models-shared.ts b/gui/src/pages/models-shared.ts index ab845a13c..a8205616c 100644 --- a/gui/src/pages/models-shared.ts +++ b/gui/src/pages/models-shared.ts @@ -38,8 +38,19 @@ export interface ModelRow { contextWindow?: number; contextCap?: number; contextCapped?: boolean; + /** Stored custom-row override (not the inherited ladder); only present on custom rows. */ + reasoningEfforts?: string[]; } +/** + * Reasoning-effort labels offered in the custom-model dialog. The full set of real + * `reasoning_effort` values (none, minimal, low, medium, high, xhigh, max). Deliberately + * excludes `ultra`: that is a Codex catalog label for the multi-agent collab surface, not a + * real `reasoning_effort` value — codex-rs converts it to `max` before any provider + * request, and the catalog writer appends it to every non-empty ladder anyway. + */ +export const REASONING_EFFORT_LEVELS = ["none", "minimal", "low", "medium", "high", "xhigh", "max"] as const; + export interface ProviderContextCapsResponse { cap?: number; value?: number; diff --git a/gui/src/styles-models-workspace.css b/gui/src/styles-models-workspace.css index 73c4901a1..e2cc78310 100644 --- a/gui/src/styles-models-workspace.css +++ b/gui/src/styles-models-workspace.css @@ -367,6 +367,12 @@ gap: var(--space-4); } +.models-field-stack { + display: flex; + flex-direction: column; + gap: var(--space-4); +} + .models-field { display: flex; flex-direction: column; @@ -382,6 +388,18 @@ cursor: pointer; } +/* Uniform checkbox rendering in the custom-model dialog: native checkbox size is + browser-dependent (Chrome ~13px, Safari ~16px) and can even differ between rows in + some renderers, so pin an explicit size for every checkbox in the modal. 13px is the + size Chrome renders natively here; pinning it keeps the effort-step checkboxes exactly + as large as the other dialog checkboxes. */ +.modal-card .models-modality-option input[type="checkbox"] { + width: 13px; + height: 13px; + margin: 0; + flex: none; +} + /* Beat .row { gap: 10px } (defined later in styles.css). */ .row.models-model-row, .row.models-cap-row, diff --git a/src/cli/models-runtime.ts b/src/cli/models-runtime.ts index e451bb100..c309274c8 100644 --- a/src/cli/models-runtime.ts +++ b/src/cli/models-runtime.ts @@ -16,7 +16,9 @@ import { const USAGE = `Usage: ocx models live [--provider ] [--json] ocx models edit [--model-id ] [--display-name ] - [--context-window ] [--modalities ] [--json] + [--context-window ] [--modalities ] + [--reasoning-efforts ] + [--default-reasoning-effort ] [--json] ocx models [--native] [--json] ocx models provider [--json] ocx models selected [--set |--clear] [--json] @@ -57,6 +59,8 @@ async function edit(argv: string[], deps: RuntimeApiDeps): Promise { const displayName = takeOption(args, "--display-name"); const contextRaw = takeOption(args, "--context-window"); const modalitiesRaw = takeOption(args, "--modalities"); + const reasoningEffortsRaw = takeOption(args, "--reasoning-efforts"); + const defaultEffortRaw = takeOption(args, "--default-reasoning-effort"); rejectArgs(args, USAGE); if (modelId !== undefined) patch.modelId = modelId; if (displayName !== undefined) patch.displayName = displayName === "-" ? "" : displayName; @@ -66,6 +70,23 @@ async function edit(argv: string[], deps: RuntimeApiDeps): Promise { patch.contextWindow = value === 0 ? null : value; } if (modalitiesRaw !== undefined) patch.inputModalities = modalitiesRaw === "-" ? [] : csv(modalitiesRaw); + // "-" restores inheritance by clearing the stored ladder (null); "" stores an explicit + // empty ladder (the "no reasoning" override, same as the dashboard's uncheck-all). + // Embedded blank CSV members (`low,,high`, `,,`) are malformed and must be rejected, not + // silently normalized by csv(). + if (reasoningEffortsRaw !== undefined) { + if (reasoningEffortsRaw === "-") { + patch.reasoningEfforts = null; + } else { + const trimmed = reasoningEffortsRaw.trim(); + const values = trimmed === "" ? [] : trimmed.split(",").map(value => value.trim()); + if (values.some(value => value === "")) { + throw new CliUsageError("--reasoning-efforts must be comma-separated values from none, minimal, low, medium, high, xhigh, max, ultra (\"\" for no reasoning, \"-\" to inherit)", USAGE); + } + patch.reasoningEfforts = values; + } + } + if (defaultEffortRaw !== undefined) patch.defaultReasoningEffort = defaultEffortRaw === "-" ? null : defaultEffortRaw; if (Object.keys(patch).length === 0) throw new CliUsageError("at least one edit option is required", USAGE); const result = await runtimeRequest(`/api/custom-models/${encodeURIComponent(id)}`, { method: "PUT", diff --git a/src/cli/models.ts b/src/cli/models.ts index 11787e9bc..83403c6a0 100644 --- a/src/cli/models.ts +++ b/src/cli/models.ts @@ -5,15 +5,71 @@ import { randomUUID } from "node:crypto"; import { createInterface } from "node:readline/promises"; import { syncModelsToCodex } from "../codex/sync"; import { hasOwnProvider, isValidProviderName, loadConfig, saveConfig } from "../config"; +import { canonicalizeReasoningEfforts, isDeclaredReasoningEffort } from "../reasoning-effort"; import { routedSlug } from "../providers/slug-codec"; import { findLiveProxy } from "../server/proxy-liveness"; import type { OcxConfig, OcxCustomModel } from "../types"; -const ADD_USAGE = "Usage: ocx models add [--display-name ] [--context-window ] [--modalities text,image,audio]"; +const ADD_USAGE = "Usage: ocx models add [--display-name ] [--context-window ] [--modalities text,image,audio] [--reasoning-efforts ] [--default-reasoning-effort ]"; const REMOVE_USAGE = "Usage: ocx models remove [--yes]"; const LIST_CUSTOM_USAGE = "Usage: ocx models list-custom [--json]"; const ALLOWED_MODALITIES = new Set(["text", "image", "audio"]); +/** + * Parse and validate the reasoning flags shared by `ocx models add` (offline path). + * "-" means "inherit" and omits the field entirely; "" means an explicit empty ladder + * ("no reasoning" override, the same state the dashboard stores for the toggle-off + * checkbox set). Malformed CSV like `low,,high` or `,,` is rejected instead of being + * silently normalized. Values are canonicalized into Codex ladder order so the stored + * config matches what the API stores. + */ +export function parseReasoningArgs( + reasoningEffortsValue: string | undefined, + defaultEffortValue: string | undefined, +): { reasoningEfforts?: string[]; defaultReasoningEffort?: string; error?: string } { + if (reasoningEffortsValue === undefined && defaultEffortValue === undefined) return {}; + let reasoningEfforts: string[] | undefined; + if (reasoningEffortsValue !== undefined) { + const trimmed = reasoningEffortsValue.trim(); + if (trimmed === "-") { + reasoningEfforts = undefined; + } else if (trimmed === "") { + // Explicit no-reasoning override, exactly like the API's [] / the dashboard's + // uncheck-all state. + reasoningEfforts = []; + } else { + const parts = trimmed.split(",").map(value => value.trim()); + if (parts.some(part => part === "")) { + return { error: "--reasoning-efforts must be comma-separated values from none, minimal, low, medium, high, xhigh, max, ultra (\"\" for no reasoning, \"-\" to inherit)" }; + } + const invalid = parts.filter(value => !isDeclaredReasoningEffort(value)); + if (invalid.length > 0) { + return { error: `unsupported reasoning effort: ${invalid.join(", ")} (allowed: none, minimal, low, medium, high, xhigh, max, ultra)` }; + } + reasoningEfforts = canonicalizeReasoningEfforts(parts); + } + } + let defaultReasoningEffort: string | undefined; + if (defaultEffortValue !== undefined) { + const trimmed = defaultEffortValue.trim(); + if (trimmed === "-") { + defaultReasoningEffort = undefined; + } else { + if (!isDeclaredReasoningEffort(trimmed)) { + return { error: `unsupported reasoning effort: ${trimmed} (allowed: none, minimal, low, medium, high, xhigh, max, ultra)` }; + } + if (!reasoningEfforts || reasoningEfforts.length === 0) { + return { error: "--default-reasoning-effort requires --reasoning-efforts" }; + } + if (!reasoningEfforts.includes(trimmed)) { + return { error: `--default-reasoning-effort "${trimmed}" is not in the declared reasoning efforts` }; + } + defaultReasoningEffort = trimmed; + } + } + return { reasoningEfforts, defaultReasoningEffort }; +} + interface ModelEntry { provider: string; model: string; @@ -118,6 +174,8 @@ async function handleCustomAdd(args: string[]): Promise { const displayNameValue = consumeFlagValue(rest, "--display-name"); const contextWindowValue = consumeFlagValue(rest, "--context-window"); const modalitiesValue = consumeFlagValue(rest, "--modalities"); + const reasoningEffortsValue = consumeFlagValue(rest, "--reasoning-efforts"); + const defaultEffortValue = consumeFlagValue(rest, "--default-reasoning-effort"); rejectUnexpectedArgs(rest, ADD_USAGE); if (!provider || !modelId) fail("provider and modelId are required", ADD_USAGE); @@ -150,6 +208,9 @@ async function handleCustomAdd(args: string[]): Promise { inputModalities = [...new Set(inputModalities)]; } + const parsed = parseReasoningArgs(reasoningEffortsValue, defaultEffortValue); + if (parsed.error) fail(parsed.error); + const existing = config.customModels ?? []; const slug = routedSlug(provider, modelId); if (existing.some(model => routedSlug(model.provider, model.modelId) === slug)) { @@ -163,6 +224,8 @@ async function handleCustomAdd(args: string[]): Promise { ...(displayName ? { displayName } : {}), ...(contextWindow ? { contextWindow } : {}), ...(inputModalities ? { inputModalities } : {}), + ...(parsed.reasoningEfforts ? { reasoningEfforts: parsed.reasoningEfforts } : {}), + ...(parsed.defaultReasoningEffort ? { defaultReasoningEffort: parsed.defaultReasoningEffort } : {}), addedAt: new Date().toISOString(), }; config.customModels = [...existing, entry]; @@ -218,12 +281,14 @@ function customModelCells(model: OcxCustomModel): string[] { model.displayName ?? "-", model.contextWindow ? `${Math.round(model.contextWindow / 1000)}k` : "-", model.inputModalities?.join(",") ?? "-", + model.reasoningEfforts?.join(",") ?? "-", + model.defaultReasoningEffort ?? "-", ]; } function printCustomModelGroup(provider: string, models: OcxCustomModel[]): void { const rows = models.map(customModelCells); - const headers = ["ID", "MODEL", "DISPLAY NAME", "CONTEXT", "MODALITIES"]; + const headers = ["ID", "MODEL", "DISPLAY NAME", "CONTEXT", "MODALITIES", "EFFORTS", "DEFAULT EFFORT"]; const widths = headers.map((header, column) => Math.max(header.length, ...rows.map(row => row[column].length))); const line = (cells: string[]) => cells.map((cell, column) => cell.padEnd(widths[column])).join(" "); console.log(`${provider}:`); diff --git a/src/clients/config-export.ts b/src/clients/config-export.ts index 44240c9ef..4c903fa0e 100644 --- a/src/clients/config-export.ts +++ b/src/clients/config-export.ts @@ -664,6 +664,15 @@ export interface PiModelEntry { input: string[]; contextWindow?: number; maxTokens?: number; + /** Advertised when the catalog row carries a non-empty effort ladder. */ + reasoning?: true; + /** + * Constrains pi's own level scale (minimal..max) to the declared ladder: members map to + * themselves, everything else is hidden (`null`). Without it pi would offer levels the + * ladder does not contain — harmless for provider-config ladders (the proxy clamps those + * at the wire) but a real 400 risk for custom-row ladders, which are advertisement-only. + */ + thinkingLevelMap?: Record; } export interface PiProviderBlock { @@ -836,16 +845,24 @@ export interface DshGeneratedConfig { * Pi's `~/.pi/agent/models.json` shape. `models` is an ARRAY (identity lives in `id`), * unlike OpenCode's keyed object. * - * Two fields are deliberately absent. `cost` requires all four price fields and we have - * no price data at all, so emitting zeros would assert every routed model is free. - * `reasoning` is a boolean in Pi while our catalog carries an effort list — mapping one - * to the other would be a guess. + * Two fields were deliberately absent once. `cost` still is: it requires all four price + * fields and we have no price data at all, so emitting zeros would assert every routed + * model is free. `reasoning` used to be omitted because Pi's boolean and the catalog's + * effort ladder did not obviously map — but a NON-EMPTY ladder is the catalog's own + * statement that the model accepts reasoning parameters (adapters honor `reasoning_effort`), + * and an empty or absent ladder is the statement that it does not. Emitting `reasoning: + * true` exactly for rows with a ladder is therefore not a guess; it is what makes Pi's + * effort control appear for routed models at all. The export also emits a `thinkingLevelMap` + * that hides every pi level outside the declared ladder, so pi never offers (and sends) an + * effort the ladder does not contain — custom-row ladders are catalog advertisement only + * and get no wire clamp, so this map is what keeps pi honest for those. Users who need a + * different mapping can still hand-tune `thinkingLevelMap` afterwards. * * Pi's input enum IS verified: its documented model configuration accepts only * `text` and `image`, and a validation failure yields an EMPTY model config * rather than dropping the offending entry — one bad value costs every routed - * model. The rest of this contract (omitting `cost` and `reasoning`) is still - * ours rather than a claim about Pi's acceptance. + * model. The rest of this contract (omitting `cost`) is still ours rather than + * a claim about Pi's acceptance. */ function buildPiClientConfig(ctx: ExportContext): PiGeneratedConfig { const models: PiModelEntry[] = []; @@ -862,6 +879,21 @@ function buildPiClientConfig(ctx: ExportContext): PiGeneratedConfig { name: exportModelLabel(model), input, }; + if (Array.isArray(model.reasoningEfforts) && model.reasoningEfforts.length > 0) { + entry.reasoning = true; + const efforts = model.reasoningEfforts; + entry.thinkingLevelMap = { + // pi's off level maps to the declared `none` sentinel (the proxy omits the + // reasoning parameter for it); hidden when the ladder does not declare none. + off: efforts.includes("none") ? "none" : null, + minimal: efforts.includes("minimal") ? "minimal" : null, + low: efforts.includes("low") ? "low" : null, + medium: efforts.includes("medium") ? "medium" : null, + high: efforts.includes("high") ? "high" : null, + xhigh: efforts.includes("xhigh") ? "xhigh" : null, + max: efforts.includes("max") ? "max" : efforts.includes("ultra") ? "ultra" : null, + }; + } const context = authoritativeContextWindow(model.contextWindow); if (context !== undefined) { entry.contextWindow = context; diff --git a/src/codex/catalog/effort.ts b/src/codex/catalog/effort.ts index b1ac6b540..4e1c9fa7f 100644 --- a/src/codex/catalog/effort.ts +++ b/src/codex/catalog/effort.ts @@ -153,8 +153,9 @@ export function applyReasoningLevels( // (no ultra->max client conversion) and codex-rs validates it by catalog membership, // so a missing max rung hard-fails spawn_agent effort overrides. The wire stays honest: // routed adapters clamp via clampToSupportedCodexEffort and natives via - // nativeEffortClamp (max -> the model's real top rung). - if (!preserveExact && efforts.length > 0) { + // nativeEffortClamp (max -> the model's real top rung). A `none`-only ladder is NOT + // reasoning-capable, so it must not grow synthetic top rungs. + if (!preserveExact && efforts.length > 0 && efforts.some(effort => effort !== "none" && effort !== "minimal")) { const additions: string[] = []; if (!efforts.includes("max")) additions.push("max"); if (!efforts.includes("ultra")) additions.push("ultra"); @@ -177,7 +178,9 @@ export function applyReasoningLevels( } entry.default_reasoning_level = defaultOverride && efforts.includes(defaultOverride) ? defaultOverride - : efforts.includes("medium") ? "medium" : efforts.includes("high") ? "high" : efforts[0]; + : efforts.includes("medium") ? "medium" : efforts.includes("high") ? "high" + // Sentinels never become the implicit default when real rungs are declared. + : efforts.find(effort => effort !== "none" && effort !== "minimal") ?? efforts[0]; } export function isGpt56NativeSlug(slug: string): boolean { diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index 137e8e441..f617f2302 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -1775,14 +1775,26 @@ async function gatherRoutedModelsUncached( ? { inputModalities: cm.inputModalities } : codexForwardNativeCapabilityAlias ? { inputModalities: nativeInputModalities(cm.modelId) } : {}), ...(typeof supportsReasoningSummaries === "boolean" ? { supportsReasoningSummaries } : {}), + // Native-alias defaults apply only where the custom row declares nothing: the explicit + // spreads below must win (later in object order), so a stored `[]` stays empty and a + // declared ladder is never replaced by the alias's native ladder. ...(codexForwardNativeCapabilityAlias ? { codexForwardNativeCapabilityAlias: true, - reasoningEfforts: nativeReasoningEfforts(cm.modelId), parallelToolCalls: nativeParallelToolCalls(cm.modelId), - ...(nativeAliasDefaultEffort ? { defaultReasoningEffort: nativeAliasDefaultEffort } : {}), + ...(Array.isArray(cm.reasoningEfforts) + ? {} + : { + reasoningEfforts: nativeReasoningEfforts(cm.modelId), + ...(nativeAliasDefaultEffort ? { defaultReasoningEffort: nativeAliasDefaultEffort } : {}), + }), } : {}), + // Explicit custom-row ladder wins over the inherited provider row below: the merge only + // gap-fills, so a stored `[]` (explicit "no reasoning") or a declared ladder is kept + // verbatim instead of being replaced by the replaced row's metadata. + ...(Array.isArray(cm.reasoningEfforts) ? { reasoningEfforts: [...cm.reasoningEfforts] } : {}), + ...(cm.defaultReasoningEffort ? { defaultReasoningEffort: cm.defaultReasoningEffort } : {}), }; // #962: the dedupe below drops the provider-derived row this custom row replaces. Inherit that // row's provider capability metadata (reasoning ladder, default effort, parallel tool calls, @@ -1791,13 +1803,19 @@ async function gatherRoutedModelsUncached( // noReasoningModels model loses its empty ladder and the catalog synthesizes the generic one, // which Codex then rejects for spawn_agent with effort "none". const replaced = replacedByRoutedSlug.get(routedSlug(cm.provider, cm.modelId)); + // The final ladder is what the catalog will advertise; the inherited default only rides + // along when it is actually a member — otherwise a provider default like "xhigh" would + // re-apply onto a narrower custom ladder and override the fallback in applyReasoningLevels. + const effectiveLadder = base.reasoningEfforts ?? replaced?.reasoningEfforts; const merged: CatalogModel = replaced ? { ...base, ...(base.contextWindow === undefined && replaced.contextWindow !== undefined ? { contextWindow: replaced.contextWindow } : {}), ...(base.maxInputTokens === undefined && replaced.maxInputTokens !== undefined ? { maxInputTokens: replaced.maxInputTokens } : {}), ...(base.inputModalities === undefined && replaced.inputModalities !== undefined ? { inputModalities: replaced.inputModalities } : {}), ...(base.reasoningEfforts === undefined && replaced.reasoningEfforts !== undefined ? { reasoningEfforts: replaced.reasoningEfforts } : {}), - ...(base.defaultReasoningEffort === undefined && replaced.defaultReasoningEffort !== undefined ? { defaultReasoningEffort: replaced.defaultReasoningEffort } : {}), + ...(base.defaultReasoningEffort === undefined && replaced.defaultReasoningEffort !== undefined + && Array.isArray(effectiveLadder) && effectiveLadder.includes(replaced.defaultReasoningEffort) + ? { defaultReasoningEffort: replaced.defaultReasoningEffort } : {}), ...(base.parallelToolCalls === undefined && replaced.parallelToolCalls !== undefined ? { parallelToolCalls: replaced.parallelToolCalls } : {}), ...(base.supportsVerbosity === undefined && replaced.supportsVerbosity !== undefined ? { supportsVerbosity: replaced.supportsVerbosity } : {}), ...(base.supportsReasoningSummaries === undefined && replaced.supportsReasoningSummaries !== undefined ? { supportsReasoningSummaries: replaced.supportsReasoningSummaries } : {}), diff --git a/src/reasoning-effort.ts b/src/reasoning-effort.ts index 5e97994e1..ff168f4b7 100644 --- a/src/reasoning-effort.ts +++ b/src/reasoning-effort.ts @@ -19,6 +19,30 @@ export function isCodexReasoningEffort(effort: string): boolean { return CODEX_REASONING_SET.has(effort); } +/** + * True for ladder members plus the `none`/`minimal` sentinels. Both are valid declared + * efforts (OpenAI accepts `minimal`; Codex validates `none` against + * `supported_reasoning_levels` for no-reasoning subagent spawns, #883/#962) but are NOT + * part of the low..ultra ladder: they never appear in default ladders, ranks, or clamps + * (`minimal` is mapped to `low` on the wire by requestToCodexEffort). + */ +export function isDeclaredReasoningEffort(effort: string): boolean { + return effort === "none" || effort === "minimal" || CODEX_REASONING_SET.has(effort); +} + +/** + * Reorder any declared subset (low..ultra, plus the optional `none`/`minimal` sentinels + * first, in that order) into canonical order and drop duplicates. Catalog + * `supported_reasoning_levels` follow the input order and the fallback default picks the + * first entry, so a caller-chosen order would otherwise leak into the catalog. + */ +export function canonicalizeReasoningEfforts(values: readonly string[]): string[] { + const seen = new Set(values); + const ordered = CODEX_REASONING_ORDER.filter(effort => seen.has(effort)); + const sentinels = ["none", "minimal"].filter(effort => seen.has(effort)); + return [...sentinels, ...ordered]; +} + /** * Reasoning ladder accepted for the OpenAI vision sidecar. `ultra` is deliberately excluded: * the vision describer is a single helper call, and `ultra` would be collapsed to `max` by the @@ -66,7 +90,9 @@ export function sanitizeCodexReasoningEfforts(efforts: readonly string[] | undef const seen = new Set(); const out: string[] = []; for (const effort of efforts) { - if (!CODEX_REASONING_SET.has(effort) || seen.has(effort)) continue; + // `none`/`minimal` are valid declared sentinels, kept and sorted first (rank -1); they + // never appear in the default ladder. + if ((effort !== "none" && effort !== "minimal" && !CODEX_REASONING_SET.has(effort)) || seen.has(effort)) continue; seen.add(effort); out.push(effort); } diff --git a/src/server/management/model-routes.ts b/src/server/management/model-routes.ts index 101602995..0cf22043f 100644 --- a/src/server/management/model-routes.ts +++ b/src/server/management/model-routes.ts @@ -29,6 +29,46 @@ function readInputModalities(raw: unknown): { values?: string[]; error?: string } return { values: raw as string[] }; } + +/** + * Custom-row reasoning ladder. Labels are validated against the Codex ladder (low..ultra) + * exactly like provider `modelReasoningEfforts` values; unknown labels would otherwise + * surface in a catalog the upstream never accepts. An empty array is meaningful (explicit + * "no reasoning" hides the effort control) and must be preserved, not cleared. + */ +function readReasoningEfforts(raw: unknown): { values?: string[]; error?: string } { + if (raw === undefined) return {}; + if (!Array.isArray(raw)) return { error: "reasoningEfforts must be an array" }; + const rejected: string[] = []; + const values: string[] = []; + for (const value of raw) { + if (typeof value !== "string") return { error: "reasoningEfforts must contain only strings" }; + if (!isDeclaredReasoningEffort(value)) { rejected.push(value); continue; } + if (!values.includes(value)) values.push(value); + } + if (rejected.length > 0) { + return { error: `unsupported reasoning effort: ${rejected.join(", ")} (allowed: none, minimal, low, medium, high, xhigh, max, ultra)` }; + } + // Canonical order: the catalog writes supported_reasoning_levels in input order and the + // fallback default picks the first entry, so a caller-chosen order must not leak through. + return { values: canonicalizeReasoningEfforts(values) }; +} + +/** Default effort must be a ladder member that the declared ladder actually includes. */ +function readDefaultReasoningEffort(raw: unknown, efforts: string[] | undefined): { value?: string; error?: string } { + if (raw === undefined) return {}; + if (raw === null) return { value: undefined }; + if (typeof raw !== "string" || !isDeclaredReasoningEffort(raw)) { + return { error: "defaultReasoningEffort must be one of: none, minimal, low, medium, high, xhigh, max, ultra" }; + } + if (efforts === undefined || efforts.length === 0) { + return { error: "defaultReasoningEffort requires a non-empty reasoningEfforts ladder" }; + } + if (!efforts.includes(raw)) { + return { error: `defaultReasoningEffort "${raw}" is not in the declared reasoningEfforts ladder` }; + } + return { value: raw }; +} import type { CatalogModel } from "../../codex/catalog"; import { accountBoundNativeOpenAiSlugsBySelector, catalogModelSlug, configuredNativeAliasSlugs, disabledNativeSlugs, invalidateCodexModelsCache, nativeModelRows, shouldIncludeAccountBoundNativeOpenAi, uniqueCatalogModelsForPublicList } from "../../codex/catalog"; import { CatalogGatherBusyError } from "../../codex/catalog/provider-fetch"; @@ -71,6 +111,7 @@ import { parseRange, parseUsageSurface, summarizeUsage } from "../../usage/summa import { stripCodexRuntimeProviderFields } from "../../codex/auth-context"; import { getProviderRegistryEntry } from "../../providers/registry"; import { getDebugLogEntries } from "../../lib/debug-log-buffer"; +import { canonicalizeReasoningEfforts, isDeclaredReasoningEffort } from "../../reasoning-effort"; import { getInjectionDebugLogEntries } from "../../lib/injection-debug-log"; import { clearDebugSettings, @@ -330,7 +371,7 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise routedSlug(cm.provider, cm.modelId) === newSlug)) { @@ -356,6 +401,8 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise 0 ? { inputModalities } : {}), + ...(reasoning.values !== undefined ? { reasoningEfforts: reasoning.values } : {}), + ...(defaultEffort.value ? { defaultReasoningEffort: defaultEffort.value } : {}), addedAt: new Date().toISOString(), }; config.customModels = [...existing, entry]; @@ -368,7 +415,7 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise cm.id === id); @@ -391,6 +438,33 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise 0 ? edited.values : undefined; } + // `null` clears the stored ladder back to "inherit from the provider row"; `[]` stays + // stored as an explicit "no reasoning" override. The default effort rides along and is + // validated against the ladder the row ends up with. + if (body.reasoningEfforts !== undefined) { + if (body.reasoningEfforts === null) { + cm.reasoningEfforts = undefined; + } else { + const edited = readReasoningEfforts(body.reasoningEfforts); + if (edited.error) return jsonResponse({ error: edited.error }, 400); + cm.reasoningEfforts = edited.values; + } + } + if (body.defaultReasoningEffort !== undefined) { + const edited = readDefaultReasoningEffort(body.defaultReasoningEffort, cm.reasoningEfforts); + if (edited.error) return jsonResponse({ error: edited.error }, 400); + cm.defaultReasoningEffort = edited.value; + } + // Mirror of the POST invariant: a default only survives as a member of the final ladder. + // Without this, a ladder shrink/clear on a row that was created with a default leaves a + // stale default that re-applies itself onto the inherited ladder in the generated catalog + // (the GUI toggle-off path sends only reasoningEfforts, never the default). + if (cm.defaultReasoningEffort !== undefined) { + const ladder = cm.reasoningEfforts; + if (!ladder || ladder.length === 0 || !ladder.includes(cm.defaultReasoningEffort)) { + cm.defaultReasoningEffort = undefined; + } + } const updatedSlug = routedSlug(cm.provider, cm.modelId); if (list.some((other, i) => i !== idx && routedSlug(other.provider, other.modelId) === updatedSlug)) { return jsonResponse({ error: "duplicate model" }, 409); diff --git a/src/server/management/model-rows.ts b/src/server/management/model-rows.ts index fa83d10ad..957aa6890 100644 --- a/src/server/management/model-rows.ts +++ b/src/server/management/model-rows.ts @@ -91,6 +91,14 @@ export async function listManagementModelRows(config: OcxConfig): Promise { */ describe("custom-model API rejects out-of-enum input modalities", () => { let persistCalls = 0; + // Shared fixture: the PUT/POST handlers mutate and persist the config object they + // receive, so seed requests and their follow-ups must see the SAME object (a fresh + // object per call would discard the seeded default before the follow-up asserts on it). + const fixtureConfig = { + providers: { deepseek: { adapter: "openai-chat", baseUrl: "https://example.invalid/v1" } }, + customModels: [] as Array<{ id: string; provider: string; modelId: string; inputModalities?: string[] }>, + } as unknown as OcxConfig; + + beforeEach(() => { + // Seeded WITH modalities on purpose: a fixture without them would let the + // clear-path test pass against a PUT that ignored the field entirely. + fixtureConfig.customModels = [ + { id: "existing-uuid", provider: "deepseek", modelId: "deepseek-v4", inputModalities: ["text", "image"] }, + ]; + }); async function callCustomModels( method: "POST" | "PUT", @@ -73,14 +89,7 @@ describe("custom-model API rejects out-of-enum input modalities", () => { return handleModelRoutes({ req, url, - config: { - providers: { deepseek: { adapter: "openai-chat", baseUrl: "https://example.invalid/v1" } }, - customModels: [ - // Seeded WITH modalities on purpose: a fixture without them would let the - // clear-path test pass against a PUT that ignored the field entirely. - { id: "existing-uuid", provider: "deepseek", modelId: "deepseek-v4", inputModalities: ["text", "image"] }, - ], - } as unknown as Parameters[0]["config"], + config: fixtureConfig, // This handler mutates and persists the config object it receives. The // fixture must NEVER reach the process-global OPENCODEX_HOME; that exact bug // replaced a real 41KB provider config with this `existing-uuid` fixture. @@ -163,3 +172,245 @@ describe("custom-model API rejects out-of-enum input modalities", () => { expect(persistCalls).toBe(1); }); }); + +/* + * The same closed-enum argument applies to the reasoning ladder: a label outside the Codex + * ladder (low..ultra) stored through /api/custom-models would surface in a catalog the + * upstream never accepts, and the GUI's effort checkboxes are only as honest as the API + * that validates them. Unlike modalities, an EMPTY ladder is meaningful here — it is the + * explicit "no reasoning" override that hides the effort control (#883) — so `[]` is + * stored, not cleared, and `null` is the only way a PUT restores inheritance. + */ +describe("custom-model API validates reasoning-effort ladders", () => { + let persistCalls = 0; + + async function callCustomModels( + method: "POST" | "PUT", + body: unknown, + pathname = "/api/custom-models", + ): Promise { + const { handleModelRoutes } = await import("../src/server/management/model-routes"); + const url = new URL(`http://127.0.0.1:10199${pathname}`); + const req = new Request(url, { + method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + return handleModelRoutes({ + req, + url, + config: { + providers: { deepseek: { adapter: "openai-chat", baseUrl: "https://example.invalid/v1" } }, + customModels: [ + // Seeded WITH a ladder on purpose: the null-clear test needs a stored value to + // remove, and the explicit-empty test needs to prove `[]` is NOT a clear. + { id: "existing-uuid", provider: "deepseek", modelId: "deepseek-v4", reasoningEfforts: ["low", "high"] }, + ], + } as unknown as Parameters[0]["config"], + deps: { + saveConfigPreservingClaudeCode: () => { persistCalls++; }, + } as Parameters[0]["deps"], + convergeCodexCatalog: async () => ({ + status: "committed", + changed: false, + degraded: false, + notices: [], + }), + syncClaudeAgentDefsBestEffort: async () => {}, + }); + } + + test("POST refuses an effort outside the Codex ladder, naming the offending value", async () => { + persistCalls = 0; + const res = await callCustomModels("POST", { + provider: "deepseek", + modelId: "deepseek-v5", + reasoningEfforts: ["low", "deep"], + }); + expect(res?.status).toBe(400); + expect((await res!.json() as { error?: string }).error).toContain("deep"); + expect(persistCalls).toBe(0); + }); + + test("POST refuses a non-string member instead of filtering it away", async () => { + persistCalls = 0; + const res = await callCustomModels("POST", { + provider: "deepseek", + modelId: "deepseek-v5", + reasoningEfforts: ["low", 42], + }); + expect(res?.status).toBe(400); + expect((await res!.json() as { error?: string }).error).toContain("strings"); + expect(persistCalls).toBe(0); + }); + + test("POST accepts a valid ladder with a member default and dedupes", async () => { + persistCalls = 0; + const res = await callCustomModels("POST", { + provider: "deepseek", + modelId: "deepseek-v6", + reasoningEfforts: ["low", "high", "high"], + defaultReasoningEffort: "high", + }); + expect(res?.status).toBe(201); + const payload = await res!.json() as { reasoningEfforts?: string[]; defaultReasoningEffort?: string }; + expect(payload.reasoningEfforts).toEqual(["low", "high"]); + expect(payload.defaultReasoningEffort).toBe("high"); + expect(persistCalls).toBe(1); + }); + + test("POST stores an explicit empty ladder as the no-reasoning override", async () => { + persistCalls = 0; + const res = await callCustomModels("POST", { + provider: "deepseek", + modelId: "deepseek-v6", + reasoningEfforts: [], + }); + expect(res?.status).toBe(201); + const payload = await res!.json() as { reasoningEfforts?: string[] }; + expect(payload.reasoningEfforts).toEqual([]); + expect(persistCalls).toBe(1); + }); + + test("POST accepts the none/minimal sentinels, canonicalized first", async () => { + persistCalls = 0; + const res = await callCustomModels("POST", { + provider: "deepseek", + modelId: "deepseek-v6", + reasoningEfforts: ["max", "none", "low", "minimal"], + defaultReasoningEffort: "none", + }); + expect(res?.status).toBe(201); + const payload = await res!.json() as { reasoningEfforts?: string[]; defaultReasoningEffort?: string }; + expect(payload.reasoningEfforts).toEqual(["none", "minimal", "low", "max"]); + expect(payload.defaultReasoningEffort).toBe("none"); + expect(persistCalls).toBe(1); + }); + + test("POST refuses a default effort outside the declared ladder", async () => { + persistCalls = 0; + const res = await callCustomModels("POST", { + provider: "deepseek", + modelId: "deepseek-v6", + reasoningEfforts: ["low", "high"], + defaultReasoningEffort: "max", + }); + expect(res?.status).toBe(400); + expect((await res!.json() as { error?: string }).error).toContain("max"); + expect(persistCalls).toBe(0); + }); + + test("POST refuses a default effort without any ladder", async () => { + persistCalls = 0; + const res = await callCustomModels("POST", { + provider: "deepseek", + modelId: "deepseek-v6", + defaultReasoningEffort: "high", + }); + expect(res?.status).toBe(400); + expect((await res!.json() as { error?: string }).error).toContain("reasoningEfforts"); + expect(persistCalls).toBe(0); + }); + + test("PUT stores an explicit empty ladder instead of clearing it", async () => { + persistCalls = 0; + const res = await callCustomModels("PUT", { reasoningEfforts: [] }, "/api/custom-models/existing-uuid"); + expect(res?.status).toBe(200); + const payload = await res!.json() as { reasoningEfforts?: string[] }; + expect(payload.reasoningEfforts).toEqual([]); + expect(persistCalls).toBe(1); + }); + + test("PUT null restores inheritance by clearing the stored ladder", async () => { + persistCalls = 0; + const res = await callCustomModels("PUT", { reasoningEfforts: null }, "/api/custom-models/existing-uuid"); + expect(res?.status).toBe(200); + const payload = await res!.json() as { reasoningEfforts?: string[] }; + expect(payload.reasoningEfforts).toBeUndefined(); + expect(persistCalls).toBe(1); + }); + + test("PUT clears the default when the ladder is removed", async () => { + persistCalls = 0; + const res = await callCustomModels( + "PUT", + { reasoningEfforts: null, defaultReasoningEffort: null }, + "/api/custom-models/existing-uuid", + ); + expect(res?.status).toBe(200); + const payload = await res!.json() as { reasoningEfforts?: string[]; defaultReasoningEffort?: string }; + expect(payload.reasoningEfforts).toBeUndefined(); + expect(payload.defaultReasoningEffort).toBeUndefined(); + expect(persistCalls).toBe(1); + }); + + // POST rejects a default outside the ladder; PUT must not be able to produce that state + // on its own. A ladder shrink/clear on a row that was created with a default (CLI) must + // drop the stale default — otherwise it re-applies itself onto the inherited ladder in + // the generated catalog (GUI toggle-off path sends only reasoningEfforts). + test("PUT ladder shrink drops a stored default that is no longer a member", async () => { + persistCalls = 0; + const seededRes = await callCustomModels("PUT", { + reasoningEfforts: ["low", "high", "max"], + defaultReasoningEffort: "max", + }, "/api/custom-models/existing-uuid"); + expect(seededRes?.status).toBe(200); + const seeded = await seededRes!.json() as { defaultReasoningEffort?: string }; + expect(seeded.defaultReasoningEffort).toBe("max"); + + persistCalls = 0; + const res = await callCustomModels("PUT", { reasoningEfforts: ["low"] }, "/api/custom-models/existing-uuid"); + expect(res?.status).toBe(200); + const payload = await res!.json() as { reasoningEfforts?: string[]; defaultReasoningEffort?: string }; + expect(payload.reasoningEfforts).toEqual(["low"]); + expect(payload.defaultReasoningEffort).toBeUndefined(); + expect(persistCalls).toBe(1); + }); + + test("PUT null-clear drops a stored default even when the body does not mention it", async () => { + persistCalls = 0; + const seededRes = await callCustomModels("PUT", { + reasoningEfforts: ["low", "high"], + defaultReasoningEffort: "high", + }, "/api/custom-models/existing-uuid"); + expect(seededRes?.status).toBe(200); + + persistCalls = 0; + const res = await callCustomModels("PUT", { reasoningEfforts: null }, "/api/custom-models/existing-uuid"); + expect(res?.status).toBe(200); + const payload = await res!.json() as { reasoningEfforts?: string[]; defaultReasoningEffort?: string }; + expect(payload.reasoningEfforts).toBeUndefined(); + expect(payload.defaultReasoningEffort).toBeUndefined(); + expect(persistCalls).toBe(1); + }); + + test("PUT explicit empty ladder also drops a stored default", async () => { + persistCalls = 0; + const seededRes = await callCustomModels("PUT", { + reasoningEfforts: ["low", "high"], + defaultReasoningEffort: "high", + }, "/api/custom-models/existing-uuid"); + expect(seededRes?.status).toBe(200); + + persistCalls = 0; + const res = await callCustomModels("PUT", { reasoningEfforts: [] }, "/api/custom-models/existing-uuid"); + expect(res?.status).toBe(200); + const payload = await res!.json() as { reasoningEfforts?: string[]; defaultReasoningEffort?: string }; + expect(payload.reasoningEfforts).toEqual([]); + expect(payload.defaultReasoningEffort).toBeUndefined(); + expect(persistCalls).toBe(1); + }); + + test("POST and PUT canonicalize the ladder into Codex order", async () => { + persistCalls = 0; + const res = await callCustomModels("POST", { + provider: "deepseek", + modelId: "deepseek-v6", + reasoningEfforts: ["max", "low", "high", "low"], + }); + expect(res?.status).toBe(201); + const payload = await res!.json() as { reasoningEfforts?: string[] }; + expect(payload.reasoningEfforts).toEqual(["low", "high", "max"]); + expect(persistCalls).toBe(1); + }); +}); diff --git a/tests/cli-models-reasoning.test.ts b/tests/cli-models-reasoning.test.ts new file mode 100644 index 000000000..4c7e13152 --- /dev/null +++ b/tests/cli-models-reasoning.test.ts @@ -0,0 +1,174 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { parseReasoningArgs, handleModels } from "../src/cli/models"; +import { handleModelsRuntimeCommand } from "../src/cli/models-runtime"; + +/** + * The API validates reasoning ladders (9 tests in catalog-input-modality-enum.test.ts), + * but the CLI paths carry their own parsing and validation copies: `ocx models add` + * validates offline before writing config.json, and `ocx models edit` maps flags onto + * the PUT body ("-" -> null). These tests pin that mapping so CLI and API cannot drift. + */ +describe("ocx models add --reasoning-efforts parsing", () => { + test("a valid ladder is canonicalized into Codex order and deduped", () => { + expect(parseReasoningArgs("max,low,high,low", undefined)).toEqual({ + reasoningEfforts: ["low", "high", "max"], + }); + }); + + test("the none sentinel is accepted and canonicalized first", () => { + expect(parseReasoningArgs("low,none,max", undefined)).toEqual({ + reasoningEfforts: ["none", "low", "max"], + }); + }); + + test("an unknown effort is rejected and names the offending value", () => { + const parsed = parseReasoningArgs("low,deep", undefined); + expect(parsed.error).toContain("deep"); + expect(parsed.reasoningEfforts).toBeUndefined(); + }); + + test('an empty string is the explicit no-reasoning ladder; malformed CSV is rejected', () => { + expect(parseReasoningArgs("", undefined)).toEqual({ reasoningEfforts: [] }); + expect(parseReasoningArgs("low,,high", undefined)?.error).toContain("comma-separated"); + expect(parseReasoningArgs(",,", undefined)?.error).toContain("comma-separated"); + }); + + test("a default still cannot ride on an explicit empty ladder", () => { + expect(parseReasoningArgs("", "low")?.error).toContain("requires --reasoning-efforts"); + }); + + test('"-" omits the field (inherit) exactly like the API null-clear', () => { + expect(parseReasoningArgs("-", undefined)).toEqual({}); + expect(parseReasoningArgs(undefined, "-")).toEqual({}); + }); + + test("a default must be a ladder member", () => { + const parsed = parseReasoningArgs("low,high", "max"); + expect(parsed.error).toContain("max"); + expect(parsed.error).toContain("not in the declared reasoning efforts"); + }); + + test("a default requires a ladder", () => { + expect(parseReasoningArgs(undefined, "high")?.error).toContain("requires --reasoning-efforts"); + }); + + test("a member default is accepted", () => { + expect(parseReasoningArgs("low,high", "high")).toEqual({ + reasoningEfforts: ["low", "high"], + defaultReasoningEffort: "high", + }); + }); +}); + +describe("ocx models edit reasoning flag mapping onto the PUT body", () => { + async function editWith(patchArgs: string[]): Promise> { + let capturedBody: Record | null = null; + const fetchImpl = async (url: string, init?: RequestInit) => { + capturedBody = JSON.parse(String(init?.body)); + return new Response(JSON.stringify({ id: "cm-1", ...capturedBody }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }; + const code = await handleModelsRuntimeCommand("edit", ["cm-1", ...patchArgs], { + baseUrl: "http://127.0.0.1:1", + fetchImpl, + }); + expect(code).toBe(0); + return capturedBody ?? {}; + } + + test('"--reasoning-efforts -" maps to null (restore inheritance)', async () => { + const body = await editWith(["--reasoning-efforts", "-"]); + expect(body.reasoningEfforts).toBeNull(); + }); + + test('"--reasoning-efforts \"\"" stores an explicit empty ladder (no-reasoning override)', async () => { + const body = await editWith(["--reasoning-efforts", ""]); + expect(body.reasoningEfforts).toEqual([]); + }); + + test("embedded blank CSV members are rejected without touching the API", async () => { + let fetchCalled = false; + const fetchImpl = async () => { fetchCalled = true; return new Response("{}", { status: 200 }); }; + const code = await handleModelsRuntimeCommand("edit", ["cm-1", "--reasoning-efforts", "low,,high"], { + baseUrl: "http://127.0.0.1:1", + fetchImpl, + }); + // runCliAction turns CliUsageError into exit code 2 without touching the API. + expect(code).toBe(2); + expect(fetchCalled).toBe(false); + }); + + test("a csv ladder maps to an array", async () => { + const body = await editWith(["--reasoning-efforts", "low,high"]); + expect(body.reasoningEfforts).toEqual(["low", "high"]); + }); + + test('"--default-reasoning-effort -" maps to null', async () => { + const body = await editWith(["--default-reasoning-effort", "-"]); + expect(body.defaultReasoningEffort).toBeNull(); + }); + + test("a member default maps to its string", async () => { + const body = await editWith(["--reasoning-efforts", "low,high", "--default-reasoning-effort", "high"]); + expect(body.reasoningEfforts).toEqual(["low", "high"]); + expect(body.defaultReasoningEffort).toBe("high"); + }); +}); + +describe("ocx models add persists reasoning metadata into config.json", () => { + const home = mkdtempSync(join(tmpdir(), "ocx-cli-test-")); + const previousHome = process.env.OPENCODEX_HOME; + + beforeAll(() => { + process.env.OPENCODEX_HOME = home; + writeFileSync(join(home, "config.json"), JSON.stringify({ + providers: { + deepseek: { adapter: "openai-chat", baseUrl: "https://example.invalid/v1", authMode: "key" }, + }, + })); + }); + + afterAll(() => { + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + rmSync(home, { recursive: true, force: true }); + }); + + function readConfig(): { customModels?: Array> } { + return JSON.parse(readFileSync(join(home, "config.json"), "utf8")); + } + + test("a ladder with a member default is stored canonicalized", async () => { + await handleModels(["add", "deepseek", "m1", "--reasoning-efforts", "max,low,high", "--default-reasoning-effort", "high"]); + const entry = readConfig().customModels!.find(model => model.modelId === "m1")!; + expect(entry.reasoningEfforts).toEqual(["low", "high", "max"]); + expect(entry.defaultReasoningEffort).toBe("high"); + }); + + test('"-" omits the reasoning fields entirely (inherit)', async () => { + await handleModels(["add", "deepseek", "m2", "--reasoning-efforts", "-"]); + const entry = readConfig().customModels!.find(model => model.modelId === "m2")!; + expect(entry.reasoningEfforts).toBeUndefined(); + expect(entry.defaultReasoningEffort).toBeUndefined(); + }); + + test("list-custom renders the stored ladder columns", async () => { + const lines: string[] = []; + const originalLog = console.log; + console.log = (...args: unknown[]) => { lines.push(args.map(String).join(" ")); }; + try { + await handleModels(["list-custom"]); + } finally { + console.log = originalLog; + } + const table = lines.join("\n"); + expect(table).toContain("EFFORTS"); + expect(table).toContain("low,high,max"); + expect(table).toContain("-"); // m2 has no ladder -> dash cell + }); +}); diff --git a/tests/client-config-export.test.ts b/tests/client-config-export.test.ts index be8491924..a9eaaff32 100644 --- a/tests/client-config-export.test.ts +++ b/tests/client-config-export.test.ts @@ -182,10 +182,56 @@ describe("Pi serializer (accept criterion 2)", () => { expect(JSON.stringify(piConfig())).not.toContain("cost"); }); - test("reasoning is omitted — an effort list is not Pi's boolean", () => { + test("reasoning is emitted only for rows with a non-empty effort ladder", () => { + // The shared fixture carries no ladder anywhere: every entry stays reasoning-free. for (const model of piConfig().providers.opencodex!.models) { expect(model).not.toHaveProperty("reasoning"); } + const config = piConfig(ctx({ + models: [ + { namespaced: "a/reasoning", provider: "a", id: "reasoning", reasoningEfforts: ["low", "high"] }, + { namespaced: "b/none", provider: "b", id: "none", reasoningEfforts: [] }, + { namespaced: "c/plain", provider: "c", id: "plain" }, + { namespaced: "d/off", provider: "d", id: "off", reasoningEfforts: ["none", "minimal", "low"] }, + ], + })); + const models = config.providers.opencodex!.models; + expect(models.find(model => model.id === "a/reasoning")!.reasoning).toBe(true); + // Pi's level scale is constrained to the ladder: members map to themselves, everything + // else (incl. minimal, which the Codex ladder has no equivalent for) is hidden. + expect(models.find(model => model.id === "a/reasoning")!.thinkingLevelMap).toEqual({ + off: null, + minimal: null, + low: "low", + medium: null, + high: "high", + xhigh: null, + max: null, + }); + // The none sentinel maps pi's off level to "none" (the proxy omits the parameter); + // minimal maps to itself. + expect(models.find(model => model.id === "d/off")!.thinkingLevelMap).toEqual({ + off: "none", + minimal: "minimal", + low: "low", + medium: null, + high: null, + xhigh: null, + max: null, + }); + // An ultra-only ladder has no exact pi level: pi's max maps to the only declared tier + // so the model's sole reasoning level is actually selectable. + const ultraOnly = piConfig(ctx({ + models: [{ namespaced: "e/ultra", provider: "e", id: "ultra", reasoningEfforts: ["ultra"] }], + })); + expect(ultraOnly.providers.opencodex!.models[0]!.thinkingLevelMap).toMatchObject({ + max: "ultra", + off: null, + minimal: null, + }); + // An explicit empty ladder is the catalog's "no reasoning" statement; no boolean. + expect(models.find(model => model.id === "b/none")).not.toHaveProperty("reasoning"); + expect(models.find(model => model.id === "c/plain")).not.toHaveProperty("reasoning"); }); test("contextWindow and maxTokens are omitted when the context window is unknown", () => { diff --git a/tests/codex-catalog.test.ts b/tests/codex-catalog.test.ts index 3ebcfe85a..60021106f 100644 --- a/tests/codex-catalog.test.ts +++ b/tests/codex-catalog.test.ts @@ -1704,8 +1704,10 @@ describe("Google Gemini catalog metadata", () => { const entry = buildCatalogEntries(nativeTemplate(), [], models) .find(row => row.slug === "google/gemini-3.6-flash"); + // The registry ladder declares minimal for Gemini 3.6 Flash; it now flows through + // (previously sanitize silently dropped it) plus the mock top rungs for subagent spawns. expect((entry?.supported_reasoning_levels as Array<{ effort: string }>).map(level => level.effort)) - .toEqual(["low", "medium", "high", "max", "ultra"]); + .toEqual(["minimal", "low", "medium", "high", "max", "ultra"]); expect(entry?.input_modalities).toEqual(["text", "image"]); expect(entry?.context_window).toBe(1_048_576); }); @@ -1877,6 +1879,243 @@ describe("configured CatalogModel displayName -> catalog display_name", () => { clearModelCache("custom-provider"); } }); + + test("a customModel reasoning ladder overrides the inherited provider ladder end-to-end", async () => { + clearModelCache("custom-provider"); + const originalFetch = globalThis.fetch; + let fetchCalls = 0; + globalThis.fetch = (() => { + fetchCalls += 1; + throw new Error("fetch should not be called"); + }) as typeof fetch; + try { + const models = await gatherRoutedModels({ + port: 10100, + defaultProvider: "custom-provider", + providers: { + "custom-provider": { + baseUrl: "https://example.invalid/v1", + adapter: "openai-chat", + authMode: "key", + liveModels: false, + models: ["baseline-model", "renamed-model"], + // The provider row for the same slug advertises low/high; the custom row must win. + modelReasoningEfforts: { "baseline-model": ["low", "high"], "renamed-model": ["low", "high"] }, + }, + }, + customModels: [ + { + id: "cm-1", + provider: "custom-provider", + modelId: "renamed-model", + displayName: "Renamed Model", + reasoningEfforts: ["medium", "max"], + defaultReasoningEffort: "max", + addedAt: "2026-01-01T00:00:00.000Z", + }, + ], + }); + + expect(fetchCalls).toBe(0); + const custom = models.find(m => m.provider === "custom-provider" && m.id === "renamed-model"); + // The explicit ladder rides on the row itself, not on the replaced provider row. + expect(custom?.reasoningEfforts).toEqual(["medium", "max"]); + expect(custom?.defaultReasoningEffort).toBe("max"); + + const entries = buildCatalogEntries(nativeTemplate(), [], models); + const row = entries.find(e => e.slug === "custom-provider/renamed-model"); + const levels = (row?.supported_reasoning_levels ?? []).map((l: { effort: string }) => l.effort); + // The sync appends the mock top rungs (max/ultra) for subagent spawn compatibility; + // the declared medium/max survive verbatim, the inherited low/high does not. + expect(levels).toEqual(["medium", "max", "ultra"]); + expect(row?.default_reasoning_level).toBe("max"); + } finally { + globalThis.fetch = originalFetch; + clearModelCache("custom-provider"); + } + }); + + test("an explicit empty customModel ladder hides the effort control despite an inherited one", async () => { + clearModelCache("custom-provider"); + const originalFetch = globalThis.fetch; + globalThis.fetch = (() => { + throw new Error("fetch should not be called"); + }) as typeof fetch; + try { + const models = await gatherRoutedModels({ + port: 10100, + defaultProvider: "custom-provider", + providers: { + "custom-provider": { + baseUrl: "https://example.invalid/v1", + adapter: "openai-chat", + authMode: "key", + liveModels: false, + models: ["renamed-model"], + modelReasoningEfforts: { "renamed-model": ["low", "high"] }, + }, + }, + customModels: [ + { + id: "cm-1", + provider: "custom-provider", + modelId: "renamed-model", + reasoningEfforts: [], + addedAt: "2026-01-01T00:00:00.000Z", + }, + ], + }); + + const custom = models.find(m => m.provider === "custom-provider" && m.id === "renamed-model"); + expect(custom?.reasoningEfforts).toEqual([]); + + const entries = buildCatalogEntries(nativeTemplate(), [], models); + const row = entries.find(e => e.slug === "custom-provider/renamed-model"); + expect(row?.supported_reasoning_levels).toEqual([]); + expect(row).not.toHaveProperty("default_reasoning_level"); + } finally { + globalThis.fetch = originalFetch; + clearModelCache("custom-provider"); + } + }); + + test("a none-only custom ladder advertises no synthetic top rungs", async () => { + clearModelCache("custom-provider"); + const originalFetch = globalThis.fetch; + globalThis.fetch = (() => { + throw new Error("fetch should not be called"); + }) as typeof fetch; + try { + const models = await gatherRoutedModels({ + port: 10100, + defaultProvider: "custom-provider", + providers: { + "custom-provider": { + baseUrl: "https://example.invalid/v1", + adapter: "openai-chat", + authMode: "key", + liveModels: false, + models: ["renamed-model"], + }, + }, + customModels: [ + { + id: "cm-1", + provider: "custom-provider", + modelId: "renamed-model", + reasoningEfforts: ["none"], + addedAt: "2026-01-01T00:00:00.000Z", + }, + ], + }); + + const custom = models.find(m => m.provider === "custom-provider" && m.id === "renamed-model"); + expect(custom?.reasoningEfforts).toEqual(["none"]); + + const entries = buildCatalogEntries(nativeTemplate(), [], models); + const row = entries.find(e => e.slug === "custom-provider/renamed-model"); + const levels = (row?.supported_reasoning_levels ?? []).map((l: { effort: string }) => l.effort); + // No reasoning-capable rung -> the mock max/ultra repair must not fire. + expect(levels).toEqual(["none"]); + expect(row?.default_reasoning_level).toBe("none"); + } finally { + globalThis.fetch = originalFetch; + clearModelCache("custom-provider"); + } + }); + + test("a mixed none+low custom ladder keeps none first and gets the mock top rungs", async () => { + clearModelCache("custom-provider"); + const originalFetch = globalThis.fetch; + globalThis.fetch = (() => { + throw new Error("fetch should not be called"); + }) as typeof fetch; + try { + const models = await gatherRoutedModels({ + port: 10100, + defaultProvider: "custom-provider", + providers: { + "custom-provider": { + baseUrl: "https://example.invalid/v1", + adapter: "openai-chat", + authMode: "key", + liveModels: false, + models: ["renamed-model"], + }, + }, + customModels: [ + { + id: "cm-1", + provider: "custom-provider", + modelId: "renamed-model", + reasoningEfforts: ["none", "low"], + addedAt: "2026-01-01T00:00:00.000Z", + }, + ], + }); + + const entries = buildCatalogEntries(nativeTemplate(), [], models); + const row = entries.find(e => e.slug === "custom-provider/renamed-model"); + const levels = (row?.supported_reasoning_levels ?? []).map((l: { effort: string }) => l.effort); + expect(levels).toEqual(["none", "low", "max", "ultra"]); + // `none` is declared but real rungs exist: the implicit default must be low, not none. + expect(row?.default_reasoning_level).toBe("low"); + } finally { + globalThis.fetch = originalFetch; + clearModelCache("custom-provider"); + } + }); + + test("an inherited provider default does not ride onto a custom ladder that excludes it", async () => { + clearModelCache("custom-provider"); + const originalFetch = globalThis.fetch; + globalThis.fetch = (() => { + throw new Error("fetch should not be called"); + }) as typeof fetch; + try { + const models = await gatherRoutedModels({ + port: 10100, + defaultProvider: "custom-provider", + providers: { + "custom-provider": { + baseUrl: "https://example.invalid/v1", + adapter: "openai-chat", + authMode: "key", + liveModels: false, + models: ["renamed-model"], + // The provider row advertises low/high with a high default; the custom ladder + // drops high, so the merged row must not keep advertising high as default. + modelReasoningEfforts: { "renamed-model": ["low", "high"] }, + modelDefaultReasoningEfforts: { "renamed-model": "high" }, + }, + }, + customModels: [ + { + id: "cm-1", + provider: "custom-provider", + modelId: "renamed-model", + reasoningEfforts: ["low"], + addedAt: "2026-01-01T00:00:00.000Z", + }, + ], + }); + + const custom = models.find(m => m.provider === "custom-provider" && m.id === "renamed-model"); + expect(custom?.reasoningEfforts).toEqual(["low"]); + expect(custom?.defaultReasoningEffort).toBeUndefined(); + + const entries = buildCatalogEntries(nativeTemplate(), [], models); + const row = entries.find(e => e.slug === "custom-provider/renamed-model"); + const levels = (row?.supported_reasoning_levels ?? []).map((l: { effort: string }) => l.effort); + expect(levels).toEqual(["low", "max", "ultra"]); + // No high in the ladder, so the fallback default is medium? low is the first rung — + // applyReasoningLevels picks medium when present, else high, else the first entry. + expect(row?.default_reasoning_level).toBe("low"); + } finally { + globalThis.fetch = originalFetch; + clearModelCache("custom-provider"); + } + }); }); describe("legacy custom-model catalog ownership", () => { @@ -2676,6 +2915,48 @@ describe("Codex catalog routed normalization", () => { expect(row?.multi_agent_version).toBeUndefined(); }); + test("an explicit empty custom ladder beats the native-alias ladder on a forward row", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (() => { throw new Error("forward providers must not fetch /models"); }) as typeof fetch; + try { + const models = await gatherRoutedModels({ + port: 10100, + defaultProvider: "openai", + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + codexAccountMode: "pool", + }, + }, + codexAccountPickerEnabled: false, + codexAccountNamespaces: { main: "@main" }, + customModels: [{ + id: "daybreak-no-reasoning", + provider: "openai", + modelId: NATIVE_DAYBREAK_BLUE_MODEL, + // Explicit "no reasoning": the alias's native ladder (low..ultra, default low) must + // not overwrite it — otherwise the catalog would advertise reasoning the user + // explicitly disabled for this row. + reasoningEfforts: [], + }], + }); + const model = models.find(row => row.provider === "openai" && row.id === NATIVE_DAYBREAK_BLUE_MODEL); + expect(model).toMatchObject({ + codexForwardNativeCapabilityAlias: true, + reasoningEfforts: [], + }); + expect(model?.defaultReasoningEffort).toBeUndefined(); + + const entries = buildCatalogEntries(nativeTemplate(), [], models); + const daybreak = entries.find(entry => entry.slug === `openai/${NATIVE_DAYBREAK_BLUE_MODEL}`); + expect(daybreak?.supported_reasoning_levels).toEqual([]); + expect(daybreak).not.toHaveProperty("default_reasoning_level"); + } finally { + globalThis.fetch = originalFetch; + } + }); + test("catalog sync upgrades fallback-quality gpt-5.6 entries but preserves genuine ones", () => { // Fallback-quality: display_name stamped with the bare slug (ocx synthesis signature), // wrong ladder (ultra on luna) left by an older ocx version.