Skip to content

fix(codex): refuse Reserve turns the Desktop authless opt-in cannot serve - #4968

Merged
lidge-jun merged 2 commits into
devfrom
codex/4940-reserve-opt-in-refusal
Sep 18, 2026
Merged

lidge-jun merged 2 commits into
devfrom
codex/4940-reserve-opt-in-refusal

Conversation

@lidge-jun

Copy link
Copy Markdown
Owner

Summary

Closes #4940.

When Codex Desktop exhausts the ChatGPT allowance it collapses its picker and sends gpt-reserve.
With codexDesktopAuthless unset, every Luna Reserve affordance in this proxy is inert:
isCodexReserveRequestEligible (src/codex/loopback-target.ts) requires the flag, so the catalog
projection, the customReserveForward main-credential substitution, the reserve authorization
handshake and the helper-unsupported guard all stay off. The request was therefore forwarded as an
ordinary native model and answered upstream with 429 The usage limit has been reached — an error
that names neither the real cause nor the setting the operator would have to change. The reporter
saw 429 openai-<acct>/gpt-reserve routeKind=native and had no route back to a working model.

This refuses that request locally instead of forwarding one the proxy can already prove will fail.

isCodexReserveOptInMissing sits beside isCodexReserveRequestEligible in
src/codex/loopback-target.ts, takes the same Pick<OcxConfig, …> and admission shapes, and is the
strict complement of it for the flag reason only: exact gpt-reserve, codexDesktopAuthless !== true,
runtimeRole !== "client", and a loopback admission source. Callers classify the destination as a
canonical OpenAI forward first, the same obligation isCodexReserveHelperUnsupported already carries.
Keeping the two functions adjacent is deliberate: flipping the flag always converts a true here into
a true there, which is what makes it honest for the refusal to name that one setting, and a test
walks the shared input space to prove they can never both hold.

Seam. src/server/responses/request-prepare.ts, immediately beside the existing Reserve helper
refusal, and src/server/responses/compact.ts, immediately after route resolution. Both run after
alias and combo resolution but before auth, host-circuit admission, the virtual-model rewrite and any
upstream byte. input-admission.ts was considered and rejected: despite the name it is a
context-window gate whose provider-canonicality checks at lines ~171 and ~250 compose native context
caps, not admission facts, and it never sees admission. Compact repeats the check rather than
inheriting it because its native branch dispatches straight to /responses/compact; only the routed
fallback replays through handleResponses. The compact call composes the same three facts in the same
order as customReserveForward a few dozen lines below it.

Error shape. A direct formatErrorResponse(400, "invalid_request_error", …) at each seam.
CodexReserveUnavailableError was rejected on inspection: it extends CodexAccountCooldownError, and
cooldownErrorResponse renders that base as 429 rate_limit_error. It suppresses Retry-After for
this subclass and cooldownErrorMessage returns the subclass message verbatim, so the wording would
have survived — but the status and type would restate the exact upstream verdict this refusal exists
to replace, and shouldMarkAccountNeedsReauthForCodexAuthFailure already has to special-case it. A
400 cannot be mistaken for a rate limit and inherits no retry semantics.

Message. Names codexDesktopAuthless, gives ocx system settings --desktop-authless on, states
plainly that Luna Reserve is not forwarded without the opt-in, and offers choosing another model.
It contains no account identifier, no token and no request body; the test asserts all three absences.

Two narrowings beyond the five conditions, both about not giving advice that does not hold, and
both of which also keep every existing Reserve contract in the suite intact.

  • Terminal vision/search helpers are excluded. Enabling the opt-in would not make a helper work — it
    would produce the existing CODEX_RESERVE_HELPER_UNSUPPORTED_MESSAGE refusal instead — so telling
    that caller to enable the flag would be actionable and wrong. The helper guard keeps owning them.
  • Non-native inbound wires are excluded. A gpt-reserve selector arriving over Chat or Anthropic
    Messages is an operator-authored route such as a claudeCode.modelMap entry, not a Codex client
    forced onto Reserve by its own usage snapshot, and it keeps the behaviour it has today.

Without those two qualifiers the change would have broken four existing controls that deliberately
pin "opt-in off, Reserve still dispatches": the still-off and off-to-on during owned auth cases in
tests/server/reserve-ingress.test.ts (terminal helpers) and both variants in
tests/server/reserve-claude-policy.test.ts (Anthropic inbound wire). They are unchanged here.

Not addressed, deliberately. The Desktop-side picker collapse is client behaviour the proxy does
not control: the app derives reserve mode from its own backend-api/wham/usage poll and rewrites the
conversation model itself, consulting no catalog we produce. Emitting a bare gpt-reserve catalog row
outside authless is #3844, closed NOT PLANNED. An independently credentialed Reserve route is #4869.
This PR only replaces an unhelpful upstream 429 with a local refusal that names the missing opt-in.

Verification

Local verification was not run: this lane forbids running the local suite, typecheck, build, install,
or the ocx binary. Hosted CI is the executable verification for this change.

Regression coverage extends tests/codex-integration/reserve-dispatch.test.ts, which already drives
both handleResponses and handleResponsesCompact against a counted fetch fixture, so no new file and
no scripts/test-layout/layout.json bookkeeping. It asserts both sides:

  • gpt-reserve, flag missing, loopback admission, canonical forward — refused on both the responses
    and compact seams with 400 invalid_request_error, no Retry-After, the opt-in message, no
    account id/token/body in the text, zero upstream sends, zero WHAM reads, and untouched account and
    upstream-host health.
  • gpt-reserve with runtimeRole: "client" — still forwards.
  • gpt-reserve with a non-loopback (dedicated) admission source — still forwards.
  • gpt-reserve on a non-canonical aliased provider route — still forwards.
  • An ordinary model on the same ingress — untouched.
  • The predicate matrix, including a sweep proving the two predicates never both hold.

Static review of every other gpt-reserve test in the tree found no behaviour change: the public
listener cases carry non-loopback admission, the local listener cases set the flag, the keyed and
combo cases are non-canonical, and the helper and Anthropic cases are excluded by the narrowings above.

Checklist

  • Scope stays focused and avoids unrelated cleanup.
  • Docs or release notes were updated when needed.
  • Security-sensitive changes were reviewed for secrets, auth, and unsafe defaults.

Docs: docs-site/src/content/docs/guides/codex-integration.md gains the behaviour note in the
existing "Routed models during Codex reserve mode" section. Structure:
structure/providers/openai-tiers.md records the predicate and error-shape contract next to the
Reserve compatibility contract it complements, and structure/transports/responses.md records the two
seams and their qualifiers. No GUI change, so no screenshot applies.

The refusal runs before authentication and emits no credential material; it strictly reduces what
reaches the upstream and changes no auth, OAuth, workflow or release path.

…erve

With codexDesktopAuthless unset, every Luna Reserve affordance is inert, so a
gpt-reserve request was forwarded as an ordinary native model and answered
upstream with 'The usage limit has been reached' -- an error naming neither the
cause nor the setting that would change it.

Add isCodexReserveOptInMissing beside isCodexReserveRequestEligible as its strict
complement for the flag reason only, and apply it at the ordinary Responses and
compact seams before auth, host-circuit admission or any upstream byte. The
refusal is a direct 400 invalid_request_error naming codexDesktopAuthless and the
command that sets it; CodexReserveUnavailableError is unsuitable because its
CodexAccountCooldownError base renders as a 429 rate_limit_error.

Terminal helpers and non-native inbound wires are excluded: enabling the opt-in
would not make a helper work, and a gpt-reserve selector on the Chat or Anthropic
wire is an operator-authored route.

Closes #4940
@lidge-jun
lidge-jun requested a review from Ingwannu as a code owner September 17, 2026 22:55
@coderabbitai

coderabbitai Bot commented Sep 17, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

Next included review available in 42 seconds.

Check out review usage here.

View limit details

Limit details: You’ve used all 10 included reviews currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Advanced

Run ID: cf4e221f-abf0-4a3d-81be-2ed82214e3ff

📥 Commits

Reviewing files that changed from the base of the PR and between 61ee647 and 8ca1156.

📒 Files selected for processing (7)
  • docs-site/src/content/docs/guides/codex-integration.md
  • src/codex/loopback-target.ts
  • src/server/responses/compact.ts
  • src/server/responses/request-prepare.ts
  • structure/providers/openai-tiers.md
  • structure/transports/responses.md
  • tests/codex-integration/reserve-dispatch.test.ts

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.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 17, 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-17T22:58:44.647222Z 0eba5c6 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.

@lidge-jun

Copy link
Copy Markdown
Owner Author

리뷰 · 우선순위 76 / 80

이 PR은 지금 dev 끝(61ee647, #4948 cold-status setup 측정) 위에, Codex Desktop이 ChatGPT 한도를 다 써서 피커를 접고 gpt-reserve만 보낼 때 생기는 나쁜 실패를 고칩니다. 지금 dev에서는 codexDesktopAuthless가 꺼져 있으면 Luna Reserve 관련 기능이 전부 잠깁니다. 카탈로그 행, 메인 자격증명 바꿔치기, 예약 권한 핸드셰이크, 헬퍼 가드까지 모두 꺼진 상태인데도 gpt-reserve 요청은 그냥 일반 네이티브 모델처럼 위로 넘어가고, 위쪽은 429 The usage limit has been reached만 돌려줍니다. 로그에는 routeKind=native만 보이고, 운영자가 켜야 할 설정 이름도 안 나옵니다. 이슈 #4940이 바로 그 상황입니다.

고치는 방법은 간단합니다. src/codex/loopback-target.ts에 isCodexReserveOptInMissing을 isCodexReserveRequestEligible 바로 옆에 두고, 플래그만 빠진 경우를 골라냅니다. 조건은 modelId === gpt-reserve, codexDesktopAuthless !== true, runtimeRole !== "client", 입학 소스가 loopback입니다. 플래그를 켜면 이 함수가 참이던 입력이 곧바로 다른 쪽 자격 함수에서도 참이 되도록 맞춰 두었고, 테스트가 두 함수가 동시에 참이 되지 않는지도 확인합니다. 거절 메시지는 CODEX_RESERVE_OPT_IN_REQUIRED_MESSAGE로 codexDesktopAuthless와 ocx system settings --desktop-authless on을 직접 적습니다.

거절은 두 군데에서 합니다. src/server/responses/request-prepare.ts에서는 기존 Reserve 헬퍼 거절 바로 옆에서, 별칭·콤보 해석 뒤·인증·호스트 회로·업스트림 전송 전에 400 invalid_request_error를 돌려줍니다. src/server/responses/compact.ts에서도 같은 검사를 한 번 더 합니다. 컴팩트 네이티브 분기는 /responses/compact로 바로 가고 handleResponses를 안 타기 때문에, prepare 한곳만 고치면 컴팩트 경로가 그대로 위로 넘어가기 때문입니다. CodexReserveUnavailableError는 쓰지 않았습니다. 그 타입은 쿨다운 계열이라 429 rate_limit_error로 그려지고, 이번 거절이 없애려는 바로 그 모양이기 때문입니다.

범위를 일부러 좁혔습니다. 터미널 vision/search 헬퍼는 빼 두었습니다. 옵트인을 켜도 헬퍼는 기존 CODEX_RESERVE_HELPER_UNSUPPORTED_MESSAGE로 막히므로, 그 호출자에게 플래그를 켜라고 하면 틀린 조언이 됩니다. Chat·Anthropic Messages 같은 비네이티브 인바운드 와이어의 gpt-reserve도 빼 두었습니다. 그건 Desktop이 강제하는 Reserve가 아니라 운영자가 맵에 넣은 경로라서, 오늘 동작을 유지합니다. 그 덕분에 tests/server/reserve-ingress.test.ts의 still-off / off-to-on 케이스와 tests/server/reserve-claude-policy.test.ts 변형은 깨지지 않습니다. 회귀는 tests/codex-integration/reserve-dispatch.test.ts에 responses·compact 양쪽 거절, client 역할·비루프백·별칭 경로·일반 모델은 그대로 전달되는 컨트롤, 두 술어 상호배타 행렬을 넣었습니다. 문서도 guides/codex-integration, openai-tiers, responses 구조 노트에 맞춰 적었습니다.

types.ts/config.ts 큰 분할 캠페인과 겹치지 않습니다. 닫을 중복 PR도 보이지 않습니다. Closes #4940은 이슈 문장의 일부만 다룹니다. 리포터는 피커에 라우티드 모델이 남거나, doctor가 Reserve 활성 시 숨김을 말하거나, 독립 자격증명 경로를 기대했습니다. Desktop 피커 접힘은 클라이언트가 backend-api/wham/usage로 스스로 접는 동작이고, 카탈로그에 옵트인 없이 gpt-reserve 행을 내는 일은 #3844 NOT PLANNED, 독립 자격 Reserve는 #4869입니다. 이 PR은 그 중 무의미한 업스트림 429를 로컬 거절로 바꾸는 한 조각만 합니다. 메시지가 다른 모델을 고르라고 해도, Desktop이 피커를 Reserve만 남긴 상태면 운영자는 옵트인을 켜거나 한도 리셋을 기다리는 수밖에 없습니다. 그 한계는 PR 본문에도 적혀 있습니다.

라인 - 이게 무슨 문제다

경로 Closes #4940 - 이슈 제목·기대는 피커 유지·doctor 안내·독립 자격 경로까지인데, 본 변경은 로컬 거절만 닫습니다. 닫기 라벨이 이슈 전체를 끝난 것처럼 보일 수 있습니다.
경로 CODEX_RESERVE_OPT_IN_REQUIRED_MESSAGE / Desktop UX - 거절 문구가 다른 모델을 고르라고 하지만, Reserve 활성 Desktop은 피커를 접어 gpt-reserve만 남깁니다. 프록시만으로는 피커를 복구하지 못하므로, 실질 복구 경로는 옵트인 또는 한도 리셋입니다.
경로 src/server/responses/request-prepare.ts 거절 조건 - inboundWire === "responses"와 visionDescribeTerminal !== true로 좁혔고, 헬퍼 거절이 먼저 돌아갑니다. 순서는 맞지만, 나중에 inboundWire 분류가 바뀌면 이 가드가 조용히 빠질 수 있으니 회귀 행렬을 유지해야 합니다.
경로 src/server/responses/compact.ts - selectedModelId로 가상 모델 리라이트 전에 검사하는 배치는 맞습니다. compact에는 헬퍼·비네이티브 한정자가 없는데, 엔드포인트 성격상 타당합니다. 다만 prepare와 조건 집합이 어긋나면 한쪽만 거절하는 드리프트가 납니다.
심볼 isCodexReserveOptInMissing - 호출자가 isCanonicalOpenAiForwardProvider를 먼저 확인해야 한다는 의무는 헬퍼 거절과 같습니다. 새 호출 지점이 그 순서를 잊으면 별칭 Reserve 경로까지 거절할 위험이 있습니다. 테스트의 alias 컨트롤이 그 계약을 붙잡고 있습니다.

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

  • #4940을 이 PR만으로 Closes 할지, 아니면 Refs로 두고 피커/doctor/[Feature] Explicit per-thread external-provider routing during Codex Desktop Luna Reserve #4869 조각을 이슈에 남길지.
  • Desktop이 피커를 접은 뒤에도 거절 메시지의 다른 모델 선택 문장을 그대로 둘지, 옵트인·한도 리셋만 강조하는 문장으로 줄일지.
  • 로컬 스위트 미실행은 이 레인 규칙과 맞습니다. 호스티드 CI의 reserve-dispatch·관련 Reserve 스위트가 초록인지 보고 머지할지.

너의 추천

호스티드 CI가 초록이면 머지해도 됩니다. 술어·시임·400 형태·헬퍼/와이어 한정·회귀 행렬이 dev 위 Reserve 계약과 잘 맞습니다. #4940을 완전 종료로 두기 부담되면 머지 전에 이슈 본문에 피커 접힘·독립 자격(#4869)은 별도라고 한 줄을 남기거나, 닫기 후 follow-up 이슈를 열어 두세요. types/config 분할에 무효화되지 않으니 닫지 말고 랜딩하면 됩니다.

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

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

&& options.visionDescribeTerminal !== true
&& isCanonicalOpenAiForwardProvider(route.provider)
&& isCodexReserveOptInMissing(options.codexAuthPolicy ?? config, route.modelId, options.admission)) {
return formatErrorResponse(400, "invalid_request_error", CODEX_RESERVE_OPT_IN_REQUIRED_MESSAGE);

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 combo fallback for Reserve opt-in refusals

When a Responses combo selects canonical gpt-reserve as its first target while codexDesktopAuthless is off, this new 400 is consumed by core-combo.ts, where comboFailureDecision treats invalid_request_error as terminal, so the combo never tries its remaining declared targets; previously the upstream 429 advanced the combo. The compact path has the same regression and can return before its routed combo fallback. Give this local refusal a target-local code that the combo classifier can hop on, and let compact routes with route.combo reach the combo dispatcher, while retaining the direct-request 400.

Useful? React with 👍 / 👎.

@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 17, 2026
// `gpt-reserve` selector reaching us over Chat or Anthropic Messages is an operator-authored
// route (a `claudeCode.modelMap` entry, say), not a Codex client that was forced onto Reserve by
// its own usage snapshot, and that route keeps whatever behavior it has today.
if (inboundWire === "responses"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This refusal has no structured code, so comboFailureDecision reads it as a plain invalid_request_error and returns stop (src/combos/failover.ts:703; policy-fallback.ts:89 uses the same decision). A failover combo or policy ladder with the canonical gpt-reserve as a target now ends the chain here, where the upstream 429 this replaces hops to the next target. Same trap as #1524 and #4903. Either skip the refusal when options.comboAttempt is set, or give it its own code plus a hop rule, and pin it with a combo case in the new test.

…shape

The alias provider fixture used "sk-reserve-optin-fixture", whose 24-character
body matches the scanner's sk-[A-Za-z0-9_-]{20,} token-looking pattern, so
bun run privacy:scan failed the gates job and the shard-4 batch that runs it.

Shortened to the same shape the existing reserve fixtures already use. The key
is inert either way; only its length mattered to the scan.
@lidge-jun

Copy link
Copy Markdown
Owner Author

Merging with macOS legs outstanding, and recording why rather than leaving it implicit.

At this exact head the full Linux suite (test 1/4 through 4/4), gates, storage policy, enforce-target, the docs build, and the keyring and npm-global smokes are green. The macOS legs are queued behind a saturated hosted-runner pool shared by several concurrent lanes, and the sharded macOS legs are separately known to go silent mid-suite and be cancelled at their job budget — a long-standing defect recorded with six occurrences in #4956, including two from the 2.58.0 round that were previously written off as capacity.

This change is platform-neutral, so waiting on a queue that is both saturated and known-unreliable would delay the work without adding information. The evidence that governs the release is not per-PR macOS legs; it is the full-platform lane=all dispatch at the frozen release candidate, which is held until #4956 has a named cause. Nothing is promoted on the strength of this merge.

Stating the boundary plainly: this is merged on Linux, gates and cross-platform smoke evidence at its exact head, with macOS coverage deferred to the candidate run rather than claimed here.

@lidge-jun
lidge-jun merged commit 277291f into dev Sep 18, 2026
26 of 27 checks passed
@lidge-jun
lidge-jun deleted the codex/4940-reserve-opt-in-refusal branch September 18, 2026 00:30
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.

2 participants