Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions docs-site/src/content/docs/guides/combos.md
Original file line number Diff line number Diff line change
Expand Up @@ -297,9 +297,12 @@ 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 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.

## Image / multimodal capability
Expand Down Expand Up @@ -414,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. 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 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. |
Expand Down
6 changes: 6 additions & 0 deletions src/adapters/openai-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,12 @@ export function buildOpenAIChatPassthroughRequest(
for (const field of CHAT_PASSTHROUGH_FIELDS) {
if (rawBody[field] !== undefined) body[field] = rawBody[field];
}
// 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);
Expand Down
20 changes: 19 additions & 1 deletion src/combos/request.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -59,9 +59,14 @@ export function concreteComboRequestBody(
target: Pick<OcxComboTarget, "provider" | "model">,
defaultEffort: OcxComboDefaultEffort | null,
targetReasoningEfforts: readonly string[] | undefined,
reasoningEffortMode: OcxComboReasoningEffortMode = "strict",
): Record<string, unknown> {
const clone = structuredClone(body) as Record<string, unknown>;
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 || (
Expand Down Expand Up @@ -104,3 +109,16 @@ export function concreteComboRequestBody(
}
return clone;
}

function stripUnsupportedReasoningControls(body: Record<string, unknown>): void {
const reasoning = body.reasoning;
if (reasoning && typeof reasoning === "object" && !Array.isArray(reasoning)) {
const next = { ...(reasoning as Record<string, unknown>) };
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;
}
1 change: 1 addition & 0 deletions src/server/responses/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down
5 changes: 3 additions & 2 deletions src/types/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down
5 changes: 5 additions & 0 deletions structure/catalog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion structure/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
3 changes: 3 additions & 0 deletions structure/data-planes/inbound-compat.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
4 changes: 3 additions & 1 deletion structure/providers/chat-compat.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 —
Expand Down
1 change: 1 addition & 0 deletions structure/runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
2 changes: 1 addition & 1 deletion structure/transports/inventory.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
9 changes: 9 additions & 0 deletions structure/transports/responses.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 23 additions & 0 deletions tests/adapters/openai/openai-chat-hardening.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,29 @@ 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.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(
configuredProvider,
rawBody,
"test-model",
false,
);
const body = JSON.parse(request.body as string) as Record<string, unknown>;

expect(body.reasoning_effort).toBe(expectedEffort);
expect(rawBody.reasoning_effort).toBe("xhigh");
});
});

function parsed(): OcxParsedRequest {
Expand Down
Loading
Loading