From 92956e7c689e8975f8f47680c1034b28048e1fa5 Mon Sep 17 00:00:00 2001 From: Plamyth Date: Mon, 26 Oct 2026 05:16:04 +0900 Subject: [PATCH 1/2] test(codex): reproduce unsupported none effort for native model --- tests/codex-integration/codex-catalog-ladders.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/codex-integration/codex-catalog-ladders.test.ts b/tests/codex-integration/codex-catalog-ladders.test.ts index cd335a5bbd8..a183f8785db 100644 --- a/tests/codex-integration/codex-catalog-ladders.test.ts +++ b/tests/codex-integration/codex-catalog-ladders.test.ts @@ -215,6 +215,10 @@ describe("catalog ultra (always-on)", () => { }); describe("mock-max wire clamp (nativeEffortClamp)", () => { + test("native model rejects none and selects its lowest supported effort", () => { + expect(nativeEffortClamp("gpt-6.1-sol", "none")).toBe("low"); + }); + test("gpt-5.5 max/ultra clamp to its real top rung (xhigh)", () => { expect(nativeEffortClamp("gpt-5.5", "max")).toBe("xhigh"); expect(nativeEffortClamp("gpt-5.5", "ultra")).toBe("xhigh"); From 283f2d02a231532ebdcc4c41b13d539e9b435889 Mon Sep 17 00:00:00 2001 From: Plamyth Date: Mon, 26 Oct 2026 05:23:49 +0900 Subject: [PATCH 2/2] fix(codex): clamp native efforts to selected model ladder --- .../docs/reference/configuration/agents.md | 3 ++ src/codex/catalog/effort.ts | 16 +++--- structure/catalog.md | 2 +- structure/transports/responses-failover.md | 5 +- .../responses-compaction-override.test.ts | 53 +++++++++++++++++++ 5 files changed, 69 insertions(+), 10 deletions(-) diff --git a/docs-site/src/content/docs/reference/configuration/agents.md b/docs-site/src/content/docs/reference/configuration/agents.md index 58ba51828c5..d03750291a2 100644 --- a/docs-site/src/content/docs/reference/configuration/agents.md +++ b/docs-site/src/content/docs/reference/configuration/agents.md @@ -384,6 +384,9 @@ Synthetic-row effort and combo defaults are preserved as the effective input bef Each selected destination resolves its own pin, then applicable caps and wire normalization. Compaction requests are exempt. `none` means effort omission and provider-default behavior, not guaranteed reasoning disablement. +For canonical OpenAI forwarding, the final model's supported effort list takes precedence: +`gpt-6.1-sol` starts at `low`, so an incoming `none` or `minimal` is sent as `low`, including +after a compaction model switch or combo failover. `GET /api/effort-caps` includes the map. `PUT /api/effort-caps` accepts `modelPinnedEfforts` alongside the existing caps: omitted fields stay unchanged, `null` clears the map, and a map diff --git a/src/codex/catalog/effort.ts b/src/codex/catalog/effort.ts index 2c8aef49a78..87f9dd0cde2 100644 --- a/src/codex/catalog/effort.ts +++ b/src/codex/catalog/effort.ts @@ -53,19 +53,20 @@ import { } from "../runtime"; export function nativeEffortClamp(slug: string, effort: string | undefined): string | null { - if (!effort || (effort !== "max" && effort !== "ultra")) return null; + if (!effort) return null; if (slug.includes("/")) return null; // routed models map efforts in their adapters // `ultra` is a client-facing catalog choice (max reasoning plus proactive delegation) that // codex-rs converts to `max` before any request (core/src/client.rs // `reasoning_effort_for_request`). The ChatGPT backend rejects it with HTTP 400 even for a // model whose pinned ladder lists it (#7590), so it is never a native wire value: fold it to // `max`, then clamp `max` like a direct selection. - const wire = "max"; + const wire = effort === "ultra" ? "max" : effort; const entry = UPSTREAM_NATIVE_ENTRIES.get(slug); const levels = Array.isArray(entry?.supported_reasoning_levels) ? entry.supported_reasoning_levels as Array<{ effort?: string }> : []; if (levels.length === 0) { + if (wire !== "max") return null; // Not snapshot-covered. gpt-5.6 natives have a REAL max rung (ensureGpt56ReasoningLevels // restores it even off-snapshot) -> never clamp. Every other bare native (gpt-5.5, // a retired slug a client still asks for, and future old-ladder slugs) @@ -76,12 +77,11 @@ export function nativeEffortClamp(slug: string, effort: string | undefined): str } const supported = levels.flatMap(l => typeof l.effort === "string" ? [l.effort] : []); if (supported.includes(wire)) return effort === wire ? null : wire; - const rank = ["minimal", "low", "medium", "high", "xhigh", "max"]; - const highest = supported - .filter(e => rank.includes(e)) - .sort((a, b) => rank.indexOf(a) - rank.indexOf(b)) - .at(-1); - return highest ?? null; + const rank = ["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"]; + const requestedRank = rank.indexOf(wire); + if (requestedRank < 0) return null; + const available = supported.filter(e => rank.includes(e)).sort((a, b) => rank.indexOf(a) - rank.indexOf(b)); + return available.filter(e => rank.indexOf(e) <= requestedRank).at(-1) ?? available[0] ?? null; } export function shouldApplyNativeEffortClamp( diff --git a/structure/catalog.md b/structure/catalog.md index 2db414d1edc..0b7be713aa7 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -481,7 +481,7 @@ The shared Responses path follows the [bounded multipart recovery contract](suba Ultra is always advertised in the catalog regardless of the `multi_agent_v2` toggle. The v2 toggle controls only the multi-agent collab surface, not ultra visibility. The `nativeEffortClamp` function -wire-clamps ultra/max to each model's real top rung (e.g. gpt-5.5 ultra → xhigh on the wire). `ultra` itself is never a native wire value: the ChatGPT backend accepts `none` through `max`, so the clamp folds it to `max` even on rows whose pinned ladder lists it (`gpt-6-astra`, `gpt-6.1-sol`), and `src/server/responses/core-normalize.ts` applies that to the raw body the passthrough serializes, which the parser's options-only fold does not reach (#7590; `tests/responses/responses-native-ultra-effort-wire.test.ts`). +wire-clamps ultra/max to each model's real top rung (e.g. gpt-5.5 ultra → xhigh on the wire). `ultra` itself is never a native wire value: the ChatGPT backend accepts `none` through `max`, so the clamp folds it to `max` even on rows whose pinned ladder lists it (`gpt-6-astra`, `gpt-6.1-sol`), and `src/server/responses/core-normalize.ts` applies that to the raw body the passthrough serializes, which the parser's options-only fold does not reach (#7590; `tests/responses/responses-native-ultra-effort-wire.test.ts`). For canonical OpenAI forwarding, other unsupported efforts map to the nearest published rung at or below the request, or the lowest rung when below the model minimum (`gpt-6.1-sol` `none` → `low`), after final model selection, including combo failover. For routed models, `modelSuppressSyntheticMax` is a catalog-only per-model setting. A true value prevents `src/codex/catalog/effort.ts` from adding a missing synthetic `max` and prevents diff --git a/structure/transports/responses-failover.md b/structure/transports/responses-failover.md index 4dcf2311c41..e328c85e012 100644 --- a/structure/transports/responses-failover.md +++ b/structure/transports/responses-failover.md @@ -284,7 +284,10 @@ canonical `openai` provider exists (#2901), not when its quota is exhausted. A h `triggers` the schema would reject disables the whole block instead of widening it, so a malformed edit can never route more than it names. -The override changes only the model and optional reasoning effort; native forwarding, routed summaries, capability handling, and retry +The override changes only the model and optional reasoning effort. Final-route normalization +checks that effort against the selected canonical native model's published ladder before each +Responses send, including a combo fallback (`gpt-6.1-sol` `none` → `low`). Native forwarding, +routed summaries, capability handling, and retry budgets stay authoritative, and native compact still removes reasoning before sending. Handoffs carry the override record (with the source model) as a recursion guard so combo children and fallbacks keep their targets. Overrides bypass shadow interception and conversation combo recall, and do not publish replacement combo/handoff recall. They never change the conversation's model or compactions outside the diff --git a/tests/responses/responses-compaction-override.test.ts b/tests/responses/responses-compaction-override.test.ts index 0fe7ed382eb..cbe8a10166a 100644 --- a/tests/responses/responses-compaction-override.test.ts +++ b/tests/responses/responses-compaction-override.test.ts @@ -293,6 +293,59 @@ describe("manual compaction config", () => { }); describe("manual compaction reuses existing handlers", () => { + test.each(["direct", "compaction switch"])("%s sends gpt-6.1-sol's supported low effort", async mode => { + const settings = config(); + settings.providers.openai = { adapter: "openai-responses", authMode: "forward", codexAccountMode: "direct", baseUrl: "https://chatgpt.com/backend-api/codex" }; + if (mode === "direct") delete settings.compactionRouting; + else { + settings.compactionRouting!.model = "gpt-6.1-sol"; + delete settings.compactionRouting!.reasoningEffort; + } + const calls: Array> = []; + globalThis.fetch = (async (_url: unknown, init?: RequestInit) => { + const outbound = JSON.parse(String(init?.body)); + calls.push(outbound); + return outbound.reasoning?.effort === "none" + ? Response.json({ error: { code: "unsupported_value", param: "reasoning.effort" } }, { status: 400 }) + : upstreamCompletion(outbound); + }) as typeof fetch; + const input = { ...body(mode !== "direct"), model: mode === "direct" ? "gpt-6.1-sol" : "gateway/normal", reasoning: { effort: "none", summary: "auto" } }; + const req = request(input, mode === "direct" ? undefined : "manual"); + req.headers.set("authorization", `Bearer ${fakeChatGptJwt({ chatgpt_account_id: "fixture-account" })}`); + const response = await handleResponses(req, settings, { model: "", provider: "" }); + await response.text(); + expect(response.status).toBe(200); + expect(calls).toHaveLength(1); + expect(calls[0]!.model).toBe("gpt-6.1-sol"); + expect(calls[0]!.reasoning).toMatchObject({ effort: "low", summary: "auto" }); + }); + + test("combo failover normalizes the second native target before its first send", async () => { + const settings = config(); + delete settings.compactionRouting; + settings.providers.openai = { adapter: "openai-responses", authMode: "forward", codexAccountMode: "direct", baseUrl: "https://chatgpt.com/backend-api/codex" }; + settings.combos = { fail: { strategy: "failover", targets: [ + { provider: "gateway", model: "normal" }, { provider: "openai", model: "gpt-6.1-sol" }, + ] } }; + const calls: Array> = []; + globalThis.fetch = (async (_url: unknown, init?: RequestInit) => { + const outbound = JSON.parse(String(init?.body)); + calls.push(outbound); + if (outbound.model === "normal") return Response.json({ error: { message: "quota", type: "rate_limit_error" } }, { status: 429 }); + return outbound.reasoning?.effort === "none" + ? Response.json({ error: { code: "unsupported_value", param: "reasoning.effort" } }, { status: 400 }) + : upstreamCompletion(outbound); + }) as typeof fetch; + const req = request({ ...body(false), model: "combo/fail", reasoning: { effort: "none", summary: "auto" } }); + req.headers.set("authorization", `Bearer ${fakeChatGptJwt({ chatgpt_account_id: "fixture-account" })}`); + const response = await handleResponses(req, settings, { model: "", provider: "" }); + await response.text(); + expect(response.status).toBe(200); + expect(calls.map(call => [call.model, call.reasoning?.effort])).toEqual([ + ["normal", "none"], ["gpt-6.1-sol", "low"], + ]); + }); + test("an ordinary turn carrying manual metadata stays on the conversation model", async () => { const settings = config(); const calls: string[] = [];