Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions docs/CACHING_AND_SAVINGS.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ Pricing relative to base input rate:
| bucket | meaning | rate |
|---|---|---:|
| `input_tokens` | uncached input | `1.0x` |
| `cache_creation_input_tokens` (`cc`) | cache write | `1.25x` |
| `cache_creation_input_tokens` (`cc`) | cache write | `1.25x` (5m) / `2x` (1h) |
| `cache_read_input_tokens` (`cr`) | cache read | `0.1x` |
| output | model reply | `5x` input on Fable 5 |

Expand Down Expand Up @@ -97,7 +97,7 @@ For each `/v1/messages` request, pxpipe records three measurements:
The actual input cost is:

```text
actual_eff = input_tokens + cc * 1.25 + cr * 0.10
actual_eff = input_tokens + cc_5m * 1.25 + cc_1h * 2.0 + cr * 0.10
```

The text baseline first splits the measured text tokens:
Expand All @@ -110,7 +110,7 @@ coldTail = baseline_tokens - cacheable
If the actual request is cold (`cr === 0`):

```text
baseline_eff = cacheable * 1.25 + coldTail
baseline_eff = cacheable * observed_write_rate + coldTail
```

If the actual request is warm (`cr > 0`):
Expand All @@ -119,10 +119,10 @@ If the actual request is warm (`cr > 0`):
reused = min(prevCacheable, cacheable)
grown = cacheable - reused

baseline_eff = reused * 0.10 + grown * 1.25 + coldTail
baseline_eff = reused * 0.10 + grown * observed_write_rate + coldTail
```

`prevCacheable` is used only after `cr > 0` proves a warm read. It refines how much of the text baseline was reused vs newly grown. If there is no completed same-session prior with the same static-prefix hash, pxpipe assumes full reuse for the text baseline: `prevCacheable = cacheable`. That is conservative for savings because it makes the text baseline cheaper.
`observed_write_rate` is the request's server-reported mix of 5-minute writes at `1.25x` and 1-hour writes at `2x`. If the API omits the tier split, pxpipe falls back to `1.25x` for backward compatibility. `prevCacheable` is used only after `cr > 0` proves a warm read. It refines how much of the text baseline was reused vs newly grown. If there is no completed same-session prior with the same static-prefix hash, pxpipe assumes full reuse for the text baseline: `prevCacheable = cacheable`. That is conservative for savings because it makes the text baseline cheaper.

Replay uses request start time (`ts - duration_ms`) to avoid overlapping requests refining each other's `prevCacheable` split before the earlier request had completed.

Expand Down Expand Up @@ -190,6 +190,8 @@ Every row in `~/.pxpipe/events.jsonl` carries the fields needed to reproduce the
- `baseline_cacheable_tokens`
- `input_tokens`
- `cache_create_tokens`
- `cache_create_5m_tokens`
- `cache_create_1h_tokens`
- `cache_read_tokens`
- `first_user_sha8`
- `system_sha8`
Expand Down
69 changes: 63 additions & 6 deletions src/core/baseline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,31 @@
* See docs/CACHING_AND_SAVINGS.md for the full derivation and audit history.
*/

/** Documented Anthropic price ratios: cc_5m = 1.25×, cr = 0.1× base input. One-line change if rates change. */
/** Documented Anthropic price ratios: cc_5m = 1.25×, cc_1h = 2×, cr = 0.1× base input. */
export const CACHE_CREATE_RATE = 1.25;
const CACHE_CREATE_1H_RATE = 2.0;
export const CACHE_READ_RATE = 0.1;

/** Effective cache-write rate for this request. Older usage payloads do not
* expose the tier split; preserve the historical/conservative 5-minute rate
* in that case. The text counterfactual uses the same observed tier mix as
* the transformed request because pxpipe relocates, rather than invents, the
* caller's cache-control markers. */
function cacheCreateRate(cc: number, cc5m?: number, cc1h?: number): number {
if (!(cc > 0)) return CACHE_CREATE_RATE;
const splitReported = cc5m !== undefined || cc1h !== undefined;
if (!splitReported) return CACHE_CREATE_RATE;
const fiveMinute = Math.max(0, cc5m ?? 0);
const oneHour = Math.max(0, cc1h ?? 0);
const splitTotal = fiveMinute + oneHour;
if (!(splitTotal > 0)) return CACHE_CREATE_RATE;
// The API contract says splitTotal === cc. Normalize malformed/mismatched
// payloads to the aggregate so telemetry never invents extra write tokens.
return (
fiveMinute * CACHE_CREATE_RATE + oneHour * CACHE_CREATE_1H_RATE
) / splitTotal;
}

/** Anthropic prompt-cache TTL (seconds). Kept for callers that display provider
* docs, but savings math does not use TTL to infer a hypothetical text-cache
* hit: text is considered warm only when the actual request reports cr > 0. */
Expand Down Expand Up @@ -103,23 +124,46 @@ export function computeBaselineInputEff(
cr: number,
warm = false,
prevCacheable = 0,
): number {
return computeBaselineInputEffWithCacheTier(
baseline, baselineCacheable, inputTokens, cc, cr, warm, prevCacheable, 0,
);
}

/** Tier-aware variant for internal telemetry accounting. */
export function computeBaselineInputEffWithCacheTier(
baseline: number,
baselineCacheable: number,
inputTokens: number,
cc: number,
cr: number,
warm: boolean,
prevCacheable: number,
cacheCreate1hTokens: number,
cacheCreate5mTokens?: number,
): number {
if (baseline <= 0) return 0;
// Probe miss: can't split prefix from tail, so credit nothing (same as actual).
if (baselineCacheable <= 0) return computeActualInputEff(inputTokens, cc, cr);
if (baselineCacheable <= 0) {
return computeActualInputEffWithCacheTier(
inputTokens, cc, cr, cacheCreate1hTokens, cacheCreate5mTokens,
);
}
const cacheable = Math.min(baselineCacheable, baseline);
const coldTail = baseline - cacheable;
const createRate = cacheCreateRate(cc, cacheCreate5mTokens, cacheCreate1hTokens);
if (warm) {
// Text reads the prefix it already had cached (0.10×) and creates only the
// growth since last turn (1.25×). Independent of the image path's cache.
// growth since last turn (at the observed write-tier rate). Independent of
// the image path's cache.
const reused = Math.min(Math.max(prevCacheable, 0), cacheable);
const grown = cacheable - reused;
return reused * CACHE_READ_RATE + grown * CACHE_CREATE_RATE + coldTail * 1.0;
return reused * CACHE_READ_RATE + grown * createRate + coldTail * 1.0;
}
// Cold (first turn / TTL expiry): no warm cache for text either, so it
// re-creates the whole cacheable prefix at the create rate — same event the
// imaged path pays. Removes the phantom "free read" that fabricated a loss.
return cacheable * CACHE_CREATE_RATE + coldTail * 1.0;
return cacheable * createRate + coldTail * 1.0;
}

/** Weighted input cost pxpipe actually paid this turn. */
Expand All @@ -128,5 +172,18 @@ export function computeActualInputEff(
cc: number,
cr: number,
): number {
return inputTokens + cc * CACHE_CREATE_RATE + cr * CACHE_READ_RATE;
return computeActualInputEffWithCacheTier(inputTokens, cc, cr, 0);
}

/** Tier-aware variant for internal telemetry accounting. */
export function computeActualInputEffWithCacheTier(
inputTokens: number,
cc: number,
cr: number,
cacheCreate1hTokens: number,
cacheCreate5mTokens?: number,
): number {
return inputTokens
+ cc * cacheCreateRate(cc, cacheCreate5mTokens, cacheCreate1hTokens)
+ cr * CACHE_READ_RATE;
}
30 changes: 21 additions & 9 deletions src/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@ import * as readline from 'node:readline';
import type { ProxyEvent } from './core/proxy.js';
import type { TrackEvent } from './core/tracker.js';
import {
computeActualInputEff,
computeBaselineInputEff,
computeActualInputEffWithCacheTier,
computeBaselineInputEffWithCacheTier,
deriveBaselineWarmth,
} from './core/baseline.js';
import {
Expand Down Expand Up @@ -167,10 +167,10 @@ export interface RecentRow {
* cache_read>0 ⇒ a warm cache existed for both paths; cache_read===0 ⇒ cold
* for both, so text re-creates its prefix too (no phantom warm read on a
* cold turn — that would fabricate a loss out of a real token win):
* warm: baseline_input_eff = reused×0.10 + grown×1.25 + cold_tail×1.0
* warm: baseline_input_eff = reused×0.10 + grown×observed_create_rate + cold_tail×1.0
* where reused = min(prevCacheable, cacheable), grown = cacheable − reused
* cold: baseline_input_eff = cacheable×1.25 + cold_tail×1.0
* actual_input_eff = input + cache_create×1.25 + cache_read×0.10
* cold: baseline_input_eff = cacheable×observed_create_rate + cold_tail×1.0
* actual_input_eff = input + cache_create_5m×1.25 + cache_create_1h×2 + cache_read×0.10
* output_equiv = output × 5 (input-token-equivalent at the 5× output rate)
* saved = baseline_input_eff − actual_input_eff
* baseline_total = baseline_input_eff + output_equiv
Expand Down Expand Up @@ -607,6 +607,8 @@ export class DashboardState {
const inp = u?.input_tokens ?? 0;
const out = u?.output_tokens ?? 0;
const cc = u?.cache_creation_input_tokens ?? 0;
const cc5m = u?.cache_creation?.ephemeral_5m_input_tokens;
const cc1h = u?.cache_creation?.ephemeral_1h_input_tokens ?? 0;
const cr = u?.cache_read_input_tokens ?? 0;
const gpt = isOpenAIEvent(ev.path, ev.accountingProvider);

Expand Down Expand Up @@ -671,7 +673,9 @@ export class DashboardState {
haveBaseline = typeof baseline === 'number' && baseline > 0 && probeOk;

// Weighted INPUT cost we actually paid this turn.
actualInputEff = haveUsage ? computeActualInputEff(inp, cc, cr) : 0;
actualInputEff = haveUsage
? computeActualInputEffWithCacheTier(inp, cc, cr, cc1h, cc5m)
: 0;

// pxpipe only reduces input by imaging the static slab. An UNCOMPRESSED
// row had its body forwarded untouched, so its unproxied counterfactual
Expand Down Expand Up @@ -710,14 +714,16 @@ export class DashboardState {
prefixShaNow,
);
baselineInputEff = creditSaving
? computeBaselineInputEff(
? computeBaselineInputEffWithCacheTier(
baseline as number,
cacheable,
inp,
cc,
cr,
warm,
prevCacheable,
cc1h,
cc5m,
)
: actualInputEff;
// Record this completed turn's prefix size for future cr>0 split estimates.
Expand Down Expand Up @@ -953,6 +959,8 @@ export class DashboardState {
const inp = t.input_tokens ?? 0;
const out = t.output_tokens ?? 0;
const cc = t.cache_create_tokens ?? 0;
const cc5m = t.cache_create_5m_tokens;
const cc1h = t.cache_create_1h_tokens ?? 0;
const cr = t.cache_read_tokens ?? 0;
const compressed = t.compressed === true;
const gpt = isOpenAIEvent(t.path, t.accounting_provider);
Expand Down Expand Up @@ -1003,7 +1011,9 @@ export class DashboardState {
const probeOk = probeStatus === 'ok'
|| (probeStatus === undefined && typeof baseline === 'number' && baseline > 0);
haveBaseline = typeof baseline === 'number' && baseline > 0 && probeOk;
actualInputEff = haveUsage ? computeActualInputEff(inp, cc, cr) : 0;
actualInputEff = haveUsage
? computeActualInputEffWithCacheTier(inp, cc, cr, cc1h, cc5m)
: 0;
// Mirror update(): only credit the cache-modeled counterfactual on
// compressed rows. Uncompressed/passthrough rows fall back to the
// actual cost so they show zero saved (no fabricated savings).
Expand All @@ -1027,14 +1037,16 @@ export class DashboardState {
prefixShaR,
);
baselineInputEff = creditSaving
? computeBaselineInputEff(
? computeBaselineInputEffWithCacheTier(
baseline as number,
cacheable,
inp,
cc,
cr,
warmR,
prevCacheableR,
cc1h,
cc5m,
)
: actualInputEff;
if (typeof sidR === 'string' && sidR.length > 0 && haveUsage) {
Expand Down
8 changes: 4 additions & 4 deletions src/dashboard/fragments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,20 +275,20 @@ export function renderHeaderFragment(s: StatsPayload, port: number): string {
statTile(
'Estimated saved',
`$${(s.saved_usd ?? 0).toFixed(2)}`,
`at $${pa.input_per_mtok}/M input tokens`,
`at $${pa.input_per_mtok}/M base input price`,
'',
'A rough dollar figure: saved tokens × the input price. Actual savings depend on your plan and caching — see the math drawer.',
'Cache-aware estimate using the server-reported 5-minute/1-hour write split when available (1.25×/2×), cache reads (0.10×), and the base input price.',
) +
costTile +
`</div>`;

// math drawer
const savedMath =
`<div><span class="k">formula:</span> <span class="v">saved = baseline − actual</span></div>` +
`<div><span class="k">weights:</span> <span class="v">input×1.0, cache_create×1.25, cache_read×0.10</span></div>` +
`<div><span class="k">weights:</span> <span class="v">input×1.0, cache_write_5m×1.25, cache_write_1h×2.0, cache_read×0.10</span></div>` +
`<div class="sp"></div>` +
mathRow('baseline', s.baseline_input_weighted, '(cache-aware: cacheable×weight + cold_tail)') +
mathRow('actual', s.actual_input_weighted, '(input + cc×1.25 + cr×0.10 from usage)') +
mathRow('actual', s.actual_input_weighted, '(input + cc_5m×1.25 + cc_1h×2.0 + cr×0.10)') +
mathRow('saved', s.saved_input_tokens, `<span class="op">=</span> baseline − actual`) +
`<span class="src">output excluded — identical with/without compression</span>`;

Expand Down
1 change: 1 addition & 0 deletions src/dashboard/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export interface StatsPayload {
compressed_minus_passthrough_avg_usd: number;
split_sufficient_sample: boolean;
split_min_sample_per_bucket: number;
/** Cache-tier-aware input-side dollar savings. */
saved_usd: number;
output_weighted: number;
baseline_token_equivalent: number;
Expand Down
16 changes: 11 additions & 5 deletions src/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@ import * as crypto from 'node:crypto';
import * as readline from 'node:readline';
import type { TrackEvent } from './core/tracker.js';
import {
computeActualInputEff,
computeBaselineInputEff,
computeActualInputEffWithCacheTier,
computeBaselineInputEffWithCacheTier,
deriveBaselineWarmth,
} from './core/baseline.js';

Expand Down Expand Up @@ -179,13 +179,17 @@ export async function aggregateSessions(
// Events missing either probe stay out of the rollup — no estimation.
const inp = ev.input_tokens ?? 0;
const cc = ev.cache_create_tokens ?? 0;
const cc5m = ev.cache_create_5m_tokens;
const cc1h = ev.cache_create_1h_tokens ?? 0;
const cr = ev.cache_read_tokens ?? 0;
const haveUsage = inp > 0 || cc > 0 || cr > 0;
const baseline = ev.baseline_tokens;
if (
typeof baseline === 'number' &&
baseline > 0 &&
haveUsage
haveUsage &&
ev.compressed === true &&
(ev.baseline_probe_status === 'ok' || ev.baseline_probe_status === undefined)
) {
const cacheable = ev.baseline_cacheable_tokens ?? 0;
const prefixSha = ev.system_sha8;
Expand All @@ -202,16 +206,18 @@ export async function aggregateSessions(
CACHE_TTL_SEC,
prefixSha,
);
const baselineEff = computeBaselineInputEff(
const baselineEff = computeBaselineInputEffWithCacheTier(
baseline,
cacheable,
inp,
cc,
cr,
warm,
prevCacheable,
cc1h,
cc5m,
);
const actualEff = computeActualInputEff(inp, cc, cr);
const actualEff = computeActualInputEffWithCacheTier(inp, cc, cr, cc1h, cc5m);
const tokensSaved = baselineEff - actualEff;
s.tokensSavedEst += Math.round(tokensSaved);
s.charsSaved += Math.round(tokensSaved * 4);
Expand Down
31 changes: 31 additions & 0 deletions tests/baseline.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { describe, it, expect } from 'vitest';
import {
computeBaselineInputEff,
computeBaselineInputEffWithCacheTier,
computeActualInputEff,
computeActualInputEffWithCacheTier,
deriveBaselineWarmth,
CACHE_CREATE_RATE,
CACHE_READ_RATE,
Expand Down Expand Up @@ -67,6 +69,35 @@ describe('computeBaselineInputEff (warmth-aware)', () => {
const warm = computeBaselineInputEff(5000, 4000, inp, cc, cr, true, 4000);
expect(cold).toBeGreaterThan(warm);
});

it('prices server-reported 1-hour cache writes at 2x on both sides', () => {
const actual = computeActualInputEffWithCacheTier(1000, 4000, 0, 4000);
const baseline = computeBaselineInputEffWithCacheTier(5000, 4000, 1000, 4000, 0, false, 0, 4000);
expect(actual).toBe(1000 + 4000 * 2);
expect(baseline).toBe(4000 * 2 + 1000);
});

it('uses the observed weighted rate for mixed 5-minute and 1-hour writes', () => {
const actual = computeActualInputEffWithCacheTier(1000, 4000, 0, 1000, 3000);
const baseline = computeBaselineInputEffWithCacheTier(
5000, 4000, 1000, 4000, 0, false, 0, 1000, 3000,
);
expect(actual).toBe(1000 + 3000 * 1.25 + 1000 * 2);
expect(baseline).toBe(1000 + 4000 * ((3000 * 1.25 + 1000 * 2) / 4000));
});

it('preserves zero savings on a probe miss with 1-hour writes', () => {
const actual = computeActualInputEffWithCacheTier(1000, 4000, 0, 4000, 0);
const baseline = computeBaselineInputEffWithCacheTier(
5000, 0, 1000, 4000, 0, false, 0, 4000, 0,
);
expect(baseline).toBe(actual);
});

it('uses both reported tiers and normalizes a mismatched split to aggregate writes', () => {
const actual = computeActualInputEffWithCacheTier(0, 4000, 0, 1000, 1000);
expect(actual).toBe(4000 * ((1000 * 1.25 + 1000 * 2) / 2000));
});
});

/**
Expand Down
Loading