Skip to content

[Bug]: shadowCallIntercept targeting a combo does not trigger handleComboResponses failover loop #4129

Closed

Description

@jaychou0642-create

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:

  1. 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).

  1. 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".

  1. 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 inside handleComboResponses, 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

  1. 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 }
    ]
  }
}
  1. Configure shadow call interception to target the combo:
"shadowCallIntercept": {
  "enabled": true,
  "model": "combo/shadow",
  "sourceModels": ["gpt-5.6-luna"]
}
  1. Trigger a rate limit / 429 on provider-a/model-a.
  2. Open a new thread in Codex Desktop and send a message (triggering gpt-5.6-luna title generation in background).
  3. Inspect request in /api/request-history/<id>:
    • routeDecision.routeKind is "combo"
    • attempts contains only 1 attempt
    • Response is 429, provider-b/model-b is 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

  1. added
    bugSomething isn't working
    account-poolOAuth, credentials, Codex pool, quota, failover, plans
    on Sep 9, 2026
  2. lidge-jun commented on Sep 9, 2026

    @lidge-jun
    Owner

    리뷰 · 우선순위 72 / 80

    보고 내용의 핵심은 이렇다. shadowCallIntercept가 켜져 있고 목표가 combo/…일 때, Codex Desktop이 보내는 gpt-5.6-luna 같은 헬퍼 호출은 가로채지지만 콤보의 첫 후보 한 번만 시도한다. 그 후보가 429/5xx여도 handleComboResponses의 while (pick) / advanceComboAfterFailure로 넘어가지 않고 바로 에러를 돌려준다. 로그에는 routeKind: "combo"인데 attempts가 1개뿐인 이상한 모습이 된다.

    현재 dev HEAD 3b4d8c439의 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 ~3279 comboIdFromRawBody / handleComboResponses — 원시 body.model 기준. shadow 전이라 헬퍼 모델은 통과한다.

    경로/심볼 - 같은 파일 ~3453–3484 shadowCallIntercept — 이후 resolveRoute(_sci.model)가 combo를 단일 후보로만 푼다. NoAvailableComboTargetsError 처리는 있어도 attempt 루프는 없다.

    경로/심볼 - 오픈 PR — #4129를 고치는 브랜치 없음. 신규 수정 PR이 필요하다.

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

    • shadow→combo를 handleComboResponses로 넣을지, 일반 경로에 콤보 attempt 루프를 복제할지(전자가 중복 적음)
    • body 조기 재작성 시 isShadowSourceModel / credential domain rewrite / _cursorIsolateConversation를 콤보 자식에도 동일 적용할지
    • 회귀 테스트: shadow→combo failover 2회 attempt, non-combo shadow는 기존 단일 경로 유지

    너의 추천
    버그로 인정하고 help wanted 또는 작은 fix PR을 연다. 권장 패치는 shadow 대상이 combo/…이면 콤보 게이트 전에 body.model을 그 값으로 바꾸고(또는 게이트 직후 shadow를 한 번 더 검사해 handleComboResponses로 재진입), 기존 handleComboResponses 페일오버를 재사용하는 것이다. 당분간 운영 회피책은 shadow 목표를 combo가 아닌 단일 provider/model로 두는 것이다.

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

  3. jaychou0642-create commented on Sep 9, 2026

    @jaychou0642-create
    ContributorAuthor

    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 is openai/gpt-5.6-luna, shadowCallTargetsIntersect matches 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 recorded routeKind: "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:

    1. Incoming bare gpt-5.6-luna → combo/shadow → first target openai/gpt-5.6-luna returns a retryable 429 or 503 → second eligible target succeeds. Verify two ordered attempts and a successful final response.
    2. The same setup with Luna succeeding → exactly one attempt; no backup is called.
    3. Multiple retryable failures → advance through eligible targets in configured order under the existing cooldown/retry policy.
    4. Preserve direct non-combo same-model behavior and ensure no recursive shadow interception or unintended reasoning-effort changes.

    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.

  4. added a commit that references this issue on Sep 9, 2026
  5. lidge-jun commented on Sep 9, 2026

    @lidge-jun
    Owner

    Fixed on dev by #4157, merged as 4498fb910.

    The combo gate now sees the rewritten model. A shadowCallIntercept whose replacement names a combo is resolved by config lookup before comboIdFromRawBody runs, so handleComboResponses takes the request and its while (pick) loop and advanceComboAfterFailure behave 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 shadowCallRewrittenFrom marker.

    Evidence: exact-head CI at 421aea87afc9e9fd356149d3944d107d40bb8b13 concluded success — gh run view 34413236789 --exit-status returns 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/dev passes.

    Closing by hand because pull requests here target dev rather 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.

  6. added 3 commits that reference this issue on Sep 9, 2026
    79fec5d
    8d751bb
    7945635
  7. added 4 commits that reference this issue on Sep 17, 2026
    d6d88c9
    4655a38
    40b26ed
    a9f3601
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    account-poolOAuth, credentials, Codex pool, quota, failover, plansbugSomething isn't working

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions