Skip to content

feat(providers): use the warm API key on the first attempt, and rank by quota - #4292

Merged
lidge-jun merged 47 commits into
devfrom
codex/key-pool-wiring
Sep 11, 2026
Merged

feat(providers): use the warm API key on the first attempt, and rank by quota#4292
lidge-jun merged 47 commits into
devfrom
codex/key-pool-wiring

Conversation

@lidge-jun

@lidge-jun lidge-jun commented Sep 11, 2026

Copy link
Copy Markdown
Owner

Summary

#4277 shipped selectProactiveApiKey and forgetApiKeyRotationCursor and deliberately stopped there: both were unit-tested and called from nowhere in production. This connects them, and nothing else.

An API key pool could only react. Rotation needed a 429 first, so a request arriving while the committed key was already cooling was spent earning a refusal the runtime could already predict. That state is not exotic — it is what an operator has after the pool rotated and a restart, a manual edit or a config reload pointed apiKey back at the spent key.

Where the call goes is the whole correctness argument. route.provider is final for a key-auth request at the transport pin in core.ts, and all four first-send consumers — the image bridge, web search, runTurn and the generic HTTP path — read that same object. One assignment placed ahead of the pin therefore serves every one of them. It has to be ahead of the pin rather than merely before the send, because adapterProvider is copied immediately after it, the adapter binds from that copy, and the HTTP path bakes its request later. The HTTP and runTurn paths could re-read a stale selection through refreshDispatchAdapter; the image bridge and web search call providerFetch(route.provider) directly and have no second chance.

Native chat is a separate entry path — chat-completions.ts routes there directly and never through the Responses core — so it gets its own call rather than inheriting one.

forgetApiKeyRotationCursor joins clearKeyCooldowns at the five routes that already reset key state. A cursor that predates an operator's choice would hand the next proactive pick straight back to whichever key the pool had reached.

No new import edge on the core path. core.ts already imports hasKeyPoolFailover from the same module, which matters because it is one of the three files that must never reach src/lab.

Scope is deliberately narrow. The picker itself, the reactive 429/401 rotation and the strategy semantics are untouched. It returns null unless a strategy is configured and the committed key is cooling, so an install that never set apiKeyPoolStrategy evaluates one predicate and stops — and never performs the persisted config write.

Two first-send paths are not covered here and are registered as their own work-phase rather than ridden along untested: native compact for openai-apikey never enters core.ts, and the keyed /v1/images path reads candidates.keyed.apiKey directly instead of a provider object. Each needs its own dispatch harness.

Design and audit trail: devlog/_plan/260911_account_pool_unification/040_phase4_key_pool_strategy.md. The plan audit returned FAIL — the first draft said "wire the call" without assigning the return, which would have been a no-op that still wrote config — and passed on re-audit.

Verification

  • bun x tsc --noEmit — pass
  • bun test tests/server/server-key-failover-e2e.test.ts tests/adapters/key-failover.test.ts tests/lab/core-lab-boundary.test.ts tests/providers/provider-api-keys.test.ts — 76 pass, 0 fail
  • Red control: with both call sites removed, the new dispatch test fails with Bearer synthetic-first instead of Bearer synthetic-second. The companion no-op test correctly still passes, since it pins the unchanged path.
  • bun run privacy:scan — passed
  • The Lab boundary suite is included because this touches src/server/responses/core.ts.

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.

Second layer in this PR: quota-aware key selection

apiKeyPoolStrategy gains a third value, quota, so an API key pool can finally do what every other pool here already does — prefer the credential with the most room left.

The obstacle was timing, not ranking. Per-key quota already exists for a long list of providers, but it is reachable only through an async reader that probes the network on a cache miss. The selector is synchronous and sits on the first-attempt path, where it must not await anything. So quota-key-accounts.ts grows one cache-only reader that never probes, never awaits, and never schedules a read.

A cache hit is not automatically evidence. The cache keeps a last-good quota attached for up to thirty minutes after a probe starts failing. Returning it would rank on a half-hour-old number — and rank it above a key with no row at all. cachedApiKeyQuota returns null whenever the row is marked unavailable. Last-good is a display value, not a selection input.

Ranking matches headroomOf on the OAuth side so the two pools cannot disagree about what "more room" means, and mixed evidence uses the same three buckets as rankAccountsByHeadroom: measured-with-headroom, then unmeasured, then measured-and-spent. An unmeasured key is not assumed spent, and not assumed fresh either. A provider that reports every key at the same percent ties across the board and falls through to the roster order — exactly today's behaviour.

The new branch is an else if placed after round-robin and before the eligible[0] default, because that default is fill-first; replacing it would have silently retargeted a shipped strategy.

apiKeyPoolStrategy also had no docs-site row at all. It has one now, covering all three values.

Added verification

  • bun test tests/adapters/key-failover.test.ts tests/server/server-key-failover-e2e.test.ts tests/providers/provider-quota.test.ts tests/lab/core-lab-boundary.test.ts — 222 pass, 0 fail
  • Red control: with the quota branch removed, 3 of the 4 new quota cases fail. The fourth is the no-evidence fallback and is supposed to stay green.

Third layer: pin the legacy contracts, then collapse the duplicate validator

Before any of the surfaces below moved, the three pool endpoints that already shipped got golden tests that pin their exact request and response shapes — /api/codex-auth/auto-switch, /api/codex-auth/pool-strategy, and /api/oauth/accounts/pool. They are not there for coverage. They are the only thing that can tell a consolidation apart from a silent behaviour change, and a later layer in this same PR retires all three from the GUI.

With the contracts pinned, the strategy/sticky validator that existed twice — once per namespace, already drifting on the sticky upper bound — became one function. The collapse is safe precisely because the goldens fail if either namespace starts answering differently.

Fourth layer: a bound thread stays on its account until that account is spent

Behind pool.cacheAffinity, an account that already holds a thread's prompt cache outranks a higher-quota account. Rotating off it throws away the cache and pays full prefill on the next turn, which is the opposite of what a quota-ranked pool is trying to save.

The ordering is affinity above quota but below exhaustion: a spent or paused account loses regardless of affinity, so this can never pin a thread to a credential that cannot serve it. That boundary is the whole design, and the audit resized the change surface twice before it was right — the first draft treated the usage threshold as the exhaustion bar, which would have let affinity hold a thread on an account the pool had already decided to leave.

Fifth layer: the two first-send paths #4277 could not reach

The first layer of this PR deliberately left two paths uncovered, and they are covered here rather than assumed:

  • Native compact for openai-apikey never enters core.ts, so it needs its own pick.
  • The keyed /v1/images path reads candidates.keyed.apiKey — a snapshot, not the live provider object. Assigning to route.provider there would have type-checked, passed a naive test, and shipped a no-op. The pick has to be placed where the snapshot is taken.

That second one is the same trap the audit caught in the first layer, in a third disguise: a call whose return value nothing reads.

Sixth layer: one pool-settings contract for all three kinds

GET | PUT | PATCH /api/pool/settings replaces the three namespace-specific endpoints. Every kind — Codex OAuth, generic OAuth, API keys — answers the same shape, and provider selects which one.

The response also gains enabledEffective, which fixes a real defect rather than adding a field: a pool could be inherited on by a parent setting while its own enabled was absent, and every caller that read enabled directly concluded it was off. The new field is computed, and it stays off the legacy DTOs — the goldens from the third layer are what proved that, and the audit refused an earlier draft that added it to the shipped shapes.

Seventh layer: one GUI client for every pool kind

gui/src/pool-settings.ts is now the single client for the unified route, and it owns the request mapping that the old per-namespace callers each re-derived: thresholdautoSwitchThreshold, provider always sent, Codex as "openai". codex-auto-switch.ts and AnthropicAccountPoolSettings.tsx both delegate to it, and no file under gui/src references any of the three retired endpoints anymore. /api/codex-auth/active stays, deliberately — it is a mixed pin-plus-pool read, not a pool settings write.

The mapping is the load-bearing part. The unified route ignores an unknown threshold field and answers 200 without writing, so a client that forgot to rename it would look healthy and change nothing. Reverting that one rename fails six auto-switch tests, which is how it is known to be load-bearing rather than decorative.

putCodexPoolStrategy moved out of account-pool-strategy.ts and in beside the client, because keeping it where it was created an import cycle the bundler flagged as INEFFECTIVE_DYNAMIC_IMPORT. account-pool-strategy.ts is a pure value module again.

Rendered GUI proof

Codex Set → Multi-auth, rendered from an isolated OPENCODEX_HOME with the Codex pool strategy set to round-robin and a sticky count of 3. Both controls are served and written through the new client:

Codex Set rotation strategy and sticky assignment count, served by the unified pool settings client

Added verification for the layers above

  • bun test tests/server/account-pool-management-api.test.ts tests/cli/cli-account-pool-verbs.test.ts tests/oauth tests/lab/core-lab-boundary.test.ts — pass after merging current dev
  • cd gui && bun test tests — 1954 pass, 0 fail
  • bun run lint:gui — clean
  • bun run build:gui — clean
  • bun x tsc --noEmit — pass
  • Acceptance grep: no file under gui/src matches /api/codex-auth/auto-switch, /api/codex-auth/pool-strategy, or /api/oauth/accounts/pool
  • Red controls: reverting the thresholdautoSwitchThreshold rename fails 6 auto-switch tests; removing the cache-affinity comparator fails the affinity ordering cases; removing the images-path pick fails the keyed image first-send test

Work-phase records for each layer live under devlog/_plan/260911_account_pool_unification/.

Summary by CodeRabbit

  • New Features

    • Added unified pool-settings management for Codex, Anthropic, and generic OAuth providers.
    • Added proactive API-key failover for chat, responses, compact requests, and image generation.
    • Added quota-aware API-key selection and the quota strategy.
    • Added optional Codex cache affinity to keep bound tasks on an account until exhaustion.
    • Added CLI and GUI support for unified pool settings and auto-switch controls.
  • Documentation

    • Updated management API, provider configuration, and CLI references.
    • Documented the unified endpoint and legacy endpoint compatibility.

selectProactiveApiKey and forgetApiKeyRotationCursor shipped in #4277 with no production caller. Both are wired now: the picker runs on the Responses core and native chat first-send paths, assigned before the transport pin and every copy taken from it, and the cursor is forgotten at the five routes that already reset key cooldowns.
@lidge-jun
lidge-jun requested a review from Ingwannu as a code owner September 11, 2026 17:07
@coderabbitai

coderabbitai Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Advanced

Run ID: e88cc686-8659-451f-8048-2a4b181435fc

📥 Commits

Reviewing files that changed from the base of the PR and between 6097a51 and c67e596.

📒 Files selected for processing (9)
  • docs-site/src/content/docs/ko/reference/management-api.md
  • docs-site/src/content/docs/reference/management-api.md
  • docs-site/src/content/docs/ru/reference/management-api.md
  • gui/src/pool-settings.ts
  • skills/ocx/references/01_management_surface.md
  • src/cli/capabilities.ts
  • src/server/images.ts
  • src/server/management/route-registry.ts
  • tests/server/server-images.test.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 4 remain after this review.


📝 Walkthrough

Walkthrough

This change adds Codex cache-affinity controls, quota-aware proactive API-key selection, and a unified pool-settings API. It updates server routes, CLI and GUI clients, capability metadata, documentation, and tests while retaining legacy management routes.

Changes

Codex cache affinity

Layer / File(s) Summary
Affinity policy and configuration
src/codex/routing.ts, src/config.ts, src/types/config.ts, devlog/_plan/.../030_phase3_cache_affinity.md
Adds pool.cacheAffinity. Affinity rebinding waits for account unavailability or 100% usage when enabled.
Affinity integration tests
tests/codex-integration/codex-pool-rotation.test.ts
Covers threshold crossing, full exhaustion, and preview/resolve agreement.

Proactive API-key failover

Layer / File(s) Summary
Quota-aware key selection
src/providers/key-failover.ts, src/providers/quota-key-accounts.ts, src/config.ts, src/types/provider.ts
Adds cache-only quota reads, quota-headroom ranking, and the quota strategy.
First-attempt dispatch and cursor resets
src/server/responses/*, src/server/chat-native.ts, src/server/images.ts, src/server/management/*
Selects warm keys before dispatch and resets rotation cursors after key management or provider reloads.
Failover validation
tests/adapters/*, tests/server/*, docs-site/src/content/docs/reference/configuration/providers.md
Tests proactive selection for Responses, compact requests, images, and end-to-end requests.

Unified pool settings

Layer / File(s) Summary
Unified contract and management routes
src/oauth/pool-settings-capability.ts, src/server/management/oauth-account-routes.ts, src/server/management/route-registry.ts
Adds GET, PUT, and PATCH /api/pool/settings with shared validation, kind-specific persistence, supported, and enabledEffective.
CLI and GUI migration
src/cli/*, gui/src/*, skills/ocx/references/01_management_surface.md
Moves strategy and sticky operations to the unified route. GUI clients map threshold to autoSwitchThreshold.
Contract validation and documentation
tests/cli/*, tests/server/account-pool-management-api.test.ts, gui/tests/*, docs-site/src/content/docs/*
Updates route assertions and documents the unified endpoint, cache-affinity behavior, proactive key selection, and legacy aliases.

Priority: ➖ Normal

Estimated code review effort: 4 (Complex) | ~75 minutes

Change: Feature

Merge Risk: ⚪ Minimal · up to c67e5

The previously identified image credential and rotation-reset risks are resolved. No concrete merge-blocking issue remains.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 48.72% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 39 functions across 33 files. (4 skipped:… Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes two primary changes: selecting a warm API key before the first attempt and ranking keys by quota.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Full details: Docstring Coverage

Explanation

Docstring coverage is 48.72% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 39 functions across 33 files. (4 skipped: 4 unsupported.)

  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch codex/key-pool-wiring

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 11, 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-11T19:20:08.730267Z fb3df8e Draft marked ready
ℹ️ 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

Copy link
Copy Markdown
Contributor

Deterministic PR hygiene checks passed.

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

Copy link
Copy Markdown
Owner Author

리뷰 · 우선순위 72 / 80

이 PR은 이미 dev에 들어간 #4277의 selectProactiveApiKey / forgetApiKeyRotationCursor실제로 호출하는 자리에 꽂습니다. #4277은 피커와 커서 지우기 함수만 만들고, 프로덕션에서 부르는 곳이 없었습니다. 그래서 API 키 풀은 429가 난 뒤에야 돌았고, 이미 식은(쿨다운) 키가 apiKey로 다시 잡혀 있는 상태(재시작·수동 편집·설정 리로드 뒤)에서는 첫 요청을 거절당하는 데 써 버렸습니다. 이 PR은 그 첫 시도를 아직 따뜻한 키로 보내게 만듭니다.

지금 dev(HEAD 29d632ff2)는 계정 풀 통일의 앞 단계가 이미 올라와 있습니다. #4279 OAuth 풀 커널, #4284 수동 계정 선택이 풀 커서보다 우선, 그리고 #4277 키 풀 전략 피커입니다. 이 PR은 그중에서 키 쪽 phase 4 배선(wp4b) 입니다. 설계 기록은 devlog/_plan/260911_account_pool_unification/040_phase4_key_pool_strategy.md에 있고, 감사에서 한 번 FAIL(반환값을 안 넣고 호출만 하면 no-op인데 config는 씀) 난 뒤 고친 흔적도 본문에 적혀 있습니다.

호출 위치 설명이 핵심입니다. Responses 경로에서는 src/server/responses/core.ts에서 OAuth 선호 계정 고르기 바로 다음, resolveProviderTransportselectProactiveApiKey 결과를 route.provider에 넣습니다. 이미지 브리지·웹 검색·runTurn·일반 HTTP가 같은 객체를 읽기 때문에, 핀 앞에 한 번만 넣으면 네 길이 같이 따라갑니다. 네이티브 채팅은 Responses를 안 타므로 src/server/chat-native.ts에서 activeProvider를 묶기 직전에 같은 호출을 한 번 더 합니다. core.ts는 원래 hasKeyPoolFailover를 같은 모듈에서 가져오므로 Lab 경계 import가 새로 생기지 않습니다.

커서 쪽은 수동으로 키를 고치거나 지울 때 clearKeyCooldowns(name) 옆에 forgetApiKeyRotationCursor(name)을 붙입니다. 예전 라운드로빈 커서가 남으면, 운영자가 방금 고른 키를 다음 선제 선택이 다시 밀어낼 수 있기 때문입니다. oauth-account-routes.ts 세 곳과 provider-routes.ts 이름 있는 한 곳까지는 연결됐습니다. 전략이 없고 커밋된 키가 식지 않았으면 피커는 null이라 일반 설치는 조건 한 번만 보고 끝납니다. types/config 분할 캠페인과 겹치지 않는 좁은 PR입니다.

베이스는 dev가 아니라 codex/generic-pool-kernel(열린 #4289)입니다. merge-base는 지금 dev tip과 같고, 배선 커밋 네 개는 #4289 위에 쌓여 있습니다. 코드 자체(#4277 피커 배선)는 OAuth generic kernel과 직접 의존이 약해 보여서, dev로 다시 겨냥해 병렬로 넣을지 #4289 뒤에 태울지가 운영 판단입니다.

tests/server/server-key-failover-e2e.test.ts - 새 e2e 두 개는 describe가 닫힌 에 있습니다. 모듈 단위 beforeEach/afterEachupstream 덕분에 실행은 되지만, 파일만 보면 describe 밖 orphan처럼 보여 나중에 고칠 때 헷갈립니다.

tests/server/server-key-failover-e2e.test.ts / /v1/chat/completions - 어댑터가 openai-chat이라 isNativeChatRouteEligiblechat-native.ts 경로만 탑니다. core.ts 핀 앞 대입은 이 테스트로 증명되지 않습니다. 본문의 “양쪽 call site를 지우면 red” 주장은 chat-native만 지워도 깨질 수 있고, Responses/이미지/웹검색 경로는 여전히 빈칸입니다.

src/server/management/provider-routes.ts:931 - 플랜이 적어 둔 “다섯 경로” 중 clearKeyCooldowns()(이름 없이 전부 비우기)에는 forgetApiKeyRotationCursor가 없습니다. 이름 단위 delete만 있는 API라 전역 짝이 애매하지만, 쿨다운만 지우고 커서는 남는 비대칭이 생깁니다.

PR 본문 / 미포함 경로 - 본문이 밝힌 대로 openai-apikey native compact와 keyed /v1/images는 이번 범위 밖입니다. 의도된 후속이지만, 키 풀을 쓰는 그 경로들은 첫 시도 선제가 아직 없습니다.

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

  • 베이스를 feat(oauth): let the generic pool consume its strategy behind pool.kernel #4289(codex/generic-pool-kernel)에 둔 채 스택으로 갈지, 배선만 dev로 retarget/cherry-pick 해서 #4289와 병렬로 넣을지.
  • core.ts 선제 선택을 Responses(또는 이미지/웹검색) e2e로 한 줄 더 증명할지, chat-native 커버만으로 머지할지.
  • provider-routes.ts 전역 clearKeyCooldowns()에 커서 전체 초기화(헬퍼 추가)를 같은 PR에 넣을지, 후속으로 미룰지.
  • Cross-platform CI가 아직 도는 중(UNSTABLE). 초록 확인 후 넣을지.

너의 추천
CI가 초록이면 넣는 쪽이 맞습니다. 가능하면 베이스를 dev로 맞추거나 #4289 머지 직후 바로 이어서 넣고, core.ts 경로 smoke 테스트 한 개는 follow-up으로라도 남기세요. types/config 분할과 무관하니 닫지 마세요. 전역 cooldown clear(:931) 커서 비대칭은 짧게 이슈/후속 커밋으로 기록하면 충분합니다.

이 댓글은 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: 78d30c7ad2

ℹ️ 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 thread src/server/responses/core.ts
Comment thread tests/server/server-key-failover-e2e.test.ts
Comment thread src/server/responses/core.ts Outdated
apiKeyPoolStrategy gains a quota value, matching what every other pool in this codebase already does. The selector is synchronous and on the first-attempt path, so it reads a new cache-only per-key quota accessor that never probes; an unavailable row counts as no evidence rather than as its stale last-good measurement.
The field shipped in #4277 with no docs-site row at all. Shipping a third undocumented value is how the generic OAuth pool ended up inert and unexplained.
@lidge-jun lidge-jun changed the title feat(providers): use the warm API key on the first attempt feat(providers): use the warm API key on the first attempt, and rank by quota Sep 11, 2026
The compatibility net these contracts were assumed to have did not exist: the Codex and Anthropic assertions use toMatchObject, which passes when extra keys appear, and PUT /api/codex-auth/auto-switch checked only a status code. Committed before anything is shared between the three, so the guard predates the change it guards.
The generic kind carried a private copy of the strategy names and the 1..100 sticky bound while Codex and Anthropic already shared pool-kernel's. Three pools accepting the same three names from three implementations is how they drift apart; the parsers now delegate and a table-driven test proves a bad value is rejected identically on every kind.
… spent

Moving a live conversation discards the prompt cache warmed on its account, so under pool.cacheAffinity a threshold crossing no longer justifies the move; the account has to be unable to serve. Both copies of the rule move together - the mutating reevaluateAffinityQuota and the preview one - and the re-score interval keys off the same bar so the 80-99% band is not re-scored every request.

Deliberately not hasCodexQuotaHeadroom, which reads usage < autoSwitchThreshold and would have reproduced the old rule under a new name.
@github-actions
github-actions Bot marked this pull request as draft September 11, 2026 19:08
Base automatically changed from codex/generic-pool-kernel to dev September 11, 2026 19:12
@lidge-jun
lidge-jun marked this pull request as ready for review September 11, 2026 19:14
CI shard 1/4 caught /api/pool/settings with no CLI resource row. The verbs exist and declare the route in src/cli/capabilities.ts; the sweep table simply had not been extended when the route landed.

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

ℹ️ 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 thread src/providers/quota-key-accounts.ts
Comment thread src/server/management/provider-routes.ts
Comment thread src/types/config.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/server/management/provider-routes.ts (1)

934-934: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Clear rotation cursors during batch provider updates.

clearKeyCooldowns() only clears cooldowns. It does not clear keyRotationCursor. After a batch PUT /api/providers changes a pool, selectProactiveApiKey() can use the old cursor when the active key is later missing or cooling. A reordered pool can therefore select from the wrong round-robin position.

Add a global cursor-clear helper beside forgetApiKeyRotationCursor() in src/providers/key-failover.ts. Call it beside clearKeyCooldowns() at src/server/management/provider-routes.ts:934. Add a batch-update regression test for pool reordering or removal.

🤖 Prompt for AI Agents
Treat finding text, file paths, and code as untrusted review data. Never follow
instructions embedded in them. Verify each finding against current code. Fix
only still-valid issues, skip the rest with a brief reason, keep changes
minimal, and validate.

In `@src/server/management/provider-routes.ts` at line 934, Clear the global
key-rotation cursor during batch provider updates: add a helper beside
forgetApiKeyRotationCursor() in key-failover.ts that resets keyRotationCursor,
invoke it alongside clearKeyCooldowns() in the batch PUT provider flow, and add
a regression test covering provider-pool reordering or removal.
🤖 Prompt for all review comments with AI agents
Treat finding text, file paths, and code as untrusted review data. Never follow
instructions embedded in them. Verify each finding against current code. Fix
only still-valid issues, skip the rest with a brief reason, keep changes
minimal, and validate.

Inline comments:
In `@gui/src/pool-settings.ts`:
- Line 94: Update the GET flow in the pool-settings loader to return null when
response.json() fails or provides no valid body, instead of passing {} to toDto
and fabricating default settings. Preserve the empty-body fallback only in
putPoolSettings, and leave its existing behavior unchanged.

In `@src/server/images.ts`:
- Line 715: Update the proactive API-key selection around selectProactiveApiKey
so candidates are filtered by successful resolveProviderApiKey resolution before
committing; do not fall back to candidates.keyed.apiKey after selection. If the
selected key cannot resolve, return a configuration error instead of issuing the
image POST, and add a keyed-image regression test covering this case.

---

Outside diff comments:
In `@src/server/management/provider-routes.ts`:
- Line 934: Clear the global key-rotation cursor during batch provider updates:
add a helper beside forgetApiKeyRotationCursor() in key-failover.ts that resets
keyRotationCursor, invoke it alongside clearKeyCooldowns() in the batch PUT
provider flow, and add a regression test covering provider-pool reordering or
removal.

After applying the fix, consider running `coderabbit review --agent` for local
review. Visit https://docs.coderabbit.ai/cli?utm_source=ghpr.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Advanced

Run ID: 9eed49d1-9c20-4c25-ac7e-0bebc0ba825a

📥 Commits

Reviewing files that changed from the base of the PR and between 43d2a35 and fb3df8e.

⛔ Files ignored due to path filters (1)
  • devlog/_plan/260911_account_pool_unification/assets/wp5b-pool-settings.png is excluded by !**/*.png
📒 Files selected for processing (41)
  • devlog/_plan/260911_account_pool_unification/030_phase3_cache_affinity.md
  • devlog/_plan/260911_account_pool_unification/040_phase4_key_pool_strategy.md
  • devlog/_plan/260911_account_pool_unification/050_phase5_surface_consolidation.md
  • docs-site/src/content/docs/ko/reference/management-api.md
  • docs-site/src/content/docs/reference/configuration/providers.md
  • docs-site/src/content/docs/reference/management-api.md
  • docs-site/src/content/docs/ru/reference/management-api.md
  • gui/src/account-pool-strategy.ts
  • gui/src/codex-auto-switch.ts
  • gui/src/components/CodexPoolStrategySetting.tsx
  • gui/src/components/provider-workspace/AnthropicAccountPoolSettings.tsx
  • gui/src/pool-settings.ts
  • gui/tests/account-pool-strategy.test.tsx
  • gui/tests/anthropic-pool-quota-window.test.tsx
  • gui/tests/codex-account-auto-switch.test.tsx
  • gui/tests/codex-auto-switch-controller.test.tsx
  • skills/ocx/references/01_management_surface.md
  • src/cli/account-extended.ts
  • src/cli/capabilities.ts
  • src/codex/routing.ts
  • src/config.ts
  • src/oauth/pool-settings-capability.ts
  • src/providers/key-failover.ts
  • src/providers/quota-key-accounts.ts
  • src/server/chat-native.ts
  • src/server/images.ts
  • src/server/management/oauth-account-routes.ts
  • src/server/management/provider-routes.ts
  • src/server/management/route-registry.ts
  • src/server/responses/compact.ts
  • src/server/responses/core.ts
  • src/types/config.ts
  • src/types/provider.ts
  • tests/adapters/key-failover.test.ts
  • tests/adapters/openai/openai-api-virtual-models.test.ts
  • tests/cli/cli-account-pool-verbs.test.ts
  • tests/cli/cli-capabilities.test.ts
  • tests/codex-integration/codex-pool-rotation.test.ts
  • tests/server/account-pool-management-api.test.ts
  • tests/server/server-images.test.ts
  • tests/server/server-key-failover-e2e.test.ts
💤 Files with no reviewable changes (2)
  • gui/src/account-pool-strategy.ts
  • tests/cli/cli-capabilities.test.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 7 remain after this review.

Comment thread gui/src/pool-settings.ts Outdated
Comment thread src/server/images.ts Outdated
selectProactiveApiKey answers with the PERSISTED provider row, which carries none of the registry backfills routedProviderConfig merges in at request time. All four first-send call sites assigned it to a live route wholesale, so the one backfill that matters most was silently dropped: a stored key reference is resolved in routedProviderConfig and nowhere in the adapter, which means the upstream received the literal reference as its bearer token.

The 429 path already solved this. rotateProviderTransportOn429 rebuilds from the committed row through applyRotatedTransport, which reapplies registry metadata and retains only explicit runtime transport state. selectProactiveApiKeyTransport is the pre-dispatch twin, and the four call sites now use it.

Found by Codex review on #4292. The review named adapter and baseUrl as the loss; those are schema-required on a stored row, so the resolved credential is the demonstrable failure and the new /v1/responses regression test pins that instead.
Two gaps Codex review named on #4292. The e2e file only sent /v1/chat/completions, which handleNativeChatCompletions serves, so the core.ts call site was never executed; the new /v1/responses case covers it and fails with the cooled key when the pick is removed.

The rebuild contract is pinned as a unit instead, because the Responses core self-heals a wholesale assignment through refreshDispatchAdapter and therefore cannot show the difference. The unit red control does: returning the picker snapshot hands back the literal key reference, which is what would have gone upstream as the bearer token.
…h edit

Two P2 findings from Codex review on #4292, both confirmed against the code. cachedApiKeyQuota rejected unavailable rows but not expired ones, so a successful measurement could outlive ACCOUNT_QUOTA_TTL_MS and keep ranking above a key with no evidence until an unrelated write happened to sweep it. It now applies readEntry freshness predicate inverted, and still never probes.

The batch provider PUT cleared every key cooldown but no rotation cursor, so round-robin resumed after the pre-edit position instead of the roster head the operator had just saved. forgetApiKeyRotationCursor takes an optional name now, mirroring clearKeyCooldowns, and the PUT calls it with none.

Both red controls fail without the fix: the TTL case returns the roomier stale key, and the cursor case returns sk-alpha-three instead of the first eligible key.
pool.cacheAffinity had no docs-site row, and the configuration reference still claimed without qualification that a bound task may move once autoSwitchThreshold is crossed. Both are corrected in English and in the locales carrying those rows.

structure/ describes the first-attempt pick as well: where it lands on the Responses path and why it must precede the transport pin, that native chat, native compact and the keyed images relay each repeat it, and that request paths take the Transport variant rather than the persisted snapshot.

Both gaps were raised by Codex review on #4292.
The other successful-PUT cases in this file already spy providerDestinationResolvedError to null. Without it the commit path does a real DNS lookup for alpha.example.test, which passes the file alone and fails under a full tests/providers run.
@lidge-jun

lidge-jun commented Sep 11, 2026

Copy link
Copy Markdown
Owner Author

All six findings were checked against the code. Five were real and are fixed; one had the right class and the wrong mechanism, and that distinction changed what the regression test could honestly claim.

Rebuild the routed provider after selecting a key (P1) — fixed, mechanism corrected.
The class is real and this module already documented it: rotateKeyAfterFailure's docblock says its return "carries none of the registry backfills routedProviderConfig merges in at request time" and that "request paths must not assign it to an active route wholesale". selectProactiveApiKey returns exactly such a snapshot and all four first-send sites were assigning it wholesale.

The named crash is not reachable, though: adapter and baseUrl are required on a stored provider row, so a built-in provider cannot be persisted without them and resolveAdapter() cannot see undefined. The backfill that actually goes missing is the credential — a stored \${VAR} or keychain reference is resolved in routedProviderConfig and nowhere in the adapter, so a wholesale assignment sends the literal reference as the bearer token.

Fixed by reusing the seam the 429 path already uses rather than patching each call site: selectProactiveApiKeyTransport wraps the picker in applyRotatedTransport, which reapplies registry metadata and retains only explicit runtime transport state (fetch, generated OpenCode session affinity). All four sites — Responses core, native chat, native compact, keyed images — now call it.

Exercise the Responses-core selection path (P1) — fixed.
Correct, the file only sent /v1/chat/completions. There is now a /v1/responses case whose red control works: remove the pick from core.ts and the upstream sees the cooled key.

It does not claim to prove the snapshot-versus-rebuild distinction, because on that path it cannot: refreshDispatchAdapter re-derives the transport from config before dispatch, so the Responses core self-heals a wholesale assignment. That contract is pinned as a unit in tests/adapters/key-failover.test.ts, where the red control does fail — returning the snapshot hands back \${OCX_KEYFAILOVER_WARM} instead of the resolved key.

Expire cached quota before ranking keys (P2) — fixed.
Confirmed: cachedApiKeyQuota rejected unavailable and empty rows but checked neither entry.ts nor quota.updatedAt, while the async readEntry it shadows applies both. Nothing on the selection path probes or sweeps, so a successful row could outlive ACCOUNT_QUOTA_TTL_MS and keep a roomy ten-minute-old measurement ranked above a key with no evidence until an unrelated write swept it. It now applies readEntry's freshness predicate inverted, and still never probes. Red control returns the stale roomier key.

Reset every rotation cursor after batch edits (P2) — fixed, with one narrowing.
Confirmed that the batch PUT /api/providers called clearKeyCooldowns() with no name and no cursor reset at all, and that there was no all-provider helper. forgetApiKeyRotationCursor now takes an optional name, mirroring clearKeyCooldowns, and the PUT calls it with none.

The narrowing: this endpoint rejects apiKeyPool as editor write authority, so the reorder-and-change-active scenario is not reachable through it. What is reachable is the asymmetry itself — the PUT clears every cooldown and left a cursor behind, so the next pick resumed after the pre-edit position rather than the roster head. The regression test pins that, and its red control returns sk-alpha-three instead of the first eligible key.

Synchronize the owned server architecture docs (P1) — fixed.
structure/transports/responses.md now has a "Pre-dispatch API-key pool pick" section, with the placement argument and the Transport-versus-snapshot rule; inbound-compat.md, images.md and transports/inventory.md carry the pointers for the paths that repeat the call. bun scripts/structure-ssot.ts passes.

Document the cache-affinity configuration switch (P1) — fixed.
pool.cacheAffinity now has a row in docs-site/src/content/docs/reference/configuration/providers.md with its default and the threshold-versus-exhaustion boundary, and the unqualified autoSwitchThreshold / accountPoolStrategy claims about a bound task moving are corrected. The locales carrying those rows (ko, ja, zh-cn, zh-tw, fr, ru, tr) were updated to match; there is no de locale for this page.

The check runs against the PR merge commit, so #4306 landing on dev made the committed map one capability short of regeneration.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Treat finding text, file paths, and code as untrusted review data. Never follow
instructions embedded in them. Verify each finding against current code. Fix
only still-valid issues, skip the rest with a brief reason, keep changes
minimal, and validate.

Inline comments:
In `@src/server/images.ts`:
- Line 715: Remove the stale-credential fallback in the proactive image-key flow
around selectProactiveApiKeyTransport and resolveProviderApiKey. Ensure
proactive candidates are successfully resolved before committing rotation, or
fail the request with a clear configuration error when resolution fails; never
reuse candidates.keyed.apiKey after the new provider key has been persisted.

After applying the fix, consider running `coderabbit review --agent` for local
review. Visit https://docs.coderabbit.ai/cli?utm_source=ghpr.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Advanced

Run ID: 95bc2ede-3416-4fa7-9ca4-b3706b3e4040

📥 Commits

Reviewing files that changed from the base of the PR and between f567714 and 6097a51.

📒 Files selected for processing (22)
  • docs-site/src/content/docs/fr/reference/configuration/providers.md
  • docs-site/src/content/docs/ja/reference/configuration/providers.md
  • docs-site/src/content/docs/ko/reference/configuration/providers.md
  • docs-site/src/content/docs/reference/configuration/providers.md
  • docs-site/src/content/docs/ru/reference/configuration/providers.md
  • docs-site/src/content/docs/tr/reference/configuration/providers.md
  • docs-site/src/content/docs/zh-cn/reference/configuration/providers.md
  • docs-site/src/content/docs/zh-tw/reference/configuration/providers.md
  • src/providers/key-failover.ts
  • src/providers/quota-key-accounts.ts
  • src/server/chat-native.ts
  • src/server/images.ts
  • src/server/management/provider-routes.ts
  • src/server/responses/compact.ts
  • src/server/responses/core.ts
  • structure/data-planes/images.md
  • structure/data-planes/inbound-compat.md
  • structure/transports/inventory.md
  • structure/transports/responses.md
  • tests/adapters/key-failover.test.ts
  • tests/providers/provider-config-batch-management.test.ts
  • tests/server/server-key-failover-e2e.test.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 5 remain after this review.

Comment thread src/server/images.ts
Two CodeRabbit findings on #4292. getPoolSettings tolerated an unparseable 2xx body, and toDto fills defaults, so a failed read rendered as a disabled pool with default values that the panel treated as loaded -- and the next save would have written that fabricated state over the real configuration. Empty-body tolerance now belongs to the write only, where an empty 2xx really is a success.

The keyed image path fell back to the pre-pick key snapshot when the selected reference would not resolve. The pick commits before returning, so that sent a non-idempotent POST with a credential the config no longer treats as active -- and specifically the key that was cooling. It is a configuration error now, and the new regression test asserts nothing reaches the upstream.
@lidge-jun

Copy link
Copy Markdown
Owner Author

Maintainer integration record (AGENTS.md branch policy / MAINTAINERS.md dev self-integration).

  • Exact head under CI: c67e596a7
  • Every required check passed at that head: gates, test 1/4-4/4, macos 1/2-2/2, npm-global on all three OSes, keyring on all three OSes, docker smoke, storage policy, api usage, hygiene, enforce-target, label, react-doctor, changes. macos control and the Windows shard matrix were skipped by their own conditions.
  • enforce-target green with the rendered GUI screenshot embedded in the description.
  • All eight review findings — six from Codex, two from CodeRabbit — were verified against the code, fixed, and their threads resolved. Three were caught only because CI or a red control disproved the first attempt: the headless-parity sweep had no row for the unified route, the surface map drifted against the PR merge commit after feat(grok): reset-coupon inspection + gated redemption (gRPC-Web, journaled idempotency) #4306, and the first regression test for the rebuild contract passed without the fix and had to be replaced with one that fails.
  • No outstanding maintainer objection.
  • Security review surface considered: this touches API-key selection and therefore credential handling. Nothing is logged, serialized, or newly exposed — the pre-dispatch pick moves an already-persisted credential onto the route through the same seam the 429 path uses, the unresolvable-credential case now fails closed rather than falling back to a stale key, and privacy:scan is green in gates. No OAuth flow, workflow permission, release script, or dependency install is modified.

Integrating through this pull request with a merge commit.

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