Skip to content

fix(responses): let a combo shadow-call target enter the failover loop - #4157

Merged
lidge-jun merged 2 commits into
devfrom
lane-a/1-4129
Sep 9, 2026
Merged

fix(responses): let a combo shadow-call target enter the failover loop#4157
lidge-jun merged 2 commits into
devfrom
lane-a/1-4129

Conversation

@lidge-jun

@lidge-jun lidge-jun commented Sep 9, 2026

Copy link
Copy Markdown
Owner

Summary

A shadowCallIntercept whose replacement names a combo ran exactly one attempt and never entered the failover loop, so a 429 or 5xx from the first target came back to the caller instead of hopping to the next one. The operator saw a log line claiming a combo route with a single attempt, which is exactly what it was.

Two cooperating causes, not one.

The combo gate reads the un-rewritten body. comboIdFromRawBody looks only at body.model, and at that point the model is still the bare helper slug (gpt-5.6-luna), which is not a combo id. handleComboResponses is therefore never called, its while (pick) loop never runs, advanceComboAfterFailure never runs, and the 429/5xx hop decisions — which exist only inside that loop — are unreachable.

The rewrite happened later, after parse, where resolveRoute("combo/shadow") goes through routeModeltryPickComboModel. That collapses the combo table to one target while still tagging routeKind: "combo". The result looks like a combo route and behaves like a single native call.

There is a second path through the same site. shouldInterceptShadowCall is isShadowSourceModel && !shadowCallTargetsIntersect, so when the collapsed first pick happens to be openai/gpt-5.6-luna the intersect check is true, the intercept is skipped outright, shadowCallRewrittenFrom stays unset, and the request leaves as a plain native route. Simply swapping the order of the two blocks does not close that path.

The fix rewrites the selector before comboIdFromRawBody reads it, and identifies the combo with resolveComboId — a pure config lookup that performs no routing and therefore cannot collapse the table. A combo selector is routing policy, not the identity of its first pick. The existing combo gate takes it from there and handleComboResponses runs its ordinary loop.

What is deliberately unchanged:

  • shouldInterceptShadowCall still suppresses direct same-provider replacements ([Bug]: Shadow Call Intercept forces effort low on every gpt-5.6-luna request when luna is the target (or main model) — max turns silently downgraded #2706). Only the combo case bypasses it, because a combo has no single identity to intersect against.
  • The late intercept site does not re-enter handleComboResponses. Doing so would double-run expandPreviousResponseInput and onRequestBodyRead.
  • The new site records the operator-configured prefix through shadowSourceModelPrefix + sanitizeLogMetadataString, exactly as the late site does, so no caller-controlled model string is ever persisted to usage.jsonl or /api/logs.
  • Object.hasOwn(config.combos, id) mirrors the existing gate, so a combo/<id> that parses but is not configured falls through to the ordinary intercept unchanged.

Known follow-ups, deliberately left out of this PR:

  • parsed._cursorIsolateConversation is not propagated to combo children. Plumbing a new HandleResponsesOptions bit is a separate change and only matters when a Cursor target sits inside the combo.
  • shadowCallTargetError in the management API collapses the same way, so a dashboard PUT naming a Luna-first combo/shadow can still return 400 even though the same file config now works. That is a management-surface fix and belongs in its own change.

Verification

Remote CI at this PR's exact head SHA is the gate for this change.

Local checks: NOT RUN. bun test, bun run test:changed, bun run typecheck, bun install, bun run build:gui, bun run lint:gui, and bun run privacy:scan were all skipped by explicit maintainer instruction for this delivery round, which overrides the PR-ready gate in AGENTS.md. Nothing in this description claims a local check passed.

Independent review that was done: a read-only reviewer audited the diff against the plan and confirmed the placement sits before comboIdFromRawBody inside !options.comboAttempt; that resolveComboId performs no routing; that src/lib/shadow-call.ts is untouched; that childLog never carries shadowCallRewrittenFrom, so the success Object.assign in handleComboResponses cannot clobber the marker; that comboFailureDecision returns hop for 429 and 5xx; and that the extended test file is already explicit in scripts/test-layout/layout.json and tests/fixtures/test-layout-expected.json, so no layout entry is added.

Regression coverage added to tests/responses/responses-shadow-intercept.test.ts:

The existing self-target, prefix-log, and gpt-5.6-terra cases in that file are untouched and must stay green.

Checklist

  • Scope stays focused and avoids unrelated cleanup.
  • Docs or release notes were updated when needed. No user-facing surface changed: the same configuration now behaves the way it is already documented to.
  • Security-sensitive changes were reviewed for secrets, auth, and unsafe defaults. The new site reuses the operator-prefix-only logging rule rather than the caller's raw model string, and it adds no credential handling — routeMayChangeCredentialDomain is already true for every combo attempt.

Closes #4129.

Summary by CodeRabbit

  • New Features

    • Shadow-call interception now supports configured combo routes.
    • Intercepted requests can use combo failover behavior, including retries to alternate targets.
    • Rewritten requests retain combo-routing details for improved visibility.
  • Bug Fixes

    • Fixed intercepted combo requests being processed as single destinations instead of combo routes.

A shadowCallIntercept whose replacement names a combo ran exactly one attempt
and never entered the failover loop, so a 429 or 5xx from the first target
returned to the caller instead of hopping to the next one.

Two cooperating causes. The combo gate reads the UN-rewritten body:
comboIdFromRawBody sees only body.model, which is still the bare helper slug
(gpt-5.6-luna) at that point, so handleComboResponses never runs and neither
does advanceComboAfterFailure. The rewrite happened later, after parse, where
resolveRoute("combo/shadow") goes through routeModel -> tryPickComboModel and
collapses the combo to ONE target while still tagging routeKind "combo". That
collapsed pick is the reported "combo route, one attempt".

There is a second path through the same site. shouldInterceptShadowCall is
isShadowSourceModel && !shadowCallTargetsIntersect, so when the collapsed first
pick happens to be openai/gpt-5.6-luna the intersect check is true, the
intercept is skipped outright, and the request leaves as a plain native route
with no marker. Swapping the two blocks does not close that path.

Rewrite the selector before comboIdFromRawBody reads it instead, and identify
the combo with resolveComboId - a pure config lookup that performs no routing
and therefore cannot collapse the table. A combo selector is routing policy,
not the identity of its first pick. The existing combo gate takes it from there
and handleComboResponses runs its ordinary loop.

shouldInterceptShadowCall is left alone: it still suppresses direct
same-provider replacements (#2706). The late intercept site does not re-enter
handleComboResponses, which would double-run expandPreviousResponseInput and
onRequestBodyRead. The marker records the operator-configured prefix through
sanitizeLogMetadataString exactly as the late site does, so no caller-controlled
string is persisted.

Closes #4129.
@lidge-jun
lidge-jun requested a review from Ingwannu as a code owner September 9, 2026 22:27
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 9, 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-09T22:33:33.764486Z 79fec5d 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

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Deterministic PR hygiene checks passed.

@github-actions github-actions Bot added the bug Something isn't working label Sep 9, 2026
@coderabbitai

coderabbitai Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Advanced

Run ID: d1a710da-5468-4b2e-9c15-2f049fe669cb

📥 Commits

Reviewing files that changed from the base of the PR and between 79fec5d and 421aea8.

📒 Files selected for processing (1)
  • tests/responses/responses-shadow-intercept.test.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 5 remain after this review.


📝 Walkthrough

Walkthrough

Shadow-call interception now rewrites eligible source models before combo detection. Configured combo targets enter handleComboResponses and use failover routing. Tests cover 429 retry behavior, source-target overlap, and non-combo interception.

Changes

Shadow combo interception

Layer / File(s) Summary
Pre-dispatch combo rewrite
src/server/responses/core.ts
At lines 3306-3329, handleResponsesInner rewrites eligible shadow source models to a configured combo before comboIdFromRawBody runs. It records shadowCallRewrittenFrom.
Combo routing and interception tests
tests/responses/responses-shadow-intercept.test.ts
Helpers configure combo failover responses. Tests verify retry after a 429, source-target overlap, combo metadata, upstream attempts, and ordinary late interception for non-combo replacements.

Priority: ➖ Normal

Estimated code review effort: 3 (Moderate) | ~20 minutes

Severity of issue fixed: Medium

Merge Risk: ⚪ Minimal · up to 421ae

Configured combo shadow replacements now retain combo failover behavior after 429 or server failures, including source-overlap cases, while ordinary non-combo interception behavior remains unchanged.

Sequence Diagram(s)

sequenceDiagram
  participant HelperRequest
  participant handleResponsesInner
  participant handleComboResponses
  participant UpstreamProviders
  HelperRequest->>handleResponsesInner: Submit shadow source model
  handleResponsesInner->>handleResponsesInner: Rewrite model to configured combo
  handleResponsesInner->>handleComboResponses: Dispatch combo
  handleComboResponses->>UpstreamProviders: Try targets in failover order
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 3 functions across 2 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the primary change: allowing a combo shadow-call target to enter the failover loop. It is concise and specific.
Linked Issues check ✅ Passed The changes address issue #4129. In src/server/responses/core.ts, the shadow model rewrite occurs before combo detection, so configured combo targets enter handleComboResponses and its failover loop. …
Out of Scope Changes check ✅ Passed The changes are limited to the shadow-call combo dispatch fix and its regression tests. Both modified files directly support issue #4129. No unrelated product behavior or unrelated public entities are…
  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch lane-a/1-4129

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.

@lidge-jun

Copy link
Copy Markdown
Owner Author

리뷰 · 우선순위 77 / 80

이 PR은 지금 dev HEAD(a7509fe00, #4155 라운드2 로드맵이 tip) 위에서 Lane A 맨 앞 칸인 #4129를 닫는다. 로드맵(devlog/_plan/260910_post249_round2/)도 Lane A 순서를 #4129 shadow combo failover → #4148 → #4141로 잠가 두었고, 이 브랜치 이름 lane-a/1-4129가 그 첫 칸이다. 버그는 운영자가 shadowCallIntercept.modelcombo/shadow처럼 콤보로 잡아도, 헬퍼 호출이 콤보 페일오버 루프에 들어가지 않고 첫 타깃 한 번만 치고 끝나는 것이다. 로그에는 routeKind: "combo"인데 attempt가 하나라서, 운영자 눈에는 “콤보인데 한 방”으로 보인다.

원인은 둘이 같이 맞물린다. 첫째, 콤보 게이트(comboIdFromRawBodyhandleComboResponses)는 아직 고쳐지기 전 본문의 body.model만 본다. 그 시점의 모델은 여전히 헬퍼 slug(gpt-5.6-luna 같은 값)라서 콤보 id가 안 나오고, while (pick) / advanceComboAfterFailure / comboFailureDecision의 429·5xx 홉이 아예 안 돈다. 둘째, 늦은 intercept 자리(파싱 뒤)는 resolveRoute("combo/shadow")routeModeltryPickComboModel로 가서 콤보 테이블을 한 타깃으로 접으면서도 routeKind: "combo" 태그만 남긴다. 그래서 “콤보처럼 보이는데 네이티브 한 방”이 된다. 접힌 첫 픽이 소스와 같은 openai/gpt-5.6-lunashouldInterceptShadowCall의 intersect 때문에 intercept 자체가 스킵되고, 마커도 없이 그냥 native로 나간다. 블록 순서만 바꿔서는 이 둘째 길이 안 막힌다.

고침은 콤보 게이트 에서, 설정만 보고 콤보인지 판별한 뒤 selector를 먼저 고쳐 쓰는 것이다. resolveComboId(config, shadowIntercept.model)은 라우팅을 하지 않는 순수 설정 lookup이라 테이블을 접지 않는다. Object.hasOwn(config.combos, id)로 실제 등록된 콤보만 받고, 마커는 늦은 자리와 같이 shadowSourceModelPrefix + sanitizeLogMetadataString으로 운영자 prefix만 남긴다. 직접 같은 프로바이더 교체를 막는 shouldInterceptShadowCall(#2706)은 그대로 두고, 늦은 자리에서 handleComboResponses를 다시 타지 않아서 expandPreviousResponseInput / onRequestBodyRead 이중 실행도 없다. 테스트 세 칸(429 홉 / 첫 타깃 self-intersect여도 combo 유지 / non-combo는 늦은 intercept)이 tests/responses/responses-shadow-intercept.test.ts에 붙었고 layout 등록은 이미 있다. 선행 #4150(콤보 hopping-413 overflow)과도 같은 페일오버 축이라, 이번 건은 “그림자 교체가 콤보일 때 그 루프에 들어가게” 입구만 연다.

라인 단위로 보면 고침 위치는 맞고, 일부러 남겨 둔 표면이 두 군데다.

src/server/responses/core.ts (early rewrite, comboIdFromRawBody 직전) - 늦은 intercept가 켜는 parsed._cursorIsolateConversation = true를 이 early 경로에서는 세우지 않는다. 콤보로 들어가면 늦은 자리를 안 타므로, 콤보 자식에 Cursor 타깃이 있을 때 부모 스레드 isolation이 빠질 수 있다. PR 본문도 후속으로 적어 두었다.

src/server/management/shadow-call-validation.ts 의 shadowCallTargetError - 대시보드 PUT이 Luna-first combo/shadow를 넣으면 예전처럼 접혀서 400이 날 수 있다. 파일 설정 경로는 이제 되는데 관리 API만 예전 동작이다. 본문이 별 PR로 떼겠다고 한 그대로다.

tests/responses/responses-shadow-intercept.test.ts - 회귀 세 칸은 의도(홉 / self-intersect / non-combo 보존)가 분명하다. 다만 이 라운드는 로컬 suite를 안 돌리기로 했으니, exact-head 원격 CI(test 1–4 / macos)가 초록인지만 게이트로 보면 된다. 지금 일부 shard는 아직 pending이다.

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

  • _cursorIsolateConversation을 콤보 자식으로 흘릴지를 이 PR에 얹을지, 본문대로 후속으로 미룰지
  • shadowCallTargetError 관리면 고침을 Lane A 안에 바로 넣을지, 파일 설정만으로 #4129를 닫고 관리 API는 따로 둘지
  • 원격 CI가 아직 도는 중인데, exact-head 초록을 기다린 뒤 머지할지(라운드 제약상 로컬 검증은 스킵)

너의 추천
머지 쪽으로 가도 된다. 입구를 콤보 게이트 앞으로 옮긴 위치가 맞고, #2706 늦은 intercept와 이중 dispatch를 건드리지 않은 것도 맞다. Cursor isolation·관리 API 400은 본문이 말한 대로 후속 이슈로 떼고, 이 PR은 #4129만 닫자. exact-head 원격 CI(새 테스트 포함)가 초록이면 dev에 올려 Lane A 다음 칸(#4148)으로 넘어가면 된다.

이 댓글은 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

Here are some automated review suggestions for this pull request.

Reviewed commit: 79fec5d6cc

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

&& isShadowSourceModel(rawShadowModel, shadowIntercept.sourceModels)) {
const shadowComboId = resolveComboId(config, shadowIntercept.model);
if (shadowComboId && Object.hasOwn(config.combos ?? {}, shadowComboId)) {
(body as Record<string, unknown>).model = shadowIntercept.model;

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 Preserve Cursor isolation for combo shadow calls

When this rewrite dispatches a shadow call to a combo whose selected target uses the Cursor adapter, it returns through handleComboResponses before the late interceptor can set parsed._cursorIsolateConversation = true. Combo children preserve x-codex-parent-thread-id, so Cursor derives the parent's conversation ID and may reuse or update its checkpoint, allowing title/commit helper traffic to contaminate the main conversation; this regresses the previous single-target path, which did set the isolation flag. Carry a shadow-isolation bit into every combo child and add a Cursor-target regression test; structure/04_transports-and-sidecars.md:1116-1117 explicitly requires isolated helper/shadow turns never to join parent or sibling conversations.

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

Useful? React with 👍 / 👎.

…openai provider

The first CI run proved the fix itself: the combo loop was entered and hopped
("[combo] shadow: xai/grok-4.5 failed with 429", then the second target). Both
new cases still failed, because the fixture used a provider literally named
"openai", whose endpoint is pinned to https://chatgpt.com/backend-api/codex.
The configured helper.example baseUrl was ignored with a warning and the second
target answered 401 instead of the mocked 200.

Move both cases onto ordinary key providers. The self-target case now uses the
same shape as the existing #2706 no-op test: a custom sourceModels prefix whose
resolved provider is also the combo's first target, so shadowCallTargetsIntersect
is genuinely true for the collapsed one-candidate pick. That is the condition
that used to suppress the intercept, and it is now reproduced without depending
on a reserved provider id.
@lidge-jun
lidge-jun merged commit 4498fb9 into dev Sep 9, 2026
31 checks passed
@lidge-jun
lidge-jun deleted the lane-a/1-4129 branch September 9, 2026 22:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant