Skip to content

feat(usage): report sends, spend and cache provenance per logical request (#4546) - #4638

Merged
lidge-jun merged 3 commits into
devfrom
codex/4546-wpg-spend-instrumentation
Sep 14, 2026
Merged

lidge-jun merged 3 commits into
devfrom
codex/4546-wpg-spend-instrumentation

Conversation

@lidge-jun

Copy link
Copy Markdown
Owner

Summary

An operator can now read what one logical request actually cost. sendCount already counted physical sends per attempt, which answers the wrong question: a turn that failed over and fanned out across combo targets is one user turn, and nothing summed it.

A request log row now carries logicalRequestId and a spend record aggregating every attempt under that turn, combo children included, split into settled — sends whose attempt reached a terminal status — and unresolved, an attempt abandoned in flight or a budget charge no attempt row accounted for. Unresolved spend is never folded into settled, because an unexplained send is exactly the quantity this record exists to expose. reserved and policyVersion report the request execution budget's final state, and moveReasons keeps every pool-binding move that discarded a warmed cache prefix rather than only the last. /api/usage totals these as sends, settledSends, unresolvedSends and spendRequests.

Cache detail is now qualified by provenance rather than read as measurement. Strict-client normalization emits zero-default token-detail objects on every bridged wire, so a cached_tokens: 0 recovered from a parsed wire proves nothing. cacheProvenance keeps observed, synthesized and unknown distinct from capture through persistence to the summary, only observed input tokens reach the cacheHitRate denominator, and the summary counts the three provenances separately instead of averaging an unknown as a zero. Rows written before the field existed are reconstructed from their own shape so historical readings do not move.

Two defects are fixed alongside. Affinity was set on the row and omitted from the field-by-field disk projection in addRequestLog, so every move was lost at the next restart — the same whitelist trap #4592 hit one layer up. And a no-account selection handed its cause to whatever later resolve happened to succeed, which for a pool that stays exhausted is never; reasons are now held per thread and model lane, consumed by that lane alone, and attached to the failing request's own row.

Stacked on #4637.

Verification

Not run, by explicit instruction: the local suite, bun run typecheck, bun install, and any build. The verification posture for this unit is hosted CI at the exact final head SHA and nothing else; this push used --no-verify.

New coverage: tests/server/request-spend-instrumentation.test.ts and tests/usage/usage-spend-cache-provenance.test.ts, both registered in scripts/test-layout/layout.json and tests/fixtures/test-layout-expected.json. One pinned assertion in tests/usage/usage-summary.test.ts is updated with its reason: it divided a single measured row's cache reads by 256 rows that never measured cache.

Known open: the producer for logCtx.executionBudget lives at the ingress in src/server/responses/core.ts, outside this lane's write set, so until it is wired the budget-derived fields stay absent while spend.sends remains live from attempts alone. No UI column was added and gui/ is untouched, so the Logs attempts table still has nowhere to render this.

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.

@lidge-jun
lidge-jun requested a review from Ingwannu as a code owner September 14, 2026 15:07
@coderabbitai

coderabbitai Bot commented Sep 14, 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: 2ce9b78e-bcf1-4152-afb2-fca7a1d798ee

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 14, 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-14T15:12:54.935893Z bd75d0d 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 enhancement New feature or request label Sep 14, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Deterministic PR hygiene checks passed.

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

ℹ️ 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/request-log.ts
const cacheProvenance = classifyCacheTelemetryProvenance(loggedUsage, {
wireParsed: logCtx.usageWireParsed === true,
});
const logicalRequestId = logCtx.logicalRequestId ?? logCtx.executionBudget?.logicalRequestId;

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 Attach the execution budget to the log context

Production requests never populate either new context field read here: the ingress paths create or reuse sendBudget in src/server/responses/core.ts, but never assign it or its ID to logCtx (repository-wide search finds assignments only in the new unit tests). Consequently real rows omit logicalRequestId, reserved, and policyVersion, and budget charges without an attempt row cannot contribute to unresolved; attach the shared budget to the context when it is created or received.

Useful? React with 👍 / 👎.

Comment thread src/usage/summary.ts
Comment on lines +471 to +475
return entry.attempts.map(attempt => {
// An attempt's own provenance wins; the row's is the fallback for a child written before
// attempt-level provenance existed. A child carrying no cache fields still resolves to
// unknown in cacheObservationFromUsage, so it cannot inherit a sibling's observation.
const cacheProvenance = attempt.cacheProvenance ?? entry.cacheProvenance;

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 Record cache provenance on each production attempt

For combo requests, this fallback applies the aggregate row provenance to every child because production code never sets PersistedUsageAttempt.cacheProvenance; only the new test fixtures do. If one child has observed positive cache reads while another carries a synthesized zero, the aggregate is observed, so the synthesized child's input tokens incorrectly enter its model's hit-rate denominator and it reports 0% instead of null. Set provenance on activeAttempt when usage is captured rather than relying on the parent fallback.

Useful? React with 👍 / 👎.

Comment thread src/server/request-log.ts
Comment on lines +1169 to +1177
export function recordNoAccountAffinityFailure(
logCtx: RequestLogContext,
lane: AffinityModelLane,
reason?: CodexAffinityReason,
): CodexAffinityReason | undefined {
const resolved = isKnownAffinityReason(reason) ? reason : takeNoAccountAffinityReason(lane);
if (resolved === undefined) return undefined;
noteAffinityMove(logCtx, "cleared", resolved);
logCtx.errorCode ??= "codex_no_account";

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 Wire the affinity recorders into the request path

These new recorders have no production callers: repository-wide search finds noteNoAccountAffinityReason, recordNoAccountAffinityFailure, and noteAffinityMove only in this file and its direct unit tests, while src/codex/routing.ts still uses the thread-only pendingReleaseReasons map and src/server/responses/core.ts still assigns the single affinity fields directly. Thus an exhausted model lane still does not annotate its failing row, lanes can still share the old thread-keyed cause, and moveReasons never accumulates multiple moves; invoke these helpers from the routing/auth failure and successful-resolution paths.

Useful? React with 👍 / 👎.

Comment thread src/usage/log.ts
Comment on lines +227 to +233
const sends = count(spend.sends);
const settled = count(spend.settled);
const unresolved = count(spend.unresolved);
if (sends === null || settled === null || unresolved === null) return undefined;
if (settled > sends) return undefined;
const reserved = "reserved" in spend ? count(spend.reserved) : undefined;
if (reserved === null) return undefined;

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 Validate the complete spend decomposition

For persisted or hand-edited rows, the normalizer only checks settled <= sends and accepts arbitrary unresolved or reserved values, despite /api/usage trusting and summing them. For example, {sends: 2, settled: 2, unresolved: 100} survives normalization and inflates unresolved spend, while {sends: 2, settled: 1, unresolved: 0} silently loses an unexplained send. Require unresolved === Math.max(sends, reserved ?? sends) - settled before accepting the record.

Useful? React with 👍 / 👎.

Comment on lines +457 to +459
A row also carries what its logical request cost upstream. `logicalRequestId` names the turn
that a retry leg, a repair refetch and a combo child all belong to, and `spend` aggregates their
physical sends: `sends` totals every attempt on the row, `settled` counts the sends whose attempt

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 Document the new public usage fields in docs-site

This adds public /api/logs and /api/usage fields and changes the documented interpretation of cacheHitRate, but the only documentation update is this internal structure note; the English management API page and its translations still omit the spend/provenance fields and the observed-only denominator. Update docs-site/src/content/docs/reference/management-api.md and keep its locales consistent with the new wire contract.

AGENTS.md reference: AGENTS.md:L380-L381

Useful? React with 👍 / 👎.

@lidge-jun

Copy link
Copy Markdown
Owner Author

리뷰 · 우선순위 64 / 80

이 PR은 스택 문서의 wpg(spend instrumentation) 레이어입니다. base가 codex/4546-wpb-combo-adapter-retries(#4637)라서, 지금 dev(627274b)만 보면 아직 없는 콤보·어댑터 전송 숫자를 집계 쪽에 올리는 작업입니다. #4546이 남긴 「논리 요청당 전송·지출·캐시 출처를 운영자가 읽을 수 없다」를 닫으려는 마지막 관측 층이고, wpc~wpb가 만든 숫자를 읽기 좋게 묶는 자리입니다.

지금 devsendCount는 시도(attempt) 단위입니다. 콤보 페일오버·어댑터 안쪽 재시도가 한 사용자 턴에 묶여도 합산 행이 없었습니다. 이 PR은 요청 로그에 logicalRequestIdspend(settled/unresolved/reserved/policyVersion)를 두고, unresolved를 settled에 섞지 않으며, 캐시 provenance와 moveReasons를 같이 남깁니다. src/usage/log.ts·summary.ts·management 공유 스키마·구조 문서·단위 테스트가 한 세트입니다.

가치는 큽니다. 다만 wpg는 「앞 레이어가 만든 것을 보고한다」가 설계 전제라서, wpb가 CI에서 [3,3,3]으로 아직 틀리면 여기 집계도 틀린 숫자를 예쁘게 보여 줄 뿐입니다. 호스티드 CI도 test·macos·enforce-target이 빨간불이고, base가 dev가 아니라 스택 tip이라 enforce-target 정책과도 겹칩니다. 로컬 스위트는 돌리지 않은 스택 공통 자세입니다.

라인 src/usage/log.ts (PersistedRequestSpend / unsettled 분해) - settled+unresolved≤sends 불변식을 파서에서 거르는 방향은 맞습니다. 다만 Cursor처럼 cap은 걸려도 sendCount가 1로 남는 경로(#4637 known open)가 있으면 settled가 과소 보고됩니다. wpg 문서/테스트에 「어댑터 미관측 전송은 unresolved가 아니라 그냥 누락」인지 한 줄로 밝혀 두는 편이 좋습니다.

경로 structure/gui-and-management-api.md - API 스키마를 문서에 맞춘 것은 좋습니다. GUI가 아직 이 필드를 안 그리면 「운영자가 읽는다」는 요약과 실제 화면 사이에 간격이 남습니다. 이번 범위 밖이면 후속 이슈 번호만 박아 주세요.

경로 CI enforce-target - base가 dev/preview가 아니라 wpb 브랜치라 게이트가 실패할 수 있습니다. 스택 PR 특성상 예상 가능한데, 랜딩 전에 base를 순차적으로 dev로 옮기는 기차만 지키면 됩니다.

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

너의 추천
#4637이 [3,2,1]·CI 녹색을 증명한 뒤에 이어서 머지하세요. 지금은 리뷰·설계만 인정하고 랜딩은 보류가 맞습니다. 머지 시 dev에 올린 뒤 epic #4546 남은 관측 항목을 이 PR SHA로 체크하면 됩니다.

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

@lidge-jun
lidge-jun force-pushed the codex/4546-wpb-combo-adapter-retries branch from 52734ae to 8f27859 Compare September 14, 2026 15:31
@lidge-jun
lidge-jun force-pushed the codex/4546-wpg-spend-instrumentation branch 2 times, most recently from 92f5ddc to 7382f6a Compare September 14, 2026 15:49
@lidge-jun
lidge-jun force-pushed the codex/4546-wpg-spend-instrumentation branch from 7382f6a to 27b5170 Compare September 14, 2026 16:08
@lidge-jun
lidge-jun force-pushed the codex/4546-wpb-combo-adapter-retries branch from 3ea8131 to 4398009 Compare September 14, 2026 17:46
…uest (#4546)

Part of the stacked delivery closing the remaining OCX-4546 cost-guard scope.

Verification posture: local suite, typecheck, install and build NOT run by explicit instruction. Hosted CI at the exact final head is the only proof. Pushed with --no-verify.
structure/gui-and-management-api.md sat exactly at the 600-line budget, so documenting the spend and cache-provenance record pushed it to 630 and structure:check failed. The grace entry is the mechanism the check itself names. The plan it stands for: the usage-aggregation half of this doc is now large enough to be its own page, and splitting it is a separate change that touches no source.

Verification posture: local suite, typecheck, install and build NOT run by explicit instruction. Hosted CI at the exact final head is the only proof.
)

The membership oracle resolves an unmapped file through the regex seeds and fails when a seed disagrees with the explicit table. request-spend-instrumentation.test.ts was claimed by the usage seed while the table pinned it to server; the file exercises the request-log writer, so the name moves rather than the domain.

Verification posture: local suite, typecheck, install and build NOT run by explicit instruction. Hosted CI at the exact final head is the only proof.
Base automatically changed from codex/4546-wpb-combo-adapter-retries to dev September 14, 2026 17:46
@lidge-jun
lidge-jun force-pushed the codex/4546-wpg-spend-instrumentation branch from 2478c48 to 6c96bdb Compare September 14, 2026 17:46
@lidge-jun
lidge-jun merged commit a223a25 into dev Sep 14, 2026
6 of 21 checks passed
@lidge-jun
lidge-jun deleted the codex/4546-wpg-spend-instrumentation branch September 14, 2026 17:46
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