Skip to content

fix(combos): reserve output headroom before fallback - #4664

Closed
RHODIZSECURITY wants to merge 3 commits into
lidge-jun:devfrom
RHODIZSECURITY:fix/combo-context-headroom-20260914
Closed

RHODIZSECURITY wants to merge 3 commits into
lidge-jun:devfrom
RHODIZSECURITY:fix/combo-context-headroom-20260914

Conversation

@RHODIZSECURITY

Copy link
Copy Markdown
Contributor

Root cause

A combo can route a large Claude Code turn onto a fallback whose total context window is too small to satisfy the request plus the caller's requested output allowance. If that target starts producing output and then returns finish_reason: length, OpenCodex maps it to Anthropic stop_reason: max_tokens. Claude Code then renders its generic Claude's response exceeded the 64000 output token maximum error even though the model emitted only hundreds of output tokens.

This was reproduced on a live RHODIZ SMC session with combo/rhodiz-coding-primary. The failing Spark attempts repeatedly ended at exactly 127,559 total tokens, e.g. ~126,981 input + 578 output and ~127,102 input + 457 output. That is a ~128k total-context boundary, not a 64k generated-output event. Raising CLAUDE_CODE_MAX_OUTPUT_TOKENS would therefore only change the number in the client error and would not fix the cause.

There was a second hole: canonical gpt-5.3-codex-spark has 128k context metadata in the generated OpenAI/OpenAI-Codex bundle, but the narrower native override map did not contain Spark, so resolveInputCeiling() returned unknown on the canonical route and the existing pre-dispatch admission gate was inert for this exact model.

Fix

  • Give canonical native input admission a static generated-metadata fallback for supported slugs absent from the narrower override table. Explicit provider/model/operator caps may only narrow it.
  • Resolve the concrete target's known max output ceiling.
  • For combo children only, when the caller explicitly supplied max_output_tokens, require:
    estimated input + min(requested output, target output ceiling) <= target context window.
  • If that cannot fit, return local 413 input_admission_refused before any upstream bytes are sent. Existing combo policy already treats that local code as a safe hop, so the ladder can select a larger-context target without replaying committed output.
  • Preserve the existing loose 2.5x pathological-input gate for direct/single-target requests, unknown-context routes, compaction turns, and requests that did not specify an output allowance.

For canonical Spark, the gate now resolves 128,000 context / 32,000 max output. A ~100k input with a 64k requested output allowance is skipped before dispatch because 100k + 32k cannot fit in 128k.

Safety boundary

This does not replay after output. It prevents dispatch to a known-incompatible combo target before output can commit. Unknown capability remains fail-open, so the change does not invent limits for custom providers. Single-target semantics are unchanged.

Verification

Exact head 024286241b17b65a4b25064fd988d5223ca55a2e, based on dev@aa91958e3b050084e1edc07dcd66b05ef6eac604 when opened:

  • focused/adjacent regression block: 509 PASS / 0 FAIL / 202,187 assertions across input admission, combo E2E/classification, routing fallback, combo integration, Claude ingress/compatibility, and OpenAI chat hardening;
  • bun x tsc --noEmitPASS;
  • git diff --checkPASS.

New E2E proves the first 128k/32k target receives zero upstream hits, the larger-context second target receives exactly one hit, and the logical combo request returns 200.

The same code is locally hotfixed on the affected HomeLab OpenCodex 2.55.0; installed-source probing confirms the observed Spark-shaped request is preflight-refused with estimatedTokens=100000, ceiling=128000, requiredOutputHeadroom=32000.

No credentials, private prompt contents, or session identifiers are included here.

Review readiness checklist

  • Focused local tests are green on the exact head.
  • Branch is based on current dev at opening time.
  • Resolve all correct hosted review findings.
  • Mark ready after hosted CI/review is complete.

@coderabbitai

coderabbitai Bot commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

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.

@github-actions

Copy link
Copy Markdown
Contributor

Deterministic PR hygiene checks passed.

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

github-actions Bot commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

⏳ DRAFT

  • review readiness checklist open (2/4 boxes ticked).

What to do

  • Tick all four boxes in the PR description once you're done (currently 2/4).

Review readiness checklist

  • ✅ All CI tests are green on my local testing.
  • ✅ I pushed my PR to the latest dev commit.
  • ⬜ I resolved all correct Codex and CodeRabbit findings.
  • ⬜ My PR is ready for review.

2/4 boxes ticked.

This PR stays in draft until every box above is ticked.

Hygiene

Deterministic PR hygiene checks passed.

Copy link
Copy Markdown
Contributor Author

Post-open live validation on the affected HomeLab path (no prompt/session contents included): after restarting the hotfixed OpenCodex 2.55.0, one real combo/rhodiz-coding-primary turn with 133,442 input tokens followed this exact sequence: Anthropic/claude-opus-5 -> 429, OpenAI/gpt-5.6-sol -> 429, OpenAI/gpt-5.3-codex-spark -> local 413 in 9 ms, then TokenRouter/z-ai/glm-5.3-free -> 200 with 157 output tokens. Logical combo status was 200. This is the intended behavior: Spark's known-small context is rejected before upstream output can commit, and the larger-context fallback serves the turn. No new SMC max_output_tokens/Repeated-529/malformed-tool errors were observed in the post-restart log window.

@lidge-jun

Copy link
Copy Markdown
Owner

리뷰 · 우선순위 67 / 80

설명

이 PR은 콤보(fallback 사다리)가 너무 작은 컨텍스트 창을 가진 후보에게 이미 바이트를 보낸 뒤 finish_reason: length로 끊기는 구멍을 막습니다. 지금 dev(HEAD aa91958e3)의 직접 요청 쪽 입력 검사는 src/server/responses/input-admission.tscheckInputAdmission이고, 허용 한도는 대략 창의 2.5배(ADMISSION_TOLERANCE)까지 느슨합니다. 콤보 자식도 같은 느슨한 문을 쓰면, Claude Code가 요청한 큰 max_output_tokens(예: 64k)와 ~100k 입력이 128k 총창 모델(실측 Spark)에 들어가면, 업스트림은 출력 수백 토큰만 내고 length로 끝납니다. 그러면 클라이언트가 “출력 64000 초과”처럼 보이는 메시지를 내고, 이미 나온 텍스트 때문에 다음 후보로 안전하게 다시 돌리기도 어렵습니다. 작성자가 HomeLab에서 재현·핫픽스 검증까지 적어 둔 케이스가 바로 이것입니다.

고친 핵심은 두 갈래입니다. 첫째, resolveInputCeiling이 캐노니컬 네이티브인데도 NATIVE_OPENAI_CONTEXT_OVERRIDES에 없는 슬러그(특히 gpt-5.3-codex-spark)에서 null로 떨어져 검사가 꺼지던 문제를, 트리 안 정적 번들 src/generated/model-metadata.tsgetModelMetadata로 Narrow만 허용하는 폴백을 붙였습니다. devmetadata.ts를 보면 Spark는 오버라이드 맵에 없고, 생성 메타에는 openai 쪽 128k/32k가 있습니다. 둘째, 콤보 시도(options.comboAttempt)일 때만 checkComboTargetInputAdmission을 써서 추정 입력 + min(요청 출력, 타깃 출력 천장) <= 타깃 창을 요구하고, 안 되면 업스트림 호출 전에 로컬 413 input_admission_refused를 돌려 기존 콤보 정책이 다음 후보로 hop 하게 합니다. src/server/responses/core.ts의 compaction 면제 가드는 그대로라, 압축 턴을 이 문으로 막아 클라이언트와 교착하는 실수는 피합니다. 직접/단일 타깃·출력 한도 미지정·창 미지 경로는 예전처럼 fail-open/느슨한 문을 유지합니다.

테스트도 의도에 맞춰 있습니다. 유닛은 100k+32k 예약으로 128k 타깃 스킵, 90k는 통과, 창 미지는 통과, 출력 한도 없으면 느슨한 계약 유지, Spark 128k/32k resolve를 고정합니다. E2E는 작은 타깃 hit=0, 큰 타깃 hit=1, 논리 콤보 200을 증명합니다. 라이브 후속 댓글(Opus/Sol 429 → Spark 로컬 413 9ms → 큰 창 폴백 200)도 같은 이야기를 현장 숫자로 뒷받침합니다.

지금 dev 방향(#4546 센드/워크플로 예산, round3 직후 파사드)과 겹치지 않는 독립 콤보 품질 수정이라 types/config 스플릿 close-don't-rebase 대상이 아닙니다. core.ts 변경은 분기 몇 줄과 메시지 조립 수준이라 갓파일 캠페인을 깨는 몸통 편집도 아닙니다. 다만 PR은 아직 draft이고 readiness 2/4입니다. 또 생성 메타에서 openai Spark maxTokens는 32k인데 openai-codex Spark는 128k로 적혀 있어, 출력 천장 resolve가 어느 키를 보느냐에 따라 예약량이 달라질 여지가 있습니다(이 PR의 nativeOpenAiMaxOutputTokens는 openai 경로).

src/server/responses/input-admission.ts (resolveOutputCeiling / nativeOpenAiMaxOutputTokens) - openai-codex Spark의 생성 maxTokens(128k)와 openai Spark(32k)가 다릅니다. 지금 헬퍼는 openai 쪽이라 실측 32k와 맞지만, 메타 불일치가 남으면 나중에 다시 눈이 멀 수 있습니다.
src/server/responses/input-admission.ts (targetOutput === null일 때 requiredOutputHeadroom = requestedOutput) - 출력 천장을 모를 때 클라이언트 요청 전체(예: 64k)를 예약해 창이 알려진 후보를 더 자주 스킵합니다. 콤보에서는 안전 쪽이지만, 메타 구멍인 커스텀 프로바이더에서 과도 스킵이 날 수 있습니다.
src/server/responses/core.ts (admission 메시지) - 콤보 거절 문구에 headroom을 넣은 것은 좋지만, 클라이언트가 413을 그대로 노출하면 Claude Code UX가 “compact 하라” 대신 새 문구를 볼 수 있습니다. 콤보 hop이 가로채면 문제 없고, hop 실패로 최종 413이 남을 때의 문구만 한 번 보면 됩니다.
PR draft / readiness 2/4 - 게이트가 DRAFT라 지금 머지하면 안 됩니다. CodeRabbit도 draft라 자동 리뷰를 건너뛴 상태입니다.
src/generated/model-metadata.ts (openai-codex gpt-5.3-codex-spark) - 컨텍스트 128k는 맞지만 maxTokens 128k 표기가 openai 32k와 어긋납니다. 이 PR 범위 밖일 수 있으나, 폴백을 생성 메타에 기대기 시작했으니 정리 이슈를 남길 가치가 있습니다.

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

  • openai vs openai-codex Spark maxTokens 불일치를 이 PR에서 같이 고칠지, 별도 catalog 이슈로 뺄지
  • 출력 천장 미지일 때 요청 출력 전체를 예약하는 정책이 커스텀 폴백에 너무 공격적인지
  • draft readiness 4칸·CodeRabbit 수동 트리거를 기다린 뒤 머지할지
  • 이 수정을 2.55.x 핫픽스 백포트 후보로 볼지(작성자 HomeLab은 2.55.0)

너의 추천
방향은 맞고 우선순위도 높습니다. 지금 머지하지 말고 draft 유지한 뒤, 작성자에게 (1) readiness 4칸, (2) Spark openai/openai-codex maxTokens 불일치 인지 여부 한 줄, (3) 가능하면 CodeRabbit 수동 리뷰를 요청하세요. CI·리뷰 정리되면 머지하세요. #4546 트레인과 충돌하지 않고, 콤보가 “작은 창에 출력 커밋 후 실패”하는 실사용 구멍을 닫습니다.

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

@lidge-jun
lidge-jun force-pushed the fix/combo-context-headroom-20260914 branch from 0242862 to d823a00 Compare September 15, 2026 10:56
Keep every assertion registered under the existing suite isolation and cleanup hooks. Static source inspection only; tests not executed on this connected host.

Co-authored-by: RHODIZ IT <info.rhodiz@gmail.com>
@lidge-jun

Copy link
Copy Markdown
Owner

Landed via #4750 at 99749e1

@lidge-jun lidge-jun added the landed-via-maintainer Original PR closed after landing via a maintainer merge train label Sep 16, 2026
@lidge-jun

Copy link
Copy Markdown
Owner

Completed on dev via #4750 (99749e1). Combo input admission now reserves min(declared max_output_tokens, target output ceiling) against the shared context window before dispatch so a too-small fallback hops with 413 input_admission_refused instead of committing a length-truncated reply.

@lidge-jun lidge-jun closed this Sep 16, 2026
luvs01 pushed a commit to luvs01/opencodex that referenced this pull request Sep 16, 2026
…un#4664) [skip ci]

A combo could route a large turn onto a fallback whose total context window
cannot hold the input plus the output allowance the caller asked for. That target
answers 200, emits a few hundred tokens and stops on finish_reason: length, which
the Anthropic surface renders as "response exceeded the output token maximum"
naming a limit the model never approached. Raising CLAUDE_CODE_MAX_OUTPUT_TOKENS
only changes the number in that message. By the time it happens, output has
committed and no later target may be tried.

Admit a combo child against both budgets before dispatch. When the caller
declared max_output_tokens, require estimated input <= input ceiling AND
estimated input + min(declared output, target output ceiling) <= context window,
and refuse locally with 413 input_admission_refused before any upstream bytes are
sent. Combo policy already treats that local code as a safe hop, so the ladder
selects a larger-context target without replaying committed output.

The two budgets are checked separately on purpose. resolveInputCeiling already
answers "how much input may this target take", and modelMaxInputTokens can
tighten it below the window; charging the output reserve against that tightened
number would count the reserve twice and skip a target that fits. The window is
what input and output actually share, so the reserve belongs there.

Reserving min(declared, target ceiling) rather than a fixed slice is what makes
this catch the reported case: the common industry reservation of
min(max_output, 20k) leaves 100k + 20k inside a 128k window, so the turn is
admitted and fails upstream anyway.

Canonical native slugs that the narrower pinned table does not carry now resolve
their window from the generated in-tree bundle. That table gap is why the gate
was completely inert on the route where this was observed. The bundle is
compiled in, not a catalog read, so this adds no I/O, and explicit provider and
operator caps may only narrow the result. It deliberately covers slugs retired
from the picker, because a retired slug is still dispatchable when an operator
names it explicitly in a combo target, which is exactly that configuration.

Scope stays narrow. Direct and single-target requests keep the deliberately loose
2.5x pathological-input gate, because they have nowhere to hop. Compaction turns
stay exempt. Unknown context and a caller that declared no output allowance both
remain fail-open, so no limits are invented for custom providers.

Closes lidge-jun#4664

Co-authored-by: RHODIZ IT <info.rhodiz@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working landed-via-maintainer Original PR closed after landing via a maintainer merge train

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants