From ad8d2d8acec5241a6b50af7907860eff2d366716 Mon Sep 17 00:00:00 2001 From: Keito Itagaki <171206780+ke-1t@users.noreply.github.com> Date: Sat, 12 Sep 2026 10:30:20 +0900 Subject: [PATCH 1/4] fix(combos): normalize reasoning controls for unknown target capabilities --- docs-site/src/content/docs/guides/combos.md | 10 ++-- src/adapters/openai-chat.ts | 3 +- src/combos/request.ts | 20 ++++++- src/server/responses/core.ts | 1 + src/types/config.ts | 5 +- structure/catalog.md | 5 ++ structure/config.md | 2 +- structure/data-planes/inbound-compat.md | 3 + structure/providers/chat-compat.md | 4 +- structure/runtime.md | 1 + structure/transports/inventory.md | 2 +- structure/transports/responses.md | 9 +++ .../openai/openai-chat-hardening.test.ts | 17 ++++++ tests/codex-integration/combos.test.ts | 58 +++++++++++++++++-- .../server/server-combo-failover-e2e.test.ts | 54 +++++++++++++++++ 15 files changed, 179 insertions(+), 15 deletions(-) diff --git a/docs-site/src/content/docs/guides/combos.md b/docs-site/src/content/docs/guides/combos.md index ef5ccde16c..4434a46bc6 100644 --- a/docs-site/src/content/docs/guides/combos.md +++ b/docs-site/src/content/docs/guides/combos.md @@ -297,9 +297,11 @@ as wildcards in both modes. } ``` -The default is `"strict"`, which keeps the original behavior. This setting changes published -catalog metadata only — it does not change target order, failover policy, or which effort a given -target receives at dispatch. In the dashboard it is the **Adaptive reasoning ladder** switch in a +The default is `"strict"`, which keeps the original picker behavior. This setting does not change +target order or failover policy. At dispatch, an explicitly empty target ladder receives no +reasoning/thinking controls in either mode; `"adaptive"` also removes those controls for an unknown +target capability, while known non-empty targets keep their existing per-target effort resolution. +In the dashboard it is the **Adaptive reasoning ladder** switch in a combo's Capabilities section. ## Image / multimodal capability @@ -414,7 +416,7 @@ Combos are stored in the top-level `combos` object, keyed by combo id: | `cooldownMs` | No | unset → upstream fallback (5 s for request-rate 429 codes `1302`/`1305`, otherwise 60 s) | Integer from 1 to 600000. When set, applies as the per-target cooldown whenever no usable upstream `Retry-After` or Codex reset signal exists, including request-rate 429s; when unset, uses the upstream fallback. | | `waitForCooldownMs` | No | `0` | Integer from 0 to 600000. Maximum time to wait for the earliest eligible cooling target before returning `combo_unavailable`; abort cancels the wait. | | `defaultEffort` | No | `null` | `low`, `medium`, `high`, `xhigh`, `max`, or `ultra`; applied only when the caller omits effort and the target advertises support. | -| `reasoningEffortMode` | No | `"strict"` | `"strict"` intersects every known target ladder, so one target advertising no effort control empties the combo's picker. `"adaptive"` excludes those empty ladders from the published intersection. Metadata only; dispatch is unchanged. | +| `reasoningEffortMode` | No | `"strict"` | `"strict"` intersects every known target ladder, so one target advertising no effort control empties the combo's picker. `"adaptive"` excludes those empty ladders from the published intersection. At dispatch, explicit empty ladders receive no reasoning/thinking controls, and adaptive unknown ladders receive no parent controls; known non-empty targets keep existing effort resolution. | | `imageInput` | No | `"auto"` | `"auto"` or `"disabled"`. `"auto"` publishes image support only when every target supports images; `"disabled"` forces text-only (drops image from published modalities and rejects image-bearing requests before dispatch). | | `alias` | No | none | Optional trimmed public model id; use the alias rules above. An empty value is stored as no alias. | | `nativeAlias` | No | `false` | Explicitly permit a currently supported bare native `alias` to take routing and catalog precedence. Never inferred from the alias. | diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index e338d845ad..46c24bb48e 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -1,7 +1,7 @@ import type { AdapterRequest, ProviderAdapter } from "./base"; import type { AdapterEvent, OcxAssistantMessage, OcxContentPart, OcxMessage, OcxParsedRequest, OcxProviderConfig, OcxTextContent, OcxThinkingContent, OcxToolCall, OcxUsage } from "../types"; import { isAllowedToolChoice, modelInList, namespacedToolName, resolveToolChoiceWireName, toolChoiceToolPredicate } from "../types"; -import { mapReasoningEffort, modelRecordValue } from "../reasoning-effort"; +import { configuredReasoningEfforts, mapReasoningEffort, modelRecordValue } from "../reasoning-effort"; import { debugProviderDiagnostic } from "../lib/debug"; import { sseFieldValue } from "../lib/sse-decoder"; import { isDebugEnabled } from "../lib/debug-settings"; @@ -130,6 +130,7 @@ export function buildOpenAIChatPassthroughRequest( for (const field of CHAT_PASSTHROUGH_FIELDS) { if (rawBody[field] !== undefined) body[field] = rawBody[field]; } + if (configuredReasoningEfforts(provider, modelId)?.length === 0) delete body.reasoning_effort; const openRouterRouting = resolveOpenRouterRouting(provider, modelId); if (openRouterRouting) body.provider = openRouterProviderPayload(openRouterRouting); diff --git a/src/combos/request.ts b/src/combos/request.ts index abafccc525..63c5ba7fca 100644 --- a/src/combos/request.ts +++ b/src/combos/request.ts @@ -1,4 +1,4 @@ -import type { OcxComboDefaultEffort, OcxComboTarget, OcxConfig } from "../types"; +import type { OcxComboDefaultEffort, OcxComboReasoningEffortMode, OcxComboTarget, OcxConfig } from "../types"; import { resolveEffortAtOrBelow } from "../reasoning-effort"; import { resolveComboId } from "./types"; @@ -59,9 +59,14 @@ export function concreteComboRequestBody( target: Pick, defaultEffort: OcxComboDefaultEffort | null, targetReasoningEfforts: readonly string[] | undefined, + reasoningEffortMode: OcxComboReasoningEffortMode = "strict", ): Record { const clone = structuredClone(body) as Record; clone.model = `${target.provider}/${target.model}`; + if (targetReasoningEfforts?.length === 0 + || (reasoningEffortMode === "adaptive" && targetReasoningEfforts === undefined)) { + stripUnsupportedReasoningControls(clone); + } if (!defaultEffort) return clone; const reasoning = clone.reasoning; const needsDefault = reasoning === undefined || ( @@ -104,3 +109,16 @@ export function concreteComboRequestBody( } return clone; } + +function stripUnsupportedReasoningControls(body: Record): void { + const reasoning = body.reasoning; + if (reasoning && typeof reasoning === "object" && !Array.isArray(reasoning)) { + const next = { ...(reasoning as Record) }; + delete next.effort; + if (Object.keys(next).length > 0) body.reasoning = next; + else delete body.reasoning; + } + delete body.reasoning_effort; + delete body.thinking_budget; + delete body.thinking; +} diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index e141b55ef0..0e81cdefec 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -2924,6 +2924,7 @@ export async function handleComboResponses( pick.target, comboDefaultEffort(config, comboId), supportedLadderFor({ provider: targetRoute.provider, modelId: targetRoute.modelId }), + combo.reasoningEffortMode, ); const childHeaders = buildComboChildHeaders(req.headers); const childRequest = new Request(req.url, { diff --git a/src/types/config.ts b/src/types/config.ts index 52c4ed8b0e..a8d925188c 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -967,8 +967,9 @@ export type OcxComboDefaultEffort = "low" | "medium" | "high" | "xhigh" | "max" * advertises no effort control (`reasoningEfforts: []`) empties the combo's picker. * `adaptive` excludes those empty ladders from the published intersection, keeping the * control usable for a mixed-capability group. Unknown (`undefined`) ladders stay - * wildcards in both modes. Dispatch is unchanged: each concrete target still resolves - * its own effort at request time. + * wildcards in both modes. An explicit empty ladder removes unsupported effort controls + * in either mode; adaptive dispatch also removes them before sending to an unknown target, + * while each known target still resolves its own effort. */ export type OcxComboReasoningEffortMode = "strict" | "adaptive"; diff --git a/structure/catalog.md b/structure/catalog.md index 0ba4acca3e..1392fc9083 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -240,6 +240,11 @@ wire-clamps ultra/max to each model's real top rung (e.g. gpt-5.5 ultra → xhig (`src/server/effort-policy.ts`): they lower or preserve the requested effort rather than rejecting the request, and they never raise it. +Combo dispatch reads the final target ladder through the same `supportedLadderFor` authority. An +explicit empty ladder means that target receives no effort control; an unknown ladder receives no +parent effort controls only when the combo opts into `reasoningEffortMode: "adaptive"`. Known +non-empty ladders continue through the existing per-target resolution. + The `ocx effort` CLI accepts only the same canonical cap ladder before live probing or persistence. Its status output preserves unsupported legacy cap values and reports that those fields are ignored; the read does not normalize or migrate them, and an ignored subagent field does not disable a valid diff --git a/structure/config.md b/structure/config.md index 80bb62bc73..6b2d16f704 100644 --- a/structure/config.md +++ b/structure/config.md @@ -45,7 +45,7 @@ matters for maintainers is which groups exist and who resolves them: | Group | Keys | Resolution rule | | --- | --- | --- | | Listener | `port`, `hostname` | The listener owns the port; `runtime-port.json` reports where it actually landed. | -| Routing | `defaultProvider`, `providers`, per-provider `selectedModels` | Explicit `provider/model` wins over `defaultProvider`. | +| Routing | `defaultProvider`, `providers`, per-provider `selectedModels`, `combos` | Explicit `provider/model` wins over `defaultProvider`; combo dispatch uses the selected target's existing capability ladder and does not create a second catalog authority. | | Catalog | `disabledModels`, `customModels`, `modelCacheTtlMs`, `providerContextCaps`, `contextCapValue`, per-provider `modelDisplayNames`, `codexAccountNamespaces`, `codexAccountPickerEnabled` | Catalog state is derived; config only records intent. Exact provider model display names are durable display only overlays. The picker flag is an explicit visibility override, while selector mappings remain the durable exact-routing contract. | | Retained state | `appOwnedMemoryBudgetMb` | Process-wide eviction target for app-owned logs, caches, blobs, and continuation payloads. Default 256 MiB, valid 64..4096; pinned state may temporarily exceed the target, but every pin-capable store has a finite local cap and their documented aggregate stays below `APP_OWNED_WORST_CASE_PINNED_BYTES` (512 MiB). Neither value caps RSS or native runtime memory. | | Transport | stream mode, timeouts, proxy settings, `websockets`, `emptyCompletionRetry` | `streamMode` persists in config.json; Windows services need a persisted input, and macOS uses it for explicit eager-relay opt-in. Empty-completion replay is an explicit top-level opt-in because its second upstream request may be billable. | diff --git a/structure/data-planes/inbound-compat.md b/structure/data-planes/inbound-compat.md index 2d17c11875..c5e1a066b5 100644 --- a/structure/data-planes/inbound-compat.md +++ b/structure/data-planes/inbound-compat.md @@ -19,6 +19,9 @@ the native passthrough there is no canonical Fast injection and no wire mapping: and `fastMode` injects nothing here. Resolved-Fast-policy injection applies only to routes that take the Chat -> Responses -> Chat bridge below. `parallel_tool_calls` is emitted only for providers opted into parallel tools (or pinned false by the existing provider opt-out contract). +The native passthrough still applies the existing model capability authority to reasoning: an +explicit empty ladder removes caller `reasoning_effort`, while an unknown ladder remains +unclassified. This guard does not alter the separate raw service-tier contract. Combo/policy routes and requests that need Responses-only hosted tools, continuation, background, or storage semantics retain the existing Chat -> Responses -> Chat bridge. diff --git a/structure/providers/chat-compat.md b/structure/providers/chat-compat.md index e0b87add2d..efaac6c8e0 100644 --- a/structure/providers/chat-compat.md +++ b/structure/providers/chat-compat.md @@ -22,7 +22,9 @@ content converter after validation and imports no optional subsystem. Stateful developer-guidance injection reuses that validator for its raw insertion boundary, so parsed messages and stored raw history retain the same task/guidance order. -Native OpenAI passthrough sanitizes routed reasoning history so `reasoning` input items do not send +Native OpenAI passthrough consults the existing configured capability ladder before forwarding +`reasoning_effort`; an explicitly empty ladder removes that unsupported control while an unknown +ladder remains unclassified. It also sanitizes routed reasoning history so `reasoning` input items do not send non-empty `content` arrays to upstream models that reject them. Chat Completions bridging repairs orphan `toolResult` messages by inserting a synthetic assistant `tool_call` before tool messages. It also repairs the opposite direction (260718): an assistant `tool_calls` round left dangling — diff --git a/structure/runtime.md b/structure/runtime.md index 522e5cabb9..a65573a9fe 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -140,6 +140,7 @@ The server exposes `POST /api/stop` which restores native Codex config, stops an | `src/providers/registry.ts` | Canonical provider presets for CLI, dashboard, OAuth, key providers, and metadata. | | `src/providers/derive.ts` | Enrichment from provider presets into user config. | | `src/oauth/` | OAuth providers, token storage, refresh, and auth-token resolution. The login callback listener binds a per-provider FIXED loopback port, so consecutive logins reuse the same number; every response it sends ends its connection (`Connection: close`, including non-callback paths such as a stray `/favicon.ico` 404). Stopping the listener does not close an established socket, so without that a pooled client would deliver the next login's callback to the retired flow, which rejects the unknown state as a CSRF mismatch while the live flow waits. | +| `src/combos/request.ts` | Clones each selected combo target request and applies the existing target capability ladder: adaptive unknown targets and explicit empty ladders receive no unsupported reasoning/thinking controls, while known ladders retain per-target resolution. | | `src/adapters/openai-responses.ts` | Native OpenAI/ChatGPT Responses passthrough. | | `src/adapters/openai-chat.ts` | OpenAI-compatible Chat Completions bridge. | | `src/adapters/anthropic.ts` | Anthropic Messages bridge. | diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index b2fc3b3fae..9994599888 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -14,7 +14,7 @@ surface is listed here so a maintainer can find the owner without grepping: | Adapter execution support | `src/adapters/run-turn-queue.ts`, `src/adapters/tool-catalog-nudge.ts`, `src/adapters/identity.ts`, `src/adapters/image.ts`, `src/adapters/upstream-http-error.ts` | Shared machinery: turn ordering, tool-catalog nudging, client fingerprinting, image conversion, upstream error normalization. | | Cursor (beyond the sections above) | `src/adapters/cursor/live-transport.ts`, `src/adapters/cursor/http1-bidi.ts`, `src/adapters/cursor/live-models.ts`, `src/adapters/cursor/transport-retry.ts`, `src/adapters/cursor/mcp-manager.ts`, `src/adapters/cursor/thread-continuity.ts`, `src/adapters/cursor/checkpoint-store.ts` | Thread continuity is the point: a retry must not start a new Cursor thread, and a validated checkpoint must not rebuild the full root history. HTTP/2 remains the default; an explicit `http1.1`/`h1` pin maps the bidi run onto Cursor's `RunSSE` receive stream plus sequenced `BidiAppend` sends, and applies to live discovery too. | | Claude Messages | `src/server/claude-messages.ts` | Routed translation, a native Anthropic passthrough branch, and `count_tokens`. | -| Chat Completions inbound | `src/server/chat-completions.ts`, `src/chat/` | Inbound translation onto the same routing pipeline. The content mapper preserves image URLs and supported detail, including screenshot-bearing tool results; target adapters own image placement on their wire. Image-free tool results stay strings. | +| Chat Completions inbound | `src/server/chat-completions.ts`, `src/chat/`, `src/adapters/openai-chat.ts` | Inbound translation onto the same routing pipeline. The content mapper preserves image URLs and supported detail, including screenshot-bearing tool results; target adapters own image placement on their wire. Image-free tool results stay strings. Native passthrough consults the existing model capability ladder before forwarding `reasoning_effort`. | | Hosted search relay | `src/server/search.ts` | Direct relay; distinct from the web-search sidecar loop below. | | Image/video generation loop | `src/images/loop.ts`, `src/images/plan.ts`, `src/images/fulfill.ts`, `src/images/xai-client.ts`, `src/images/xai-video-client.ts`, `src/images/artifacts.ts` | A provider-returned image URL is downloaded into a local artifact once, then served locally; warnings stay URL-free because provider CDN URLs may embed credentials. | | GitHub Copilot | `src/providers/xai-transport.ts` (`resolveProviderTransport`), `src/providers/github-copilot-transport.ts` | `resolveProviderTransport` selects the Copilot transport when the routed provider name is `github-copilot`; the Copilot module then resolves its headers and base URL, and the registry seeds the provider row and model fallback. | diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 2321d78dd6..9c128ebf3b 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -489,6 +489,15 @@ combo whose remaining eligible targets use other providers. > Decision record: [ADR-0070](../decisions/ADR-0070-same-provider-combo-quota-fallback.md) +## Combo per-target reasoning controls + +`src/server/responses/core.ts` passes the combo's `reasoningEffortMode` and the final target's +`supportedLadderFor` result to `src/combos/request.ts` before adapter parsing. Explicit empty +capability ladders remove effort and thinking controls in every combo mode; adaptive mode also +removes those controls for unknown ladders and preserves `reasoning.summary`. Known non-empty +ladders retain the existing per-target effort resolution. This request normalization does not +change target order, attempt accounting, or the existing provider-400 failover classification. + ## Combo streaming commit boundary An HTTP 200 does not by itself commit a streaming combo child. The combo parent runs the child's diff --git a/tests/adapters/openai/openai-chat-hardening.test.ts b/tests/adapters/openai/openai-chat-hardening.test.ts index b051817469..5d6a3b5538 100644 --- a/tests/adapters/openai/openai-chat-hardening.test.ts +++ b/tests/adapters/openai/openai-chat-hardening.test.ts @@ -140,6 +140,23 @@ describe("AgentRouter openai-chat compatibility", () => { expect(body.messages[0]?.content.map(part => part.text)).toEqual([preamble, "responda somente: OK"]); expect(rawBody.messages[0]?.content).toBe("responda somente: OK"); }); + + test("passthrough chat drops reasoning_effort for an explicitly empty capability ladder", () => { + const rawBody = { + messages: [{ role: "user", content: "hi" }], + reasoning_effort: "xhigh", + }; + const request = buildOpenAIChatPassthroughRequest( + provider({ reasoningEfforts: [] }), + rawBody, + "test-model", + false, + ); + const body = JSON.parse(request.body as string) as Record; + + expect(body).not.toHaveProperty("reasoning_effort"); + expect(rawBody.reasoning_effort).toBe("xhigh"); + }); }); function parsed(): OcxParsedRequest { diff --git a/tests/codex-integration/combos.test.ts b/tests/codex-integration/combos.test.ts index 76e4f26ca9..c45898f242 100644 --- a/tests/codex-integration/combos.test.ts +++ b/tests/codex-integration/combos.test.ts @@ -297,22 +297,72 @@ describe("combo request cloning", () => { expect(concrete.input).not.toBe(raw.input); }); - test("combo default respects client-owned ignored reasoning values", () => { + test("combo target capability strips unsupported client reasoning controls", () => { expect(concreteComboRequestBody({ model: "combo/x", reasoning: null }, target, "high", []).reasoning).toBeNull(); expect(concreteComboRequestBody( { model: "combo/x", reasoning: { effort: "" } }, target, "high", [], - ).reasoning).toEqual({ effort: "" }); + ).reasoning).toBeUndefined(); expect(concreteComboRequestBody( { model: "combo/x", reasoning: { effort: "banana" } }, target, "high", [], - ).reasoning).toEqual({ effort: "banana" }); + ).reasoning).toBeUndefined(); expect(concreteComboRequestBody( { model: "combo/x", reasoning: { effort: null } }, target, "high", [], - ).reasoning).toEqual({ effort: null }); + ).reasoning).toBeUndefined(); expect(concreteComboRequestBody( { model: "combo/x", reasoning: { summary: "concise" } }, target, "high", ["high"], ).reasoning).toEqual({ summary: "concise", effort: "high" }); }); + test("adaptive normalization strips unsupported controls for an unknown target while preserving summary", () => { + const raw = { + model: "combo/x", + input: "hi", + reasoning: { effort: "xhigh", summary: "concise" }, + reasoning_effort: "xhigh", + thinking_budget: 8192, + thinking: { type: "enabled" }, + }; + const concrete = concreteComboRequestBody(raw, target, null, undefined, "adaptive"); + + expect(concrete).toEqual({ + model: "a/m1", + input: "hi", + reasoning: { summary: "concise" }, + }); + expect(raw).toEqual({ + model: "combo/x", + input: "hi", + reasoning: { effort: "xhigh", summary: "concise" }, + reasoning_effort: "xhigh", + thinking_budget: 8192, + thinking: { type: "enabled" }, + }); + }); + + test("explicit empty ladder strips unsupported controls while preserving reasoning summary", () => { + const concrete = concreteComboRequestBody({ + model: "combo/x", + reasoning: { effort: "xhigh", summary: "concise" }, + reasoning_effort: "xhigh", + thinking_budget: 8192, + thinking: { type: "enabled" }, + }, target, "high", []); + + expect(concrete).toEqual({ + model: "a/m1", + reasoning: { summary: "concise" }, + }); + }); + + test("adaptive normalization preserves xhigh for a known reasoning ladder", () => { + const concrete = concreteComboRequestBody({ + model: "combo/x", + reasoning: { effort: "xhigh", summary: "concise" }, + }, target, null, ["low", "medium", "high", "xhigh"], "adaptive"); + + expect(concrete.reasoning).toEqual({ effort: "xhigh", summary: "concise" }); + }); + test("omits combo defaults for unset, no-reasoning, and unknown target capabilities", () => { expect(concreteComboRequestBody({ model: "combo/x" }, target, null, ["high"]).reasoning).toBeUndefined(); // An explicitly empty ladder is how a no-reasoning model is expressed. diff --git a/tests/server/server-combo-failover-e2e.test.ts b/tests/server/server-combo-failover-e2e.test.ts index 6f649c52d3..76b90720ba 100644 --- a/tests/server/server-combo-failover-e2e.test.ts +++ b/tests/server/server-combo-failover-e2e.test.ts @@ -1182,6 +1182,60 @@ describe("server combo failover 030 activation matrix", () => { expectMappedReceipt(hydrated[0]!); }); + test("adaptive combo normalizes unknown and empty target capability before the upstream wire", async () => { + const bodies: Array> = []; + const upstream = serve(async request => { + bodies.push(await request.json() as Record); + return chatSuccess("normalized", "m1"); + }); + const request = { + reasoning: { effort: "xhigh", summary: "concise" }, + reasoning_effort: "xhigh", + thinking_budget: 8192, + thinking: { type: "enabled" }, + }; + + const unknownResponse = await post( + comboConfig( + { a: provider("openai-chat", baseUrl(upstream), "key-a") }, + undefined, + { reasoningEffortMode: "adaptive" }, + ), + request, + ); + expect(unknownResponse.status).toBe(200); + + const emptyResponse = await post( + comboConfig( + { a: provider("openai-chat", baseUrl(upstream), "key-a", { reasoningEfforts: [] }) }, + undefined, + { reasoningEffortMode: "adaptive" }, + ), + request, + ); + expect(emptyResponse.status).toBe(200); + + const knownResponse = await post( + comboConfig( + { a: provider("openai-chat", baseUrl(upstream), "key-a", { + reasoningEfforts: ["low", "medium", "high", "xhigh"], + }) }, + undefined, + { reasoningEffortMode: "adaptive" }, + ), + request, + ); + expect(knownResponse.status).toBe(200); + + expect(bodies).toHaveLength(3); + for (const body of bodies.slice(0, 2)) { + expect(body).not.toHaveProperty("reasoning_effort"); + expect(body).not.toHaveProperty("thinking_budget"); + expect(body).not.toHaveProperty("thinking"); + } + expect(bodies[2]!.reasoning_effort).toBe("xhigh"); + }); + test("all-target exhaustion promotes the final attempt reasoning wire to the logical row", async () => { const a = serve(() => Response.json({ error: { message: "first overloaded" } }, { status: 503 })); const b = serve(() => Response.json({ error: { message: "last overloaded" } }, { status: 503 })); From 9b6c3e8bce137a1730305ffd7096abb6298800e0 Mon Sep 17 00:00:00 2001 From: Keito Itagaki <171206780+ke-1t@users.noreply.github.com> Date: Sat, 12 Sep 2026 11:02:27 +0900 Subject: [PATCH 2/4] docs(combos): clarify normalized reasoning controls --- docs-site/src/content/docs/guides/combos.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/docs-site/src/content/docs/guides/combos.md b/docs-site/src/content/docs/guides/combos.md index 4434a46bc6..c497ec918f 100644 --- a/docs-site/src/content/docs/guides/combos.md +++ b/docs-site/src/content/docs/guides/combos.md @@ -298,9 +298,10 @@ as wildcards in both modes. ``` The default is `"strict"`, which keeps the original picker behavior. This setting does not change -target order or failover policy. At dispatch, an explicitly empty target ladder receives no -reasoning/thinking controls in either mode; `"adaptive"` also removes those controls for an unknown -target capability, while known non-empty targets keep their existing per-target effort resolution. +target order or failover policy. At dispatch, an explicitly empty target ladder has its unsupported +effort/thinking controls removed in either mode while preserving supported non-effort reasoning fields +such as `reasoning.summary`; `"adaptive"` applies the same normalization to an unknown target +capability, while known non-empty targets keep their existing per-target effort resolution. In the dashboard it is the **Adaptive reasoning ladder** switch in a combo's Capabilities section. @@ -416,7 +417,7 @@ Combos are stored in the top-level `combos` object, keyed by combo id: | `cooldownMs` | No | unset → upstream fallback (5 s for request-rate 429 codes `1302`/`1305`, otherwise 60 s) | Integer from 1 to 600000. When set, applies as the per-target cooldown whenever no usable upstream `Retry-After` or Codex reset signal exists, including request-rate 429s; when unset, uses the upstream fallback. | | `waitForCooldownMs` | No | `0` | Integer from 0 to 600000. Maximum time to wait for the earliest eligible cooling target before returning `combo_unavailable`; abort cancels the wait. | | `defaultEffort` | No | `null` | `low`, `medium`, `high`, `xhigh`, `max`, or `ultra`; applied only when the caller omits effort and the target advertises support. | -| `reasoningEffortMode` | No | `"strict"` | `"strict"` intersects every known target ladder, so one target advertising no effort control empties the combo's picker. `"adaptive"` excludes those empty ladders from the published intersection. At dispatch, explicit empty ladders receive no reasoning/thinking controls, and adaptive unknown ladders receive no parent controls; known non-empty targets keep existing effort resolution. | +| `reasoningEffortMode` | No | `"strict"` | `"strict"` intersects every known target ladder, so one target advertising no effort control empties the combo's picker. `"adaptive"` excludes those empty ladders from the published intersection. At dispatch, explicit empty or adaptive unknown ladders remove unsupported effort/thinking controls while preserving supported non-effort reasoning fields such as `reasoning.summary`; known non-empty targets keep existing effort resolution. | | `imageInput` | No | `"auto"` | `"auto"` or `"disabled"`. `"auto"` publishes image support only when every target supports images; `"disabled"` forces text-only (drops image from published modalities and rejects image-bearing requests before dispatch). | | `alias` | No | none | Optional trimmed public model id; use the alias rules above. An empty value is stored as no alias. | | `nativeAlias` | No | `false` | Explicitly permit a currently supported bare native `alias` to take routing and catalog precedence. Never inferred from the alias. | From 0e28507a9f84c6a79627587c1fd383512c912c55 Mon Sep 17 00:00:00 2001 From: Keito Itagaki <171206780+ke-1t@users.noreply.github.com> Date: Sat, 12 Sep 2026 12:55:35 +0900 Subject: [PATCH 3/4] test(combos): preserve strict unknown capability passthrough --- tests/codex-integration/combos.test.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/codex-integration/combos.test.ts b/tests/codex-integration/combos.test.ts index c45898f242..0bc1230e0f 100644 --- a/tests/codex-integration/combos.test.ts +++ b/tests/codex-integration/combos.test.ts @@ -339,6 +339,27 @@ describe("combo request cloning", () => { }); }); + test("strict normalization preserves reasoning controls for an unknown target", () => { + const raw = { + model: "combo/x", + input: "hi", + reasoning: { effort: "xhigh", summary: "concise" }, + reasoning_effort: "xhigh", + thinking_budget: 8192, + thinking: { type: "enabled" }, + }; + const concrete = concreteComboRequestBody(raw, target, null, undefined, "strict"); + + expect(concrete).toEqual({ + model: "a/m1", + input: "hi", + reasoning: { effort: "xhigh", summary: "concise" }, + reasoning_effort: "xhigh", + thinking_budget: 8192, + thinking: { type: "enabled" }, + }); + }); + test("explicit empty ladder strips unsupported controls while preserving reasoning summary", () => { const concrete = concreteComboRequestBody({ model: "combo/x", From f579e11b56c1f232233b129f75464082703b6734 Mon Sep 17 00:00:00 2001 From: Keito Itagaki <171206780+ke-1t@users.noreply.github.com> Date: Sat, 12 Sep 2026 14:24:13 +0900 Subject: [PATCH 4/4] fix(openai-chat): preserve effort for unknown capability ladders --- src/adapters/openai-chat.ts | 9 +++++++-- tests/adapters/openai/openai-chat-hardening.test.ts | 12 +++++++++--- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index 46c24bb48e..a121f06d1a 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -1,7 +1,7 @@ import type { AdapterRequest, ProviderAdapter } from "./base"; import type { AdapterEvent, OcxAssistantMessage, OcxContentPart, OcxMessage, OcxParsedRequest, OcxProviderConfig, OcxTextContent, OcxThinkingContent, OcxToolCall, OcxUsage } from "../types"; import { isAllowedToolChoice, modelInList, namespacedToolName, resolveToolChoiceWireName, toolChoiceToolPredicate } from "../types"; -import { configuredReasoningEfforts, mapReasoningEffort, modelRecordValue } from "../reasoning-effort"; +import { mapReasoningEffort, modelRecordValue } from "../reasoning-effort"; import { debugProviderDiagnostic } from "../lib/debug"; import { sseFieldValue } from "../lib/sse-decoder"; import { isDebugEnabled } from "../lib/debug-settings"; @@ -130,7 +130,12 @@ export function buildOpenAIChatPassthroughRequest( for (const field of CHAT_PASSTHROUGH_FIELDS) { if (rawBody[field] !== undefined) body[field] = rawBody[field]; } - if (configuredReasoningEfforts(provider, modelId)?.length === 0) delete body.reasoning_effort; + // Sanitizing the ladder collapses raw non-rankable values such as ["enabled"] to [], + // but that is an unknown capability rather than an explicit no-effort declaration. + const rawReasoningEfforts = modelRecordValue(provider.modelReasoningEfforts, modelId) ?? provider.reasoningEfforts; + if (modelInList(provider.noReasoningModels, modelId) || rawReasoningEfforts?.length === 0) { + delete body.reasoning_effort; + } const openRouterRouting = resolveOpenRouterRouting(provider, modelId); if (openRouterRouting) body.provider = openRouterProviderPayload(openRouterRouting); diff --git a/tests/adapters/openai/openai-chat-hardening.test.ts b/tests/adapters/openai/openai-chat-hardening.test.ts index 5d6a3b5538..025c63a121 100644 --- a/tests/adapters/openai/openai-chat-hardening.test.ts +++ b/tests/adapters/openai/openai-chat-hardening.test.ts @@ -141,20 +141,26 @@ describe("AgentRouter openai-chat compatibility", () => { expect(rawBody.messages[0]?.content).toBe("responda somente: OK"); }); - test("passthrough chat drops reasoning_effort for an explicitly empty capability ladder", () => { + test.each([ + ["noReasoningModels", provider({ noReasoningModels: ["test-model"] }), undefined], + ["explicit empty raw ladder", provider({ reasoningEfforts: [] }), undefined], + ["non-empty non-rankable raw ladder", provider({ reasoningEfforts: ["enabled"] }), "xhigh"], + ["raw ladder unset", provider(), "xhigh"], + ["known reasoning ladder", provider({ reasoningEfforts: ["low", "medium", "high", "xhigh"] }), "xhigh"], + ] as const)("passthrough chat preserves capability-specific reasoning_effort behavior (%s)", (_caseName, configuredProvider, expectedEffort) => { const rawBody = { messages: [{ role: "user", content: "hi" }], reasoning_effort: "xhigh", }; const request = buildOpenAIChatPassthroughRequest( - provider({ reasoningEfforts: [] }), + configuredProvider, rawBody, "test-model", false, ); const body = JSON.parse(request.body as string) as Record; - expect(body).not.toHaveProperty("reasoning_effort"); + expect(body.reasoning_effort).toBe(expectedEffort); expect(rawBody.reasoning_effort).toBe("xhigh"); }); });