File size: 6,225 Bytes
f500658 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 | import type { AssistantMessage } from "@earendil-works/pi-ai";
import type { SessionEntry } from "./session-manager.ts";
/**
* Prompt-cache TTL: idle gaps longer than this are worth mentioning as the
* likely cause of a miss. Anthropic's default cache TTL is 5 minutes.
*/
export const CACHE_TTL_MS = 5 * 60 * 1000;
/** Per-turn misses at or below this are cache breakpoint granularity noise. */
const NOISE_FLOOR_TOKENS = 1024;
/** A counted cache miss on a single assistant message. */
export interface CacheMiss {
/** Prompt tokens that were in the previous turn's prompt but not read from cache. */
missedTokens: number;
/** Extra dollars paid vs. a full cache hit; 0 when pricing is unknown. */
missedCost: number;
/** Milliseconds since the previous request (which last refreshed the cache). */
idleMs: number;
/** True when the model changed relative to the previous request. */
modelChanged: boolean;
}
export interface CacheWasteTotals {
missedTokens: number;
missedCost: number;
/** Number of counted misses (turns above the noise floor). */
missCount: number;
}
/** Minimal pricing lookup, satisfied by ModelRuntime. Cost is $/million tokens. */
export interface ModelPriceSource {
getModel(provider: string, modelId: string): { cost: { cacheRead: number } } | undefined;
}
/** The last request seen by the scan; everything in its prompt should be cached. */
interface PreviousRequest {
promptTokens: number;
modelKey: string;
timestamp: number;
/**
* Sticky: some earlier request in this scan segment reported cache activity.
* Distinguishes a total miss on a cache-read-only provider (OpenAI-style,
* writes unreported) from a provider that never reports caching at all.
*/
reportedCache: boolean;
}
/**
* Compute the cache miss for one assistant message relative to the previous
* request. Returns undefined when nothing is counted: first turn, after a
* reset, no cache activity ever reported (provider without cache support), or
* miss below the noise floor.
*/
function detectMiss(
prev: PreviousRequest | undefined,
message: AssistantMessage,
models: ModelPriceSource,
): CacheMiss | undefined {
const usage = message.usage;
const promptTokens = usage.input + usage.cacheRead + usage.cacheWrite;
// A zero-cache turn only counts when cache activity was reported before:
// on cache-read-only providers that is a total miss, while on providers
// that never report caching it means nothing.
if (!prev || promptTokens <= 0 || (usage.cacheRead + usage.cacheWrite === 0 && !prev.reportedCache)) {
return undefined;
}
const missedTokens = Math.min(prev.promptTokens, promptTokens) - usage.cacheRead;
if (missedTokens <= NOISE_FLOOR_TOKENS) return undefined;
// Extra cost = missed tokens billed at the actual paid rate (input/cacheWrite,
// incl. write premium) instead of the cache-read rate. Missed tokens can only
// land in the input or cacheWrite buckets, so the paid rate comes straight
// from this message's own cost breakdown.
const paidTokens = usage.input + usage.cacheWrite;
const paidPerToken = paidTokens > 0 ? (usage.cost.input + usage.cost.cacheWrite) / paidTokens : 0;
const readPerToken =
usage.cacheRead > 0
? usage.cost.cacheRead / usage.cacheRead
: (models.getModel(message.provider, message.model)?.cost.cacheRead ?? 0) / 1_000_000;
return {
missedTokens,
missedCost: missedTokens * Math.max(0, paidPerToken - readPerToken),
idleMs: Math.max(0, message.timestamp - prev.timestamp),
modelChanged: `${message.provider}/${message.model}` !== prev.modelKey,
};
}
function asPreviousRequest(message: AssistantMessage, reportedCache: boolean): PreviousRequest | undefined {
const usage = message.usage;
const promptTokens = usage.input + usage.cacheRead + usage.cacheWrite;
if (promptTokens <= 0) return undefined;
return {
promptTokens,
modelKey: `${message.provider}/${message.model}`,
timestamp: message.timestamp,
reportedCache: reportedCache || usage.cacheRead + usage.cacheWrite > 0,
};
}
function scan(
entries: SessionEntry[],
models: ModelPriceSource,
): { prev: PreviousRequest | undefined; totals: CacheWasteTotals; misses: Map<AssistantMessage, CacheMiss> } {
let prev: PreviousRequest | undefined;
const totals: CacheWasteTotals = { missedTokens: 0, missedCost: 0, missCount: 0 };
const misses = new Map<AssistantMessage, CacheMiss>();
for (const entry of entries) {
if (entry.type === "compaction" || entry.type === "branch_summary") {
// The context legitimately changed; the next turn's prompt is new content,
// not re-billed content. Model switches are NOT exempt: they re-bill the
// full prompt and should be counted.
prev = undefined;
continue;
}
if (entry.type === "message" && entry.message.role === "assistant") {
const miss = detectMiss(prev, entry.message, models);
if (miss) {
totals.missedTokens += miss.missedTokens;
totals.missedCost += miss.missedCost;
totals.missCount += 1;
misses.set(entry.message, miss);
}
prev = asPreviousRequest(entry.message, prev?.reportedCache ?? false) ?? prev;
}
}
return { prev, totals, misses };
}
/**
* Cumulative cache waste across a session: prompt tokens that should have been
* cache reads (they were in the previous turn's prompt) but were re-billed.
*/
export function computeCacheWaste(entries: SessionEntry[], models: ModelPriceSource): CacheWasteTotals {
return scan(entries, models).totals;
}
/**
* All counted cache misses across a session, keyed by the assistant message
* (by reference) that paid for them. Used to re-derive transcript notices when
* rebuilding the chat from entries (resume, post-compaction rebuild).
*/
export function collectCacheMisses(
entries: SessionEntry[],
models: ModelPriceSource,
): Map<AssistantMessage, CacheMiss> {
return scan(entries, models).misses;
}
/**
* Detect a cache miss on a just-completed assistant message.
* `entries` must not yet contain `message` (message_end fires before persistence).
*/
export function detectCacheMiss(
entries: SessionEntry[],
message: AssistantMessage,
models: ModelPriceSource,
): CacheMiss | undefined {
return detectMiss(scan(entries, models).prev, message, models);
}
|