Skip to content

feat(responses): opt-in ceiling for oversized outbound passthrough bodies - #3196

Merged
lidge-jun merged 2 commits into
devfrom
codex/outbound-body-guard-2511
Sep 1, 2026
Merged

feat(responses): opt-in ceiling for oversized outbound passthrough bodies#3196
lidge-jun merged 2 commits into
devfrom
codex/outbound-body-guard-2511

Conversation

@lidge-jun

Copy link
Copy Markdown
Owner

Summary

Reimplements #3142 (thanks @olddonkey) with the guard off by default, and adds the rebuild site and refusal shape that version was missing. Adds an opt-in maxUpstreamBodyBytes ceiling for serialized native Responses passthrough bodies.

The byte measurement, local refusal, image diagnostics, body-observation release and probe-lease handling in #3142 are that PR's work and are kept as-is.

Why default-off rather than a 15 MiB default

#3142 ships config.maxUpstreamBodyBytes ?? 15 MiB, which changes behavior for users who configure nothing. The evidence does not support any implicit ceiling:

So a 15 MiB default refuses turns that succeed today. That holds on canonical ChatGPT too, which is why this goes further than the review's requested canonical-only default — and it applies to Azure and custom Responses gateways, whose limits were never measured at all.

With this PR an unset proxy measures nothing and sends exactly what it sends today. An operator who has hit a real wall sets one integer and gets an actionable local error instead of an opaque upstream failure.

This also restores the shape #2511 asked for (opt-in, default-off), so the two stop contradicting each other.

Two fixes beyond #3142

The 401 replay was unguarded. The stored/main pool 401 replay rebuilds its body and sent it unchecked — a replay is precisely when a grown payload reappears. It is now guarded like every other build site. The guard runs at all five: initial build, rebuildAndRefetch, OAuth-refresh rebuild, alternate-account retry, and the 401 pool replay.

A streaming refusal must be terminal. #3142 returns formatErrorResponse(413, ...). Codex treats an HTTP 413 as a retryable transport failure and resends the same oversized body — the reconnect loop this feature exists to stop. Streaming refusals now return streamingContextOverflowResponse (response.failed / context_length_exceeded, retryable: false), which is the contract the upstream-413 path already uses (#3177). Non-streaming turns keep the JSON 413, where it is the right shape.

RequestLogContext gains a proxy-owned errorCode so a locally refused request is named as such rather than classified from a status with no upstream message behind it.

Verification

bun x tsc --noEmit                                    # exit 0
bun test tests/outbound-body-guard.test.ts \
         tests/empty-completion-core.test.ts \
         tests/request-log.test.ts
# 84 pass, 0 fail, exit 0

bun run privacy:scan reports one pre-existing hit in devlog/_plan/260902_multiplatform_qa_and_gui/091_wp6_merge_outcome.md (from 3b9cddf00, unrelated to this branch).

Activation-grounded regressions:

  • an unconfigured limit sends a 20 MiB passthrough body upstream — the regression proof for the default decision; asserts the fetch actually happened
  • an oversized body with a configured limit is refused with zero upstream fetches and exactly one observation release
  • a streaming refusal emits response.failed / context_length_exceeded, not a retryable 413
  • an explicit 0 admits without measuring; a normal body still reaches upstream
  • UTF-8 byte counting (not code units), unparseable bodies, images nested in function_call_output, singular/plural message forms

Checklist

Scope

Does not close #2511. That issue asks for per-provider image downscaling and oldest-first pruning — active remediation that mutates request content. This is refusal only. It should make #2511 easier: the measurement helper, the image walk and the rebuild-site inventory are the parts that issue would need, and the key names do not collide.

@lidge-jun
lidge-jun requested a review from Ingwannu as a code owner September 1, 2026 18:01
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 1, 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-01T18:06:45.967639Z ec162d9 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 1, 2026

Copy link
Copy Markdown
Contributor

Deterministic PR hygiene checks passed.

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

@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: ec162d99b0

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

Comment on lines +3940 to +3944
if (clientRequestedStream) {
return streamingContextOverflowResponse(
parsed._responseModelId ?? parsed.modelId,
translatorBudget,
);

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 Avoid re-refusing the compaction turn this response triggers

When a canonical OpenAI forward request exceeds the configured ceiling, this context_length_exceeded response directs Codex into compaction, but canonical compaction remains on this passthrough path and carries the full input plus a compaction_trigger. The resulting compaction request therefore exceeds the same unconditional byte check and is refused again instead of shrinking the conversation. Handle _compactionRequest specially—either allowing a viable compaction path or returning a terminal refusal that does not instruct Codex to compact—and add focused canonical-compaction coverage.

AGENTS.md reference: AGENTS.md:L339-L342

Useful? React with 👍 / 👎.

@lidge-jun

Copy link
Copy Markdown
Owner Author

리뷰 · 우선순위 67 / 80

현재 dev HEAD f3bcc67a7 (패키지 2.40.0)에는 이미 #3177이 있습니다. 스트리밍 Responses에서 업스트림이 413을 주면 src/server/responses/context-overflow.tsstreamingContextOverflowResponse가 SSE response.failed / context_length_exceeded / retryable: false를 보냅니다. Codex는 HTTP 413을 다시 보내는 전송 실패로 보고, 이 SSE 모양만 끝으로 봅니다. HEAD의 패스스루 413 갈래는 src/server/responses/core.ts에서 그 함수를 이미 부릅니다. HEAD에는 maxUpstreamBodyBytes와 outbound-body-guard가 없습니다. 이 PR이 그 가드를 새로 넣습니다.

이 PR은 만든 네이티브 Responses 패스스루 본문이 운영자가 정한 바이트보다 크면, 업스트림에 보내기 전에 프록시가 거절합니다. 키를 안 쓰거나 0이면 측정조차 하지 않고 지금과 똑같이 보냅니다. 통합 테스트는 설정이 없을 때 20 MiB 본문이 실제로 fetch 된다고 확인합니다. 원래 기여자 PR #3142는 config.maxUpstreamBodyBytes ?? 15 MiB라서, 설정을 안 한 사용자도 15 MiB 위에서 로컬 413을 받습니다. 이 저장소에서 실제로 잰 한도는 WebSocket create 프레임뿐입니다 (src/server/responses/ws-upstream.ts). 그 주석은 같은 본문이 HTTP SSE로는 성공한다고 말하고, #2473은 큰 프레임을 거절하지 않고 HTTP로 넘깁니다. #2426은 18.2 MB HTTP 200을 기록했습니다. 그래서 15 MiB 기본값은 오늘 되는 요청을 막습니다. Azure와 커스텀 Responses도 패스스루를 쓰는데, 그쪽 한도는 잰 적이 없습니다. 기본값을 끄는 편이 #2511이 원래 말한 모양과도 맞습니다.

#3142에 없던 두 가지를 고칩니다. 첫째, 저장/메인 풀 401 재전송은 본문을 다시 만들고 검사 없이 보냈습니다. HEAD core.ts의 401 갈래가 그 자리입니다. 이 PR은 다시 만든 뒤 refuseOversizedOutboundBody를 넣습니다. 둘째, #3142는 스트리밍에서도 formatErrorResponse(413, ...) JSON을 줬습니다. Codex는 그걸 재시도해서 같은 큰 본문을 다시 보냅니다. 이 PR은 스트리밍 거절을 HEAD #3177과 같은 streamingContextOverflowResponse로 바꿉니다. 스트리밍이 아닌 요청만 JSON 413 / outbound_body_too_large를 유지합니다. 요청 로그에는 RequestLogContext.errorCode를 넣어, 업스트림 메시지 없이 프록시가 거절한 일을 이름으로 남깁니다.

막는 자리는 패스스루 갈래 안에서 넷입니다. 처음 만든 본문, rebuildAndRefetch, OAuth 갱신 뒤 다시 만들기, 401 풀 재전송. 거절하면 본문 관측을 풀고, 호스트 입장과 프로브 리스를 풀고, 업스트림을 중단합니다. 번역 어댑터 길은 안 건드립니다. 타입은 src/types/config.tsmaxUpstreamBodyBytes이고, 스키마는 src/config.ts이며, src/types.tsOcxConfig를 다시 내보내기만 합니다. 두 파일을 다시 합치지 않으므로 close-don't-rebase 대상이 아닙니다. 베이스는 c87071400이고 HEAD와의 차이는 #3195 인용 마커뿐입니다. src/responses/citation-markers.ts / src/bridge.ts와는 겹치지 않습니다.

본문은 #2511을 닫지 않습니다. #2511은 제공자별 이미지 축소와 오래된 것부터 잘라 내기입니다. 이 PR은 거절만 합니다. 원래 기여자 PR은 #3142입니다. 그 PR은 이미 grok-bot 리뷰가 있고, 이 댓글은 #3142에 다시 달지 않습니다. 이 PR이 랜딩한 뒤 leftover로 닫으면 됩니다.

CI에서 test 1/4부터 4/4와 typecheck는 초록입니다. gates의 privacy:scan만 빨갛습니다. 이유는 이 PR 파일이 아니라 이미 dev에 있는 devlog/_plan/260902_multiplatform_qa_and_gui/091_wp6_merge_outcome.md 13번째 줄이 /Users/junny//Users/tig/를 예시로 다시 적었기 때문입니다. #3181이 머지된 문서입니다. 단위 테스트는 UTF-8 바이트, 설정 없음/0은 통과, 파싱 실패, function_call_output 안의 이미지, 단수/복수 문구를 밟습니다.

src/server/responses/core.ts retryCodexPoolOnAlternateAccount - 본문과 문서가 다섯 군데를 지킨다고 했지만, 이 함수는 새 본문을 만들고 바로 보냅니다. refuseOversizedOutboundBody를 부르지 않습니다. 그 헬퍼는 패스스루 갈래 안에만 있어서 여기까지 닿지 않습니다. #3142는 이 자리를 막았습니다.
docs-site/src/content/docs/reference/configuration/providers.md maxUpstreamBodyBytes - 계정 교체 재시도까지 검사한다고 적혀 있는데, 위 함수는 검사를 안 합니다. ko/ja/zh-cn/zh-tw/fr/ru/tr 사본 표에는 이 키 자체가 없습니다.
tests/empty-completion-core.test.ts - 처음 보내는 길만 검증합니다. 401 재전송, rebuildAndRefetch, 계정 교체에서 큰 본문이 다시 나가는지 밟는 테스트가 없습니다.
src/config.ts maxUpstreamBodyBytes - 잘못된 값은 catch(undefined)로 가드를 끕니다. 바로 위 circuit threshold처럼 손편집 경고는 남기지 않습니다.
devlog/_plan/260902_multiplatform_qa_and_gui/091_wp6_merge_outcome.md:13 - 이미 머지된 #3181 문서가 홈 경로를 다시 적어서, 이 PR의 gates privacy:scan이 빨간 문제입니다.

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

  • privacy:scan을 이 PR에서 같이 고칠지, 이미 머지된 docs(devlog): close the multiplatform QA and GUI unit #3181 문서를 먼저 고칠지 정해야 합니다. 지금 상태로는 필수 gates가 빨갛습니다.
  • 기본값을 끈 채로 실을지 정해야 합니다. 이 PR의 핵심 결정이고, 15 MiB 기본값으로 되돌리면 오늘 되는 요청이 다시 막힙니다.
  • #3142를 이 PR 머지 전에 닫을지, 머지한 뒤 Landed via #3196으로 닫을지 정해야 합니다. 이 PR이 #3142를 기본값 끄기와 401/스트리밍 거절 모양까지 다시 구현했습니다.
  • 계정 교체 재시도 구멍을 지금 막을지, 후속으로 둘지 정해야 합니다. 첫 검사에서 이미 통과한 같은 parsed 본문을 다시 만드는 자리입니다.
  • #2511을 이 PR과 같이 닫을지 정해야 합니다. 본문은 의도적으로 안 닫습니다. 축소/잘라 내기는 아직 없습니다.

너의 추천
닫거나 다시 만들 필요는 없습니다. types.ts/config.ts 분리 때문에 버릴 PR도 아닙니다. privacy:scan은 이 PR 파일이 아닙니다. 테스트 샤드가 초록이면 지금 형태로 머지하는 것을 추천합니다. 계정 교체 자리는 후속으로 막아도 됩니다. #3142는 이 PR이 랜딩한 뒤 Landed via #3196으로 닫으면 됩니다. #2511은 열어 둡니다.

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

jun added 2 commits September 2, 2026 03:18
…dies

Reimplements #3142 (thanks @olddonkey) with the guard off by default, and
adds the rebuild site and refusal shape that version was missing.

The measurement, local refusal, image diagnostics, body-observation release
and probe-lease handling are that PR's work and are kept.

Why default-off rather than the 15 MiB default: the only measured ceiling in
this codebase belongs to the WebSocket transport, and the comment recording it
says the same body still succeeds over HTTP SSE. #2473 acts on that by falling
back to HTTP rather than refusing, and #2426 records an 18.2 MB HTTP 200. A
15 MiB default would therefore refuse turns that work today - on canonical
ChatGPT as well as on Azure and custom Responses gateways whose limits were
never measured at all. An unset proxy now measures nothing and sends exactly
what it sends today.

Two fixes beyond the original:

- The stored/main pool 401 replay rebuilds its body and sent it unchecked. It
  is now guarded like every other build site; a replay is precisely when a
  grown payload reappears.
- A streaming refusal returns terminal response.failed / context_length_exceeded
  instead of a JSON 413. Codex treats HTTP 413 as a retryable transport error
  and resends the same oversized body, which is the loop this feature exists to
  stop. That is the contract the upstream-413 path already uses (#3177).

RequestLogContext gains a proxy-owned errorCode so a locally refused request is
named as such instead of being classified from a status with no upstream
message behind it.

Refs #3142. Related to #2511, which asks for per-provider downscaling and
pruning and is deliberately not implemented here.
@lidge-jun
lidge-jun force-pushed the codex/outbound-body-guard-2511 branch from ec162d9 to 67835c0 Compare September 1, 2026 18:19
@lidge-jun

Copy link
Copy Markdown
Owner Author

Rebased unique commit onto origin/dev after #3190/#3197. New head 67835c0. Previous gates failure was the 091 home-path citation, already fixed on dev. Local: tsc 0, privacy:scan 0, 26 pass on outbound-body-guard and empty-completion-core.

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