Repository navigation
[Bug]: shadowCallIntercept targeting a combo does not trigger handleComboResponses failover loop #4129
Description
Summary
When shadow call intercept is enabled (shadowCallIntercept.enabled: true) and targets a combo (e.g. shadowCallIntercept.model: "combo/shadow"), background helper calls such as gpt-5.6-luna (used by Codex Desktop for thread title generation, summarization, etc.) are rewritten, but only execute a single attempt against the combo's first candidate.
If the primary candidate fails with HTTP 429 (rate limit / quota exhausted) or HTTP 5xx, the proxy immediately returns the error to the client with a single attempt recorded, completely skipping the failover loop to subsequent combo candidates.
Root Cause
In src/server/responses/core.ts, the request routing and dispatch pipeline executes in the wrong order relative to combo handling:
- Combo dispatch gate is early (lines ~3173-3175):
const comboId = !options.comboAttempt ? comboIdFromRawBody(body, config) : null;
if (comboId && Object.hasOwn(config.combos ?? {}, comboId)) {
options.onRequestBodyRead?.();
return handleComboResponses(req, body, comboId, config, logCtx, ...);
}At this point, the incoming helper request has body.model = "gpt-5.6-luna", so comboIdFromRawBody returns null. The request skips handleComboResponses (which hosts the while (pick) loop and failover / advanceComboAfterFailure logic).
- Shadow call intercept runs later (lines ~3345-3375):
const _sci = config.shadowCallIntercept;
if (_sci?.enabled && _sci.model && isShadowSourceModel(parsed.modelId, _sci.sourceModels)) {
...
const targetRoute = resolveRoute(_sci.model);
if (shouldInterceptShadowCall(parsed.modelId, _sci.sourceModels, sourceIdentity, targetRoute)) {
parsed.modelId = _sci.model;
logCtx.shadowCallRewrittenFrom = sanitizeLogMetadataString(
shadowSourceModelPrefix(_sciOriginal, _sci.sourceModels),
);
shadowRoute = targetRoute;
}
}
route = shadowRoute ?? resolveRoute(parsed.modelId);Here, resolveRoute("combo/shadow") calls tryPickComboModel, which selects only a single concrete candidate (e.g. sensenova/deepseek-v4-flash) and returns a route tagged routeKind: "combo".
- Single attempt executed without retry loop:
The request proceeds down the normal dispatch path and sends a single request to the selected upstream provider. When upstream returns 429, because the execution is not insidehandleComboResponses, there is no outer loop to catch the failure and advance to the next target. The 429 error is directly returned to the client.
Reproduction
- Configure a failover combo in
config.json:
"combos": {
"shadow": {
"strategy": "failover",
"targets": [
{ "provider": "provider-a", "model": "model-a", "weight": 1 },
{ "provider": "provider-b", "model": "model-b", "weight": 1 }
]
}
}- Configure shadow call interception to target the combo:
"shadowCallIntercept": {
"enabled": true,
"model": "combo/shadow",
"sourceModels": ["gpt-5.6-luna"]
}- Trigger a rate limit / 429 on
provider-a/model-a. - Open a new thread in Codex Desktop and send a message (triggering
gpt-5.6-lunatitle generation in background). - Inspect request in
/api/request-history/<id>:routeDecision.routeKindis"combo"attemptscontains only 1 attempt- Response is 429,
provider-b/model-bis never tried.
Suggested Fix
Move the shadow call model rewrite before the combo dispatch check (comboIdFromRawBody at line 3173) so that when parsed.modelId is rewritten to combo/..., it is recognized by comboIdFromRawBody and routed into handleComboResponses, enabling the full multi-target failover loop.
Activity
lidge-jun commentedon Sep 9, 2026
리뷰 · 우선순위 72 / 80
보고 내용의 핵심은 이렇다.
shadowCallIntercept가 켜져 있고 목표가combo/…일 때, Codex Desktop이 보내는gpt-5.6-luna같은 헬퍼 호출은 가로채지지만 콤보의 첫 후보 한 번만 시도한다. 그 후보가 429/5xx여도handleComboResponses의while (pick)/advanceComboAfterFailure로 넘어가지 않고 바로 에러를 돌려준다. 로그에는routeKind: "combo"인데attempts가 1개뿐인 이상한 모습이 된다.현재
devHEAD3b4d8c439의src/server/responses/core.ts순서를 직접 보면 주장이 맞다. 대략 3279–3287에서comboIdFromRawBody(body, config)로 콤보 게이트를 먼저 보고, 콤보면 바로handleComboResponses로 들어간다. 이 시점의body.model은 아직gpt-5.6-luna라서 콤보가 아니다. shadow 재작성은 그 훨씬 뒤, parse 이후 약 3453–3484에서parsed.modelId를_sci.model(예:combo/shadow)로 바꾼 뒤resolveRoute한다.resolveRoute("combo/shadow")는 콤보에서 후보 하나만 고른 일반 라우트로 떨어지고, 바깥 페일오버 루프가 없다. 그래서 단일 시도로 끝난다.관련 오픈 PR을 찾아 봤다.
shadowCallIntercept+ combo failover를 고치는 열린 PR은 없다(과거 shadow 관련 PR들은 다른 주제였고 이미 머지/닫힘). 재현 설정(failover combo +shadowCallIntercept.model: "combo/…"+ 1차 후보 429)도 코드 경로와 일치한다. 계정 풀·할당량 상황에서 “헬퍼만 죽어 제목이 안 생기고 본문은 살아 있는” UX로 드러나기 쉬워account-pool라벨과도 맞다.수정 방향은 이슈에 적힌 대로 shadow 모델 재작성을 콤보 게이트보다 앞으로 옮기거나, shadow 목표가
combo/…이면 body.model을 먼저combo/…로 고친 뒤comboIdFromRawBody가 잡게 하는 것이다. 다만 shadow는 지금 parse된parsed.modelId와 source identity 검사(shouldInterceptShadowCall)에 의존하므로, body만 일찍 바꾸면 source 판정·로그 필드(shadowCallRewrittenFrom)·Cursor isolate 플래그와의 순서를 같이 맞춰야 한다. 단순 “두 블록 위치 swap”만으로는 테스트가 깨질 수 있다.경로/심볼 -
src/server/responses/core.ts~3279comboIdFromRawBody/handleComboResponses— 원시 body.model 기준. shadow 전이라 헬퍼 모델은 통과한다.경로/심볼 - 같은 파일 ~3453–3484
shadowCallIntercept— 이후resolveRoute(_sci.model)가 combo를 단일 후보로만 푼다.NoAvailableComboTargetsError처리는 있어도 attempt 루프는 없다.경로/심볼 - 오픈 PR — #4129를 고치는 브랜치 없음. 신규 수정 PR이 필요하다.
메인테이너의 판단이 필요한 지점
handleComboResponses로 넣을지, 일반 경로에 콤보 attempt 루프를 복제할지(전자가 중복 적음)isShadowSourceModel/ credential domain rewrite /_cursorIsolateConversation를 콤보 자식에도 동일 적용할지너의 추천
버그로 인정하고 help wanted 또는 작은 fix PR을 연다. 권장 패치는 shadow 대상이
combo/…이면 콤보 게이트 전에body.model을 그 값으로 바꾸고(또는 게이트 직후 shadow를 한 번 더 검사해handleComboResponses로 재진입), 기존handleComboResponses페일오버를 재사용하는 것이다. 당분간 운영 회피책은 shadow 목표를 combo가 아닌 단일 provider/model로 두는 것이다.이 댓글은 grok-bot이 작성했습니다
jaychou0642-create commentedon Sep 9, 2026
Additional edge case: preserve failover when the first combo target is the source Luna model
Please include the same-provider/same-model first-target case in this fix. My intended behavior is to prefer OpenAI Luna for helper calls and use the remaining combo targets only when an eligible failure occurs.
Example configuration:
{ "shadowCallIntercept": { "enabled": true, "model": "combo/shadow", "sourceModels": ["gpt-5.6-luna"] }, "combos": { "shadow": { "strategy": "failover", "targets": [ { "provider": "openai", "model": "gpt-5.6-luna", "weight": 1 }, { "provider": "google-antigravity", "model": "gemini-3.8-flash", "weight": 1 }, { "provider": "google-antigravity", "model": "claude-sonnet-4-6", "weight": 1 }, { "provider": "sensenova", "model": "deepseek-v4-flash", "weight": 1 } ] } } }In the currently installed v2.49.0 source, the shadow path resolves the combo to a concrete target before calling
shouldInterceptShadowCall. If that target isopenai/gpt-5.6-luna,shadowCallTargetsIntersectmatches the source provider/model and interception is skipped. The request then takes the native Luna route, losing the combo fallback chain entirely. A successful local helper request with this configuration recordedrouteKind: "native"and one Luna attempt; this confirms the bypass path, but is not a forced-failure test.Suggested behavior: treat a configured combo as a routing policy, not as an identity-equivalent replacement merely because its first selected target matches the source. Dispatch the helper into the existing combo loop before collapsing it to a concrete target, preserving the original shadow attribution and conversation isolation. Keep the direct same-model replacement guard where appropriate, and prevent combo child attempts from being intercepted recursively. Simply moving the current interception block earlier while retaining the first-target identity check could leave this edge case unresolved.
Suggested regression tests:
gpt-5.6-luna→combo/shadow→ first targetopenai/gpt-5.6-lunareturns a retryable 429 or 503 → second eligible target succeeds. Verify two ordered attempts and a successful final response.This would cover the desired “Luna first, backups on failure” behavior within #4129. Whether Gemini and Claude remain independently eligible after quota exhaustion is a separate cooldown-scope concern; the tests above should distinguish combo entry/failover from that issue.
test(responses): keep the #4129 combo fixture off the fixed-endpoint …
lidge-jun commentedon Sep 9, 2026
Fixed on
devby #4157, merged as4498fb910.The combo gate now sees the rewritten model. A
shadowCallInterceptwhose replacement names a combo is resolved by config lookup beforecomboIdFromRawBodyruns, sohandleComboResponsestakes the request and itswhile (pick)loop andadvanceComboAfterFailurebehave normally — a 429 or 5xx from the first target hops to the next one instead of coming back to the caller.That also closes the second path in this report: because the combo is no longer collapsed to a single pick before the identity check, a combo whose first target happens to be the helper model itself no longer skips interception and fall through to a native route with no
shadowCallRewrittenFrommarker.Evidence: exact-head CI at
421aea87afc9e9fd356149d3944d107d40bb8b13concluded success —gh run view 34413236789 --exit-statusreturns 0 for Cross-platform CI, with React Doctor, Enforce PR target branch and PR hygiene also 0. Twenty-eight checks succeeded at that SHA and none were cancelled.git merge-base --is-ancestor 421aea87a origin/devpasses.Closing by hand because pull requests here target
devrather than the default branch, so GitHub does not auto-close a linked issue.Plan and root-cause record:
devlog/_plan/260910_post249_round2/020_4129_shadow_combo_failover.md.fix(responses): let a combo shadow-call target enter the failover loop
docs(devlog): record the round-2 landings and the two blockers
docs(devlog): close the round-2 record at the screenshot gate
fix(responses): let a combo shadow-call target enter the failover loop
test(responses): keep the lidge-jun#4129 combo fixture off the fixed-…
docs(devlog): record the round-2 landings and the two blockers
docs(devlog): close the round-2 record at the screenshot gate