Ctrl K
ComunidadGuías…

evidence/BillingIdentity.md

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# Billing identity across credential rotation — review #3

Lane: `BillingIdentityFix` · Worktree `w62-billingid` · Branch `fix/billing-identity` · Base `0dea7e2` (release/3.10.0)
No push, no tag.

## Finding (GPT-6-Pro review #3, `evidence/GPT6ProReview.md:108-131`)

> API key 在请求过程中轮换,会让成功流请求漏记账号账本 … `auth.js:3042` reservation 保存取得账号时的 API key;`auth.js:2279` `setAccountTokens()` 可以合法替换账号当前 key;`chat.js:3983` 首次成功 streaming 路径用 reservation 中的旧 key 调用 `recordAccountSpend()`;`auth.js:3992` 按当前 key 查找账号;`:3995` 找不到便直接返回。

Reproduced by the reviewer: rotate the key mid-stream, finish with usage 12 → account request ring 0, `_totalSpend.requests` 0; non-stream was correct because it resolved the current key by account id.

Requirement: **the immutable `accountId` owns the ledger; the rotatable secret only authenticates the network call.** A late failure from an old credential epoch must not penalize the new epoch.

## Root cause

`recordAccountSpend(apiKey, …)` used `accounts.find(a => a.apiKey === apiKey)`. A request holds the acquire-time reservation; `setAccountTokens()` / a background re-login can replace `account.apiKey` in place before the turn completes. The lookup then misses, `recordAccountSpend` returns early, and the successful turn disappears from the account ledger (global token stats still move, so the two views disagree).

## Changes

### `src/auth.js`

- `recordAccountSpend(identity, usage, opts)` (`:4087`) now takes an **attempt identity**: `{ accountId|id, apiKey, attemptId|reservationTimestamp }`, or a bare apiKey for env-token / detached paths.
- `resolveSpendAccount()` (`:4066`) resolves by id first. **A supplied id is authoritative**: when it does not resolve, the key is not tried (a credential that outlived its account may have been reused elsewhere).
- `spendAttemptId()` (`:4080`) derives the attempt id from the reservation timestamp; `recordAccountRequest()` (`:4006`) stores it on the ring row as `attemptId` for provenance.
- String apiKey input remains supported, so the existing env-token/detached fallback behavior is unchanged.

### `src/handlers/chat.js`

All production spend sites now pass identity, not a credential:

| Path | Site | Identity |
|---|---|---|
| connect stream (first success) | `:4021` | the reservation object `a` |
| connect stream (re-login retry) | `:4061` | `{ accountId: a.id, apiKey: freshKey, attemptId: a.reservationTimestamp }` |
| connect non-stream | `:4371` | the reservation `acct` |
| Cascade non-stream | `:5944` | `{ accountId, apiKey }` (new `accountId` parameter threaded from `acct.id`) |
| Cascade stream | `:7619` | the reservation `acct` |
| Cascade stream, detached resume | `:7623` | `{ accountId: resumeBillingAccountId, apiKey: ownerKey }` |

Credential-epoch guard:

- `credentialEpochCurrent(acct, credentialKey)` (`:2533`) compares the credential an attempt presented with the account's live key (same epoch test auth.js uses for credit refreshes/catalog syncs).
- `finalizeConnectAccount()` (`:2540`) accepts `credentialKey` (the key a re-login retry actually presented) and gates **credential-scoped** verdicts on epoch currency: `reportError`, `reportInternalError`, `reportSuccess`, and `reLoginAccount`. Account-level cooldowns (`QUOTA_EXHAUSTED`, `RATE_LIMITED`, `CAPACITY`) still land on the live account — REF-2 behavior preserved.
- Both handler re-login recovery paths (`:4052` stream, `:4283` non-stream) skip re-login when the 401 came from a retired credential. The failover loop then re-acquires the account under its live key (or moves on) without minting a third key.
- Re-login retry finalize calls pass `credentialKey: freshKey`, so a retry on the freshly minted key is still scored normally.

### Test

`test/billing-identity-rotation.test.js` (new, 6 tests):
1. **Exact reviewer scenario** — stream mocked at `__setConnectDeps`; real `setAccountTokens()` rotates the key mid-stream; stream finishes with usage 12 → ring `total === 1`, `summary.totalTokens === 12`, `_totalSpend.requests === 1`, `_totalSpend.totalTokens === 12`, client gets `[DONE]`.
2. Late `UNAUTHORIZED` from the retired credential after rotation → `errorCount 0`, status `active`, slot released.
3. Control: the same 401 on the current credential → `errorCount 1` (guard is epoch-scoped, not a blanket suppression).
4. Handler-level stale 401 with `DEVIN_CONNECT_AUTO_RELOGIN=1` and a fake login → `windsurfLogin` never called, the rotated key is not overwritten, the request recovers on the live key, `errorCount 0`.
5. `recordAccountSpend({ accountId, apiKey: stale, attemptId })` → row lands on the account, `attemptId` recorded.
6. Unknown `accountId` + a key that resolves elsewhere → no row (id authoritative).

## Red-before (base `0dea7e2` + new test only)

```
node --import ./test/setup-env.mjs --test test/billing-identity-rotation.test.js
✖ a streaming turn that finishes after the key rotated still bills the account
  AssertionError: exactly one ledger row for the turn — 0 !== 1
✖ a late 401 from the retired credential does not charge the new epoch
  AssertionError: the retired credential's failure must not charge the new key — 1 !== 0
✖ a stale 401 does not trigger a re-login that would rotate the fresh key
  AssertionError: a failure from the retired credential must not re-login the account — 1 !== 0
✖ resolves by accountId even when the key snapshot is stale
  AssertionError: 0 !== 1
ℹ tests 6 · pass 2 · fail 4
```

## Green-after

```
node --import ./test/setup-env.mjs --test test/billing-identity-rotation.test.js
ℹ tests 6 · pass 6 · fail 0
```

Consolidated regression (spend, finalize/epoch, connect handler, detach billing):
```
node --import ./test/setup-env.mjs --test \
  test/billing-identity-rotation.test.js test/auth-total-spend.test.js \
  test/account-dirty-retry.test.js test/connect-rate-table-wiring.test.js \
  test/dashboard-account-requests.test.js test/strict-usage-total.test.js \
  test/cascade-stream-account-spend.test.js test/connect-error-blame.test.js \
  test/connect-relogin-refcount.test.js test/connect-abort-cooldown.test.js \
  test/connect-capacity-cooldown.test.js test/connect-dimension-behaviour.test.js \
  test/connect-dimension-guard.test.js test/devin-connect-stream-lifecycle.test.js \
  test/devin-connect-failover.test.js test/connect-sticky-e2e.test.js \
  test/session-reauth-hooks.test.js test/sticky-queue-on-pin.test.js \
  test/devin-connect-pool.test.js test/s4-r5.ladder.test.js \
  test/detached-registry.test.js test/cascade-detach-lazy-resume.test.js \
  test/cascade-turn-snapshot-v2.test.js
ℹ tests 258 · pass 258 · fail 0
```

Wider sweep (`test/billing-*`, `test/dashboard-*`, `test/account-*`, `test/auth-*`):
```
ℹ tests 691 · pass 689 · fail 2
✖ test/dashboard-escape-callsites.test.js (2) — pre-existing on base, verified below
```

Additional handler batches (all files importing `__setConnectDeps`): 57/57 and 89/89 pass.

## Pre-existing failures (verified on base `integrate3100` @ `0dea7e2`, not caused by this lane)

- `test/connect-token-usage.test.js`: 2/4 fail on base (source regexes still expect `recordTokenUsage(_sr?.usage)` without the second `reqModelName` argument).
- `test/dashboard-escape-callsites.test.js`: 2/5 fail on base.

## Not verified / handoffs

- `[UNVERIFIED]` Mutation harness (`scripts/mutate-verify*.mjs`) cannot run on this Windows box: `mutation-harness-utils.mjs` requires git at a POSIX absolute path (`/usr/bin/git`, …). Red-before above is the equivalent defect re-introduction.
- **Cross-lane handoff**: `test/mutations/detach-p1-soak.json` M8's anchor embeds the old detached-billing call. It was reverted from this lane per ownership; the integration/detach lane must update the anchor tail from `recordAccountSpend(ownerKey, usage);` to `recordAccountSpend({ accountId: resumeBillingAccountId, apiKey: ownerKey }, usage);` or the mutation harness will report a missing anchor. All four chat.js anchors were checked to match exactly once with that one-line update.
- ABA edge: the epoch test compares key values, not a monotonic generation counter. A rotation away from and back to the same key value during one attempt would read as current. The codebase already uses key equality for the same class of guard (`isCurrentConnectCatalogAttempt`, credit refresh); a generation counter is the architecture-level follow-up, not this stopgap.
- `recordRequest()`'s `accountCounts` dimension is still a credential-prefix display key (documented in `auth.js`); it was left unchanged because it is not the spend ledger and the finding is scoped to ledger attribution.

## Verdict

The exact reviewer scenario is red on base and green with the fix. The ledger is owned by `accountId`; a rotated credential cannot lose attribution, and a late failure from a retired credential no longer charges or re-rotates the new epoch.