From 3ee25f58e1d457fce943b0c9c71ac441c48f72b2 Mon Sep 17 00:00:00 2001 From: jun Date: Wed, 2 Sep 2026 02:29:31 +0900 Subject: [PATCH 1/3] feat(combo): adapt reasoning effort to target capabilities Adopts the design from #2734 and completes the surfaces it left open. Two defects, both opt-in fixes that default to today's behavior: - A combo intersects its targets' effort ladders, and an explicitly empty ladder (a target with no effort control) collapsed the intersection for the whole group. reasoningEffortMode: "adaptive" excludes empty ladders from the published intersection; "strict" remains the default. - openai-chat could declare per-model effort support but not a per-request rule, so a model that rejects effort only alongside tools had to use noReasoningModels and lose its picker entirely. omitReasoningEffortWithToolsModels drops the wire field for tool-bearing requests only. Beyond #2734: the dashboard round-trip. PUT /api/combos replaces the stored combo wholesale and the GUI serializer is a field allowlist, so a hand-edited reasoningEffortMode was silently destroyed by any dashboard combo edit. The field is now parsed, serialized, dirty-checked and sync-keyed, and the shared sparse helper keeps GET, the PUT response and disk consistent so an unset default is never materialized into config.json. The opt-in is a switch in the combo Capabilities section beside image input, where combo-wide policy already lives - not under Default reasoning, which is a value chosen from the intersection rather than a rule that changes it. Both the create modal and the detail panel share that component, so the create flow shows the corrected picker too. Refs #2731. Supersedes #2734. --- .../000_scope.md | 42 +++++ .../010_wp1_adaptive_reasoning_effort.md | 153 ++++++++++++++++++ .../011_wp1_audit_r1_synthesis.md | 82 ++++++++++ gui/src/combo-workspace-data.ts | 18 +++ .../components/combo-workspace-add-modal.tsx | 5 +- .../components/combo-workspace-controls.tsx | 18 ++- .../combo-workspace-detail-panel.tsx | 7 +- gui/src/i18n/de.ts | 2 + gui/src/i18n/en.ts | 2 + gui/src/i18n/fr.ts | 2 + gui/src/i18n/ja.ts | 2 + gui/src/i18n/ko.ts | 2 + gui/src/i18n/ru.ts | 2 + gui/src/i18n/tr.ts | 2 + gui/src/i18n/zh-TW.ts | 2 + gui/src/i18n/zh.ts | 2 + src/adapters/openai-chat.ts | 12 +- src/codex/catalog/aggregation.ts | 12 +- src/combos/types.ts | 12 ++ src/config.ts | 14 ++ src/server/auth-cors.ts | 6 + src/server/management/combo-routes.ts | 24 ++- src/server/management/provider-routes.ts | 14 ++ src/types.ts | 1 + src/types/config.ts | 17 ++ src/types/provider.ts | 8 + 26 files changed, 447 insertions(+), 16 deletions(-) create mode 100644 devlog/_plan/260902_nonbug_adoption_backlog/000_scope.md create mode 100644 devlog/_plan/260902_nonbug_adoption_backlog/010_wp1_adaptive_reasoning_effort.md create mode 100644 devlog/_plan/260902_nonbug_adoption_backlog/011_wp1_audit_r1_synthesis.md diff --git a/devlog/_plan/260902_nonbug_adoption_backlog/000_scope.md b/devlog/_plan/260902_nonbug_adoption_backlog/000_scope.md new file mode 100644 index 00000000000..890823eac4d --- /dev/null +++ b/devlog/_plan/260902_nonbug_adoption_backlog/000_scope.md @@ -0,0 +1,42 @@ +# Non-bug adoption backlog — scope + +Fifteen non-bug items selected from the open issue/PR set by the maintainer's own +recorded `우선순위 NN / 80` review scores. Excludes every `bug`-labeled item, +maintainer-authored items (#3158, PR #3061, PR #2783), and #3146 (already closed by +PR #3151). + +One work-phase per item, highest score first, except where a smaller verified diff +is sequenced earlier to establish the landing pattern. + +| wp | Item | Score | Existing PR | +|----|------|-------|-------------| +| wp1 | #2731 adaptive reasoning effort | 62 | #2734 (draft) | +| wp2 | PR #3142 + #2511 oversized body refusal | 64 | #3142 (ready) | +| wp3 | #2901 compaction provider selection | 58 | none | +| wp4 | #1690 retainModels allowlist | 58 | #2122, #2860 (rival) | +| wp5 | PR #2986 xAI Imagine relay | 58 | #2986 (ready) | +| wp6 | #1107 authless Codex Desktop routing | 71 | none | +| wp7 | #2713 shim-free token injection | 58 | none | +| wp8 | #1525 Windows proxy auto | 60 | none | +| wp9 | #1221 OS keychain provider keys | 61 | none | +| wp10 | #2201 model display names | 60 | #2715, #2716 | +| wp11 | #1082 per-account Gem/Cla quota | 63 | #2123 | +| wp12 | #695 generic OAuth pool failover | 69 | none | +| wp13 | #822 reset-credit auto-redemption | 59 | none | +| wp14 | #2816 upstream Responses WebSocket | 59 | #2817 | +| wp15 | #2495 plaintext V2 collaboration | 65 | #2496 | + +## Standing constraints + +- Never run the local suite. Focused typecheck only, and only when cheap. +- Push `--no-verify`; merge with admin authority; CI is trailing evidence. +- Every capability is opt-in and defaults to today's behavior. +- The opt-in must be discoverable where a user already looks for that concern. + +## The UX rule that decides these + +A capability whose only surface is a hand-edited `config.json` key is not +discoverable. When the concern already has a dashboard editor, the opt-in belongs +in that editor — and it must survive a round-trip through it. A field the GUI +silently drops on save is worse than no field, because the user sees their setting +disappear with no error. diff --git a/devlog/_plan/260902_nonbug_adoption_backlog/010_wp1_adaptive_reasoning_effort.md b/devlog/_plan/260902_nonbug_adoption_backlog/010_wp1_adaptive_reasoning_effort.md new file mode 100644 index 00000000000..784c65184ae --- /dev/null +++ b/devlog/_plan/260902_nonbug_adoption_backlog/010_wp1_adaptive_reasoning_effort.md @@ -0,0 +1,153 @@ +# wp1 — #2731 adaptive reasoning effort (PR #2734) + +Issue #2731, score 62. Draft PR #2734 by the issue author, +180/-8 across 17 files, +head `573d0f65c`, behind current `dev` `e40245e4c`. CI shows `hygiene` and +`enforce-target` failing. + +## The two defects + +**(a) No per-request tool rule.** `createOpenAIChatAdapter.buildRequest` +(`src/adapters/openai-chat.ts:1441`) computes +`mapReasoningEffort(provider, parsed.modelId, parsed.options.reasoning)` with no +access to tool presence. The only model-scoped switch is `noReasoningModels`, which +is all-or-nothing: it strips reasoning from every request, so a model that accepts +effort on plain turns but rejects it alongside function tools has to give up its +effort ladder entirely. The wire `tools` array *is* already in scope at +`openai-chat.ts:1452`; nothing reads it for this decision. + +**(b) Empty ladders poison the combo intersection.** +`deriveComboCatalogModel` (`src/codex/catalog/aggregation.ts:126`) filters only +`undefined` ladders as wildcards: + +``` +const advertisedLadders = members + .map(member => member.reasoningEfforts) + .filter((ladder): ladder is string[] => ladder !== undefined); +const reasoningEfforts = advertisedLadders.length === 0 ? [] : intersectStrings(advertisedLadders); +``` + +An explicit `[]` — meaning "this target has no effort control" — survives into +`intersectStrings` (`aggregation.ts:74`) and empties the result. The empty result +then flows into `effectiveComboDefault` (`aggregation.ts:170`), so the combo also +loses its default. One no-effort target silences the picker for every sibling that +does support tuning. `tests/codex-catalog.test.ts:250` pins this as current +intended behavior, which is why the fix must be opt-in rather than a correction. + +## What PR #2734 gets right + +Both halves default to today's behavior: + +- `normalizeComboConfig` (`src/combos/types.ts:361`) maps anything that is not the + literal `"adaptive"` to `"strict"`, and the catalog branch only filters empty + ladders under `=== "adaptive"`. +- `omitReasoningEffortWithToolsModels` is optional, and `modelInList` returns + `false` for an absent or empty list. + +A user who sets nothing observes an identical catalog and identical wire bodies. +That is the back-compat bar from the goal criteria, and the PR clears it on the +request path. + +## Blocker: the GUI silently destroys the setting + +`PUT /api/combos` replaces the stored combo wholesale +(`src/server/management/combo-routes.ts:170-172`: `nextCombos[id] = stored`). +The dashboard builds that body with an allowlist serializer, `toPutBody` +(`gui/src/combo-workspace-data.ts:496-527`), which enumerates `targets`, +`strategy`, `defaultEffort`, `imageInput`, `stickyLimit`, `alias`, +`nativeAlias`, `displayName` — and nothing else. `parseCombos` +(`combo-workspace-data.ts:222-232`) likewise never reads `reasoningEffortMode`. + +So: a user hand-edits `reasoningEffortMode: "adaptive"`, later opens the dashboard +and renames the combo or reorders a target, and the save silently drops the field. +The picker they fixed goes empty again with no error and no diff they can see. +PR #2734 touches no `gui/` file, so it ships this hole. + +This is the goal's UX criterion failing, not a nitpick: the opt-in is neither +discoverable nor durable. + +## Second defect: the GUI has its own copy of the intersection + +`intersectComboEfforts` (`gui/src/combo-workspace-data.ts:54`) reimplements the +same rule client-side and skips only `listed === undefined`. Even with the field +preserved, the dashboard's own effort dropdown stays empty under `adaptive` +because it never learns the mode. Fixing the server alone fixes the served +catalog and leaves the editor lying. + +## Third: non-sparse persistence + +`combo-routes.ts:139-152` destructures `alias`/`nativeAlias`/`displayName`/ +`imageInput` out of the normalized object so defaults are not written, but +`reasoningEffortMode` stays in `normalizedBase`. Every combo save would therefore +stamp `"reasoningEffortMode": "strict"` into `config.json` for users who never +asked for the feature. Config churn on an untouched setting, against the file's +stated sparse convention. + +## Plan + +Adopt the PR's design — it is the right shape — and complete it in a +maintainer-authored branch that closes #2734 with credit. + +### File change map + +| File | Action | Change | +|------|--------|--------| +| `src/types/config.ts` | MODIFY | `OcxComboReasoningEffortMode`; `reasoningEffortMode?` on `OcxComboConfig` next to `defaultEffort` (:773) | +| `src/types.ts` | MODIFY | re-export the new type | +| `src/combos/types.ts` | MODIFY | field on `NormalizedComboConfig`; validate in `comboConfigIssues` (:175); normalize in `normalizeComboConfig` (:353) defaulting to `strict` | +| `src/codex/catalog/aggregation.ts` | MODIFY | under `adaptive`, drop zero-length ladders before `intersectStrings` | +| `src/adapters/openai-chat.ts` | MODIFY | `omitReasoningEffortWithTools` guard at :1428 and on the `gateway-object` branch | +| `src/types/provider.ts` | MODIFY | `omitReasoningEffortWithToolsModels?: string[]` beside `noStructuredOutputModels` (:506) | +| `src/config.ts` | MODIFY | zod schema (:516) + `nonBlankStringArrayConfigError` superRefine (:1216) | +| `src/server/auth-cors.ts` | MODIFY | `providerManagementConfigError` validation + `safeConfigDTO` exposure | +| `src/server/management/provider-routes.ts` | MODIFY | PATCH field handling + GET projection | +| `src/server/management/combo-routes.ts` | MODIFY | **NEW vs PR** — destructure `reasoningEffortMode` out of `normalizedBase`; persist only when `"adaptive"` | +| `gui/src/combo-workspace-data.ts` | MODIFY | **NEW vs PR** — `reasoningEffortMode` on `ComboItem`; read in `parseCombos`; emit in `toPutBody`; add to `baselineSyncKey` and the dirty comparison; teach `intersectComboEfforts` the mode | +| `gui/src/components/combo-workspace-detail-panel.tsx` | MODIFY | **NEW vs PR** — opt-in toggle directly under the Default-reasoning field | +| `gui/src/i18n/*.ts` | MODIFY | **NEW vs PR** — label + hint for all locales | +| `docs-site/.../combos.md`, `routing.md`, `providers.md` | MODIFY | as in the PR, minus the stale strategy list | +| `tests/codex-catalog.test.ts` | MODIFY | adaptive keeps sibling ladder; strict unchanged | +| `tests/combos.test.ts` | MODIFY | validation + normalization default | +| `tests/combo-management-api.test.ts` | MODIFY | round-trip; strict is NOT persisted | +| `tests/combo-workspace-data.test.ts` | MODIFY | `toPutBody` preserves the mode; GUI intersection honors it | +| `tests/openai-chat-hardening.test.ts` | MODIFY | tools present/absent, sibling model unaffected | + +### UX decision + +The control goes in the combo detail panel immediately below "Default reasoning", +because that is the field whose options the mode changes. Default state is the +current behavior. The hint says what turning it on does in one sentence, in the +user's terms: targets that have no reasoning control stop hiding the control for +the rest of the group. + +No new top-level navigation, no new settings page. The concern already has a home. + +### Scope boundary + +IN: the two defects, GUI round-trip, sparse persistence, docs, focused tests. + +OUT: `concreteComboRequestBody` per-target effort stripping (investigator concern +2) — that is a genuine gap but it is request-path routing behavior for combos +generally, not this issue's picker/wire problem, and folding it in here would +expand a +180 diff into cross-module routing work. Record it as follow-up. +OUT: an adapter-type guard on the provider key (concern 7) — it is a no-op outside +`openai-chat` today. + +### Accept criteria + +1. Unset config: catalog rows and wire bodies byte-identical to `e40245e4c`. + Activation: run the existing strict-mode assertions in + `tests/codex-catalog.test.ts` unchanged. +2. `adaptive` set: a combo with one `[]`-ladder member publishes the surviving + sibling intersection instead of `[]`. Activation: new case asserting a non-empty + ladder where the strict case asserts `[]`. +3. Tool-bearing request to a listed model omits `reasoning_effort`; the same model + without tools still sends it; an unlisted sibling always sends it. Activation: + three assertions in `tests/openai-chat-hardening.test.ts`. +4. Dashboard round-trip preserves `adaptive`. Activation: `toPutBody` on an item + parsed from a combo carrying the mode still contains it. +5. A strict combo saved through the API writes no `reasoningEffortMode` key. + +### Verifier + +`bun x tsc --noEmit` (whole-project, reads every file above) plus the five named +test files run individually by path. The full suite is forbidden by the operator. diff --git a/devlog/_plan/260902_nonbug_adoption_backlog/011_wp1_audit_r1_synthesis.md b/devlog/_plan/260902_nonbug_adoption_backlog/011_wp1_audit_r1_synthesis.md new file mode 100644 index 00000000000..a801485df95 --- /dev/null +++ b/devlog/_plan/260902_nonbug_adoption_backlog/011_wp1_audit_r1_synthesis.md @@ -0,0 +1,82 @@ +# wp1 audit round 1 — synthesis + +Reviewer: grok-4.6 adversarial lane (agent `01a05df6`). +Verdict: `GO-WITH-FIXES (blockers=3)`. All three accepted and folded. No rebuttals. + +A first reviewer (`01a05de5`) produced nothing across four wait cycles and was +retired under DISPATCH-RETIRE-01; this is the replacement's round. + +The reviewer independently confirmed the central blocker — `toPutBody` allowlist +plus wholesale `nextCombos[id] = stored` — so the plan's justification stands. + +## Blocker 1 — GET echo, not just disk churn (ACCEPTED) + +I planned to sparsify only the PUT persist destructure. The reviewer found +`sparseComboConfig` (`src/server/management/combo-routes.ts:71-78`), which the GET +list handler (`:84-91`) and the PUT response (`:248`) both run, and which today +strips exactly one default: `imageInput: "auto"`. + +Since `getCombo` returns an already-normalized combo, every GET row would echo +`"reasoningEffortMode": "strict"` for users who never opted in. Worse, any client +that round-trips GET into PUT would then write that default straight back to disk, +defeating the persist-side fix entirely. + +Correction: extend `sparseComboConfig` itself to drop `reasoningEffortMode: "strict"`, +which fixes GET, the PUT response, and the round-trip in one place. The persist-side +destructure becomes redundant — use the shared helper rather than two rules that can +drift. + +## Blocker 2 — wrong file for the sync key, two GUI sites missing (ACCEPTED) + +`baselineSyncKey` is at `gui/src/components/combo-workspace-detail-panel.tsx:90`, +not in `combo-workspace-data.ts` as my map said. Following the map as written would +ship a toggle whose draft never resyncs when only the mode changes. + +Also missing: `emptyDraft` (`combo-workspace-data.ts:619-631`), `draftEquals` +(`:478-488`), and the create path — `combo-workspace-add-modal.tsx:50-52` calls +`intersectComboEfforts` with no mode, so a mixed group's picker still reads empty +while the user is creating the combo. Fixing only the detail panel leaves the +create flow lying at exactly the moment the user is assembling the mixed group +that motivates the feature. + +## Blocker 3 — criteria that pass without the branch firing (ACCEPTED) + +- Criterion 3 asserted only the plain `reasoning_effort` path, but the PR also + edits the `reasoningWireFormat === "gateway-object"` branch + (`src/adapters/openai-chat.ts:1498`). That branch could be left stale and the + criterion would still go green. Now requires a gateway-object provider assertion. +- Criterion 4 was a `toPutBody` unit assertion, which passes even if PUT still + drops the field and GET still echoes `strict`. Now requires the management + round-trip: PUT `adaptive` then GET and see it survive. +- Criterion 1 proved the catalog default, not wire byte-identity. Now split: a + catalog assertion and an adapter assertion that an unlisted model's body is + unchanged. + +## UX correction (reviewer point 6 — accepted, changes the design) + +I had placed the toggle under "Default reasoning". The reviewer's objection is +correct and it is not cosmetic: `defaultEffort` is a *value chosen from* the +intersection, whereas this is a *policy that changes what the intersection is* — +the same kind of thing as `imageInput`, which already lives in `ComboCapabilities` +as a switch (`gui/src/components/combo-workspace-controls.tsx:100-136`). + +Placing a policy switch under a value dropdown invites the reading "this changes +my default effort", which is precisely what it does not do. + +Revised: the toggle goes in `ComboCapabilities` beside the image-input switch, +where combo-wide capability policy already lives. That is also the component the +create modal and the detail panel share, so the create flow gets the control for +free — which is what blocker 2 requires anyway. + +## Not folded + +`buildOpenAIChatPassthroughRequest` (`openai-chat.ts:108-142`) ignores this key — +but it equally ignores `noReasoningModels` today, so it is a pre-existing passthrough +boundary, not a regression this unit introduces. Recorded, not fixed here. + +Docs wording: the reviewer notes "unsupported effort fields are omitted" overclaims, +because Responses does not run `stripEmptyLadderEffort` (Chat-only, +`src/server/chat-completions.ts:191-196`). The docs sentence will be narrowed to +what is true rather than the feature being expanded. + +Line drift: plan cited `openai-chat.ts:1428`; live reasoning block is `:1494-1498`. diff --git a/gui/src/combo-workspace-data.ts b/gui/src/combo-workspace-data.ts index d6d9ea204f5..bf8b881c554 100644 --- a/gui/src/combo-workspace-data.ts +++ b/gui/src/combo-workspace-data.ts @@ -54,6 +54,7 @@ const COMBO_STRATEGY_SET = new Set(COMBO_STRATEGIES); export function intersectComboEfforts( targets: readonly ComboTarget[], modelEfforts: ReadonlyMap, + reasoningEffortMode: "strict" | "adaptive" = "strict", ): ComboEffort[] { const complete = targets.filter((t) => t.provider.trim() && t.model.trim()); if (complete.length === 0) return [...COMBO_EFFORTS]; @@ -63,6 +64,9 @@ export function intersectComboEfforts( const key = `${target.provider.trim()}/${target.model.trim()}`; const listed = modelEfforts.get(key); if (listed === undefined) continue; + // Adaptive mirrors the served catalog: a target advertising no effort control is + // excluded from the intersection rather than collapsing it for every sibling. + if (reasoningEffortMode === "adaptive" && listed.length === 0) continue; const member = listed.filter((effort) => effortSet.has(effort)); if (common === null) { common = member; @@ -106,6 +110,10 @@ function normalizeImageInput(value: unknown): "auto" | "disabled" { return value === "disabled" ? "disabled" : "auto"; } +function normalizeReasoningEffortMode(value: unknown): "strict" | "adaptive" { + return value === "adaptive" ? "adaptive" : "strict"; +} + export interface ComboItem { id: string; /** Wire id shown to clients, e.g. combo/free */ @@ -120,6 +128,11 @@ export interface ComboItem { stickyLimit: number; defaultEffort: ComboEffort | null; imageInput?: "auto" | "disabled"; + /** + * Picker-ladder policy. `adaptive` lets targets that advertise no effort control drop + * out of the intersection instead of emptying it for the whole group. + */ + reasoningEffortMode?: "strict" | "adaptive"; targets: ComboTarget[]; } @@ -228,6 +241,7 @@ export function parseComboList(payload: unknown): ComboItem[] { stickyLimit: normalizeStickyLimit(r.stickyLimit), defaultEffort: normalizeDefaultEffort(r.defaultEffort), imageInput: normalizeImageInput(r.imageInput), + reasoningEffortMode: normalizeReasoningEffortMode(r.reasoningEffortMode), targets, }); } @@ -485,6 +499,7 @@ export function draftEquals(a: ComboItem, b: ComboItem): boolean { || a.stickyLimit !== b.stickyLimit || a.defaultEffort !== b.defaultEffort || (a.imageInput ?? "auto") !== (b.imageInput ?? "auto") + || (a.reasoningEffortMode ?? "strict") !== (b.reasoningEffortMode ?? "strict") ) return false; if (a.targets.length !== b.targets.length) return false; return a.targets.every((t, i) => { @@ -502,6 +517,7 @@ export function toPutBody(item: ComboItem, options: { renameFrom?: string } = {} stickyLimit?: number; defaultEffort: ComboEffort | null; imageInput?: "disabled"; + reasoningEffortMode?: "adaptive"; alias?: string; nativeAlias?: true; displayName?: string; @@ -518,6 +534,7 @@ export function toPutBody(item: ComboItem, options: { renameFrom?: string } = {} strategy: item.strategy, defaultEffort: item.defaultEffort, ...(item.imageInput === "disabled" ? { imageInput: "disabled" as const } : {}), + ...(item.reasoningEffortMode === "adaptive" ? { reasoningEffortMode: "adaptive" as const } : {}), ...(item.strategy === "round-robin" ? { stickyLimit: item.stickyLimit } : {}), ...(item.alias && item.alias.trim() ? { alias: item.alias.trim() } : {}), ...(item.nativeAlias ? { nativeAlias: true } : {}), @@ -627,6 +644,7 @@ export function emptyDraft(id = ""): ComboItem { stickyLimit: 1, defaultEffort: null, imageInput: "auto", + reasoningEffortMode: "strict", targets: [newComboTarget()], }; } diff --git a/gui/src/components/combo-workspace-add-modal.tsx b/gui/src/components/combo-workspace-add-modal.tsx index 57f6253509d..67a0f4f36dd 100644 --- a/gui/src/components/combo-workspace-add-modal.tsx +++ b/gui/src/components/combo-workspace-add-modal.tsx @@ -48,8 +48,8 @@ export function AddComboModal({ return map; }, [models]); const allowedEfforts = useMemo( - () => intersectComboEfforts(draft.targets, effortMap), - [draft.targets, effortMap], + () => intersectComboEfforts(draft.targets, effortMap, draft.reasoningEffortMode ?? "strict"), + [draft.targets, effortMap, draft.reasoningEffortMode], ); const allTargetsExhausted = comboQuotaState(draft.targets, providerQuotaStates, providerMap) === "exhausted"; @@ -220,6 +220,7 @@ export function AddComboModal({ targets={draft.targets} models={models} imageInput={draft.imageInput ?? "auto"} + reasoningEffortMode={draft.reasoningEffortMode ?? "strict"} disabled={busy} onChange={(patch) => setDraft((d) => ({ ...d, ...patch }))} /> diff --git a/gui/src/components/combo-workspace-controls.tsx b/gui/src/components/combo-workspace-controls.tsx index b9a6acdcb99..7cafea176f2 100644 --- a/gui/src/components/combo-workspace-controls.tsx +++ b/gui/src/components/combo-workspace-controls.tsx @@ -101,14 +101,16 @@ export function ComboCapabilities({ targets, models, imageInput, + reasoningEffortMode, disabled, onChange, }: { targets: ComboTarget[]; models: ModelOption[]; imageInput: "auto" | "disabled"; + reasoningEffortMode: "strict" | "adaptive"; disabled?: boolean; - onChange: (patch: { imageInput?: "auto" | "disabled" }) => void; + onChange: (patch: { imageInput?: "auto" | "disabled"; reasoningEffortMode?: "strict" | "adaptive" }) => void; }) { const t = useT(); const imagesSupported = comboImagesSupported(targets, models); @@ -135,6 +137,20 @@ export function ComboCapabilities({ label={t("cws.capability.imageInput")} /> +
+
+ {t("cws.capability.adaptiveEffort")} +

{t("cws.capability.adaptiveEffortHint")}

+
+ { + onChange({ reasoningEffortMode: reasoningEffortMode === "adaptive" ? "strict" : "adaptive" }); + }} + disabled={disabled} + label={t("cws.capability.adaptiveEffort")} + /> +
); } diff --git a/gui/src/components/combo-workspace-detail-panel.tsx b/gui/src/components/combo-workspace-detail-panel.tsx index 32a6625fde9..b33d937917f 100644 --- a/gui/src/components/combo-workspace-detail-panel.tsx +++ b/gui/src/components/combo-workspace-detail-panel.tsx @@ -87,7 +87,7 @@ export function DetailPanel({ const [copied, setCopied] = useState(false); const dirty = !draftEquals(draft, baseline); const allTargetsExhausted = comboQuotaState(draft.targets, providerQuotaStates, providerMap) === "exhausted"; - const baselineSyncKey = `${baseline.id}:${baseline.alias ?? ""}:${baseline.nativeAlias}:${baseline.displayName ?? ""}:${baseline.strategy}:${baseline.stickyLimit}:${baseline.defaultEffort}:${baseline.imageInput ?? "auto"}:${baseline.targets.map((t) => `${t.provider}/${t.model}:${t.weight ?? 1}`).join(",")}`; + const baselineSyncKey = `${baseline.id}:${baseline.alias ?? ""}:${baseline.nativeAlias}:${baseline.displayName ?? ""}:${baseline.strategy}:${baseline.stickyLimit}:${baseline.defaultEffort}:${baseline.imageInput ?? "auto"}:${baseline.reasoningEffortMode ?? "strict"}:${baseline.targets.map((t) => `${t.provider}/${t.model}:${t.weight ?? 1}`).join(",")}`; const effortMap = useMemo(() => { const map = new Map(); for (const model of models) { @@ -96,8 +96,8 @@ export function DetailPanel({ return map; }, [models]); const allowedEfforts = useMemo( - () => intersectComboEfforts(draft.targets, effortMap), - [draft.targets, effortMap], + () => intersectComboEfforts(draft.targets, effortMap, draft.reasoningEffortMode ?? "strict"), + [draft.targets, effortMap, draft.reasoningEffortMode], ); const updateDraft = useCallback((updater: (prev: ComboItem) => ComboItem) => { @@ -371,6 +371,7 @@ export function DetailPanel({ targets={draft.targets} models={models} imageInput={draft.imageInput ?? "auto"} + reasoningEffortMode={draft.reasoningEffortMode ?? "strict"} disabled={busy} onChange={(patch) => updateDraft((d) => ({ ...d, ...patch }))} /> diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index eff4574b7a3..09bef10d1b1 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -2110,6 +2110,8 @@ export const de: Record = { "cws.capability.imageInputUnavailable": "Erst verfügbar, wenn jedes gewählte Ziel Bildeingabe unterstützt.", "cws.capability.imageInputHint": "Standardmäßig aktiv, wenn jedes Ziel Bilder unterstützt. Ausschalten für nur Text.", "cws.capability.imageInput": "Bild / multimodal", + "cws.capability.adaptiveEffort": "Adaptive Denkstufen", + "cws.capability.adaptiveEffortHint": "Aus: Ziele ohne Denkstufen-Regelung blenden die Auswahl für die gesamte Kombination aus. An: Solche Ziele bleiben nutzbar, und die Auswahl zeigt weiterhin die Stufen der übrigen Ziele.", "cws.capabilities": "Fähigkeiten", "cws.field.defaultEffortUnsupported": "Dieser Aufwand liegt nicht in der gemeinsamen Leiter der Ziele — er wird zur Anfragezeit ignoriert oder angepasst.", "cws.field.defaultEffortUnsupportedOption": "nicht in der Schnittmenge", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 8abb9816fa6..1b0f0d4d433 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -2153,6 +2153,8 @@ export const en = { "cws.capability.imageInputUnavailable": "Unavailable until every selected target supports image input.", "cws.capability.imageInputHint": "On by default when every target supports images. Turn off to accept text only.", "cws.capability.imageInput": "Image / multimodal", + "cws.capability.adaptiveEffort": "Adaptive reasoning ladder", + "cws.capability.adaptiveEffortHint": "Off: a target with no reasoning control hides the effort picker for the whole combo. On: those targets stay usable and the picker keeps the levels the remaining targets share.", "cws.capabilities": "Capabilities", "cws.field.defaultEffortUnsupported": "This effort is not in the targets' common ladder — it will be ignored or snapped at request time.", "cws.field.defaultEffortUnsupportedOption": "not in intersection", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 4797b8719b6..ee785ecace7 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -2077,6 +2077,8 @@ export const fr: Record = { "cws.capability.imageInputUnavailable": "Indisponible tant que toutes les cibles sélectionnées ne prennent pas en charge les images.", "cws.capability.imageInputHint": "Activé par défaut lorsque toutes les cibles prennent en charge les images. Désactivez cette option pour n’accepter que du texte.", "cws.capability.imageInput": "Images / multimodal", + "cws.capability.adaptiveEffort": "Échelle de raisonnement adaptative", + "cws.capability.adaptiveEffortHint": "Désactivé : une cible sans réglage de raisonnement masque le sélecteur pour toute la combinaison. Activé : ces cibles restent utilisables et le sélecteur conserve les niveaux communs aux autres cibles.", "cws.capabilities": "Capacités", "cws.allCombos": "Toutes les combinaisons", "cws.copyModel": "Copier l’identifiant", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 0c2a1b69d81..780fd60da8a 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -2169,6 +2169,8 @@ export const ja: Record = { "cws.capability.imageInputUnavailable": "選択した全ターゲットが画像入力に対応すると有効になります。", "cws.capability.imageInputHint": "全ターゲットが画像対応なら既定でオン。オフにするとテキストのみ。", "cws.capability.imageInput": "画像 / マルチモーダル", + "cws.capability.adaptiveEffort": "適応的な推論レベル", + "cws.capability.adaptiveEffortHint": "オフ: 推論レベルを持たない対象があると、コンボ全体のセレクターが消えます。オン: その対象はそのまま使え、セレクターには残りの対象で共通するレベルが表示されます。", "cws.capabilities": "能力", "cws.field.defaultEffortUnsupported": "この負荷はターゲット共通の階段にありません — リクエスト時に無視またはスナップされます。", "cws.field.defaultEffortUnsupportedOption": "交差に含まれない", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 898cd67d3e5..71d3ef913af 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -2137,6 +2137,8 @@ export const ko: Record = { "cws.capability.imageInputUnavailable": "선택한 모든 대상이 이미지 입력을 지원해야 사용할 수 있습니다.", "cws.capability.imageInputHint": "모든 대상이 이미지를 지원하면 기본으로 켜집니다. 끄면 텍스트만 허용합니다.", "cws.capability.imageInput": "이미지 / 멀티모달", + "cws.capability.adaptiveEffort": "적응형 추론 단계", + "cws.capability.adaptiveEffortHint": "끔: 추론 단계를 조절할 수 없는 대상이 하나라도 있으면 콤보 전체의 선택기가 사라집니다. 켬: 그런 대상도 그대로 쓰면서, 선택기에는 나머지 대상이 공통으로 지원하는 단계가 남습니다.", "cws.capabilities": "기능", "cws.field.defaultEffortUnsupported": "이 수준은 대상의 공통 사다리에 없습니다 — 요청 시 무시되거나 스냅됩니다.", "cws.field.defaultEffortUnsupportedOption": "교집합에 없음", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 12c47f477cd..2fdbf1aa434 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -2220,6 +2220,8 @@ export const ru: Record = { "cws.capability.imageInputUnavailable": "Доступно, когда все выбранные цели поддерживают ввод изображений.", "cws.capability.imageInputHint": "Включено по умолчанию, если все цели поддерживают изображения. Выключите, чтобы принимать только текст.", "cws.capability.imageInput": "Изображения / мультимодальность", + "cws.capability.adaptiveEffort": "Адаптивная шкала рассуждений", + "cws.capability.adaptiveEffortHint": "Выкл.: цель без настройки рассуждений скрывает выбор уровня для всей комбинации. Вкл.: такие цели остаются доступными, а в выборе сохраняются уровни, общие для остальных целей.", "cws.capabilities": "Возможности", "cws.field.defaultEffortUnsupported": "Этот уровень не входит в общую лестницу целей — при запросе он будет проигнорирован или снижен.", "cws.field.defaultEffortUnsupportedOption": "нет в пересечении", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 4aaeffec669..f9cdeb87dc5 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -2140,6 +2140,8 @@ export const tr: Record = { "cws.capability.imageInputUnavailable": "Seçilen tüm hedefler görsel girişini destekleyene kadar kullanılamaz.", "cws.capability.imageInputHint": "Tüm hedefler görselleri desteklediğinde varsayılan olarak açıktır. Yalnızca metin kabul etmek için kapatın.", "cws.capability.imageInput": "Görsel / çok modlu", + "cws.capability.adaptiveEffort": "Uyarlanabilir akıl yürütme düzeyi", + "cws.capability.adaptiveEffortHint": "Kapalı: akıl yürütme denetimi olmayan bir hedef, tüm kombinasyonun seçicisini gizler. Açık: bu hedefler kullanılabilir kalır ve seçici, kalan hedeflerin ortak düzeylerini gösterir.", "cws.capabilities": "Yetenekler", "cws.field.defaultEffortUnsupported": "Bu çaba hedeflerin ortak merdiveninde yok — istek anında yok sayılacak veya uydurulacaktır.", "cws.field.defaultEffortUnsupportedOption": "kesişimde değil", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 182fa487518..134e2075bcc 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -1648,6 +1648,8 @@ export const zhTW: Record = { "cws.capability.imageInputUnavailable": "所有已選目標都支援圖片輸入後才可使用。", "cws.capability.imageInputHint": "所有目標都支援圖片時預設開啟;關閉後僅接受文字。", "cws.capability.imageInput": "圖片 / 多模態", + "cws.capability.adaptiveEffort": "自適應推理層級", + "cws.capability.adaptiveEffortHint": "關閉:只要有一個目標不支援推理層級,整個組合的選擇器都會消失。開啟:這些目標仍可使用,選擇器保留其餘目標共有的層級。", "cws.capabilities": "功能", "cws.field.defaultEffortUnsupported": "此 effort 不在目標的共同階梯中 — 請求時會被忽略或就近對應。", "cws.field.defaultEffortUnsupportedOption": "不在交集中", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 96f3d939c28..a2b5879e0f5 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -2130,6 +2130,8 @@ export const zh: Record = { "cws.capability.imageInputUnavailable": "所有已选目标均支持图片输入后才可用。", "cws.capability.imageInputHint": "所有目标均支持图片时默认开启;关闭后仅接受文本。", "cws.capability.imageInput": "图片 / 多模态", + "cws.capability.adaptiveEffort": "自适应推理档位", + "cws.capability.adaptiveEffortHint": "关闭:只要有一个目标不支持推理档位,整个组合的选择器都会消失。开启:这些目标仍可使用,选择器保留其余目标共有的档位。", "cws.capabilities": "能力", "cws.field.defaultEffortUnsupported": "该级别不在目标的公共阶梯中 — 请求时会被忽略或就近映射。", "cws.field.defaultEffortUnsupportedOption": "不在交集中", diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index ad90a522f4c..8b7d9c8614d 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -1492,10 +1492,18 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd } if (parsed.options.stopSequences !== undefined) body.stop = parsed.options.stopSequences; const reasoningDisabled = modelInList(provider.noReasoningModels, parsed.modelId); - const reasoningEffort = mapReasoningEffort(provider, parsed.modelId, parsed.options.reasoning); + // Some gateways accept a reasoning-effort field on a plain turn but reject the + // effort + tools combination. `noReasoningModels` would fix that only by + // stripping reasoning everywhere, costing the model its whole picker. This keeps + // the ladder advertised and drops the wire field for tool-bearing requests only. + const omitReasoningEffortWithTools = !!tools + && modelInList(provider.omitReasoningEffortWithToolsModels, parsed.modelId); + const reasoningEffort = omitReasoningEffortWithTools + ? undefined + : mapReasoningEffort(provider, parsed.modelId, parsed.options.reasoning); const nativeOpenAI = isNativeOpenAIChatTarget(provider); let reasoningLog: AdapterRequest["reasoningLog"]; - if (!reasoningDisabled && provider.reasoningWireFormat === "gateway-object" && parsed.options.reasoning === "none") { + if (!reasoningDisabled && !omitReasoningEffortWithTools && provider.reasoningWireFormat === "gateway-object" && parsed.options.reasoning === "none") { if (nativeOpenAI) { body.reasoning_effort = "none"; reasoningLog = { diff --git a/src/codex/catalog/aggregation.ts b/src/codex/catalog/aggregation.ts index 44ee1c07fa4..e43fddbf1d0 100644 --- a/src/codex/catalog/aggregation.ts +++ b/src/codex/catalog/aggregation.ts @@ -134,10 +134,18 @@ export function deriveComboCatalogModel( : derivedInputModalities; if (inputModalities.length === 0) return null; // Unknown ladders (`undefined`) are wildcards for catalog derivation — same - // boundary as the GUI picker. An explicit empty ladder still constrains. - const advertisedLadders = members + // boundary as the GUI picker. Under the default `strict` mode an explicit empty + // ladder still constrains, so one target that advertises no effort control empties + // the whole combo's picker. `adaptive` is the opt-in for mixed-capability groups: + // empty ladders drop out of the published intersection while non-empty ladders + // still define it. Dispatch is unaffected either way — each concrete target + // resolves its own effort at request time. + const knownLadders = members .map(member => member.reasoningEfforts) .filter((ladder): ladder is string[] => ladder !== undefined); + const advertisedLadders = combo.reasoningEffortMode === "adaptive" + ? knownLadders.filter(ladder => ladder.length > 0) + : knownLadders; const reasoningEfforts = advertisedLadders.length === 0 ? [] : intersectStrings(advertisedLadders); diff --git a/src/combos/types.ts b/src/combos/types.ts index cd65e1c35ec..b3dec16d097 100644 --- a/src/combos/types.ts +++ b/src/combos/types.ts @@ -3,6 +3,7 @@ import { SUPPORTED_NATIVE_OPENAI_SLUGS } from "../codex/catalog/native-models"; import type { OcxComboConfig, OcxComboDefaultEffort, + OcxComboReasoningEffortMode, OcxComboStrategy, OcxComboTarget, OcxConfig, @@ -37,6 +38,8 @@ export interface NormalizedComboConfig { strategy: OcxComboStrategy; stickyLimit: number; defaultEffort: OcxComboDefaultEffort | null; + /** Picker-ladder derivation policy; `strict` preserves the legacy intersection rule. */ + reasoningEffortMode: OcxComboReasoningEffortMode; /** Disable image input; `auto` preserves the intersection derived from all targets. */ imageInput: "auto" | "disabled"; /** Trimmed public alias, or null when the combo keeps the default `combo/` slug. */ @@ -238,6 +241,14 @@ export function comboConfigIssues( if (body.imageInput !== undefined && body.imageInput !== "auto" && body.imageInput !== "disabled") { issues.push({ path: ["imageInput"], message: 'imageInput must be "auto" or "disabled"' }); } + if (body.reasoningEffortMode !== undefined + && body.reasoningEffortMode !== "strict" + && body.reasoningEffortMode !== "adaptive") { + issues.push({ + path: ["reasoningEffortMode"], + message: 'reasoningEffortMode must be "strict" or "adaptive"', + }); + } if (body.alias !== undefined) { if (typeof body.alias !== "string") { @@ -357,6 +368,7 @@ export function normalizeComboConfig(raw: OcxComboConfig): NormalizedComboConfig strategy: raw.strategy ?? "failover", stickyLimit: raw.stickyLimit ?? 1, defaultEffort: raw.defaultEffort ?? null, + reasoningEffortMode: raw.reasoningEffortMode === "adaptive" ? "adaptive" : "strict", imageInput: raw.imageInput === "disabled" ? "disabled" : "auto", alias: alias || null, nativeAlias: raw.nativeAlias === true, diff --git a/src/config.ts b/src/config.ts index 7d5f36e6a86..f68244d636b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -529,6 +529,9 @@ const providerConfigSchema = z.object({ noStructuredOutputModels: z.array(z.string().min(1)) .transform(normalizeNonBlankStringArray) .optional(), + omitReasoningEffortWithToolsModels: z.array(z.string().min(1)) + .transform(normalizeNonBlankStringArray) + .optional(), retryOn429: retryOn429PolicySchema.optional(), transientRetryOn5xx: transientRetryOn5xxPolicySchema.optional(), codexAccountMode: z.enum(["pool", "direct"]).optional(), @@ -1359,6 +1362,17 @@ const configSchema = z.object({ message: structuredOutputOptOutError, }); } + const toolReasoningOptOutError = nonBlankStringArrayConfigError( + (provider as { omitReasoningEffortWithToolsModels?: unknown }).omitReasoningEffortWithToolsModels, + "omitReasoningEffortWithToolsModels", + ); + if (toolReasoningOptOutError) { + ctx.addIssue({ + code: "custom", + path: ["providers", redactSecretString(name), "omitReasoningEffortWithToolsModels"], + message: toolReasoningOptOutError, + }); + } if (Object.hasOwn(provider, "codexAccountMode") && provider.codexAccountMode !== undefined) { // Persisted account mode is valid ONLY on the canonical built-in `openai` forward provider. // Old openai-multi rows stay parseable (they never carry a mode) so startup can migrate them. diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index 82ee28c56ad..934e09ae597 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -694,6 +694,11 @@ export function providerManagementConfigError(name: unknown, provider: unknown): "noStructuredOutputModels", ); if (structuredOutputOptOutError) return `provider ${name} ${structuredOutputOptOutError}`; + const toolReasoningOptOutError = nonBlankStringArrayConfigError( + raw.omitReasoningEffortWithToolsModels, + "omitReasoningEffortWithToolsModels", + ); + if (toolReasoningOptOutError) return `provider ${name} ${toolReasoningOptOutError}`; const openRouterError = openRouterRoutingConfigError(typed); if (openRouterError) return `provider ${name} ${openRouterError}`; const vercelError = vercelGatewayRoutingConfigError(typed); @@ -787,6 +792,7 @@ export function safeConfigDTO(config: OcxConfig): unknown { "noTopPModels", "noPenaltyModels", "noStructuredOutputModels", + "omitReasoningEffortWithToolsModels", "upstreamHttpVersion", "autoToolChoiceOnlyModels", "preserveReasoningContentModels", diff --git a/src/server/management/combo-routes.ts b/src/server/management/combo-routes.ts index 192ca547502..72282d54450 100644 --- a/src/server/management/combo-routes.ts +++ b/src/server/management/combo-routes.ts @@ -68,12 +68,24 @@ import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body"; import { shadowCallTargetError } from "./shadow-call-validation"; -/** Management wire shape: omit default imageInput "auto" (persist/response sparse). */ -function sparseComboConfig(combo: T): Omit & { imageInput?: "disabled" } { - const { imageInput, ...rest } = combo; +/** + * Management wire shape: omit fields whose value is the default, so GET responses and + * persisted config stay sparse. A default echoed here would be written straight back by + * any client that round-trips GET into PUT, which is how an unset option ends up + * materialized in every user's config.json. + */ +function sparseComboConfig(combo: T): Omit & { + imageInput?: "disabled"; + reasoningEffortMode?: "adaptive"; +} { + const { imageInput, reasoningEffortMode, ...rest } = combo; return { ...rest, ...(imageInput === "disabled" ? { imageInput: "disabled" as const } : {}), + ...(reasoningEffortMode === "adaptive" ? { reasoningEffortMode: "adaptive" as const } : {}), }; } @@ -137,19 +149,19 @@ export async function handleComboRoutes(ctx: ManagementContext): Promise 0) next.omitReasoningEffortWithToolsModels = models; + else delete next.omitReasoningEffortWithToolsModels; + } + touched = true; + } // headers is the one object-valued field in the mask. PATCH semantics merge it // shallowly into the existing block so a single fingerprint header can be added @@ -525,6 +538,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise Date: Wed, 2 Sep 2026 02:33:41 +0900 Subject: [PATCH 2/3] test(combo): cover adaptive effort mode and the tool-bearing opt-out Activation-grounded regressions for both halves of #2731, plus the dashboard round-trip the original PR left uncovered. - codex-catalog: adaptive keeps the surviving sibling ladder where strict empties it, still intersects non-empty ladders, and yields nothing when every target is empty. - openai-chat-hardening: effort dropped only with tools present, kept without them, untouched for an unlisted sibling and for an unset provider list, and suppressed on the gateway-object branch that writes its own reasoning field. - combo-management-api: adaptive survives PUT to disk to GET; the strict default is never materialized; turning it back off clears the stored field; an unknown value is rejected. - combo-workspace-data: the GUI intersection honors the mode, parse and toPutBody round-trip it, and draftEquals treats a change as dirty so Save enables. - management-provider-validation: validates, exposes and normalizes the new provider list. --- docs-site/src/content/docs/guides/combos.md | 32 ++++++++ .../docs/reference/configuration/providers.md | 1 + .../docs/reference/configuration/routing.md | 1 + tests/codex-catalog.test.ts | 28 +++++++ tests/combo-management-api.test.ts | 61 +++++++++++++++ tests/combo-workspace-data.test.ts | 45 +++++++++++ tests/combos.test.ts | 12 +++ tests/management-provider-validation.test.ts | 44 +++++++++++ tests/openai-chat-hardening.test.ts | 76 +++++++++++++++++++ 9 files changed, 300 insertions(+) diff --git a/docs-site/src/content/docs/guides/combos.md b/docs-site/src/content/docs/guides/combos.md index 020c5f8d398..99c8c86963b 100644 --- a/docs-site/src/content/docs/guides/combos.md +++ b/docs-site/src/content/docs/guides/combos.md @@ -240,6 +240,37 @@ default and leaves the target's own behavior unchanged. Supported values are `lo `high`, `xhigh`, `max`, and `ultra`; omit the field or set it to `null` to leave effort entirely to the caller and target. +### Mixed-capability groups (`reasoningEffortMode`) + +The effort levels a combo advertises are the intersection of what its targets advertise. A target +that explicitly advertises **no** effort control takes part in that intersection, so a single +no-effort backup empties the effort picker for the whole combo — including for the targets that do +support tuning. + +Set `reasoningEffortMode: "adaptive"` to exclude those empty ladders from the published +intersection instead. The picker then shows the levels the remaining targets share, and the +no-effort target stays eligible for routing. Targets whose ladder is simply *unknown* are treated +as wildcards in both modes. + +```json +{ + "combos": { + "mixed": { + "targets": [ + { "provider": "openai-apikey", "model": "gpt-5.6-luna" }, + { "provider": "local", "model": "no-effort-model" } + ], + "reasoningEffortMode": "adaptive" + } + } +} +``` + +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 +combo's Capabilities section. + ## Image / multimodal capability By default a combo publishes the **intersection** of its targets' input modalities (image is @@ -346,6 +377,7 @@ Combos are stored in the top-level `combos` object, keyed by combo id: | `strategy` | No | `"failover"` | `"failover"`, `"round-robin"`, `"random"`, `"least-used"`, or `"reset-window"`. | | `stickyLimit` | No | `1` | Integer from 1 to 100 successful requests per round-robin selection. Applies only to round-robin. | | `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. | | `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/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 1fdf9bdbcd8..7b68b696543 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -115,6 +115,7 @@ differing backup and rewrites known legacy namespaced selected ids to bare ids. | `noTopPModels?` | `string[]` | Models that reject caller-specified `top_p`. | | `noPenaltyModels?` | `string[]` | Models that reject presence/frequency penalties. | | `noStructuredOutputModels?` | `string[]` | Exact model IDs whose `openai-chat` endpoint rejects `response_format`. Only an exact requested-model match omits the field; structured-output translation stays enabled for every other `openai-chat` model. | +| `omitReasoningEffortWithToolsModels?` | `string[]` | Exact `openai-chat` model IDs that accept a reasoning-effort field on an ordinary turn but reject it once function tools are present. The model keeps its advertised effort ladder; OpenCodex omits the wire field for tool-bearing requests only and the upstream default applies. Narrower than `noReasoningModels`, which strips reasoning from every request and costs the model its picker entirely. | | `parallelToolCalls?` | `boolean` | Toggle parallel tool calls. OpenAI Chat defaults on; non-chat adapters advertise only on explicit `true`. | | `terminalContinuationGuard?` | `boolean` | Opt in an `openai-chat` provider to one bounded internal re-ask when an actionable turn announces work, then cleanly stops without a tool call. Defaults to `false`; explicit `false` behaves like omission. Combo attempts and routed compaction turns are excluded, and non-`openai-chat` adapters ignore this option. | | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` | Disabled-by-default downstream SSE repair for exact placeholder ids, missing terminal ids, and (with `repairInvalidIds`) message/reasoning ids missing the canonical `msg_`/`rs_` prefix. Function-call ids are never rewritten. Built-in DeepSeek enables the last two by default. | diff --git a/docs-site/src/content/docs/reference/configuration/routing.md b/docs-site/src/content/docs/reference/configuration/routing.md index f254b547082..4bac3170e9c 100644 --- a/docs-site/src/content/docs/reference/configuration/routing.md +++ b/docs-site/src/content/docs/reference/configuration/routing.md @@ -89,6 +89,7 @@ namespace, and cannot use reserved bare native families such as `gpt-*`, `o1-*`, | `strategy?` | `"failover" \| "round-robin" \| "random" \| "least-used" \| "reset-window"` | `"failover"` | Selection strategy. Target order is failover priority; weights shape round-robin and random draws; least-used follows recorded successes; reset-window follows the soonest quota reset. | | `stickyLimit?` | `number` | `1` | Successful requests retained in one round-robin batch. Range 1–100. Applies only to round-robin. | | `defaultEffort?` | `"low" \| "medium" \| "high" \| "xhigh" \| "max" \| "ultra" \| null` | unset | Applied only when the caller omits effort and the selected target advertises the requested rung. | +| `reasoningEffortMode?` | `"strict" \| "adaptive"` | `"strict"` | `"strict"` intersects every known target effort ladder, so a target advertising no effort control empties the combo's picker. `"adaptive"` excludes those empty ladders from the published intersection. Picker metadata only; target selection and dispatch are unchanged. | | `imageInput?` | `"auto" \| "disabled"` | `"auto"` | `"auto"` publishes image only when every target supports images; `"disabled"` forces text-only (drops image from published modalities and rejects image-bearing requests before dispatch). | | `alias?` | `string` | — | Optional public model id in place of the canonical picker slug. | | `nativeAlias?` | `boolean` | `false` | Let a currently supported bare native id take precedence only for that unqualified id. Bare `gpt-5.6-*` ids use Codex Pool/Direct credentials. Account-qualified routes remain distinct. Provider-qualified routes such as `openai-apikey/gpt-5.6-*` use their configured API-key route and never fall through to the native alias. | diff --git a/tests/codex-catalog.test.ts b/tests/codex-catalog.test.ts index 143dc8a9861..9e3cceda46b 100644 --- a/tests/codex-catalog.test.ts +++ b/tests/codex-catalog.test.ts @@ -72,6 +72,7 @@ function normalizedCombo( strategy: "failover", stickyLimit: 1, defaultEffort: "medium", + reasoningEffortMode: "strict", imageInput: "auto", alias: null, nativeAlias: false, @@ -265,6 +266,33 @@ describe("combo catalog capability intersection", () => { expect(empty).not.toHaveProperty("defaultReasoningEffort"); }); + test("adaptive mode keeps the surviving ladder when a target advertises no effort control", () => { + // The strict case above is the baseline: memberB's explicit [] empties the picker for + // the whole combo. Adaptive is the opt-in that excludes it instead, so the effort + // control stays usable for the siblings that do support tuning. + const adaptive = deriveComboCatalogModel( + "adaptive", + normalizedCombo({ defaultEffort: "medium", reasoningEffortMode: "adaptive" }), + [memberA, { ...memberB, reasoningEfforts: [] }], + ); + expect(adaptive?.reasoningEfforts).toEqual(["low", "medium", "high"]); + expect(adaptive?.defaultReasoningEffort).toBe("medium"); + + // Adaptive only drops EMPTY ladders; non-empty ones still intersect normally. + expect(deriveComboCatalogModel( + "adaptive-intersect", + normalizedCombo({ defaultEffort: "medium", reasoningEffortMode: "adaptive" }), + [memberA, { ...memberB, reasoningEfforts: ["medium", "high"] }], + )?.reasoningEfforts).toEqual(["medium", "high"]); + + // Every target empty under adaptive still yields no ladder — there is nothing to keep. + expect(deriveComboCatalogModel( + "adaptive-all-empty", + normalizedCombo({ defaultEffort: "medium", reasoningEffortMode: "adaptive" }), + [{ ...memberA, reasoningEfforts: [] }, { ...memberB, reasoningEfforts: [] }], + )?.reasoningEfforts).toEqual([]); + }); + test("fails closed for missing members, unknown context, duplicate targets, and empty modalities", () => { expect(deriveComboCatalogModel("missing", normalizedCombo(), [memberA])).toBeNull(); expect(deriveComboCatalogModel("context", normalizedCombo(), [ diff --git a/tests/combo-management-api.test.ts b/tests/combo-management-api.test.ts index b0489a9baaf..082e58e26ba 100644 --- a/tests/combo-management-api.test.ts +++ b/tests/combo-management-api.test.ts @@ -296,6 +296,67 @@ describe("combo management API", () => { }); }); + test("reasoningEffortMode survives a management round-trip and stays sparse when strict", async () => { + await withTempHome(async () => { + const config = baseConfig({ combos: undefined }); + saveConfig(config); + + // Opting in must survive PUT -> disk -> GET. The dashboard replaces the whole combo + // on save, so a field that is not echoed here is a field the UI silently destroys. + const opted = await comboApi(config, "PUT", "/api/combos", { + id: "mixed", + combo: { + targets: [{ provider: "a", model: "m1" }], + reasoningEffortMode: "adaptive", + }, + }); + expect(opted?.status).toBe(200); + expect(await responseJson(opted)).toMatchObject({ + combo: { reasoningEffortMode: "adaptive" }, + }); + expect(config.combos?.mixed).toMatchObject({ reasoningEffortMode: "adaptive" }); + const listed = await responseJson(await comboApi(config, "GET", "/api/combos")); + expect(listed.combos).toEqual([expect.objectContaining({ + id: "mixed", reasoningEffortMode: "adaptive", + })]); + + // The default is never materialized: neither on disk nor in the wire shape, so a + // client that round-trips GET into PUT cannot write it back into every config. + const plain = await comboApi(config, "PUT", "/api/combos", { + id: "plain", + combo: { targets: [{ provider: "a", model: "m1" }] }, + }); + expect((await responseJson(plain)).combo).not.toHaveProperty("reasoningEffortMode"); + expect(config.combos?.plain).not.toHaveProperty("reasoningEffortMode"); + + // Explicitly turning it back off clears the stored field rather than pinning "strict". + await comboApi(config, "PUT", "/api/combos", { + id: "mixed", + combo: { + targets: [{ provider: "a", model: "m1" }], + reasoningEffortMode: "strict", + }, + }); + expect(config.combos?.mixed).not.toHaveProperty("reasoningEffortMode"); + }); + }); + + test("PUT rejects an unknown reasoningEffortMode", async () => { + await withTempHome(async () => { + const config = baseConfig({ combos: undefined }); + saveConfig(config); + const response = await comboApi(config, "PUT", "/api/combos", { + id: "bad", + combo: { + targets: [{ provider: "a", model: "m1" }], + reasoningEffortMode: "aggressive", + }, + }); + expect(response?.status).toBe(400); + expect(config.combos?.bad).toBeUndefined(); + }); + }); + test("PUT stores aliases and GET exposes the public model", async () => { await withTempHome(async () => { const config = baseConfig({ combos: undefined }); diff --git a/tests/combo-workspace-data.test.ts b/tests/combo-workspace-data.test.ts index c62d9b36401..b7d11471610 100644 --- a/tests/combo-workspace-data.test.ts +++ b/tests/combo-workspace-data.test.ts @@ -114,6 +114,7 @@ describe("combo-workspace-data", () => { stickyLimit: 1, defaultEffort: null, imageInput: "auto", + reasoningEffortMode: "strict", targets: [{ provider: "a", model: "m1", weight: 1, clientKey: expect.stringMatching(/^ct-\d+$/) }], }, { @@ -126,6 +127,7 @@ describe("combo-workspace-data", () => { stickyLimit: 4, defaultEffort: "high", imageInput: "auto", + reasoningEffortMode: "strict", targets: [ { provider: "a", model: "m1", weight: 3, clientKey: expect.stringMatching(/^ct-\d+$/) }, { provider: "b", model: "m2", weight: 1, clientKey: expect.stringMatching(/^ct-\d+$/) }, @@ -224,6 +226,49 @@ describe("combo-workspace-data", () => { )).toEqual([]); }); + test("intersectComboEfforts drops empty ladders in adaptive mode", () => { + const map = new Map([ + ["a/m1", ["low", "medium"]], + ["b/no-reasoning", []], + ]); + const targets = [{ provider: "a", model: "m1" }, { provider: "b", model: "no-reasoning" }]; + // The editor must agree with the served catalog: under adaptive the no-effort target + // stops emptying the picker, otherwise the dashboard shows a control the proxy does not. + expect(intersectComboEfforts(targets, map, "adaptive")).toEqual(["low", "medium"]); + // Explicit strict, and the default argument, both keep today's restrictive behavior. + expect(intersectComboEfforts(targets, map, "strict")).toEqual([]); + expect(intersectComboEfforts(targets, map)).toEqual([]); + }); + + test("reasoningEffortMode survives parse and serialize", () => { + // toPutBody is an allowlist and PUT replaces the whole combo, so a field missing here + // is silently destroyed the next time the user edits anything in the dashboard. + const [parsedItem] = parseComboList({ + combos: [{ + id: "mixed", + model: "combo/mixed", + strategy: "failover", + stickyLimit: 1, + defaultEffort: null, + reasoningEffortMode: "adaptive", + targets: [{ provider: "a", model: "m1" }], + }], + }); + expect(parsedItem?.reasoningEffortMode).toBe("adaptive"); + expect(toPutBody(parsedItem!).combo.reasoningEffortMode).toBe("adaptive"); + + // The default stays off the wire so a GET -> PUT round-trip never writes it back. + expect(toPutBody(combo()).combo).not.toHaveProperty("reasoningEffortMode"); + expect(toPutBody(combo({ reasoningEffortMode: "strict" })).combo) + .not.toHaveProperty("reasoningEffortMode"); + }); + + test("draftEquals treats a reasoningEffortMode change as dirty", () => { + // Without this the Save button stays disabled after toggling the switch. + expect(draftEquals(combo(), combo({ reasoningEffortMode: "adaptive" }))).toBe(false); + expect(draftEquals(combo({ reasoningEffortMode: "strict" }), combo())).toBe(true); + }); + test("attention flags zero-target and one-target defensive rows", () => { const attention = buildComboAttention([ combo({ id: "empty", model: "combo/empty", targets: [] }), diff --git a/tests/combos.test.ts b/tests/combos.test.ts index a3f17c20f49..f4068949920 100644 --- a/tests/combos.test.ts +++ b/tests/combos.test.ts @@ -807,6 +807,7 @@ describe("combo validation and normalization", () => { strategy: "failover", stickyLimit: 1, defaultEffort: "high", + reasoningEffortMode: "strict", imageInput: "auto", alias: null, nativeAlias: false, @@ -814,6 +815,17 @@ describe("combo validation and normalization", () => { targets: [{ provider: "a", model: "m1", weight: 2 }], }); expect(normalizeComboConfig({ targets: [{ provider: "a", model: "m1" }] }).defaultEffort).toBeNull(); + // Anything that is not the literal "adaptive" normalizes to today's behavior, so a + // malformed or absent value can never silently opt a user in. + expect(normalizeComboConfig({ targets: [{ provider: "a", model: "m1" }] }).reasoningEffortMode).toBe("strict"); + expect(normalizeComboConfig({ + reasoningEffortMode: "adaptive", + targets: [{ provider: "a", model: "m1" }], + }).reasoningEffortMode).toBe("adaptive"); + expect(comboConfigIssues("free", { + reasoningEffortMode: "aggressive", + targets: [{ provider: "a", model: "m1" }], + }, baseConfig().providers).some(issue => issue.path[0] === "reasoningEffortMode")).toBe(true); expect(comboDefaultEffort(baseConfig(), "free")).toBeNull(); const aliased = baseConfig({ combos: { free: { ...VALID_COMBO, alias: " deepseek-v4-flash " } }, diff --git a/tests/management-provider-validation.test.ts b/tests/management-provider-validation.test.ts index 6149eeefb69..ec255b61f84 100644 --- a/tests/management-provider-validation.test.ts +++ b/tests/management-provider-validation.test.ts @@ -330,6 +330,50 @@ describe("provider management validation", () => { .toEqual(["deepseek-v4-flash", "other-model"]); }); + test("validates, exposes, and normalizes tool-bearing reasoning-effort opt-outs", () => { + const provider = { + adapter: "openai-chat", + baseUrl: "https://relay.example/v1", + omitReasoningEffortWithToolsModels: ["picky-model"], + }; + expect(providerManagementConfigError("relay", provider)).toBeNull(); + for (const omitReasoningEffortWithToolsModels of [ + "picky-model", + [""], + [" "], + [42], + ]) { + expect(providerManagementConfigError("relay", { + ...provider, + omitReasoningEffortWithToolsModels, + })).toContain("omitReasoningEffortWithToolsModels"); + } + + const dto = safeConfigDTO({ + port: 10100, + defaultProvider: "relay", + providers: { relay: provider }, + } as OcxConfig) as { providers: Record }; + expect(dto.providers.relay?.omitReasoningEffortWithToolsModels).toEqual(["picky-model"]); + + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + writeFileSync(join(TEST_DIR, "config.json"), JSON.stringify({ + ...config("127.0.0.1"), + defaultProvider: "relay", + providers: { + relay: { + adapter: "openai-chat", + baseUrl: "https://relay.example/v1", + omitReasoningEffortWithToolsModels: [" picky-model ", "picky-model", " other-model "], + }, + }, + })); + expect(loadConfig().providers.relay?.omitReasoningEffortWithToolsModels) + .toEqual(["picky-model", "other-model"]); + }); + test("provider management validates annotateEmptyToolOutputs as boolean", () => { const provider = { adapter: "openai-chat", diff --git a/tests/openai-chat-hardening.test.ts b/tests/openai-chat-hardening.test.ts index 52983f8377f..cd89a8aff69 100644 --- a/tests/openai-chat-hardening.test.ts +++ b/tests/openai-chat-hardening.test.ts @@ -22,6 +22,82 @@ afterEach(() => { describe("AgentRouter openai-chat compatibility", () => { const preamble = "[Instruction: Process the user request below and respond in the appropriate language.]"; + describe("omitReasoningEffortWithToolsModels", () => { + const toolBearing = (modelId: string): OcxParsedRequest => ({ + modelId, + context: { + messages: [{ role: "user", content: "hi", timestamp: 0 }], + tools: [{ + name: "read_file", + description: "Read a file", + parameters: { type: "object", properties: { path: { type: "string" } } }, + }], + }, + stream: false, + options: { reasoning: "high" }, + }); + const plain = (modelId: string): OcxParsedRequest => ({ + modelId, + context: { messages: [{ role: "user", content: "hi", timestamp: 0 }] }, + stream: false, + options: { reasoning: "high" }, + }); + const gated = provider({ omitReasoningEffortWithToolsModels: ["picky-model"] }); + + test("drops the wire effort only when tools are present", () => { + const withTools = JSON.parse( + createOpenAIChatAdapter(gated).buildRequest(toolBearing("picky-model")).body, + ) as Record; + expect(withTools.reasoning_effort).toBeUndefined(); + // The tools themselves must still be sent — this is an effort opt-out, not a tool opt-out. + expect(Array.isArray(withTools.tools)).toBe(true); + + const withoutTools = JSON.parse( + createOpenAIChatAdapter(gated).buildRequest(plain("picky-model")).body, + ) as Record; + expect(withoutTools.reasoning_effort).toBe("high"); + }); + + test("leaves an unlisted sibling model untouched", () => { + const sibling = JSON.parse( + createOpenAIChatAdapter(gated).buildRequest(toolBearing("other-model")).body, + ) as Record; + expect(sibling.reasoning_effort).toBe("high"); + }); + + test("an unset provider list changes nothing", () => { + const body = JSON.parse( + createOpenAIChatAdapter(provider()).buildRequest(toolBearing("picky-model")).body, + ) as Record; + expect(body.reasoning_effort).toBe("high"); + }); + + test("suppresses the gateway-object reasoning block for tool-bearing requests", () => { + // The gateway-object branch writes its own reasoning field, so it needs the same + // guard; without it the wire effort returns through a second path. + const gatewayProvider = provider({ + reasoningWireFormat: "gateway-object", + omitReasoningEffortWithToolsModels: ["picky-model"], + }); + const none = (modelId: string): OcxParsedRequest => ({ + ...toolBearing(modelId), + options: { reasoning: "none" }, + }); + + const suppressed = JSON.parse( + createOpenAIChatAdapter(gatewayProvider).buildRequest(none("picky-model")).body, + ) as Record; + expect(suppressed.reasoning).toBeUndefined(); + expect(suppressed.reasoning_effort).toBeUndefined(); + + // An unlisted model still takes the gateway-object path. + const untouched = JSON.parse( + createOpenAIChatAdapter(gatewayProvider).buildRequest(none("other-model")).body, + ) as Record; + expect(untouched.reasoning ?? untouched.reasoning_effort).toBeDefined(); + }); + }); + test("adds a stable Codex originator while preserving operator header precedence", () => { const automatic = createOpenAIChatAdapter(provider({ baseUrl: "https://agentrouter.org/v1" })).buildRequest(parsed()); expect(automatic.headers.originator).toBe("codex_cli_rs"); From 64ff03a41af0c3583be7d59205123e6b97b0a449 Mon Sep 17 00:00:00 2001 From: jun Date: Wed, 2 Sep 2026 03:10:14 +0900 Subject: [PATCH 3/3] docs(devlog): record the 3190 cherry-pick onto privacy-clean dev --- .../260902_admin_merge_3190/021_wp2_cherry_pick.md | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 devlog/_plan/260902_admin_merge_3190/021_wp2_cherry_pick.md diff --git a/devlog/_plan/260902_admin_merge_3190/021_wp2_cherry_pick.md b/devlog/_plan/260902_admin_merge_3190/021_wp2_cherry_pick.md new file mode 100644 index 00000000000..06e8c88a6e7 --- /dev/null +++ b/devlog/_plan/260902_admin_merge_3190/021_wp2_cherry_pick.md @@ -0,0 +1,10 @@ +# 021 — wp2 cherry-pick onto privacy-clean origin/dev + +wp1 landed as #3197 squash `4be4326d7`. Unique #3190 commits cherry-picked onto that tip: + +- `e1fc1729b` -> `3ee25f58e` feat(combo): adapt reasoning effort to target capabilities +- `5f8cd24dd` -> `c2a89e321` test(combo): cover adaptive effort mode and the tool-bearing opt-out + +Conflicts: none. Auto-merged `src/codex/catalog/aggregation.ts`, `src/server/management/provider-routes.ts`, `docs-site/src/content/docs/reference/configuration/providers.md`, `tests/combos.test.ts`, `tests/management-provider-validation.test.ts`. + +Focused checks on `c2a89e321`: `bun x tsc --noEmit` exit 0; `cd gui && bun x tsc --noEmit` exit 0; `bun run privacy:scan` exit 0; `bun test` of the six named files 528 pass / 0 fail.