Skip to content

feat(combo): adapt reasoning effort to target capabilities (#2731) - #3190

Merged
lidge-jun merged 3 commits into
devfrom
codex/adaptive-reasoning-effort-2731
Sep 1, 2026
Merged

lidge-jun merged 3 commits into
devfrom
codex/adaptive-reasoning-effort-2731

Conversation

@lidge-jun

Copy link
Copy Markdown
Owner

Summary

Adopts the design from #2734 (thanks @TooSpace) and completes the surfaces it left open. Closes #2731.

Two independent defects, each fixed behind an opt-in that defaults to today's behavior:

1. An empty effort ladder poisons the whole combo picker. A combo publishes the intersection of its targets' advertised effort ladders. deriveComboCatalogModel treated an explicitly empty ladder — a target that advertises no effort control — as a participant in that intersection, so one such target emptied the picker for every sibling that does support tuning, and took the combo's defaultEffort with it. reasoningEffortMode: "adaptive" excludes empty ladders from the published intersection. "strict" remains the default and preserves the existing contract.

2. openai-chat could not express a per-request rule. A model that accepts reasoning effort on an ordinary turn but rejects it once function tools are present had only noReasoningModels, which strips reasoning from every request and costs the model its picker entirely. omitReasoningEffortWithToolsModels omits the wire field for tool-bearing requests only. The guard is applied on both the plain reasoning_effort path and the gateway-object branch, which writes its own reasoning field.

Beyond #2734: the dashboard round-trip

PUT /api/combos replaces the stored combo wholesale, and the GUI serializer toPutBody is a field allowlist. A field the dashboard does not round-trip is therefore silently destroyed by any combo edit — a user who set reasoningEffortMode by hand would rename the combo, reorder a target, and watch their picker go empty again with no error. #2734 touches no gui/ file, so it shipped that hole.

This PR closes it: the field is parsed, serialized, dirty-checked and sync-keyed, and sparseComboConfig now covers it so GET, the PUT response and disk agree — an unset default is never materialized into config.json, and a GET→PUT round-trip cannot write it back.

UI

The opt-in is a switch in a combo's Capabilities section beside image input, where combo-wide policy already lives — deliberately not under "Default reasoning", which is a value chosen from the intersection rather than a rule that changes what the intersection is. The detail panel and the create modal share that component, so the create flow shows the corrected picker too, and the GUI intersection honors the mode instead of contradicting the served catalog. Labels and hints are translated for all 9 locales.

No visual change on the default path; the new control is a switch in the existing Capabilities section.

Verification

bun x tsc --noEmit                      # exit 0 (root)
cd gui && bun x tsc --noEmit            # exit 0
bun test tests/codex-catalog.test.ts \
         tests/openai-chat-hardening.test.ts \
         tests/combo-management-api.test.ts \
         tests/combo-workspace-data.test.ts \
         tests/combos.test.ts \
         tests/management-provider-validation.test.ts
# 520 pass, 0 fail, exit 0

bun run privacy:scan                    # Privacy scan passed
bun test tests/core-lab-boundary.test.ts tests/repo-hygiene.test.ts   # 29 pass, 0 fail

Regressions are activation-grounded rather than status-only — each new conditional branch has a case that triggers it and observes its effect:

  • adaptive keeps the surviving sibling ladder where strict empties it; still intersects non-empty ladders; yields nothing when every target is empty
  • effort dropped only with tools present; kept without them; untouched for an unlisted sibling and for an unset provider list; suppressed on the gateway-object branch
  • adaptive survives PUT→disk→GET; the strict default is never persisted; turning it off clears the field; an unknown value is rejected 400
  • the GUI intersection honors the mode; parse/toPutBody round-trip it; draftEquals marks a change dirty so Save enables

Back-compat: with nothing configured, catalog rows and wire bodies are unchanged. normalizeComboConfig maps anything that is not the literal "adaptive" to "strict", and modelInList is false for an absent list.

Checklist

  • Focused tests added near the existing tests for each touched subsystem
  • bun x tsc --noEmit passes (root and gui)
  • bun run privacy:scan passes
  • User-facing behavior documented in docs-site/ (combos guide, routing reference, providers reference)
  • New capability is opt-in and defaults to current behavior
  • Targets dev

Follow-ups (deliberately out of scope)

  • concreteComboRequestBody does not strip a caller-supplied effort for a target that cannot use it; Responses does not run stripEmptyLadderEffort (Chat-only). Pre-existing, orthogonal to the picker/wire defects here.
  • buildOpenAIChatPassthroughRequest ignores this key exactly as it already ignores noReasoningModels.

@lidge-jun
lidge-jun requested a review from Ingwannu as a code owner September 1, 2026 17:34
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 1, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-09-01T17:39:06.352153Z 181bb60 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@lidge-jun
lidge-jun force-pushed the codex/adaptive-reasoning-effort-2731 branch from 181bb60 to 5f8cd24 Compare September 1, 2026 17:35
@github-actions github-actions Bot added the enhancement New feature or request label Sep 1, 2026
@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

✅ Deterministic PR hygiene checks passed.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 181bb60729

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +1499 to +1500
const omitReasoningEffortWithTools = !!tools
&& modelInList(provider.omitReasoningEffortWithToolsModels, parsed.modelId);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Apply the effort guard to native Chat passthrough

When a client calls /v1/chat/completions directly with both tools and reasoning_effort, src/server/chat-native.ts uses buildOpenAIChatPassthroughRequest, which copies both fields without consulting omitReasoningEffortWithToolsModels; this new guard runs only on the translated adapter path. Consequently, the exact configured model can still receive the rejected combination and return an upstream error for native Chat clients. Apply the same capability gate in the passthrough builder and add coverage for that entry point.

Useful? React with 👍 / 👎.

Comment on lines +150 to +151
const advertisedLadders = combo.reasoningEffortMode === "adaptive"
? knownLadders.filter(ladder => ladder.length > 0)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Strip unsupported effort before Responses combo dispatch

When an adaptive combo contains an openai-responses target with reasoningEfforts: [], excluding that ladder advertises the siblings' efforts to Codex, so ordinary requests now carry an effort. If that target is selected, concreteComboRequestBody preserves caller-supplied effort and the Responses path has no equivalent of the Chat-only stripEmptyLadderEffort call, allowing an unsupported field to reach the upstream and fail the combo attempt. Strip effort according to the selected concrete target before Responses dispatch, or do not publish it for combinations where dispatch cannot safely adapt.

Useful? React with 👍 / 👎.

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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep the adaptive picker empty when every ladder is empty

When every selected target explicitly advertises reasoningEfforts: [], adaptive mode skips every target, leaves common as null, and the function consequently returns the full COMBO_EFFORTS list. The server-side derivation deliberately returns [] for this same case, so the dashboard offers and can save unsupported defaults even though the served catalog has no effort picker. Track whether empty known ladders were skipped and return [] when none of the targets supplies a surviving non-empty ladder.

AGENTS.md reference: gui/AGENTS.md:L9-L10

Useful? React with 👍 / 👎.

@lidge-jun

Copy link
Copy Markdown
Owner Author

리뷰 · 우선순위 60 / 80

현재 dev HEAD 5557772b7에서 콤보는 대상들이 광고하는 추론 단계를 교집합으로 만듭니다. src/codex/catalog/aggregation.ts의 deriveComboCatalogModel은 사다리가 undefined이면 모른다고 보고 건너뜁니다. 그런데 사다리가 빈 배열이면 "이 모델은 단계를 고를 수 없다"는 뜻이라서 교집합에 그대로 들어갑니다. 그래서 단계를 지원하는 대상이 있어도, 단계를 못 고르는 대상이 하나면 콤보 전체의 선택기가 비어 버립니다. 대시보드 gui/src/combo-workspace-data.ts의 intersectComboEfforts도 같은 규칙입니다.

바로 전에 들어온 #3172는 콤보 기본 단계를 남은 사다리 안에서 아래로 맞춥니다. 선택기 자체가 비면 맞출 곳이 없어서, 빈 사다리가 선택기를 지우는 문제는 그대로입니다. 이 PR은 그 규칙을 바꾸는 스위치를 넣습니다. 콤보에 reasoningEffortMode: "adaptive"를 켜면 빈 사다리는 교집합에서 빠집니다. 기본값 "strict"는 지금과 같습니다. 요청을 실제로 보낼 때는 각 대상이 자기 단계를 그대로 정합니다. 카탈로그와 선택기에 무엇을 보여줄지만 바뀝니다.

두 번째 구멍은 openai-chat입니다. 어떤 모델은 도구 없는 요청에는 추론 단계를 받지만, 함수 도구가 있으면 그 필드를 거절합니다. 지금은 noReasoningModels만 있어서 단계를 아예 광고하지 않게 만들 수밖에 없습니다. 이 PR의 omitReasoningEffortWithToolsModels는 도구가 있는 요청에서만 그 필드를 뺍니다. src/adapters/openai-chat.ts의 일반 reasoning_effort 길과 gateway-object 길 둘 다 막습니다. 도구가 없으면 toolsToChatFormatForProvider가 undefined를 돌려주므로, 빈 도구 배열 때문에 단계를 잘못 빼지는 않습니다.

원래 기여자 PR #2734는 이 두 구멍을 봤지만 gui/를 안 건드렸습니다. 대시보드 PUT /api/combos는 콤보 전체를 갈아끼우고, toPutBody는 허용된 필드만 보냅니다. 그래서 손으로 reasoningEffortMode를 넣은 뒤 대시보드에서 이름만 바꿔도 스위치가 사라집니다. 이 PR은 그 필드를 파싱하고, 저장하고, 더러워졌는지 보고, GET과 PUT과 디스크를 sparseComboConfig로 맞춥니다. 기본값 "strict"는 파일에 안 남습니다. 스위치는 Capabilities의 이미지 입력 옆에 있고, 로케일 9개가 같은 문구를 가집니다. 본문은 Closes #2731이라고 적혀 있습니다.

types.ts/config.ts 분리는 지킵니다. 타입은 src/types/config.ts의 OcxComboReasoningEffortMode, 제공자 필드는 src/types/provider.ts, 스키마는 src/config.ts, src/types.ts는 다시 내보내기만 합니다. 두 파일을 다시 합치지 않으므로 close-don't-rebase 대상이 아닙니다.

테스트가 분기를 직접 밟습니다. adaptive는 빈 사다리를 빼고, 비어 있지 않은 사다리는 계속 교집합하고, 전부 빈 사다리면 선택기가 비어 있습니다. 도구가 있을 때만 와이어 단계를 빼고, 목록에 없는 형제는 그대로 둡니다. PUT에서 디스크로 다시 GET까지 adaptive가 남고, strict는 파일에 안 쓰이고, 모르는 값은 400입니다. test 1/4부터 4/4까지는 초록입니다. gates의 privacy:scan만 빨갛습니다. 이유는 이 PR 파일이 아니라 이미 dev에 있는 devlog/_plan/260902_multiplatform_qa_and_gui/091_wp6_merge_outcome.md 13번째 줄이 /Users/junny/와 /Users/tig/를 예시로 다시 적었기 때문입니다. #3181이 머지된 문서입니다. 로컬에서 이 PR만 스캔하면 통과했다고 본문에 적혀 있지만, CI는 트리 전체를 봅니다.

src/codex/catalog/aggregation.ts deriveComboCatalogModel / 요청 본문 - adaptive여도 빈 사다리 대상에 호출자가 고른 단계를 그대로 실어 보낼 수 있는 문제입니다. PR이 후속으로 남겨 두었습니다.
src/adapters/openai-chat.ts buildOpenAIChatPassthroughRequest - 새 키를 noReasoningModels와 같이 무시해서, 패스스루 길에서는 도구가 있어도 단계가 남는 문제입니다.
Responses 어댑터 - omitReasoningEffortWithToolsModels를 읽지 않아서, Responses로 나가는 같은 모델은 도구가 있어도 단계가 남는 문제입니다.
devlog/_plan/260902_multiplatform_qa_and_gui/091_wp6_merge_outcome.md:13 - 이미 머지된 #3181 문서가 홈 경로를 다시 적어서, 이 PR의 gates privacy:scan이 빨간 문제입니다.
PR 본문 스크린샷 - 주소가 전부 0인 1픽셀 자리 표시입니다. privacy:scan 때문에 스크린샷 게이트가 아직 안 돌아간 문제입니다.

메인테이너의 판단이 필요한 지점

  • privacy:scan을 이 PR에서 같이 고칠지, 이미 머지된 docs(devlog): close the multiplatform QA and GUI unit #3181 문서를 먼저 고칠지 정해야 합니다. 지금 상태로는 필수 gates가 빨갛습니다.
  • 초안 #2734를 이 PR 머지 전에 닫을지, 머지한 뒤 Landed via #3190으로 닫을지 정해야 합니다. 이 PR이 #2734가 비운 대시보드 왕복을 채웠습니다.
  • Responses와 패스스루와 요청 본문 구멍을 지금 넣을지, PR이 말한 대로 후속으로 둘지 정해야 합니다. 선택기와 실제 전송이 어긋날 수 있는 자리입니다.

너의 추천
닫거나 다시 만들 필요는 없습니다. types.ts/config.ts 분리 때문에 버릴 PR도 아닙니다. privacy:scan이 초록이 되면 지금 형태로 머지하는 것을 추천합니다. #2734는 이 PR이 랜딩한 뒤 Landed via #3190으로 닫으면 됩니다. Responses와 패스스루 구멍은 후속으로 두어도 됩니다. #2731은 본문의 Closes로 같이 닫힙니다.

이 댓글은 grok-bot이 작성했습니다

jun added 3 commits September 2, 2026 03:06
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.
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.
@lidge-jun
lidge-jun force-pushed the codex/adaptive-reasoning-effort-2731 branch from 5f8cd24 to 64ff03a Compare September 1, 2026 18:10
@lidge-jun

Copy link
Copy Markdown
Owner Author

Rebased unique commits onto origin/dev after #3197 (privacy-scan citation fix, 4be4326). New head 64ff03a = cherry-picks of e1fc172 + 5f8cd24 plus the 021 cherry-pick record. Local: bun x tsc --noEmit 0, gui tsc 0, privacy:scan 0, 528 pass / 0 fail on the six named files.

@lidge-jun
lidge-jun merged commit 88c4275 into dev Sep 1, 2026
25 checks passed
@lidge-jun
lidge-jun deleted the codex/adaptive-reasoning-effort-2731 branch September 1, 2026 18:16
tarunravi pushed a commit to tarunravi/opencodex that referenced this pull request Sep 14, 2026
…#2731) (lidge-jun#3190)

* feat(combo): adapt reasoning effort to target capabilities

Adopts the design from lidge-jun#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 lidge-jun#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 lidge-jun#2731. Supersedes lidge-jun#2734.

* test(combo): cover adaptive effort mode and the tool-bearing opt-out

Activation-grounded regressions for both halves of lidge-jun#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(devlog): record the 3190 cherry-pick onto privacy-clean dev

---------

Co-authored-by: jun <jun@lidge.dev>
agentHits pushed a commit to agentHits/opencodex that referenced this pull request Sep 17, 2026
…#2731) (lidge-jun#3190)

* feat(combo): adapt reasoning effort to target capabilities

Adopts the design from lidge-jun#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 lidge-jun#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 lidge-jun#2731. Supersedes lidge-jun#2734.

* test(combo): cover adaptive effort mode and the tool-bearing opt-out

Activation-grounded regressions for both halves of lidge-jun#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(devlog): record the 3190 cherry-pick onto privacy-clean dev

---------

Co-authored-by: jun <jun@lidge.dev>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant