Skip to content
Open
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
220 changes: 220 additions & 0 deletions extensions/model-info/cache-diagnostics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
import { createHash } from "node:crypto";
import type { Usage } from "@earendil-works/pi-ai";

export const CACHE_DIAGNOSTICS_CHANNEL = "model-info:cache-diagnostics";

export const CACHE_WARM_MINIMUM_TOKENS = 2_048;

export type CacheSemantics =
| "explicit-prefix"
| "implicit-best-effort"
| "unknown";

export type CacheObservationKind =
| "first-turn"
| "cold"
| "warm"
| "partial-hit"
| "miss-after-warm-prefix"
| "unknown";

export type CacheCorrelation =
| "model-change"
| "thinking-change"
| "tool-surface-change"
| "system-prompt-change"
| "compaction"
| "branch-change";

export interface CacheTurnIdentity {
provider: string;
modelId: string;
thinking: string;
toolSurfaceFingerprint: string;
systemPromptFingerprint: string;
}

export interface CacheTurnObservation {
turnIndex: number;
provider: string;
semantics: CacheSemantics;
kind: CacheObservationKind;
usage: {
input: number;
cacheRead: number;
cacheWrite: number;
promptTokens: number;
};
previousCacheRead: number | null;
reprocessedTokens: number | null;
correlations: CacheCorrelation[];
evidence: "observation";
verifiedCause: null;
explanation: string;
}

type TurnSample = {
identity: CacheTurnIdentity;
usage: CacheTurnObservation["usage"];
};

const IMPLICIT_CACHE_PROVIDERS = new Set([
"azure-openai-responses",
"google",
"google-antigravity",
"google-gemini-cli",
"openai",
"openai-codex",
"openai-responses",
]);

export function cacheSemanticsForProvider(provider: string): CacheSemantics {
const normalized = provider.trim().toLowerCase();
if (normalized === "anthropic") return "explicit-prefix";
if (IMPLICIT_CACHE_PROVIDERS.has(normalized)) return "implicit-best-effort";
return "unknown";
}

export function fingerprintCacheSurface(value: unknown) {
return createHash("sha256").update(JSON.stringify(value)).digest("hex");
}

function finiteUsage(value: number) {
return Number.isFinite(value) && value > 0 ? value : 0;
}

function promptUsage(usage: Usage): CacheTurnObservation["usage"] {
const input = finiteUsage(usage.input);
const cacheRead = finiteUsage(usage.cacheRead);
const cacheWrite = finiteUsage(usage.cacheWrite);
return {
input,
cacheRead,
cacheWrite,
promptTokens: input + cacheRead + cacheWrite,
};
}

function identityCorrelations(
previous: CacheTurnIdentity,
current: CacheTurnIdentity,
) {
const correlations: CacheCorrelation[] = [];
if (
previous.provider !== current.provider ||
previous.modelId !== current.modelId
) {
correlations.push("model-change");
}
if (previous.thinking !== current.thinking) {
correlations.push("thinking-change");
}
if (previous.toolSurfaceFingerprint !== current.toolSurfaceFingerprint) {
correlations.push("tool-surface-change");
}
if (previous.systemPromptFingerprint !== current.systemPromptFingerprint) {
correlations.push("system-prompt-change");
}
return correlations;
}

function classify(
semantics: CacheSemantics,
current: CacheTurnObservation["usage"],
previous: TurnSample | undefined,
): Pick<CacheTurnObservation, "kind" | "reprocessedTokens" | "explanation"> {
if (!previous) {
return {
kind: "first-turn",
reprocessedTokens: null,
explanation:
"No prior turn exists, so cache continuity cannot be inferred.",
};
}

const previousWarm = previous.usage.cacheRead >= CACHE_WARM_MINIMUM_TOKENS;
if (current.cacheRead > 0) {
return {
kind:
current.cacheRead < previous.usage.cacheRead ? "partial-hit" : "warm",
reprocessedTokens: null,
explanation:
current.cacheRead < previous.usage.cacheRead
? "The provider reported a smaller cache read than on the prior turn."
: "The provider reported cached prompt tokens on this turn.",
};
}
if (!previousWarm) {
return {
kind: "cold",
reprocessedTokens: null,
explanation:
"The prior turn had no sufficiently warm prefix, so this cold turn is not an invalidation signal.",
};
}
if (semantics !== "explicit-prefix") {
return {
kind: "unknown",
reprocessedTokens: null,
explanation:
semantics === "implicit-best-effort"
? "A warm-to-cold transition was observed, but this provider exposes only best-effort cache usage."
: "A warm-to-cold transition was observed, but the provider cache contract is unknown.",
};
}
return {
kind: "miss-after-warm-prefix",
reprocessedTokens: current.input,
explanation:
"An explicit-prefix provider reported a warm prior turn and zero cache read now; local boundaries are correlations, not verified causes.",
};
}

export function createCacheDiagnosticsTracker() {
let previous: TurnSample | undefined;
let pendingCorrelations = new Set<CacheCorrelation>();

const reset = () => {
previous = undefined;
pendingCorrelations = new Set();
};

const mark = (correlation: CacheCorrelation) => {
pendingCorrelations.add(correlation);
};

const observe = (options: {
turnIndex: number;
identity: CacheTurnIdentity;
usage: Usage;
}) => {
const usage = promptUsage(options.usage);
const semantics = cacheSemanticsForProvider(options.identity.provider);
const classification = classify(semantics, usage, previous);
const correlations = previous
? identityCorrelations(previous.identity, options.identity)
: [];
for (const pending of pendingCorrelations) correlations.push(pending);
const uniqueCorrelations = [...new Set(correlations)];

const observation: CacheTurnObservation = {
turnIndex: options.turnIndex,
provider: options.identity.provider,
semantics,
kind: classification.kind,
usage,
previousCacheRead: previous?.usage.cacheRead ?? null,
reprocessedTokens: classification.reprocessedTokens,
correlations: uniqueCorrelations,
evidence: "observation",
verifiedCause: null,
explanation: classification.explanation,
};

previous = { identity: options.identity, usage };
pendingCorrelations.clear();
return observation;
};

return { mark, observe, reset };
}
37 changes: 36 additions & 1 deletion extensions/model-info/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ import {
REFRESH_CHANNEL,
} from "../shared/dashboard-state.ts";
import { createSessionMetricsTracker } from "./session-metrics.ts";
import {
CACHE_DIAGNOSTICS_CHANNEL,
createCacheDiagnosticsTracker,
fingerprintCacheSurface,
type CacheTurnIdentity,
} from "./cache-diagnostics.ts";

const CHARS_PER_ESTIMATED_TOKEN = 4;
const LIVE_UPDATE_INTERVAL_MS = 200;
Expand All @@ -29,6 +35,8 @@ export default function modelInfo(pi: ExtensionAPI) {
let lastLiveUpdate = 0;
let currentContext: ExtensionContext | undefined;
const sessionMetrics = createSessionMetricsTracker();
const cacheDiagnostics = createCacheDiagnosticsTracker();
let cacheIdentity: CacheTurnIdentity | undefined;

const publish = () => pi.events.emit(MODEL_INFO_CHANNEL, { ...state });

Expand Down Expand Up @@ -74,11 +82,14 @@ export default function modelInfo(pi: ExtensionAPI) {
runContentStreamMs = 0;
state = { ...state, tokensPerSecond: null, generating: false };
sessionMetrics.reset();
cacheDiagnostics.reset();
cacheIdentity = undefined;
syncSessionMetrics(ctx);
refresh(ctx);
});

pi.on("model_select", (event, ctx) => {
cacheDiagnostics.mark("model-change");
state = {
...state,
provider: event.model.provider,
Expand All @@ -91,6 +102,7 @@ export default function modelInfo(pi: ExtensionAPI) {
});

pi.on("thinking_level_select", (event) => {
cacheDiagnostics.mark("thinking-change");
state = { ...state, thinking: event.level };
publish();
});
Expand All @@ -103,6 +115,17 @@ export default function modelInfo(pi: ExtensionAPI) {
refresh(ctx);
});

pi.on("before_agent_start", (event, ctx) => {
const selectedTools = event.systemPromptOptions.selectedTools ?? [];
cacheIdentity = {
provider: ctx.model?.provider ?? "",
modelId: ctx.model?.id ?? "no-model",
thinking: ctx.model?.reasoning ? pi.getThinkingLevel() : "off",
toolSurfaceFingerprint: fingerprintCacheSurface(selectedTools),
systemPromptFingerprint: fingerprintCacheSurface(event.systemPrompt),
};
});

pi.on("message_start", (event) => {
if (event.message.role === "assistant") resetMessageTracking();
});
Expand Down Expand Up @@ -191,20 +214,32 @@ export default function modelInfo(pi: ExtensionAPI) {
refresh(ctx);
});

pi.on("turn_end", (_event, ctx) => {
pi.on("turn_end", (event, ctx) => {
syncSessionMetrics(ctx);
refresh(ctx);
if (event.message?.role === "assistant" && cacheIdentity) {
pi.events.emit(
CACHE_DIAGNOSTICS_CHANNEL,
cacheDiagnostics.observe({
turnIndex: event.turnIndex,
identity: cacheIdentity,
usage: event.message.usage,
}),
);
}
});

// Compaction and branch moves rewrite history, so the cached percentage is
// stale the moment they land. Pi reports unknown occupancy until the next
// assistant reply, which is the honest state to show.
pi.on("session_compact", (_event, ctx) => {
cacheDiagnostics.mark("compaction");
syncSessionMetrics(ctx);
refresh(ctx);
});

pi.on("session_tree", (_event, ctx) => {
cacheDiagnostics.mark("branch-change");
syncSessionMetrics(ctx);
refresh(ctx);
});
Expand Down
Loading
Loading