Skip to content

feat(reasoning): derive routed effort ladders from models.dev and replay a refused rung - #4488

Merged
lidge-jun merged 5 commits into
devfrom
codex/260913-carry-4409
Sep 13, 2026
Merged

feat(reasoning): derive routed effort ladders from models.dev and replay a refused rung#4488
lidge-jun merged 5 commits into
devfrom
codex/260913-carry-4409

Conversation

@lidge-jun

Copy link
Copy Markdown
Owner

Summary

  • A routed gateway such as OpenCode Zen Go answers /models with ids and nothing else, so the Codex catalog had no way to know which reasoning rungs a model actually accepts. It advertised rungs the upstream refuses, and the turn failed.
  • src/providers/reasoning-metadata.ts snapshots the models.dev reasoning ladders for the gated destinations into ~/.opencodex/reasoning-metadata-cache.json (24h TTL, stale-but-readable offline, atomic write). configuredReasoningEfforts() consults it only when nothing is configured for that model, so every hand-written contract stays authoritative.
  • A rung the upstream actually refuses is learned into reasoning-support-cache.json, dropped from every later ladder including registry-pinned ones, and the refused request is replayed once at the next lower published rung instead of failing. requestedEffort and effectiveEffort stay distinguishable in usage under the reasoning-effort-downgrade recovery kind.
  • Carries feat(reasoning): derive routed effort ladders from models.dev and replay a refused rung #4409 by @yxr1995-maker onto current dev, with a Co-authored-by trailer on the branch commit.

Stacked on #4475 (lane R, link 1). Retarget to dev once that lands.

Conflict resolved

src/server/responses/core.ts conflicted at both insertion points. dev has since grown a consoleGoUploadRetryGuard replay block in the same position in both the passthroughRecovery: and the generic recovery: loop. The two recoveries are independent and happened to share their trailing continue tail, which is what produced the conflict. Both blocks are kept, console-go first, each closing its own if. Neither guard can mask the other: console-go matches an exact gateway upload rejection, the downgrade matches a reasoning-effort refusal.

Review findings folded in

CodeRabbit left eight findings on #4409. Seven are fixed here; the eighth is recorded as open with a reason.

Finding Resolution
Downgrade guard declared inside the generic recovery: loop Moved outside, beside the opaque-blob and console-go guards. Every continue recovery was handing the turn a fresh downgrade budget, so one request could walk the whole ladder down. A regression test pins a single downgrade when the replay is refused again.
planReasoningEffortDowngrade read metadata before the configured ladder Precedence now matches configuredReasoningEfforts(): model ladder, then provider ladder, then metadata. Same family-prefix and case-folded id lookup as modelRecordValue(), mirrored locally because reasoning-effort.ts imports this module.
Refusal classifier matched the bare parameter name It now requires the upstream to name the parameter, or rejection language within 48 characters of the effort term, with the ubiquitous invalid_request_error tag excluded. A 400 that refuses max_tokens while echoing the request back no longer spends the turn's one replay or persists a false refusal.
loadSupport() skipped TTL expiry on a memo hit The 30-day TTL is re-applied on every read. A long-running proxy was clamping on month-old refusals, and dropLearnedUnsupportedReasoningEfforts() inherited it through the same map.
ensureReasoningMetadataSnapshot() called after the lookup Called before it. A missing or corrupt snapshot is exactly the case that returns undefined, so the one situation needing a refresh never asked for one.
Streamed test used an openai-chat fixture That fixture is not a passthrough, so the test exercised the generic recovery loop while its name claimed the passthrough one. It keeps that coverage and gains an openai-responses case for passthroughRecovery:.
Decision record claimed models.dev outranks a hand-written ladder Corrected. It contradicted both the code and its own layer list.
Refusal cache keys collide across provider entries Not fixed. See below.

The cache still keys on destination, model and effort, so two configured entries pointing at the same gateway share learned refusals. The write path and every read path would all need a stable provider identity, which reaches through configuredReasoningEfforts() into the catalog; a partial fix where the write key is wider than the read key would silently stop honouring learned refusals. A credential-derived key would also put this link inside the MAINTAINERS.md security-review boundary, which this lane is not carrying. The practical effect today is a conservative clamp for a sibling entry, not a wrong or unsafe request, so it is recorded as open rather than half-applied.

Test layout

The branch registered only reasoning-metadata.test.ts. responses-reasoning-effort-downgrade.test.ts is now in both scripts/test-layout/layout.json and tests/fixtures/test-layout-expected.json, which tests/test-layout.test.ts and tests/test-layout-tooling.test.ts enforce as a pair.

Verification

  • bun test tests/responses/responses-reasoning-effort-downgrade.test.ts tests/codex-integration/reasoning-metadata.test.ts tests/codex-integration/reasoning-effort.test.ts tests/test-layout.test.ts tests/test-layout-tooling.test.ts — 85 pass, 0 fail.
  • bun run typecheck — clean.
  • bun run structure:check — passed.
  • bun run privacy:scan — passed.
  • Full suite runs in hosted CI on the lane tip. This is a non-tip link, so its head commit carries [skip ci].

Checklist

  • Scope stays focused and avoids unrelated cleanup.
  • Docs or release notes were updated when needed.
  • Security-sensitive changes were reviewed for secrets, auth, and unsafe defaults.

No credential material enters either cache file. reasoning-support-cache.json stores a destination key, model id, effort and a truncated rejection message; the decision not to widen that key with credential-derived material is the security-relevant call recorded above.

…lay a refused rung [skip ci]

A routed gateway publishes model ids but not the reasoning ladder behind them,
so the catalog advertised rungs the upstream refuses. A models.dev snapshot now
supplies the ladder when nothing is configured for that model, and a rung the
upstream actually refuses is learned, dropped from every later ladder, and
replayed once at the next lower published rung instead of failing the turn.
`requestedEffort` and `effectiveEffort` keep both values in usage under the
`reasoning-effort-downgrade` recovery kind.

Carries #4409 by yxr1995-maker onto current dev.

Conflict resolved in src/server/responses/core.ts: dev grew a
`consoleGoUploadRetryGuard` replay block at both of the insertion points this
branch targets. The two recoveries are independent and share only their trailing
`continue` tail, so both blocks are kept, console-go first, each closing its own
`if`.

Review findings folded in:

- The generic `recovery:` loop declared its downgrade guard inside the loop, so
  every `continue recovery` handed the turn a fresh downgrade budget. The guard
  now sits outside, beside the opaque-blob and console-go guards, and a
  regression test pins one downgrade for a replay that is refused again.
- `planReasoningEffortDowngrade` read the models.dev ladder before the
  configured one, so a replay could land on a rung a pinned registry ladder
  deliberately excludes. Precedence now matches `configuredReasoningEfforts()`:
  model ladder, then provider ladder, then metadata, with the same family and
  case-folded id lookup.
- `isReasoningEffortRejection` treated the bare parameter name as evidence, so a
  400 refusing another field while echoing the request back spent the turn's one
  replay and persisted a false refusal for thirty days. It now needs the
  upstream to name the parameter, or rejection language beside the effort term.
- `loadSupport()` applied the 30-day TTL only on the first disk read, so a
  long-running proxy kept clamping on month-old refusals through the memo.
- `configuredReasoningEfforts()` asked for a metadata refresh only after a
  successful lookup, which is the one path a missing or corrupt snapshot never
  reaches. The refresh is now requested before the lookup.
- The streamed test used an `openai-chat` fixture and so exercised the generic
  recovery loop while claiming to cover the passthrough one. It keeps that
  coverage and gains an `openai-responses` case for `passthroughRecovery:`.
- The decision record claimed models.dev outranks a hand-written ladder, which
  contradicted both the code and its own layer list.

`responses-reasoning-effort-downgrade.test.ts` is registered in both
`scripts/test-layout/layout.json` and `tests/fixtures/test-layout-expected.json`;
the branch had registered only `reasoning-metadata.test.ts`.

Not folded in: the refusal cache still keys on destination, model and effort, so
two configured entries pointing at the same gateway share learned refusals.
Widening the key needs a provider identity threaded through every read path in
the catalog, and a credential-derived key would put this link inside the
security-review boundary. Recorded as open rather than half-applied.

Co-authored-by: yxr1995-maker <257504378+yxr1995-maker@users.noreply.github.com>
@lidge-jun
lidge-jun requested a review from Ingwannu as a code owner September 13, 2026 07:15
@coderabbitai

coderabbitai Bot commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

🗂️ Base branches to auto review (2)
  • ^dev$
  • ^preview$

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Advanced

Run ID: f76cc697-b2f0-4610-85ad-a488cc83fc07

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 13, 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-13T07:21:38.311755Z cbdaf50 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.

@github-actions

Copy link
Copy Markdown
Contributor

Deterministic PR hygiene checks passed.

@github-actions github-actions Bot added the enhancement New feature or request label Sep 13, 2026
@lidge-jun

Copy link
Copy Markdown
Owner Author

리뷰 · 우선순위 65 / 80

설명
이 PR은 기여자 @yxr1995-maker의 #4409를 현재 dev로 옮긴 캐리입니다. OpenCode Zen Go 같은 라우팅 게이트웨이는 /models에 id만 주고 reasoning 사다리를 안 줍니다. 그래서 카탈로그가 업스트림이 거절하는 rung까지 광고했다가 턴이 깨졌습니다. 새 파일 src/providers/reasoning-metadata.ts가 models.dev 사다리를 ~/.opencodex/reasoning-metadata-cache.json에 스냅샷합니다(24시간 TTL, 오프라인은 stale 허용, atomic write). configuredReasoningEfforts()그 모델에 손수 쓴 계약이 없을 때만 이 스냅샷을 봅니다. 손으로 핀한 사다리가 항상 이깁니다.

두 번째 축은 거절 학습과 한 번 재시도입니다. 업스트림이 실제로 거절한 rung은 reasoning-support-cache.json에 기억되고, 이후 사다리(레지스트리 핀 포함)에서 빠지며, 다음 낮은 공개 rung으로 한 번만 다시 보냅니다. usage에는 requestedEfforteffectiveEffortreasoning-effort-downgrade recovery로 구분됩니다. tip(f7d9dbad0)에는 reasoning-metadata.ts가 아직 없습니다. 방향은 tip의 effort ceiling·라우팅 게이트웨이 이야기와 맞고, 다만 이번에 새로 생기는 디스크 캐시·재시도 예산이라 계약이 큽니다.

베이스는 열린 #4475(codex/260913-carry-4455, lane R link 1)이고, 이 PR은 non-tip이라 헤드에 [skip ci]가 있습니다. src/server/responses/core.ts 충돌은 tip에 생긴 consoleGoUploadRetryGuard와 같은 자리에 끼워 넣으며, console-go를 먼저 두고 각자 if를 닫았다고 합니다. CodeRabbit이 #4409에 남긴 여덟 개 중 일곱은 여기서 고쳤습니다. 가드를 루프 밖으로 빼서 재시도마다 예산이 리셋되지 않게 했고, 다운그레이드 계획의 우선순위를 설정 사다리→메타데이터 순으로 맞췄고, 거절 분류기가 파라미터 이름·거절 문구를 더 까다롭게 보게 했으며, support TTL을 메모 히트에도 다시 적용하고, 메타데이터 refresh를 lookup 전에 부르며, passthrough·generic 둘 다 테스트하고, 결정 문서의 "models.dev가 손수 사다리를 이긴다" 문장을 고쳤습니다.

남긴 여덟 번째가 중요합니다. 거절 캐시 키가 destination+model+effort라서, 같은 게이트웨이를 가리키는 설정 항목 둘이 학습을 공유합니다. provider id를 키에 넣으려면 카탈로그 read 경로 전체를 건드려야 하고, credential 파생 키는 보안 리뷰 경계에 들어갑니다. 지금은 형제 항목을 보수적으로 조이는 쪽이지 잘못된 요청을 만드는 쪽은 아니라고 본문에 적혀 있습니다. 테스트 레이아웃에 responses-reasoning-effort-downgrade.test.tsreasoning-metadata.test.ts를 둘 다 등록한 것도 tip 관례와 맞습니다.

경로/심볼 - 베이스가 #4475라 tip에 단독 머지하면 lane R이 꼬입니다. #4475 머지 후 dev 리타겟이 필요합니다.
경로/심볼 - 헤드 [skip ci]라서 이 PR 단독 전수 CI가 없습니다. 레인 tip 체크가 초록인지 확인한 뒤 합치세요. 원본 #4409도 아직 open입니다. 캐리가 들어가면 원본은 close/supersede 처리가 필요합니다.
src/providers/reasoning-metadata.ts - 새 디스크 캐시 두 개입니다. 경로·TTL·atomic write·민감정보 미포함은 본문 기준으로 괜찮아 보이지만, 멀티 인스턴스·권한·용량 한도는 후속 관찰 포인트입니다.
src/server/responses/core.ts - console-go 재시도와 effort 다운그레이드가 이웃합니다. 리타겟 때 두 블록이 서로 가리지 않는지(각자 continue 꼬리) diff로 한 번 더 보세요.
경로/심볼 - 거절 캐시 키에 provider id가 없습니다. 형제 항목 공유를 이번 머지에 허용할지, 보안 경계 안으로 넣는 후속 WP를 열지가 남아 있습니다.

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

  • #4475를 먼저 머지한 뒤 이 링크를 올릴지, lane R tip이 준비될 때까지 기다릴지.
  • 거절 캐시 키 공유를 열린 채로 머지할지, provider-scoped 키를 후속 필수 WP로 묶을지.
  • 원본 #4409를 landed-via로 닫을지(캐리 머지 직후 leftover 처리).
  • models.dev 스냅샷 실패·오프라인 stale을 운영 기본으로 받아들일지.

너의 추천
#4475가 dev에 들어간 뒤 이 PR을 리타겟하고, 레인 tip CI가 초록이면 머지하세요. squash에 yxr1995-maker Co-authored-by를 유지하고, 원본 #4409는 landed-via로 닫으세요. 캐시 키 공유는 본문대로 열린 이슈로 남겨도 되지만, 한 줄로 "형제 항목은 보수적 clamp"라고 CREDITS/이슈에 남겨 두세요. tip에 단독으로 먼저 넣지 마세요.

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

@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

https://github.com/lidge-jun/opencodex/blob/cbdaf5068ef5eb07831f0b34e893e4b79ff2de78//workspace/opencodx/src/reasoning-effort.ts#L155-L156
P2 Badge Apply learned exclusions before wire-map lookup

For models with reasoningEffortMap or modelReasoningEffortMap, mapReasoningEffort() returns the mapped value before it ever calls this newly filtered configuredReasoningEfforts(). Consequently, after a mapped rung such as DeepSeek's max is learned as refused, the catalog hides it but saved or explicit max requests continue sending max and failing upstream; the wire mapping must clamp against the learned ladder before taking the alias shortcut.

AGENTS.md reference: src/AGENTS.md:L18-L18

ℹ️ 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".

if (!text) return false;
if (/unsupported.{0,24}effort/i.test(text)) return true;
// An upstream that names the offending parameter has already said which one it means.
if (/["']?param["']?\s*[:=]\s*["']?(?:reasoning[._ ]effort|reasoning)/i.test(text)) return true;

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 Match only the exact effort parameter

When an upstream rejects a sibling field such as reasoning.summary or reasoning.encrypted_content, this alternation still succeeds on the reasoning prefix. The proxy then spends a hidden retry lowering an unrelated effort and persists that rung as unsupported for 30 days, while the actually invalid field remains unchanged. Require an exact parameter match, including a closing quote or other boundary, instead of accepting bare reasoning as a prefix.

Useful? React with 👍 / 👎.

Comment on lines +165 to +166
function supportKey(providerKey: string, modelId: string, effort: string): string {
return providerKey + KEY_SEP + modelId + KEY_SEP + effort;

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 Scope learned refusals to the configured credential

When two configured provider entries use different keys against the same ZenCode ZenX base baseZen/ZenCode GoOpenCode endpoint, a rejection from either entry produces the identical opencode-go|model|effort key. The other credential may have the required entitlement, but its ladder is still clamped for 30 days, so the cache key needs a stable provider-entry or credential-scope dimension rather than only the destination-derived provider key.

Useful? React with 👍 / 👎.

Comment on lines +539 to +542
const snapshot = loadSnapshot();
if (snapshot && Date.now() - snapshot.fetchedAt <= CACHE_TTL_MS) return;
if (refreshInFlight) return;
void refreshReasoningMetadata().catch(() => undefined);

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 Back off failed metadata refreshes

When the snapshot is absent or stale and models.dev is offline, a failed refresh leaves that same absent/stale state and records no last-attempt time. As soon as refreshInFlight settles, the next catalog or request-time effort lookup starts another 15-second fetch, so active offline installations can maintain nearly continuous outbound refresh attempts; retain a bounded retry timestamp/backoff independently of snapshot freshness.

Useful? React with 👍 / 👎.

@@ -0,0 +1,543 @@
/**

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 Update the owned structure documents

This commit adds a persistent provider-metadata subsystem and changes shared catalog and Responses recovery behavior under src/providers/, src/reasoning-effort.ts, and src/server/, but it updates no file under structure/. The repository requires every structure document mapped to a changed source area to be updated in the same change, so the applicable architecture/ownership records need to describe this new cache and recovery path before landing.

AGENTS.md reference: src/AGENTS.md:L10-L11

Useful? React with 👍 / 👎.

lidge-jun and others added 2 commits September 13, 2026 16:21
…tting OPENCODEX_HOME [skip ci]

Two defects found by running the carried change against tests/web-search, which
the reasoning-focused test selection did not reach.

configuredReasoningEfforts() asked for a models.dev snapshot refresh before the
lookup so a missing or corrupt snapshot could recover, but it asked for every
provider. A destination the snapshot does not cover gained a background fetch on
its request path that could never help it, and in tests it consumed the mocked
fetch that the web-search bridge was counting. The refresh now sits behind
providerUsesReasoningMetadata(), which is true only for the gated destinations
ladders are stored for.

tests/codex-integration/reasoning-metadata.test.ts deleted OPENCODEX_HOME in
afterEach instead of restoring it, so every later file in the same bun process
read the real ~/.opencodex. That failed unrelated suites depending on file
order, and made the run depend on the machine's actual configuration.

Co-authored-by: yxr1995-maker <257504378+yxr1995-maker@users.noreply.github.com>
lidge-jun and others added 2 commits September 13, 2026 17:01
Reverts the review finding that asked configuredReasoningEfforts() to request a
models.dev refresh before the metadata lookup rather than after it. The
reasoning was that a missing or corrupt snapshot is the case the lookup cannot
serve, so asking only on success never refreshes it. That is true, and it is
still the wrong place.

A missing snapshot is the default state of a fresh install and of every test
process. Asking there put a models.dev fetch on the request path of the first
routed turn to a gated destination, which is observable: the lane tip run failed
tests/responses/responses-console-go-upload-retry.test.ts and
tests/providers/opencode-go-session-header.test.ts, where the extra bodyless
request landed in the middle of a recovery replay the test was counting, and
tests/web-search saw it consume the mocked destination's next leg.

Refreshing a snapshot that does not exist yet is catalog-sync work. The refresh
stays where the branch put it, so it only ever refreshes a stale snapshot that
has already answered a lookup.

Co-authored-by: yxr1995-maker <257504378+yxr1995-maker@users.noreply.github.com>
lidge-jun added a commit that referenced this pull request Sep 13, 2026
Lane R of the contributor carry train, the serialized responses/core lane: code-mode view_image through unified exec (#4455 by jeongjin0, also carrying the duplicate #4171 by rrmlima), routed effort ladders from models.dev with a refused-rung replay (#4409 by yxr1995-maker), and web-search continuations bound to the serving API key (#4387 by luvs01).

Cross-platform CI run 34746891233 concluded success on 2c28886, the exact head merged here, and it covers every link because the lane is cumulative. #4475 and #4488 carry no ci check of their own; their head commits carry [skip ci] by design, under the owner-authorized tip-only CI economy for this batch.

The fourth planned link, #4086 by Eleven-is-cool, is not here because it is already on dev as d6723f7 with its own Co-authored-by trailer. The lane attempted the carry first and found a modify/delete conflict on structure/04_transports-and-sidecars.md, which the #4276 SSOT restructure had removed; the landed version is a superset of the branch.

All four source authors are credited by Co-authored-by trailers in the landed commits.
Base automatically changed from codex/260913-carry-4455 to dev September 13, 2026 08:24
@lidge-jun
lidge-jun merged commit 2c28886 into dev Sep 13, 2026
5 checks passed
@lidge-jun
lidge-jun deleted the codex/260913-carry-4409 branch September 13, 2026 08:24
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