Skip to content

[WRONG BRANCH] fix(usage): attribute each attempt to the account that dispatched it (#4717) - #4760

Merged
lidge-jun merged 14 commits into
codex/bl3-budget-refusal-semanticsfrom
codex/bl4-key-usage-attribution
Sep 16, 2026
Merged

lidge-jun merged 14 commits into
codex/bl3-budget-refusal-semanticsfrom
codex/bl4-key-usage-attribution

Conversation

@lidge-jun

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

Copy link
Copy Markdown
Owner

Summary

A request that started on API key A and retried on key B attributed the whole request's usage to A, so budget diagnostics blamed a key that never served the successful attempt. Key selection is captured before secret resolution and dispatch can rebuild against a newer selection, but attempts still went through the generic send path and the account-label validator rejected key identities outright.

This carries #4717 by @thisisjun786, whose commits are preserved with their original authorship and credited with a Co-authored-by trailer on the final commit.

What it does:

  • Commits the physical identity after queued reselection, at the actual dispatch boundary, so an attempt is attributed to the key that really sent it.
  • Adds a validated k + 32-hex account label derived from the provider and the captured key-selection reference. The raw reference is hash input and never label output; the persisted-label validator accepts only that shape.
  • Keeps a key change from overwriting the preceding account's record: the snapshot is taken before rotation, and the stable active attempt object the streaming and combo callbacks hold is reused rather than replaced.
  • Captures raw terminal usage once, before continuation, search and image aggregation, and replaces the current send's snapshot on repeated inspection of the same response rather than summing it again.

Dropped from the original PR: the Command Code reasoning-effort retry commit. That retry stays on the same selected key, so it is not needed to tell one account's usage from another's, and leaving it out keeps this layer reviewable. The small injected-executor fix it depended on is retained.

Added here: a consumer-side assertion the attribution depends on. A row carries both the per-attempt records and the request total, and a reader that added them would report 600 input tokens for 300 that were actually spent. usageAttributions takes the attempts when a row has them and the entry row only when it has none, so the parent total is a fallback for rows written before attempts existed rather than another column to sum.

That same arithmetic is why hidden attempts must not be folded into the response the client sees: the Codex client treats response.completed.usage as the exact usage for that response and adds it to its durable turn and thread totals, so a proxy-side sum would corrupt accounting it owns.

Closes #4717

Verification

No local suite, focused test, typecheck, install, or build step was run. The repository owner prohibits local suite execution in this lane after a past local run deleted real user home data. Verification is static reading plus hosted CI.

Static checks performed:

  • Carried by git cherry-pick of the fork's own commits rather than by reimplementation, so authorship and the reviewed diff are preserved. Three of four commits applied; the Command Code commit was skipped deliberately.
  • Three conflicts, all resolved by keeping both sides: core-combo.ts (the attribution initialisation now sits beside the existing requested-effort initialisation), structure/runtime.md (the upstream-key section added after the combo-effort section), and one import line in server-combo-failover-e2e.test.ts.
  • Verified the layers below survived the carry intact: adapterDispatchBudget is still what reaches every adapter-owned transport in adapter-dispatch.ts, adapter-continuation.ts and run-turn-execution.ts, and assumeCharge is still the settlement seam in request-send-budget.ts. The skipped commit would have reverted those to the bare holder.
  • Confirmed no stale layout entry was left for the skipped commit's test file; key-attribution.test.ts is registered in both scripts/test-layout/layout.json and tests/fixtures/test-layout-expected.json, and tests/providers/command-code-retry.test.ts is absent with no entry pointing at it.
  • Read the label derivation: the raw reference enters SHA-256 and only the first 128 digest bits are returned, and the persisted-label validator accepts only k followed by 32 lowercase hex characters. This is pseudonymisation, not secrecy against offline guessing by a holder of candidate configuration — the property that matters here is that no raw credential is written.

Hosted CI: non-tip layer of a stacked lane, carrying [skip ci] under the maintainer-approved DEV-STACK-08 tip-only CI policy. The lane's CI gate runs on the tip branch.

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.

thisisjun786 and others added 4 commits September 16, 2026 11:23
…4546) [skip ci]

Carried from PR #4717. The Command Code reasoning-effort retry commit is left
behind: that retry stays on the same selected key, so it is not needed to tell
account A's usage from account B's, and keeping it out keeps this layer
reviewable. The injected-executor fix it depended on is already here.

Adds the consumer-side assertion the attribution depends on. A row carries BOTH
the per-attempt records and the request total, and a reader that added them
would report 600 input tokens for 300 that were actually spent. usageAttributions
takes the attempts when a row has them and the entry row only when it has none,
so the parent total is a fallback for rows written before attempts existed rather
than another column to sum.

That arithmetic is also why hidden attempts must not be folded into the response
the client sees: the Codex client treats response.completed.usage as the exact
usage for that response and adds it to its durable turn and thread totals, so a
proxy-side sum would corrupt accounting it owns.

Closes #4717

Co-authored-by: thisisjun786 <259586770+thisisjun786@users.noreply.github.com>
@lidge-jun
lidge-jun requested a review from Ingwannu as a code owner September 16, 2026 02:26
@coderabbitai

coderabbitai Bot commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

🗂️ Base branches to auto review (2)
  • ^dev$
  • ^preview$

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Advanced

Run ID: 10b8ba53-0139-4cc6-affb-3f4dc04fe790

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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 16, 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-16T02:31:34.755328Z 06a117c 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 added the bug Something isn't working label Sep 16, 2026
@lidge-jun

Copy link
Copy Markdown
Owner Author

리뷰 · 우선순위 76 / 80

이 PR은 API 키로 보내는 요청에서 실제로 나간 키가 누구인지를 usage 로그에 남기게 고칩니다. 지금 dev(HEAD 3070d64d8, 패키지 2.57.0)에서는 요청이 키 A로 시작했다가 재시도로 키 B가 성공해도, 사용량이 처음 고른 A 쪽에 붙을 수 있습니다. 그러면 예산·한도 진단이 “성공한 키”가 아니라 “처음 고른 키”를 탓하게 됩니다. 이 변경은 그 귀속을 물리 dispatch 직전·직후로 옮깁니다.

스택은 #4745(bl1 hop-permit-charge) → #4756(bl2 durable ledger) → #4758(bl3 budget-refusal) → #4760(bl4 key-usage-attribution) 입니다. base는 dev가 아니라 codex/bl3-budget-refusal-semantics입니다. 원본 #4717(thisisjun786)을 cherry-pick으로 가져왔고, Command Code reasoning-effort 재시도 커밋은 의도적으로 뺐습니다. 그 재시도는 같은 키 안에서만 돌기 때문에 “계정 A vs B”를 가르는 데 필요 없고, 레이어를 얇게 유지하려는 선택입니다. 의존하던 injected-executor 수정(command-code.tsctx?.executor ?? executor)만 남겼습니다.

핵심 설계는 네 겹입니다. (1) apiKeyAccountLogLabel이 provider + selection reference를 SHA-256으로 잘라 k+32hex 라벨을 만들고, 원문 키/레퍼런스는 로그에 안 씁니다. (2) noteRoutedAttemptSend는 키 인증이면 메타데이터를 pending에만 두고, 실제 전송 경계의 commitKeyAttemptSend에서 noteProviderAttemptSend로 봉인합니다. 큐에서 키가 바뀌어도 “보낸 키”가 남습니다. (3) 키가 바뀌면 이전 attempt를 불변 스냅샷으로 남기고, 스트리밍·콤보 콜백이 들고 있는 같은 active 객체를 비운 뒤 새 신분으로 채웁니다. 그래서 부모가 예전 컨텍스트 복사본을 들고 있어도 최종 키 사용량을 덮어쓰지 않습니다. (4) 와이어 usage는 전송 전 baseline 기준으로 현재 전송분만 교체해서, 같은 응답을 여러 번 읽어도 합산하지 않습니다. 실패 응답 본문에서 측정된 usage는 recordKeyAttemptFailure로 남깁니다.

추가로 tests/usage/key-attribution.test.tsusageAttributions의 “attempts가 있으면 attempts만, 없으면 entry total만” 경계를 소스 문자열로 고정합니다. 한 행에 attempts(100+200)와 요청 total(300)이 같이 있어도 소비자가 둘을 더하면 600이 되므로, 클라이언트에게 숨은 attempt 합을 response.completed.usage로 밀어 넣으면 Codex 쪽 누적 회계가 망가집니다. 이 계약은 이미 summary.ts에 있고, 이번 테스트는 그 계약을 깨지 못하게 못을 박습니다. e2e(server-key-failover-e2e)도 pacing 후 선택된 키만 로그되는지, 429 회전 시 A/B 각각에 usage가 붙는지, 원문 키가 파일에 안 남는지까지 잡습니다.

현재 dev에는 KEY_ACCOUNT_LOG_LABEL_RE / noteProviderAttemptSend / apiKeyAccountLogLabel이 없습니다. 즉 이 기능은 아직 tip에 없고, 이 스택이 올라와야 생깁니다. types.ts/config.ts 분할 캠페인과 충돌하는 모놀리스 경로 편집은 보이지 않습니다. MERGEABLE이고 mergeStateStatus는 UNSTABLE(위생·라벨·resolve-pr 등 큐/진행 중, CodeRabbit SUCCESS). tip 커밋에 [skip ci]가 붙어 있어 tip-only CI 정책과 맞물리면 이 레이어에서 전체 스위트가 안 돌 수 있습니다.

라인 tip 커밋 [skip ci] - 이 PR이 bl 스택 tip(codex/bl4-key-usage-attribution)인데 tip에 skip이 있으면 DEV-STACK-08 tip-only CI가 이 레이어를 건너뛸 수 있다
라인 noteProviderAttemptSend 키 교체 경로 - active attempt의 모든 키를 지운 뒤 Object.assign으로 채우는 in-place 재사용은 콜백 안정에는 맞지만, 필드 참조를 따로 들고 있는 코드가 있으면 깨지기 쉽다
경로 structure/* 카탈로그 한 줄 링크 전파 - 계약 문서화에는 맞지만 파일 수가 많아 리뷰 노이즈가 크다(내용은 attribution 앵커 연결로 타당)
경로 원본 #4717 - 아직 OPEN; 이 캐리가 랜딩되면 landed-via-maintainer로 닫아야 한다

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

  • tip [skip ci]를 유지할지, tip에서 skip을 빼거나 skip 없는 follow-up tip을 둘지
  • Command Code reasoning-effort 재시도 커밋을 계속 별 PR로 둘지(같은 키 재시도라 attribution과 무관하다는 설명은 타당)
  • #4717 원본을 이 PR closes 문구대로 닫을지, 아니면 캐리 커밋 범위(스킵한 커밋) 때문에 부분 supersede로 처리할지

너의 추천
아래 스택 #4745#4756#4758이 먼저 안정적으로 랜딩된 뒤 이 tip을 이어서 머지하세요. tip의 [skip ci]는 머지 전에 제거하거나, tip-only 전체 CI가 도는 커밋을 tip에 한 번 더 올려 주세요. 머지 후에는 #4717Landed via #4760 at <commit> + landed-via-maintainer로 닫으세요. 지금은 keep.

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

@github-actions

Copy link
Copy Markdown
Contributor

Deterministic PR hygiene checks passed.

…later (#4546) [skip ci]

The transient-hold resolver and the pool-wide recovery limiter added in #4626
still have no production caller, so #4701 is not closed here. Wiring them turned
up a defect in the thing being wired, and that has to be fixed first.

A withheld dispatch promises the caller a retry time. It was computed from the
probe pacing alone. When the RATIO limiter is what refused, the account usually
has no probe state at all -- nothing was ever granted for it -- so nextProbeAt
returned now, and the refusal told the caller to try again immediately. A
withheld dispatch that busy-loops puts the same load on an already-failing pool
as the dispatch it refused, which is the opposite of what the limiter is for.
It also violates the Retry-After half of #4701's completion criteria directly.

The limiter is the only thing that knows when its own window moves, so it now
says: nextRecoveryAt returns now while the allowance is unspent, and otherwise
the moment the oldest bucket still inside the window falls out. Every such
bucket started after now - windowMs, so the answer is always strictly in the
future, and it is a real change point rather than a guessed delay. The withheld
result takes the later of that and the probe pacing.

The existing zero-allowance test asserted only that the result was withheld,
which is why the defect survived the unit suite that was written to cover this
module. It now asserts the time as well.

Refs #4701
…#4546)

Each layer of this lane closes one seam of the #4546 amplification: the
credential hop that was charged twice, the spend ledger with no caller, the
refusal reported as a provider fault, the usage attributed to the wrong key, the
withheld recovery that said "retry now". What none of them checks is whether the
seams agree with each other.

This composes the real primitives -- the request execution budget, the durable
spend ledger with its request-scoped caller, the pool recovery limiter -- and
asserts that the numbers describe the same events: physical sends, budget
consumption, ledger reservation and settlement, and the refusal the caller is
given.

The scenarios are the incident's own: a request whose every layer tries to
recover, concurrent requests contending for one process-wide recovery
allowance, a caller that keeps its detour instead of adding a second trial to a
failing account, a fan-out child spending the parent's allowance rather than a
fresh one, and a restart that must neither reset a ceiling nor settle the same
send twice.

A fixture that only counted sends would have passed throughout the incident,
which is why every case ties a send count to the spend the ledger recorded for
it.
The account-change scenario the incident needs, written against current
behaviour because the #4710 refusal is owned by another lane and is not in this
stack yet.

What it pins now: continuation state is dropped and the turn continues, an
uploaded file reference is classified non-portable and is NOT removed by the
scrub, and the carriers must be read directly because the portability verdict
reports only the first reason it finds -- a body carrying both a response id and
a file reports the response id.

What it documents: once the refusal lands, that body must be declined before
dispatch and the refusal must win over the response id. The two properties above
are what the change has to preserve, so they are asserted now.

Also pins the accounting invariant that refusal owes: a decision made before
dispatch spends no send and books no ledger entry. A refusal counted as a send
would appear as provider load that never happened and would push a healthy
account toward a cooldown.
…fusing sends (#4546)

Four fixes, batched into one push so the queue only pays once.

1. src/server/responses/core.ts was 214 lines against a 210-line cap in
   tests/fixtures/file-size-baseline.json. The spend-observer wiring added four
   lines of comment and continuation. The comment is now one line and the
   expression one line, and the file is back at its cap. The ratchet only ever
   lowers caps, so growing past one is a hard failure rather than a nudge.

2. The spend tracker refused a dispatch on ANY ledger denial. Only an operator's
   configured ceiling should: capacity, durability and a journal this process
   could not prove complete all mean the ledger cannot ACCOUNT for the send,
   which is not a reason to refuse one. An unconfigured install keeps the count
   caps it already had and is not newly refused, and a degraded ledger must not
   become an outage.

3. The shared ledger is now resolved on the first charge rather than when the
   request is built. It opens a journal under the OpenCodex home, and a request
   that never dispatches has no business creating one; this also means the home
   in effect at dispatch is the one written to, instead of whichever home was
   current when the first request of the process happened to be constructed.

4. Three assertions in the new tests claimed states the code never reaches.
   The concurrent-probe case asserted a limiter refusal, but the second caller
   short-circuits on the lease before it reaches the limiter and costs no
   allowance; the shared bound is now proved by asking the limiter directly.
   The exhausted-ceiling case asserted final-recovery-spent where the total
   ceiling refuses first, so it asserts total-exhausted and checks reserveSpent
   separately for the point it was making. The unstructured-error control
   asserted an exact 502 where the property that matters is that the identity is
   gone, so it asserts that instead.

Tests are not typechecked -- tsconfig includes only src -- so a test that
asserts the opposite of what it claims passes silently. These were found by
reading, not by running.
Two source-of-truth failures from the previous tip run, both mine.

tests/lib/transient-budget-scope-source.test.ts pinned the exact core.ts line
that mints the request's send budget, and bl2 changed it to install the spend
observer. The oracle now matches the new shape and additionally asserts the
observer is attached at the same place, which is the property that actually
matters: a combo child inherits the parent's holder and must not open a second
set of ledger entries for the same physical sends.

tests/lib/spend-reservation-ledger.test.ts caught a real defect in the replay
reconciliation, not a stale expectation. An exhausted scope must still be
exhausted after a restart -- that is the whole reason the ledger is on disk --
and abandoning a replayed undispatched reservation handed its tokens back and
reset the ceiling.

The distinction I drew was wrong. "Open" does not prove nothing was sent: the
torn-tail rule immediately above says the journal may be missing its last
record, so a send can dispatch and die before its dispatch record lands.
Both live states now resolve to unresolved spend, which is the conservative
answer and the one that preserves the ceiling.

The bl2 wiring test asserted the old split and is updated to the new figures,
along with the structure contract and the tracker's own comment.
…gression

test(responses): pin the #4546 incident as one system, not five fixes (#4546)
…spatch

fix(routing): give a withheld recovery a retry time that is actually later (#4546)
@lidge-jun

Copy link
Copy Markdown
Owner Author

Cascading downward. Retry usage is attributed to the account that actually dispatched it.

Evidence at the verified tip d9e5b28 (tree b69974e3f2c5da4e1cda8e302943a5ea6b107474), from run 35055864527:

  • test 1/4, 2/4, 3/4 and 4/4 all completed with conclusion success, confirmed through the check-runs API rather than the check rollup, so the heavy jobs actually executed and were not path-filtered.
  • macos 1/2 and 2/2, gates and the aggregate ci check all completed with conclusion success.
  • The same commit also carries a ci failure and a gates cancellation from run 35055864360. Its annotation reads needed job(s) did not pass: changes=cancelled: that run was superseded by workflow concurrency when the six branches were pushed together. It is a cancellation, not a test failure.
  • The lane absorbed dev at 5e3029e from the bottom layer upward, so each pull request keeps its own layer diff (8 / 11 / 8 / 51 / 2 / 10 files) and the tip stays reviewable. Four merge conflicts were resolved by keeping both sides: the documentation sections that dev and this lane each added, and all four test imports. No test was deleted and no dev-only content was lost.
  • git merge-tree --write-tree origin/dev <tip> reports a clean merge.
  • Ancestry verified so each layer closes as MERGED: bl1, bl2, bl3, bl4 and bl5 are all ancestors of this tip.

Chained-child stacks merge top-down, so this lands in the parent branch and cascades to dev. CI evidence transfers by tree identity at each step.

Maintainer integration decision under MAINTAINERS.md / AGENTS.md: a maintainer with maintain or admin access may integrate into dev without a second maintainer approval, recording the decision and exact-head CI evidence.

@lidge-jun
lidge-jun merged commit b0cceec into codex/bl3-budget-refusal-semantics Sep 16, 2026
7 checks passed
@lidge-jun
lidge-jun deleted the codex/bl4-key-usage-attribution branch September 16, 2026 04:48
@github-actions

github-actions Bot commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

⏳ DRAFT

  • wrong target branch (codex/bl3-budget-refusal-semantics); retarget to dev.

What to do

  • Retarget this PR to dev — all contributions go to dev.

Its title has been prefixed with [WRONG BRANCH].
Automatic draft conversion failed (token cannot change draft status). Please convert this pull request to a draft manually. The required enforce-target check will keep failing until every issue above is resolved.

@github-actions github-actions Bot changed the title fix(usage): attribute each attempt to the account that dispatched it (#4717) [WRONG BRANCH] fix(usage): attribute each attempt to the account that dispatched it (#4717) Sep 16, 2026
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