Skip to content

Commit c1f3aac

Browse files
kevin-dpclaude
andauthored
fix(agents-server-ui): show uncached input tokens in the meta row (#4567)
## Summary Follow-up to #4502. The input side of the per-response token-usage label summed `input + cacheRead + cacheWrite`, but `cacheRead` re-counts the entire conversation history on every warm-cache step — so the label ballooned into a cumulative number that said nothing about the work the response actually did. The adapter now surfaces only the **uncached** input side: fresh prompt tokens plus cache writes, with prompt-cache reads excluded. `cacheWrite` is counted because cache-enabled providers report newly appended prompt tokens there (with `input` collapsing to ~0); excluding it would surface tiny "3 ↑" labels instead. This matches the accounting the #4552 goal-budget progress bar uses, so the two displays stay consistent. ## Changes - `pi-adapter.ts`: drop `usage.cacheRead` from the input sum (legacy flat `inputTokens` fallback unchanged — no cache split means the whole side counts as uncached) - Comments updated across `outbound-bridge.ts`, `entity-schema.ts`, `entity-timeline.ts` and `TokenUsage.tsx` to document the uncached semantics of `input_tokens` - Test updated: `input: 50, cacheRead: 1200, cacheWrite: 100` now persists `150`, not `1350` ## Notes - Steps recorded before this change keep their stored cache-inclusive totals — both fields are optional and the display sums what's persisted, so no migration is needed. - #4552 touches the same `pi-adapter.ts` block (it splits total vs. uncached for budget enforcement) and will need a small rebase after this merges. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 708c946 commit c1f3aac

7 files changed

Lines changed: 58 additions & 22 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@electric-ax/agents-server-ui': patch
3+
'@electric-ax/agents-runtime': patch
4+
---
5+
6+
Show only uncached input tokens in the per-response token usage label.
7+
8+
The input side previously summed `input + cacheRead + cacheWrite`, so
9+
on warm-cache turns the meta row re-counted the entire conversation on
10+
every step and ballooned into a cumulative number that said nothing
11+
about the work the response actually did. The adapter now surfaces the
12+
uncached side only — fresh prompt tokens plus cache writes, with
13+
prompt-cache reads excluded. (`cacheWrite` is counted because
14+
cache-enabled providers report newly appended prompt tokens there,
15+
with `input` collapsing to ~0.)
16+
17+
Steps recorded before this change keep their stored cache-inclusive
18+
totals — both step fields are optional and the display just sums
19+
what's persisted, so no migration is needed.

packages/agents-runtime/src/entity-schema.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,9 @@ type StepValue = {
160160
// end-of-message `usage` payload. Populated on `onStepEnd` when the
161161
// adapter has the data — older events without these fields stay
162162
// valid (both optional), so this is a strictly additive change.
163+
// `input_tokens` is the *uncached* input side (fresh tokens plus
164+
// cache writes; cache reads excluded) — the cache-inclusive total
165+
// would re-count the whole conversation on every step.
163166
input_tokens?: number
164167
output_tokens?: number
165168
}

packages/agents-runtime/src/entity-timeline.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,10 @@ export type EntityTimelineSection =
6262
done?: true
6363
error?: string
6464
// Summed across all steps of the run that produced this section.
65-
// Either side may be missing if the provider didn't report it
66-
// (e.g. older events recorded before tokens were persisted).
65+
// `input` is the uncached side only (fresh tokens + cache writes)
66+
// — see `StepValue.input_tokens`. Either side may be missing if
67+
// the provider didn't report it (e.g. older events recorded
68+
// before tokens were persisted).
6769
tokens?: {
6870
input?: number
6971
output?: number

packages/agents-runtime/src/outbound-bridge.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,9 @@ export interface OutboundBridge {
148148
onStepStart: (opts?: { modelProvider?: string; modelId?: string }) => void
149149
onStepEnd: (opts?: {
150150
finishReason?: string
151+
// Uncached input side only (fresh prompt tokens + cache writes;
152+
// prompt-cache *reads* excluded) — the cache-inclusive total would
153+
// re-count the whole conversation on every warm-cache step.
151154
tokenInput?: number
152155
// Uncached portion of the input side (no cacheRead/cacheWrite). Not
153156
// persisted to the step row — forwarded to hooks for budget accounting.

packages/agents-runtime/src/pi-adapter.ts

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -468,19 +468,24 @@ export function createPiAgentAdapter(
468468
// `cacheRead` (prompt-cache hits — typically the
469469
// system prompt + prior history once the cache is
470470
// warm) and `cacheWrite` (tokens added to the cache
471-
// this turn). What the user wants in the meta row is
472-
// the total prompt volume the model actually saw, so
473-
// we sum every side that arrived as a number. Reading
474-
// only `usage.input` undercounts massively on second+
475-
// turns where most of the prompt hits the cache and
476-
// `usage.input` collapses to a handful of tokens.
471+
// this turn). The meta row shows the *uncached* input
472+
// — `input + cacheWrite` — i.e. the new prompt work
473+
// this step did. `cacheRead` is deliberately excluded:
474+
// it re-counts the entire conversation on every warm
475+
// turn, so including it balloons the label into a
476+
// cumulative number that says nothing about this
477+
// response. `cacheWrite` IS counted: cache-enabled
478+
// providers report newly appended prompt tokens there
479+
// (with `input` collapsing to ~0), so excluding it
480+
// would surface tiny "3 input" labels instead.
477481
//
478482
// `inputTokens` / `outputTokens` are legacy flat
479483
// aliases (kept as a fallback for non-pi-ai providers
480-
// that don't split the cache columns). We deliberately
481-
// do NOT coerce a missing side to `0` — doing so
482-
// would be indistinguishable from a real zero-token
483-
// step in the meta row, and the query-layer
484+
// that don't split the cache columns); with no cache
485+
// split, the whole side counts as uncached. We
486+
// deliberately do NOT coerce a missing side to `0` —
487+
// doing so would be indistinguishable from a real
488+
// zero-token step in the meta row, and the query-layer
484489
// `count(...)` aggregate would mark the side as
485490
// present when it really isn't.
486491
const sumPresentNumbers = (
@@ -497,11 +502,7 @@ export function createPiAgentAdapter(
497502
return saw ? total : undefined
498503
}
499504
const usageInput =
500-
sumPresentNumbers([
501-
usage?.input,
502-
usage?.cacheRead,
503-
usage?.cacheWrite,
504-
]) ??
505+
sumPresentNumbers([usage?.input, usage?.cacheWrite]) ??
505506
(typeof usage?.inputTokens === `number`
506507
? usage.inputTokens
507508
: undefined)

packages/agents-runtime/test/pi-adapter.test.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -934,11 +934,14 @@ describe(`toAgentHistory`, () => {
934934
expect(stepValue?.output_tokens).toBe(567)
935935
})
936936

937-
it(`sums input + cacheRead + cacheWrite into the input token total`, async () => {
937+
it(`sums input + cacheWrite (cache reads excluded) into the input tokens`, async () => {
938938
// Anthropic + other prompt-cache providers split input across
939-
// three counters; reading only `usage.input` would surface
940-
// tiny "3 input" labels on cache-warm turns. The adapter sums
941-
// all three so the meta row reflects the real prompt volume.
939+
// three counters. The adapter surfaces the *uncached* side —
940+
// fresh tokens plus cache writes. `cacheRead` re-counts the
941+
// entire history on every warm turn, so including it would make
942+
// the meta row a runaway cumulative number; `cacheWrite` must be
943+
// counted because cache-enabled providers report newly appended
944+
// prompt tokens there (with `input` collapsing to ~0).
942945
const events = await runOnce(
943946
makeCompletedMessage({
944947
input: 50,
@@ -948,7 +951,7 @@ describe(`toAgentHistory`, () => {
948951
})
949952
)
950953
const stepValue = findStepUpdate(events)
951-
expect(stepValue?.input_tokens).toBe(1350)
954+
expect(stepValue?.input_tokens).toBe(150)
952955
expect(stepValue?.output_tokens).toBe(80)
953956
})
954957

packages/agents-server-ui/src/components/TokenUsage.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ import styles from './TokenUsage.module.css'
1010
* jittering as numbers tick up (input grows when a tool result is
1111
* fed back; output grows when the model streams a new step).
1212
*
13+
* `input` is the uncached input side only — fresh prompt tokens plus
14+
* cache writes, with prompt-cache *reads* excluded. The cache-inclusive
15+
* total re-counts the entire history on every step, so it balloons into
16+
* a cumulative number that says nothing about the work this response did.
17+
*
1318
* Either side may be `undefined` (the provider didn't emit it, or
1419
* the section is historical and was recorded before tokens were
1520
* persisted) — we skip the missing half rather than print `0`.

0 commit comments

Comments
 (0)