Skip to main content

Chatticus

Cost accounting spike T2: per-call cost records in the pi-durable session

Status: FINDINGS of Kanbus chatticus-9e7710 (epic chatticus-8c857b), measured against @earendil-works/pi-durable 1.0.2 and @earendil-works/pi-ai as installed at the repo root, on the production IndexedStorage (DynamoDB plus S3 on moto). docs/COST_ACCOUNTING.md (PR 533) is not on develop yet, so these findings live here. Fold this file into section 3.2 and 3.3 of that document when it lands, then delete this file. Nothing here changes production behavior.

Evidence is the vitest conversation/test/pi-durable-cost-contract.test.ts (14 tests, all passing). It is kept as a guard of the library contract the design relies on: if a pi-durable upgrade changes any of these facts, the test fails and the design must be re-read. Items labelled INFERRED were read from library code and not run.

The question

Can a per-model-call cost record be written into the durable session in the same commit as the response, so cost rides with the session across handoffs, crashes, retries and takeovers? The design (COST_ACCOUNTING.md section 3.2) assumed the afterResponse hook of GenerationHooks could write a defineDoc document atomically with the response.

Short answer

No. The afterResponse hook cannot join the response commit. It runs before the commit, with a read-only API, so anything it writes is a separate commit. That is not a blocker: the response commit already contains everything a cost record needs (the assistant entry carries usage and pi-ai's cost estimate, and pi-durable's own pi.usage document is updated in that same commit). The right design is the fallback in the ticket: derive keyed records from the session at the existing finalize points. It is idempotent by construction and needs no hook.

Findings

1. Atomicity: the hook is outside the commit

2. Idempotency under retry and takeover

3. Readable by key, and carried with the session

4. Is usage plus pi-ai's estimate enough to price with our own book?

Yes, with no new hook. Each assistant entry stores usage.input (uncached, because the OpenAI adapter subtracts cached and cache write tokens from input_tokens), cacheRead, cacheWrite, output (reasoning is a subset of output, with reasoning carried separately), totalTokens, and cost from pi-ai's catalog, plus provider, model, responseModel, responseId and stopReason. The context size for tier selection is input + cacheRead + cacheWrite. The test recomputes dollars from a separate price book (base rates plus a tier above 272,000 context tokens) and matches the stored estimate to 12 decimal places for a normal and a long-context call. So the cost record can be a pure function of (stored usage, our price book): store the raw counters, the price book id and our computed dollars, and keep pi-ai's cost.total only as the cross-check the plan wants.

What the stored response does not carry:

Recommendation

Do not build on the afterResponse hook. Use the fallback: idempotent keyed derivation at the existing finalize points.

  1. The cost record is derived from the assistant entries of a conversation, keyed entry-<entryId> (family document, scope conversation, history latest, fork: "initial"), written in one commit per derivation, skipping filled keys. It is atomic with nothing extra to prove, because its source (the entry and pi.usage) is already in the response commit.
  2. Derive at conclude, fail, yield and park (the safety-net points already in section 3.3), and on takeover before the new owner's first model call. Each run is a no-op for records already written, so repeat, retry and takeover are free.
  3. Price at record time from the versioned book and store priceBookId, tier, raw counters and our dollars on the record. Keep pi-ai's estimate as a field for the cross-check.
  4. Account for compaction as a per-conversation remainder: pi.usage total minus the sum of entry usage, recomputed (not accumulated) at the same points, written under a key such as compaction-remainder-<conversationId>-<turnId>.
  5. Keep the hook out of the design. It adds only one thing the derivation does not: a record for a response whose commit then failed. That window is narrow (hook to commit) and is already the job of the gateway observation (T5), which also covers the wider window before the hook. If the owner ever wants the hook as an optimisation, it must use the undocumented runtime commit and a key derived from responseId, and the derivation must still run, so it buys nothing.
  6. Channel total (ticket item 4, a design decision rather than a measurement): sessions are per bot and channel, so the channel total belongs in the messaging table. Use a sibling item pk = {tenant}#cost#channel#{channelId} updated in the same transaction as the turn index item (idempotent by turn id), not a GSI: one household, a handful of channels, and a GSI adds an index to the table for a read that a single key serves.

Effect on the plan and tickets