From 86131b9ca440486aa7604a1f3b5807fe33874b51 Mon Sep 17 00:00:00 2001 From: "seakleang.nhak" Date: Wed, 5 Aug 2026 11:39:07 +0700 Subject: [PATCH] feat(codex): support GPT-5.6 Max and Ultra overrides Add "ultra" reasoning level for Codex GPT-5.6 Sol and Terra, and expose Max for Luna (Luna falls back Ultra to Max since it is not supported upstream). Scoped to cx/ routes only; Kiro and generic OpenAI routing unchanged. --- ...08-02-gpt-5-6-codex-reasoning-overrides.md | 328 ++++++++++++++++++ ...pt-5-6-codex-reasoning-overrides-design.md | 122 +++++++ open-sse/executors/codex.js | 13 +- open-sse/handlers/chatCore.js | 15 +- open-sse/providers/thinkingLevels.js | 11 +- .../translator/concerns/thinkingUnified.js | 17 +- tests/translator/thinking-unified.test.js | 25 ++ tests/unit/codex-fast-capacity.test.js | 30 ++ .../codex-native-passthrough-thinking.test.js | 111 ++++++ tests/unit/thinking-levels-gpt56-sol.test.js | 22 +- 10 files changed, 675 insertions(+), 19 deletions(-) create mode 100644 docs/superpowers/plans/2026-08-02-gpt-5-6-codex-reasoning-overrides.md create mode 100644 docs/superpowers/specs/2026-08-02-gpt-5-6-codex-reasoning-overrides-design.md create mode 100644 tests/unit/codex-native-passthrough-thinking.test.js diff --git a/docs/superpowers/plans/2026-08-02-gpt-5-6-codex-reasoning-overrides.md b/docs/superpowers/plans/2026-08-02-gpt-5-6-codex-reasoning-overrides.md new file mode 100644 index 00000000..8ff5a940 --- /dev/null +++ b/docs/superpowers/plans/2026-08-02-gpt-5-6-codex-reasoning-overrides.md @@ -0,0 +1,328 @@ +# GPT-5.6 Codex Reasoning Overrides Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Preserve Codex-advertised Max and Ultra overrides for GPT-5.6 Sol and Terra, preserve Max for Luna, and convert Luna Ultra to Max without changing Kiro or generic OpenAI-format behavior. + +**Architecture:** Keep the supported reasoning matrix in the existing `getThinkingLevels(provider, model)` resolver and reuse that result in both translation and Codex executor normalization. The dashboard already consumes this resolver, so no UI component change is required. Unsupported top-end levels remain safely normalized, with Luna Ultra selecting Luna's supported Max level. + +**Tech Stack:** JavaScript ES modules, Next.js, Vitest, Codex Responses transport. + +## Global Constraints + +- Apply the new overrides only to the OpenAI Codex provider (`codex`, exposed as `cx/`). +- Sol and Terra support `max` and `ultra`; Luna supports `max` but not `ultra`. +- Convert Luna `ultra` requests to `max` in both translated and native passthrough request paths. +- Preserve existing Kiro and generic OpenAI-compatible normalization. +- Do not add runtime model-catalog fetching, dependencies, pricing changes, or unrelated refactors. +- Write each behavior test first and observe the expected failure before changing production code. + +--- + +### Task 1: Provider-scoped GPT-5.6 level matrix + +**Files:** +- Modify: `tests/unit/thinking-levels-gpt56-sol.test.js` +- Modify: `open-sse/providers/thinkingLevels.js` + +**Interfaces:** +- Consumes: `getThinkingLevels(provider, model)` and existing capability metadata. +- Produces: `getThinkingLevels(provider, model): string[] | null` with Codex-only GPT-5.6 level overrides. + +- [ ] **Step 1: Replace the Sol-only assertions with the complete behavior matrix** + +Use literal expected arrays so each model/provider contract is independently checked: + +```js +it.each([ + ["gpt-5.6-sol", ["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"]], + ["gpt-5.6-terra", ["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"]], + ["gpt-5.6-luna", ["none", "minimal", "low", "medium", "high", "xhigh", "max"]], + ["gpt-5.6-sol-review", ["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"]], + ["gpt-5.6-terra-review", ["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"]], + ["gpt-5.6-luna-review", ["none", "minimal", "low", "medium", "high", "xhigh", "max"]], +])("returns Codex levels for %s", (model, expected) => { + expect(getThinkingLevels("codex", model)).toEqual(expected); +}); + +it("does not expose Codex-only GPT-5.6 overrides on Kiro", () => { + expect(getThinkingLevels("kiro", "gpt-5.6-sol")).toEqual([ + "none", "minimal", "low", "medium", "high", "xhigh", + ]); +}); +``` + +Keep the older Codex-model assertion to protect the existing `gpt-5.3-codex` behavior. + +- [ ] **Step 2: Run the level test and verify it fails for the missing matrix/provider scoping** + +Run: + +```bash +npx vitest run tests/unit/thinking-levels-gpt56-sol.test.js +``` + +Expected: FAIL because Sol lacks Ultra, Terra/Luna lack Max, and Kiro currently inherits Sol Max. + +- [ ] **Step 3: Add provider-aware pattern matching and the three Codex model rules** + +Update `PATTERN_THINKING` entries to accept an optional `provider` field and match it in `getThinkingLevels`: + +```js +const CODEX_GPT_5_6_LEVELS = ["none", "minimal", "low", "medium", "high", "xhigh", "max"]; + +const PATTERN_THINKING = [ + { provider: "codex", pattern: "*gpt-5.6-sol*", levels: [...CODEX_GPT_5_6_LEVELS, "ultra"] }, + { provider: "codex", pattern: "*gpt-5.6-terra*", levels: [...CODEX_GPT_5_6_LEVELS, "ultra"] }, + { provider: "codex", pattern: "*gpt-5.6-luna*", levels: CODEX_GPT_5_6_LEVELS }, + { pattern: "*codex*", levels: ["low", "medium", "high", "xhigh"] }, +]; + +const hit = PATTERN_THINKING.find((entry) => + (!entry.provider || entry.provider === provider) && matchPattern(entry.pattern, model) +); +``` + +- [ ] **Step 4: Re-run the level test and verify it passes** + +Run: + +```bash +npx vitest run tests/unit/thinking-levels-gpt56-sol.test.js +``` + +Expected: 1 test file passed with no failures. + +- [ ] **Step 5: Commit the capability matrix** + +```bash +git add open-sse/providers/thinkingLevels.js tests/unit/thinking-levels-gpt56-sol.test.js +git commit -m "feat(codex): expose GPT-5.6 reasoning overrides" +``` + +### Task 2: Model-aware shared thinking translation + +**Files:** +- Modify: `tests/translator/thinking-unified.test.js` +- Modify: `open-sse/translator/concerns/thinkingUnified.js` + +**Interfaces:** +- Consumes: `getThinkingLevels(provider, cleanModel): string[] | null` from Task 1. +- Produces: `parseSuffix(model)` support for `ultra` and `applyThinking(...)` output that preserves supported Codex levels. + +- [ ] **Step 1: Add failing suffix and translation tests** + +Add a literal parser assertion: + +```js +expect(parseSuffix("gpt-5.6-sol(ultra)")).toEqual({ + cleanModel: "gpt-5.6-sol", + override: { mode: "level", level: "ultra" }, +}); +``` + +Add table-driven Codex assertions using direct request fields: + +```js +it.each([ + ["gpt-5.6-sol", "max", "max"], + ["gpt-5.6-sol", "ultra", "ultra"], + ["gpt-5.6-terra", "max", "max"], + ["gpt-5.6-terra", "ultra", "ultra"], + ["gpt-5.6-luna", "max", "max"], + ["gpt-5.6-luna", "ultra", "max"], +])("normalizes Codex %s effort %s to %s", (model, effort, expected) => { + const out = apply("openai-responses", model, { reasoning: { effort } }, "codex"); + expect(out.reasoning_effort).toBe(expected); +}); +``` + +Add a parenthesized override assertion and Kiro isolation assertion: + +```js +expect(apply("openai-responses", "gpt-5.6-sol(ultra)", {}, "codex").reasoning_effort).toBe("ultra"); +expect(apply("openai", "gpt-5.6-sol", { reasoning_effort: "max" }, "kiro").reasoning_effort).toBe("xhigh"); +``` + +- [ ] **Step 2: Run the translator test and verify it fails for Ultra parsing and preserved Max/Ultra** + +Run: + +```bash +npx vitest run tests/translator/thinking-unified.test.js +``` + +Expected: FAIL because Ultra suffixes are ignored and OpenAI translation clamps Max to XHigh. + +- [ ] **Step 3: Implement supported-level normalization in the shared translator** + +Import `getThinkingLevels`. Recognize `ultra` explicitly in `parseSuffix` without adding it to the budget map. Resolve supported levels once in `applyThinking` and pass them to `applyFormat`. + +Use this normalization rule for the OpenAI format: + +```js +function normalizeOpenAILevel(level, supportedLevels) { + if (level !== "max" && level !== "ultra") return level; + if (supportedLevels?.includes(level)) return level; + if (level === "ultra" && supportedLevels?.includes("max")) return "max"; + return "xhigh"; +} +``` + +Keep `none`, automatic effort, budget conversion, and every non-OpenAI format unchanged. + +- [ ] **Step 4: Re-run the translator and generic OpenAI clamp tests** + +Run: + +```bash +npx vitest run tests/translator/thinking-unified.test.js tests/unit/thinking-effort-openai-max-clamp.test.js +``` + +Expected: 2 test files passed; generic OpenAI Max still becomes XHigh. + +- [ ] **Step 5: Commit shared translation support** + +```bash +git add open-sse/translator/concerns/thinkingUnified.js tests/translator/thinking-unified.test.js +git commit -m "feat(codex): preserve supported reasoning efforts" +``` + +### Task 3: Codex native passthrough normalization + +**Files:** +- Modify: `tests/unit/codex-fast-capacity.test.js` +- Modify: `open-sse/executors/codex.js` + +**Interfaces:** +- Consumes: `getThinkingLevels("codex", upstreamModel): string[] | null` from Task 1. +- Produces: `CodexExecutor.transformRequest(...)` payloads with model-supported upstream `reasoning.effort` values. + +- [ ] **Step 1: Add failing Codex executor behavior tests** + +Add a separate `describe("Codex reasoning normalization", ...)` block with real `transformRequest` calls: + +```js +it.each([ + ["gpt-5.6-sol", "max", "max"], + ["gpt-5.6-sol", "ultra", "ultra"], + ["gpt-5.6-terra", "max", "max"], + ["gpt-5.6-terra", "ultra", "ultra"], + ["gpt-5.6-luna", "max", "max"], + ["gpt-5.6-luna", "ultra", "max"], +])("normalizes %s effort %s to %s", (model, effort, expected) => { + const body = new CodexExecutor().transformRequest(model, { + model, + input: "hi", + reasoning: { effort }, + }, true, {}); + expect(body.reasoning.effort).toBe(expected); +}); + +it("resolves review models before applying the reasoning matrix", () => { + const body = new CodexExecutor().transformRequest("gpt-5.6-terra-review", { + model: "gpt-5.6-terra-review", + input: "hi", + reasoning_effort: "ultra", + }, true, {}); + expect(body.model).toBe("gpt-5.6-terra"); + expect(body.reasoning.effort).toBe("ultra"); +}); +``` + +Keep the existing GPT-5.5 Max-to-XHigh fast-tier test. + +- [ ] **Step 2: Run the executor test and verify supported values fail by being clamped** + +Run: + +```bash +npx vitest run tests/unit/codex-fast-capacity.test.js +``` + +Expected: FAIL because current normalization maps supported Max to XHigh and does not map Luna Ultra to Max. + +- [ ] **Step 3: Make Codex normalization model-aware** + +Import `getThinkingLevels` and replace the global Max clamp with: + +```js +function normalizeReasoningEffort(model, value) { + const supportedLevels = getThinkingLevels("codex", model); + if (supportedLevels?.includes(value)) return value; + if (value === "ultra" && supportedLevels?.includes("max")) return "max"; + if (value === "max" || value === "ultra") return "xhigh"; + return value; +} +``` + +Call it only after `body.model` has resolved review aliases to their upstream base model. Pass `body.model` for both `reasoning_effort` and existing `reasoning.effort` request shapes. + +- [ ] **Step 4: Re-run the executor and focused feature suites** + +Run: + +```bash +npx vitest run tests/unit/codex-fast-capacity.test.js tests/unit/thinking-levels-gpt56-sol.test.js tests/translator/thinking-unified.test.js tests/unit/thinking-effort-openai-max-clamp.test.js +``` + +Expected: 4 test files passed with no failures. + +- [ ] **Step 5: Commit native Codex normalization** + +```bash +git add open-sse/executors/codex.js tests/unit/codex-fast-capacity.test.js +git commit -m "feat(codex): forward GPT-5.6 max and ultra efforts" +``` + +### Task 4: Full verification and pull request + +**Files:** +- Verify all changed production, test, design, and plan files. + +**Interfaces:** +- Consumes: completed Tasks 1-3. +- Produces: verified branch pushed to `origin` and a pull request targeting `decolua/9router:master`. + +- [ ] **Step 1: Run all focused regression tests** + +```bash +npx vitest run tests/unit/thinking-levels-gpt56-sol.test.js tests/translator/thinking-unified.test.js tests/unit/thinking-effort-openai-max-clamp.test.js tests/unit/codex-fast-capacity.test.js +``` + +Expected: all selected test files and tests pass. + +- [ ] **Step 2: Run the complete unit test suite** + +```bash +npx vitest run tests/unit tests/translator +``` + +Expected: all test files pass with zero failed tests. + +- [ ] **Step 3: Run the production build** + +```bash +npm run build +``` + +Expected: Next.js production build exits with status 0. + +- [ ] **Step 4: Verify repository hygiene and requirement coverage** + +```bash +git diff --check upstream/master...HEAD +git status --short --branch +git log --oneline upstream/master..HEAD +``` + +Expected: no whitespace errors, no uncommitted source changes, and only scoped feature commits. + +- [ ] **Step 5: Push the feature branch and open the pull request** + +```bash +git push -u origin codex/gpt-5-6-reasoning-overrides +gh pr create --repo decolua/9router --base master --head seakleangnhak:codex/gpt-5-6-reasoning-overrides --title "feat(codex): support GPT-5.6 Max and Ultra overrides" --body $'## Summary\n- expose Max and Ultra for Codex GPT-5.6 Sol and Terra\n- expose Max for Codex GPT-5.6 Luna and normalize Luna Ultra to Max\n- keep Kiro and generic OpenAI-compatible reasoning behavior unchanged\n\n## Verification\n- `npx vitest run tests/unit tests/translator`\n- `npm run build`' +``` + +The pull request body must summarize the Codex-only support matrix, Luna Ultra-to-Max fallback, Kiro isolation, and fresh test/build evidence. diff --git a/docs/superpowers/specs/2026-08-02-gpt-5-6-codex-reasoning-overrides-design.md b/docs/superpowers/specs/2026-08-02-gpt-5-6-codex-reasoning-overrides-design.md new file mode 100644 index 00000000..832fb5ce --- /dev/null +++ b/docs/superpowers/specs/2026-08-02-gpt-5-6-codex-reasoning-overrides-design.md @@ -0,0 +1,122 @@ +# GPT-5.6 Codex Reasoning Overrides Design + +## Goal + +Expose and preserve the reasoning levels currently advertised by the OpenAI +Codex model catalog for GPT-5.6 Sol, Terra, and Luna when they are routed +through the `codex` provider (`cx/`). + +The supported override matrix is: + +| Model family | Max | Ultra | +| --- | --- | --- | +| GPT-5.6 Sol | Yes | Yes | +| GPT-5.6 Terra | Yes | Yes | +| GPT-5.6 Luna | Yes | No | + +The same matrix applies to 9router's virtual `-review` variants because they +resolve to the corresponding upstream base model. + +## Scope + +This change is limited to OpenAI Codex (`cx/`) routes. Kiro (`kr/`) and other +OpenAI-format providers retain their existing reasoning-level behavior even +when they expose models with the same GPT-5.6 names. + +The change covers the complete local request path: + +1. The provider page advertises only the levels supported by each Codex model. +2. A copied model suffix such as `gpt-5.6-sol(ultra)` is parsed as a reasoning + override. +3. The shared thinking translator preserves a supported Codex override while + retaining the existing `xhigh` fallback for unsupported OpenAI levels. +4. The Codex executor sends supported `max` and `ultra` values unchanged to the + upstream Codex Responses endpoint. + +## Current Behavior + +`gpt-5.6-luna` and the other GPT-5.6 models already exist in the Codex model +registry. The capability picker has a global Sol-only `max` pattern, which also +affects providers such as Kiro unintentionally. The shared OpenAI translator +and Codex executor then convert `max` to `xhigh`, so the advertised override is +not preserved end to end. `ultra` is not recognized as a model suffix. + +## Design + +### Provider-scoped level resolution + +Extend the existing model-pattern overrides in +`open-sse/providers/thinkingLevels.js` with an optional provider constraint. +Add three Codex-only GPT-5.6 patterns in most-specific order: + +- Sol: existing levels plus `max` and `ultra`. +- Terra: existing levels plus `max` and `ultra`. +- Luna: existing levels plus `max`. + +Matching remains wildcard-based so virtual `-review` variants inherit the +base model's levels. Provider matching prevents these overrides from changing +Kiro or other providers. + +### Shared translation + +Teach the suffix parser to recognize `ultra` as a discrete level without +assigning it a synthetic token budget. When applying the OpenAI wire format, +reuse the resolved per-provider model levels: + +- Preserve `max` or `ultra` when the target provider/model explicitly supports + the requested level. +- Convert `ultra` to `max` for GPT-5.6 Luna, preserving the highest level Luna + supports. +- Convert other unsupported `max` or `ultra` requests to `xhigh`, preserving + the existing safe fallback for generic OpenAI-compatible providers. +- Leave all existing lower levels and `none` handling unchanged. + +This keeps one capability source for the dashboard and translation behavior +instead of duplicating the GPT-5.6 matrix. + +### Codex executor + +Make Codex reasoning normalization model-aware. After virtual review models +are resolved to their upstream base model, preserve a requested level when +the Codex capability resolver lists it. Continue converting unsupported +`max` or `ultra` values to `xhigh`, except that Luna converts `ultra` to its +supported `max` level. + +Do not add `max` to the executor's legacy hyphen-suffix parser because +`gpt-5.1-codex-max` is an actual model identifier. Dashboard overrides use the +existing parenthesized suffix and the shared translator removes that suffix +before executor dispatch. + +## Error and Compatibility Behavior + +- `cx/gpt-5.6-luna(ultra)` becomes `max` rather than sending an unsupported + level upstream. +- Non-GPT-5.6 Codex models retain their current supported levels and fallback + behavior. +- Kiro GPT-5.6 routes no longer inherit the Codex Sol-only picker override and + continue using Kiro's existing effort normalization. +- Direct request fields and parenthesized model overrides follow the same + model-aware rules. + +## Testing + +Use test-driven development with focused unit coverage: + +1. Level resolver tests for Sol, Terra, Luna, their review variants, an older + Codex model, and Kiro isolation. +2. Shared translator tests proving `max` and `ultra` survive only for supported + Codex model/provider combinations, Luna `ultra` becomes `max`, and other + unsupported combinations become `xhigh`. +3. Codex executor tests proving native and translated request shapes preserve + supported values after upstream model resolution. +4. Existing thinking translation and Codex executor suites to guard generic + OpenAI clamping and fast-tier behavior. +5. Project lint/build checks in proportion to the changed JavaScript modules. + +## Non-goals + +- Runtime fetching or caching of the Codex model catalog. +- Adding these levels to Kiro or another provider. +- Changing model pricing, quotas, defaults, or service tiers. +- Adding Codex Ultra's multi-agent orchestration behavior inside 9router; + 9router only forwards the catalog-advertised reasoning override. diff --git a/open-sse/executors/codex.js b/open-sse/executors/codex.js index 3b04445f..4d9acbd2 100644 --- a/open-sse/executors/codex.js +++ b/open-sse/executors/codex.js @@ -8,6 +8,7 @@ import { import { normalizeResponsesInput } from "../translator/formats/responsesApi.js"; import { fetchImageAsBase64 } from "../translator/concerns/image.js"; import { getModelUpstreamId } from "../config/providerModels.js"; +import { getThinkingLevels } from "../providers/thinkingLevels.js"; import { DEFAULT_RETRY_CONFIG, HTTP_STATUS, resolveRetryEntry } from "../config/runtimeConfig.js"; import { dbg } from "../utils/debugLog.js"; import { resolveSessionId } from "../utils/sessionManager.js"; @@ -124,8 +125,12 @@ function resolveCacheSessionId(body, credentials) { }); } -function normalizeReasoningEffort(value) { - return value === "max" ? "xhigh" : value; +function normalizeReasoningEffort(model, value) { + const supportedLevels = getThinkingLevels("codex", model); + if (supportedLevels?.includes(value)) return value; + if (value === "ultra" && supportedLevels?.includes("max")) return "max"; + if (value === "max" || value === "ultra") return "xhigh"; + return value; } function findNestedMessage(value, depth = 0) { @@ -440,10 +445,10 @@ export class CodexExecutor extends BaseExecutor { // Priority: explicit reasoning.effort > reasoning_effort param > model suffix > default (medium) if (!body.reasoning) { - const effort = normalizeReasoningEffort(body.reasoning_effort || modelEffort || 'low'); + const effort = normalizeReasoningEffort(body.model, body.reasoning_effort || modelEffort || 'low'); body.reasoning = { effort, summary: "auto" }; } else { - body.reasoning.effort = normalizeReasoningEffort(body.reasoning.effort); + body.reasoning.effort = normalizeReasoningEffort(body.model, body.reasoning.effort); if (!body.reasoning.summary) body.reasoning.summary = "auto"; } delete body.reasoning_effort; diff --git a/open-sse/handlers/chatCore.js b/open-sse/handlers/chatCore.js index 4f91e020..c32b74bb 100644 --- a/open-sse/handlers/chatCore.js +++ b/open-sse/handlers/chatCore.js @@ -1,6 +1,6 @@ import { detectFormat, getTargetFormat, resolveTransport } from "../services/provider.js"; import { translateRequest } from "../translator/index.js"; -import { stripThinkingSuffix } from "../translator/concerns/thinkingUnified.js"; +import { applyThinking, extractThinking, stripThinkingSuffix } from "../translator/concerns/thinkingUnified.js"; import { FORMATS } from "../translator/formats.js"; import { normalizeClaudePassthrough } from "../translator/formats/claude.js"; import { createStreamController } from "../utils/streamHandler.js"; @@ -28,7 +28,6 @@ import { compressWithPxpipe } from "../rtk/pxpipe.js"; import { getCapabilitiesForModel } from "../providers/capabilities.js"; import { stripUnsupportedModalities } from "../translator/concerns/modality.js"; import { prefetchRemoteImages } from "../translator/concerns/prefetch.js"; -import { extractThinking } from "../translator/concerns/thinkingUnified.js"; import { resolveSessionId } from "../utils/sessionManager.js"; /** @@ -137,6 +136,18 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred if (passthrough) { log?.debug?.("PASSTHROUGH", `${clientTool} → ${provider} | native lossless`); translatedBody = { ...body, model: stripThinkingSuffix(upstreamModel) }; + if (provider === "codex") { + const suffixThinking = {}; + applyThinking(sourceFormat, upstreamModel, suffixThinking, provider); + if (suffixThinking.reasoning_effort) { + const reasoning = translatedBody.reasoning; + translatedBody.reasoning = { + ...(reasoning && typeof reasoning === "object" && !Array.isArray(reasoning) ? reasoning : {}), + effort: suffixThinking.reasoning_effort, + }; + delete translatedBody.reasoning_effort; + } + } // Normalize newer Cowork/CC beta shapes (adaptive thinking, mid-conversation system) the API rejects if (clientTool === "claude") normalizeClaudePassthrough(translatedBody, translatedBody.model); } else { diff --git a/open-sse/providers/thinkingLevels.js b/open-sse/providers/thinkingLevels.js index 7b638700..f42cdd84 100644 --- a/open-sse/providers/thinkingLevels.js +++ b/open-sse/providers/thinkingLevels.js @@ -31,10 +31,13 @@ const FORMAT_LEVELS = { step: L.base, }; +const CODEX_GPT_5_6_LEVELS = ["none", "minimal", "low", "medium", "high", "xhigh", "max"]; + // Model-name pattern overrides (glob, first match wins) — more precise than format default. const PATTERN_THINKING = [ - // gpt-5.6-sol accepts max (maps to xhigh on wire); live probe rejected ultra. - { pattern: "*gpt-5.6-sol*", levels: ["none", "minimal", "low", "medium", "high", "xhigh", "max"] }, + { provider: "codex", pattern: "*gpt-5.6-sol*", levels: [...CODEX_GPT_5_6_LEVELS, "ultra"] }, + { provider: "codex", pattern: "*gpt-5.6-terra*", levels: [...CODEX_GPT_5_6_LEVELS, "ultra"] }, + { provider: "codex", pattern: "*gpt-5.6-luna*", levels: CODEX_GPT_5_6_LEVELS }, { pattern: "*codex*", levels: ["low", "medium", "high", "xhigh"] }, // codex cannot disable thinking ]; @@ -43,7 +46,9 @@ export function getThinkingLevels(provider, model) { if (provider === "kiro" && resolveKiroEffortPath(model) === null) return null; const caps = getCapabilitiesForModel(provider, model); if (!caps.reasoning) return null; - const hit = PATTERN_THINKING.find((p) => matchPattern(p.pattern, model)); + const hit = PATTERN_THINKING.find((entry) => + (!entry.provider || entry.provider === provider) && matchPattern(entry.pattern, model) + ); let levels = hit?.levels || FORMAT_LEVELS[caps.thinkingFormat] || L.base; if (caps.thinkingCanDisable === false) levels = levels.filter((l) => l !== "none"); return levels; diff --git a/open-sse/translator/concerns/thinkingUnified.js b/open-sse/translator/concerns/thinkingUnified.js index ee0b86aa..5dbf756b 100644 --- a/open-sse/translator/concerns/thinkingUnified.js +++ b/open-sse/translator/concerns/thinkingUnified.js @@ -3,6 +3,7 @@ // never hardcoded per-model here. See .docs/thinking/plan.md MATRIX VI-A. import { getCapabilitiesForModel } from "../../providers/capabilities.js"; +import { getThinkingLevels } from "../../providers/thinkingLevels.js"; import { PROVIDERS } from "../../providers/index.js"; import { LEVEL_TO_BUDGET, budgetToLevel, effortToBudget, effortToThinkingLevel } from "./thinking.js"; @@ -37,6 +38,7 @@ export function parseSuffix(model) { const raw = m[2].trim().toLowerCase(); if (raw === "none" || raw === "off") return { cleanModel, override: { mode: "none" } }; if (raw === "auto") return { cleanModel, override: { mode: "auto" } }; + if (raw === "ultra") return { cleanModel, override: { mode: "level", level: raw } }; if (/^\d+$/.test(raw)) return { cleanModel, override: { mode: "budget", budget: Number(raw) } }; if (LEVEL_TO_BUDGET[raw] !== undefined) return { cleanModel, override: { mode: "level", level: raw } }; return { cleanModel, override: null }; @@ -134,6 +136,13 @@ function toLevel(cfg) { return null; } +function normalizeOpenAILevel(level, supportedLevels) { + if (level !== "max" && level !== "ultra") return level; + if (supportedLevels?.includes(level)) return level; + if (level === "ultra" && supportedLevels?.includes("max")) return "max"; + return "xhigh"; +} + function toGeminiThinkingLevel(cfg) { const raw = cfg.mode === "auto" ? "high" : (toLevel(cfg) || "high"); return effortToThinkingLevel(raw); @@ -213,7 +222,7 @@ function stripAll(body) { } // Apply unified thinking config to body in the resolved provider-native format. -function applyFormat(fmt, body, cfg, caps) { +function applyFormat(fmt, body, cfg, caps, supportedLevels) { const none = cfg.mode === "none"; const canDisable = caps.thinkingCanDisable !== false; // Model cannot disable thinking → clamp "none" to minimal effort instead. @@ -223,8 +232,7 @@ function applyFormat(fmt, body, cfg, caps) { case "openai": { if (none && canDisable) { body.reasoning_effort = "none"; break; } const level = toLevel(eff); - // OpenAI reasoning_effort enum caps at "xhigh" (no "max"); clamp Claude Code's "max". - if (level) body.reasoning_effort = level === "max" ? "xhigh" : level; + if (level) body.reasoning_effort = normalizeOpenAILevel(level, supportedLevels); break; } case "claude-adaptive": { @@ -329,7 +337,8 @@ export function applyThinking(targetFormat, model, body, provider = null, intent if (!cfg) return body; const fmt = resolveFormat(targetFormat, cleanModel, provider); + const supportedLevels = getThinkingLevels(provider, cleanModel); stripAll(body); - applyFormat(fmt, body, cfg, caps); + applyFormat(fmt, body, cfg, caps, supportedLevels); return body; } diff --git a/tests/translator/thinking-unified.test.js b/tests/translator/thinking-unified.test.js index 7f276d5c..ee48e3bb 100644 --- a/tests/translator/thinking-unified.test.js +++ b/tests/translator/thinking-unified.test.js @@ -18,6 +18,12 @@ describe("parseSuffix", () => { it("parses level suffix", () => { expect(parseSuffix("gpt-5(high)")).toEqual({ cleanModel: "gpt-5", override: { mode: "level", level: "high" } }); }); + it("parses ultra suffix", () => { + expect(parseSuffix("gpt-5.6-sol(ultra)")).toEqual({ + cleanModel: "gpt-5.6-sol", + override: { mode: "level", level: "ultra" }, + }); + }); it("parses numeric budget suffix", () => { expect(parseSuffix("model(8192)")).toEqual({ cleanModel: "model", override: { mode: "budget", budget: 8192 } }); }); @@ -157,6 +163,25 @@ describe("applyThinking per provider format", () => { const out = apply("openai", "gpt-5.3-codex", { reasoning_effort: "xhigh" }, "codex"); expect(out.reasoning_effort).toBe("xhigh"); }); + it.each([ + ["gpt-5.6-sol", "max", "max"], + ["gpt-5.6-sol", "ultra", "ultra"], + ["gpt-5.6-terra", "max", "max"], + ["gpt-5.6-terra", "ultra", "ultra"], + ["gpt-5.6-luna", "max", "max"], + ["gpt-5.6-luna", "ultra", "max"], + ])("normalizes Codex %s effort %s to %s", (model, effort, expected) => { + const out = apply("openai-responses", model, { reasoning: { effort } }, "codex"); + expect(out.reasoning_effort).toBe(expected); + }); + it("applies a supported Codex Ultra suffix", () => { + const out = apply("openai-responses", "gpt-5.6-sol(ultra)", {}, "codex"); + expect(out.reasoning_effort).toBe("ultra"); + }); + it("keeps Codex-only GPT-5.6 levels out of Kiro translation", () => { + const out = apply("openai", "gpt-5.6-sol", { reasoning_effort: "max" }, "kiro"); + expect(out.reasoning_effort).toBe("xhigh"); + }); }); describe("extractReasoningText (response shapes)", () => { diff --git a/tests/unit/codex-fast-capacity.test.js b/tests/unit/codex-fast-capacity.test.js index aad85284..818831e1 100644 --- a/tests/unit/codex-fast-capacity.test.js +++ b/tests/unit/codex-fast-capacity.test.js @@ -69,3 +69,33 @@ describe("Codex fast tier and capacity handling", () => { await expect(new Response(peek.replacementBody).text()).resolves.toBe(text); }); }); + +describe("Codex reasoning normalization", () => { + it.each([ + ["gpt-5.6-sol", "max", "max"], + ["gpt-5.6-sol", "ultra", "ultra"], + ["gpt-5.6-terra", "max", "max"], + ["gpt-5.6-terra", "ultra", "ultra"], + ["gpt-5.6-luna", "max", "max"], + ["gpt-5.6-luna", "ultra", "max"], + ])("normalizes %s effort %s to %s", (model, effort, expected) => { + const body = new CodexExecutor().transformRequest(model, { + model, + input: "hi", + reasoning: { effort }, + }, true, {}); + + expect(body.reasoning.effort).toBe(expected); + }); + + it("resolves review models before applying the reasoning matrix", () => { + const body = new CodexExecutor().transformRequest("gpt-5.6-terra-review", { + model: "gpt-5.6-terra-review", + input: "hi", + reasoning_effort: "ultra", + }, true, {}); + + expect(body.model).toBe("gpt-5.6-terra"); + expect(body.reasoning.effort).toBe("ultra"); + }); +}); diff --git a/tests/unit/codex-native-passthrough-thinking.test.js b/tests/unit/codex-native-passthrough-thinking.test.js new file mode 100644 index 00000000..bba0b630 --- /dev/null +++ b/tests/unit/codex-native-passthrough-thinking.test.js @@ -0,0 +1,111 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { executeMock, forcedSSEToJsonMock } = vi.hoisted(() => ({ + executeMock: vi.fn(), + forcedSSEToJsonMock: vi.fn(), +})); + +vi.mock("../../open-sse/executors/index.js", () => ({ + getExecutor: () => ({ + noAuth: true, + execute: executeMock, + }), +})); + +vi.mock("../../open-sse/utils/requestLogger.js", () => ({ + createRequestLogger: async () => ({ + logClientRawRequest: vi.fn(), + logRawRequest: vi.fn(), + logTargetRequest: vi.fn(), + logProviderResponse: vi.fn(), + logConvertedResponse: vi.fn(), + logError: vi.fn(), + }), +})); + +vi.mock("@/lib/usageDb.js", () => ({ + trackPendingRequest: vi.fn(), + appendRequestLog: vi.fn(async () => {}), + saveRequestDetail: vi.fn(async () => {}), +})); + +vi.mock("../../open-sse/handlers/chatCore/sseToJsonHandler.js", () => ({ + handleForcedSSEToJson: forcedSSEToJsonMock, +})); + +const { handleChatCore } = await import("../../open-sse/handlers/chatCore.js"); + +async function runNativeCodexRequest(model, reasoning) { + const body = { + model, + input: "hello", + stream: false, + ...(reasoning ? { reasoning } : {}), + }; + + await handleChatCore({ + body, + modelInfo: { provider: "codex", model }, + credentials: { accessToken: "test-token", providerSpecificData: {} }, + log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn() }, + connectionId: "test-connection", + rtkEnabled: false, + headroomEnabled: false, + cavemanEnabled: false, + ponytailEnabled: false, + pxpipeEnabled: false, + sourceFormatOverride: "openai-responses", + clientRawRequest: { + endpoint: "/v1/responses", + body, + headers: { + accept: "application/json", + "user-agent": "codex-cli/0.144.1", + }, + }, + }); + + return executeMock.mock.calls.at(-1)[0].body; +} + +describe("native Codex passthrough thinking suffixes", () => { + beforeEach(() => { + vi.clearAllMocks(); + executeMock.mockResolvedValue({ + response: new Response("", { status: 200 }), + url: "https://chatgpt.com/backend-api/codex/responses", + headers: {}, + transformedBody: null, + }); + forcedSSEToJsonMock.mockResolvedValue({ + success: true, + response: new Response("{}", { status: 200 }), + }); + }); + + it("forwards Ultra for Sol", async () => { + const body = await runNativeCodexRequest("gpt-5.6-sol(ultra)"); + + expect(body.model).toBe("gpt-5.6-sol"); + expect(body.reasoning).toEqual({ effort: "ultra" }); + }); + + it("converts unsupported Luna Ultra to Max without dropping reasoning metadata", async () => { + const body = await runNativeCodexRequest("gpt-5.6-luna(ultra)", { + effort: "low", + summary: "detailed", + }); + + expect(body.model).toBe("gpt-5.6-luna"); + expect(body.reasoning).toEqual({ effort: "max", summary: "detailed" }); + }); + + it("forwards Ultra through a Terra review alias", async () => { + const body = await runNativeCodexRequest("gpt-5.6-terra-review(ultra)", { + effort: "low", + }); + + expect(body.model).toBe("gpt-5.6-terra"); + expect(body.reasoning).toEqual({ effort: "ultra" }); + }); +}); diff --git a/tests/unit/thinking-levels-gpt56-sol.test.js b/tests/unit/thinking-levels-gpt56-sol.test.js index 1a5a3ceb..5ff6546c 100644 --- a/tests/unit/thinking-levels-gpt56-sol.test.js +++ b/tests/unit/thinking-levels-gpt56-sol.test.js @@ -2,11 +2,21 @@ import { describe, it, expect } from "vitest"; import { getThinkingLevels } from "../../open-sse/providers/thinkingLevels.js"; describe("getThinkingLevels", () => { - it("adds max for gpt-5.6-sol on codex", () => { - const levels = getThinkingLevels("codex", "gpt-5.6-sol"); - expect(levels).toContain("max"); - expect(levels).toContain("xhigh"); - expect(levels).not.toContain("ultra"); + it.each([ + ["gpt-5.6-sol", ["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"]], + ["gpt-5.6-terra", ["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"]], + ["gpt-5.6-luna", ["none", "minimal", "low", "medium", "high", "xhigh", "max"]], + ["gpt-5.6-sol-review", ["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"]], + ["gpt-5.6-terra-review", ["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"]], + ["gpt-5.6-luna-review", ["none", "minimal", "low", "medium", "high", "xhigh", "max"]], + ])("returns Codex levels for %s", (model, expected) => { + expect(getThinkingLevels("codex", model)).toEqual(expected); + }); + + it("does not expose Codex-only GPT-5.6 overrides on Kiro", () => { + expect(getThinkingLevels("kiro", "gpt-5.6-sol")).toEqual([ + "none", "minimal", "low", "medium", "high", "xhigh", + ]); }); it("does not add max for other codex models", () => { @@ -14,7 +24,7 @@ describe("getThinkingLevels", () => { expect(levels).toEqual(["low", "medium", "high", "xhigh"]); }); - it("does not add max for other gpt-5.6 models", () => { + it("does not add max for other Codex models", () => { const levels = getThinkingLevels("codex", "gpt-5.5"); expect(levels || []).not.toContain("max"); });