pi-diagnostics 0.0.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +24 -2
- package/package.json +53 -8
- package/src/core.ts +408 -0
- package/src/expiry-core.ts +734 -0
- package/src/expiry.ts +267 -0
- package/src/index.ts +333 -0
- package/src/native.ts +181 -0
- package/index.js +0 -2
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tianren Dong
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# pi-diagnostics
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Know why your prompt cache missed, and when it's gone.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
- **Cache-miss reasons.** When a turn re-bills cached tokens, see the provider's explanation (Anthropic, OpenAI):
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
Cache miss: 42k tokens re-billed (~$0.12)
|
|
9
|
+
|
|
10
|
+
↳ Provider diagnostics reason: tools_changed
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
- **Expiry reminders.** A note appears when your cache has likely expired, so you know it's a cheap moment to `/compact`, switch models, or change tools.
|
|
14
|
+
|
|
15
|
+
Both are display-only and never add to model context.
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pi install npm:pi-diagnostics
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Run `/diagnostics` to see recent results.
|
|
24
|
+
|
|
25
|
+
## License
|
|
26
|
+
|
|
27
|
+
MIT
|
package/package.json
CHANGED
|
@@ -1,16 +1,61 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-diagnostics",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
5
|
-
"
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
"
|
|
10
|
-
"
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Prompt-cache diagnostics and expiry reminders for pi",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi",
|
|
7
|
+
"pi-package",
|
|
8
|
+
"prompt-cache",
|
|
9
|
+
"diagnostics",
|
|
10
|
+
"anthropic",
|
|
11
|
+
"openai",
|
|
12
|
+
"cache-expiry"
|
|
11
13
|
],
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"author": "Tianren Dong",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/tianrendong/pi-diagnostics.git"
|
|
19
|
+
},
|
|
20
|
+
"homepage": "https://github.com/tianrendong/pi-diagnostics#readme",
|
|
21
|
+
"bugs": {
|
|
22
|
+
"url": "https://github.com/tianrendong/pi-diagnostics/issues"
|
|
23
|
+
},
|
|
12
24
|
"publishConfig": {
|
|
13
25
|
"access": "public",
|
|
14
26
|
"registry": "https://registry.npmjs.org/"
|
|
27
|
+
},
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=22.19.0"
|
|
30
|
+
},
|
|
31
|
+
"type": "module",
|
|
32
|
+
"files": [
|
|
33
|
+
"src",
|
|
34
|
+
"README.md",
|
|
35
|
+
"LICENSE"
|
|
36
|
+
],
|
|
37
|
+
"pi": {
|
|
38
|
+
"extensions": [
|
|
39
|
+
"./src/index.ts",
|
|
40
|
+
"./src/expiry.ts"
|
|
41
|
+
]
|
|
42
|
+
},
|
|
43
|
+
"peerDependencies": {
|
|
44
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
45
|
+
"@earendil-works/pi-ai": "*",
|
|
46
|
+
"@earendil-works/pi-tui": "*"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
50
|
+
"@earendil-works/pi-ai": "*",
|
|
51
|
+
"@earendil-works/pi-tui": "*",
|
|
52
|
+
"@types/node": "^22.19.0",
|
|
53
|
+
"typescript": "^5.9.3",
|
|
54
|
+
"jiti": "^2.7.0"
|
|
55
|
+
},
|
|
56
|
+
"scripts": {
|
|
57
|
+
"check": "tsc --noEmit",
|
|
58
|
+
"test": "node --test test/*.test.mjs test/*.test.ts",
|
|
59
|
+
"prepublishOnly": "npm run check && npm test"
|
|
15
60
|
}
|
|
16
61
|
}
|
package/src/core.ts
ADDED
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider prompt-cache diagnostics for pi.
|
|
3
|
+
*
|
|
4
|
+
* Anthropic Messages: https://platform.claude.com/docs/en/build-with-claude/cache-diagnostics
|
|
5
|
+
* request: diagnostics: { previous_message_id: string | null } (every turn; null = opt in)
|
|
6
|
+
* response: message_start.message.diagnostics
|
|
7
|
+
*
|
|
8
|
+
* OpenAI Responses: https://developers.openai.com/api/docs/guides/prompt-caching/diagnostics
|
|
9
|
+
* request: prompt_cache_options: { comparison_response_id: string } (omit on first turn)
|
|
10
|
+
* response: response.completed.response.prompt_cache_diagnostics
|
|
11
|
+
*
|
|
12
|
+
* Pure logic lives here so it can be tested without a pi runtime.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export type DiagnosticsKind = "anthropic" | "openai";
|
|
16
|
+
|
|
17
|
+
export const DIAGNOSTIC_TYPE: Record<DiagnosticsKind, string> = {
|
|
18
|
+
anthropic: "anthropic_cache_diagnostics",
|
|
19
|
+
openai: "openai_prompt_cache_diagnostics",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** Pi API ids whose request payload shape we know how to extend. */
|
|
23
|
+
export const API_KIND: Record<string, DiagnosticsKind> = {
|
|
24
|
+
"anthropic-messages": "anthropic",
|
|
25
|
+
"openai-responses": "openai",
|
|
26
|
+
"azure-openai-responses": "openai",
|
|
27
|
+
"openai-codex-responses": "openai",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Provider entries are `provider` (every supported API) or `provider:kind` (one dialect).
|
|
32
|
+
* Ramp Router is limited to its OpenAI Responses adapter: its Anthropic Messages route returns
|
|
33
|
+
* `diagnostics: null` for every response, even for requests whose tools and system prompt changed.
|
|
34
|
+
*/
|
|
35
|
+
export const DEFAULT_PROVIDERS = ["anthropic", "openai", "ramp-router:openai"];
|
|
36
|
+
|
|
37
|
+
export interface ModelRef {
|
|
38
|
+
provider: string;
|
|
39
|
+
api: string;
|
|
40
|
+
id: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface Config {
|
|
44
|
+
enabled: boolean;
|
|
45
|
+
/**
|
|
46
|
+
* Provider entries allowed to receive diagnostics fields: `provider`, `provider:anthropic`,
|
|
47
|
+
* `provider:openai`, or "*" for every provider on a supported API.
|
|
48
|
+
*/
|
|
49
|
+
providers: string[];
|
|
50
|
+
/** When to show a UI notification. */
|
|
51
|
+
notify: "miss" | "all" | "off";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function parseConfig(env: Record<string, string | undefined>): Config {
|
|
55
|
+
const providers = (env.PI_DIAGNOSTICS_PROVIDERS ?? DEFAULT_PROVIDERS.join(","))
|
|
56
|
+
.split(",")
|
|
57
|
+
.map((p) => p.trim())
|
|
58
|
+
.filter(Boolean);
|
|
59
|
+
const notifyRaw = (env.PI_DIAGNOSTICS_NOTIFY ?? "miss").trim().toLowerCase();
|
|
60
|
+
const notify = notifyRaw === "all" || notifyRaw === "off" ? notifyRaw : "miss";
|
|
61
|
+
const enabledRaw = (env.PI_DIAGNOSTICS ?? "1").trim().toLowerCase();
|
|
62
|
+
const enabled = !["0", "false", "off", "no"].includes(enabledRaw);
|
|
63
|
+
return { enabled, providers, notify };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Returns the diagnostics dialect for this model, or undefined when diagnostics should not be sent. */
|
|
67
|
+
export function kindFor(model: ModelRef | undefined, config: Config): DiagnosticsKind | undefined {
|
|
68
|
+
if (!config.enabled || !model) return undefined;
|
|
69
|
+
const kind = API_KIND[model.api];
|
|
70
|
+
if (!kind) return undefined;
|
|
71
|
+
const allowed = config.providers.includes("*")
|
|
72
|
+
|| config.providers.includes(model.provider)
|
|
73
|
+
|| config.providers.includes(`${model.provider}:${kind}`);
|
|
74
|
+
return allowed ? kind : undefined;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// Baseline selection
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
export interface Baseline {
|
|
82
|
+
responseId: string;
|
|
83
|
+
modelId: string;
|
|
84
|
+
/** A compaction/branch summary sits between the baseline and now, so a miss is expected. */
|
|
85
|
+
afterSummary: boolean;
|
|
86
|
+
/** Baseline prompt size (input + cacheRead + cacheWrite); 0 when usage is unknown. */
|
|
87
|
+
promptTokens: number;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
interface EntryLike {
|
|
91
|
+
type: string;
|
|
92
|
+
message?: {
|
|
93
|
+
role?: string;
|
|
94
|
+
provider?: string;
|
|
95
|
+
api?: string;
|
|
96
|
+
model?: string;
|
|
97
|
+
responseId?: string;
|
|
98
|
+
stopReason?: string;
|
|
99
|
+
usage?: { input?: number; cacheRead?: number; cacheWrite?: number };
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Most recent successful assistant response on the active branch from the same provider + API.
|
|
105
|
+
* Response ids are provider-scoped, so other providers' ids are never used.
|
|
106
|
+
*/
|
|
107
|
+
export function findBaseline(branch: readonly EntryLike[], model: ModelRef): Baseline | undefined {
|
|
108
|
+
let afterSummary = false;
|
|
109
|
+
for (let i = branch.length - 1; i >= 0; i--) {
|
|
110
|
+
const entry = branch[i];
|
|
111
|
+
if (entry.type === "compaction" || entry.type === "branch_summary") {
|
|
112
|
+
afterSummary = true;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
const message = entry.type === "message" ? entry.message : undefined;
|
|
116
|
+
if (!message || message.role !== "assistant") continue;
|
|
117
|
+
if (message.provider !== model.provider || message.api !== model.api) continue;
|
|
118
|
+
if (!message.responseId || message.stopReason === "error" || message.stopReason === "aborted") continue;
|
|
119
|
+
const usage = message.usage;
|
|
120
|
+
const promptTokens = usage ? (usage.input ?? 0) + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0) : 0;
|
|
121
|
+
return { responseId: message.responseId, modelId: message.model ?? "", afterSummary, promptTokens };
|
|
122
|
+
}
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
// Request payload injection
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
type JsonRecord = Record<string, unknown>;
|
|
131
|
+
|
|
132
|
+
function isRecord(value: unknown): value is JsonRecord {
|
|
133
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Returns a payload with diagnostics fields added, or undefined to leave the payload unchanged.
|
|
138
|
+
* `previousId` of undefined means no baseline (first turn).
|
|
139
|
+
*/
|
|
140
|
+
export function injectDiagnostics(
|
|
141
|
+
payload: unknown,
|
|
142
|
+
kind: DiagnosticsKind,
|
|
143
|
+
previousId: string | undefined,
|
|
144
|
+
): JsonRecord | undefined {
|
|
145
|
+
if (!isRecord(payload)) return undefined;
|
|
146
|
+
|
|
147
|
+
if (kind === "anthropic") {
|
|
148
|
+
if (!Array.isArray(payload.messages)) return undefined;
|
|
149
|
+
// Don't overwrite a caller that already opted in explicitly.
|
|
150
|
+
if (isRecord(payload.diagnostics)) return undefined;
|
|
151
|
+
return { ...payload, diagnostics: { previous_message_id: previousId ?? null } };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// OpenAI Responses: records are stored by default; only the comparison needs opting in.
|
|
155
|
+
if (!("input" in payload)) return undefined;
|
|
156
|
+
if (!previousId) return undefined;
|
|
157
|
+
const existing = isRecord(payload.prompt_cache_options) ? payload.prompt_cache_options : {};
|
|
158
|
+
if (typeof existing.comparison_response_id === "string") return undefined;
|
|
159
|
+
return { ...payload, prompt_cache_options: { ...existing, comparison_response_id: previousId } };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
// Request sniffing (inside fetch)
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
const ANTHROPIC_KEY = '"previous_message_id":"';
|
|
167
|
+
const OPENAI_KEY = '"comparison_response_id":"';
|
|
168
|
+
|
|
169
|
+
export interface Probe {
|
|
170
|
+
kind: DiagnosticsKind;
|
|
171
|
+
comparedTo: string;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Detect a serialized request body carrying one of our comparison ids. JSON keys inside
|
|
176
|
+
* string content are escaped (\"), so an unescaped match is a real top-level-ish key. Callers
|
|
177
|
+
* must still confirm the id was armed by this extension.
|
|
178
|
+
*/
|
|
179
|
+
export function sniffBody(body: unknown): Probe | undefined {
|
|
180
|
+
if (typeof body !== "string") return undefined;
|
|
181
|
+
for (const [kind, key] of [
|
|
182
|
+
["anthropic", ANTHROPIC_KEY],
|
|
183
|
+
["openai", OPENAI_KEY],
|
|
184
|
+
] as const) {
|
|
185
|
+
const at = body.indexOf(key);
|
|
186
|
+
if (at === -1) continue;
|
|
187
|
+
const start = at + key.length;
|
|
188
|
+
const end = body.indexOf('"', start);
|
|
189
|
+
if (end === -1 || end - start > 256) continue;
|
|
190
|
+
return { kind, comparedTo: body.slice(start, end) };
|
|
191
|
+
}
|
|
192
|
+
return undefined;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
// SSE tap
|
|
197
|
+
// ---------------------------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
export interface RawResult {
|
|
200
|
+
kind: DiagnosticsKind;
|
|
201
|
+
responseId: string;
|
|
202
|
+
/**
|
|
203
|
+
* Raw provider diagnostics value. `undefined` means the response had no diagnostics field at
|
|
204
|
+
* all (e.g. a proxy dropped it), which is different from Anthropic's explicit `null`.
|
|
205
|
+
*/
|
|
206
|
+
raw: unknown;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const OPENAI_TERMINAL = new Set(["response.completed", "response.incomplete", "response.failed"]);
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Inspect SSE data lines for the diagnostics-bearing event. Returns a result, "stop" when the
|
|
213
|
+
* relevant event can no longer appear, or undefined to keep reading.
|
|
214
|
+
*/
|
|
215
|
+
export function inspectSseData(kind: DiagnosticsKind, data: string): RawResult | "stop" | undefined {
|
|
216
|
+
if (kind === "anthropic") {
|
|
217
|
+
// message_start carries diagnostics. Ignore ping and other events until it arrives.
|
|
218
|
+
if (!data.includes('"message_start"')) return undefined;
|
|
219
|
+
const event = safeParse(data);
|
|
220
|
+
if (!isRecord(event) || event.type !== "message_start" || !isRecord(event.message)) return "stop";
|
|
221
|
+
const id = event.message.id;
|
|
222
|
+
if (typeof id !== "string") return "stop";
|
|
223
|
+
return { kind, responseId: id, raw: Object.hasOwn(event.message, "diagnostics") ? event.message.diagnostics : undefined };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// OpenAI: terminal event carries the full response. Cheap substring check before parsing.
|
|
227
|
+
if (!data.includes('"response.completed"') && !data.includes('"response.incomplete"') && !data.includes('"response.failed"')) {
|
|
228
|
+
return undefined;
|
|
229
|
+
}
|
|
230
|
+
const event = safeParse(data);
|
|
231
|
+
if (!isRecord(event) || typeof event.type !== "string" || !OPENAI_TERMINAL.has(event.type)) return undefined;
|
|
232
|
+
if (!isRecord(event.response) || typeof event.response.id !== "string") return "stop";
|
|
233
|
+
return { kind, responseId: event.response.id, raw: event.response.prompt_cache_diagnostics ?? null };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function safeParse(text: string): unknown {
|
|
237
|
+
try {
|
|
238
|
+
return JSON.parse(text);
|
|
239
|
+
} catch {
|
|
240
|
+
return undefined;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Pass-through stream that forwards every byte unchanged and reports diagnostics once seen.
|
|
246
|
+
* Preserves backpressure and cancellation; inspection errors never affect the stream.
|
|
247
|
+
*/
|
|
248
|
+
export function createSseTap(
|
|
249
|
+
kind: DiagnosticsKind,
|
|
250
|
+
onResult: (result: RawResult) => void,
|
|
251
|
+
): TransformStream<Uint8Array, Uint8Array> {
|
|
252
|
+
const decoder = new TextDecoder();
|
|
253
|
+
let buffer = "";
|
|
254
|
+
let done = false;
|
|
255
|
+
|
|
256
|
+
const consume = (text: string, final: boolean) => {
|
|
257
|
+
buffer += text;
|
|
258
|
+
let newline = buffer.indexOf("\n");
|
|
259
|
+
while (newline !== -1 && !done) {
|
|
260
|
+
handleLine(buffer.slice(0, newline));
|
|
261
|
+
buffer = buffer.slice(newline + 1);
|
|
262
|
+
newline = buffer.indexOf("\n");
|
|
263
|
+
}
|
|
264
|
+
if (final && !done && buffer) handleLine(buffer);
|
|
265
|
+
if (done || final) buffer = "";
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
const handleLine = (rawLine: string) => {
|
|
269
|
+
const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
|
|
270
|
+
if (!line.startsWith("data:")) return;
|
|
271
|
+
const outcome = inspectSseData(kind, line.slice(5).trimStart());
|
|
272
|
+
if (outcome === undefined) return;
|
|
273
|
+
done = true;
|
|
274
|
+
if (outcome !== "stop") onResult(outcome);
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
return new TransformStream<Uint8Array, Uint8Array>({
|
|
278
|
+
transform(chunk, controller) {
|
|
279
|
+
controller.enqueue(chunk);
|
|
280
|
+
if (done) return;
|
|
281
|
+
try {
|
|
282
|
+
consume(decoder.decode(chunk, { stream: true }), false);
|
|
283
|
+
} catch {
|
|
284
|
+
done = true;
|
|
285
|
+
}
|
|
286
|
+
},
|
|
287
|
+
flush() {
|
|
288
|
+
if (done) return;
|
|
289
|
+
try {
|
|
290
|
+
consume(decoder.decode(), true);
|
|
291
|
+
} catch {
|
|
292
|
+
// ignore
|
|
293
|
+
}
|
|
294
|
+
},
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// ---------------------------------------------------------------------------
|
|
299
|
+
// Normalization
|
|
300
|
+
// ---------------------------------------------------------------------------
|
|
301
|
+
|
|
302
|
+
/** `expired`: the provider found no prompt change, but cached tokens dropped (TTL/eviction). */
|
|
303
|
+
export type Outcome = "hit" | "miss" | "expired" | "pending" | "not_found" | "unavailable" | "none";
|
|
304
|
+
|
|
305
|
+
export interface Summary {
|
|
306
|
+
outcome: Outcome;
|
|
307
|
+
reason?: string;
|
|
308
|
+
missedTokens?: number;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export interface SummarizeContext {
|
|
312
|
+
/** Cached tokens dropped versus the baseline turn (see `cacheDrop`). */
|
|
313
|
+
cacheDropped?: boolean;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export function summarize(kind: DiagnosticsKind, raw: unknown, context: SummarizeContext = {}): Summary {
|
|
317
|
+
if (kind === "anthropic") {
|
|
318
|
+
// Field absent: nothing was reported (proxy dropped it or the route does not support it).
|
|
319
|
+
if (raw === undefined) return { outcome: "none" };
|
|
320
|
+
// null => the comparison found no divergence (we always send a real previous id when
|
|
321
|
+
// summarizing). With low cache reads that means the entry expired, not a hit.
|
|
322
|
+
if (raw === null) return { outcome: context.cacheDropped ? "expired" : "hit" };
|
|
323
|
+
if (!isRecord(raw)) return { outcome: "unavailable" };
|
|
324
|
+
const reason = raw.cache_miss_reason;
|
|
325
|
+
if (reason === null || reason === undefined) return { outcome: "pending" };
|
|
326
|
+
if (!isRecord(reason) || typeof reason.type !== "string") return { outcome: "unavailable" };
|
|
327
|
+
if (reason.type === "previous_message_not_found") return { outcome: "not_found", reason: reason.type };
|
|
328
|
+
if (reason.type === "unavailable") return { outcome: "unavailable", reason: reason.type };
|
|
329
|
+
const missed = reason.cache_missed_input_tokens;
|
|
330
|
+
return { outcome: "miss", reason: reason.type, ...(typeof missed === "number" ? { missedTokens: missed } : {}) };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (!isRecord(raw) || typeof raw.type !== "string") return { outcome: "none" };
|
|
334
|
+
switch (raw.type) {
|
|
335
|
+
case "cache_hit":
|
|
336
|
+
return { outcome: "hit" };
|
|
337
|
+
case "cache_miss": {
|
|
338
|
+
const missed = raw.cache_missed_tokens;
|
|
339
|
+
return {
|
|
340
|
+
outcome: "miss",
|
|
341
|
+
...(typeof raw.reason === "string" ? { reason: raw.reason } : {}),
|
|
342
|
+
...(typeof missed === "number" ? { missedTokens: missed } : {}),
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
case "comparison_response_not_found":
|
|
346
|
+
return { outcome: "not_found", reason: raw.type };
|
|
347
|
+
default:
|
|
348
|
+
return { outcome: "unavailable", reason: raw.type };
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** Reasons that the conversation itself explains (model switch, compaction). */
|
|
353
|
+
export function isExpectedMiss(summary: Summary, baseline: { modelId: string; afterSummary: boolean } | undefined, modelId: string): boolean {
|
|
354
|
+
if (summary.outcome !== "miss" || !baseline) return false;
|
|
355
|
+
if (summary.reason === "model_changed" && baseline.modelId && baseline.modelId !== modelId) return true;
|
|
356
|
+
if (baseline.afterSummary && (summary.reason === "messages_changed" || summary.reason === "input_changed" || summary.reason === "context_compacted")) {
|
|
357
|
+
return true;
|
|
358
|
+
}
|
|
359
|
+
return false;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** Per-turn drops at or below this are cache breakpoint granularity noise (matches pi's native notice). */
|
|
363
|
+
const DROP_NOISE_FLOOR_TOKENS = 1024;
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Tokens from the baseline prompt that were not read from cache this turn, or undefined when
|
|
367
|
+
* cached tokens did not actually drop. A drop means less than half the baseline prompt was read
|
|
368
|
+
* from cache and the shortfall is above the noise floor.
|
|
369
|
+
*/
|
|
370
|
+
export function cacheDrop(baselinePromptTokens: number, cacheRead: number): number | undefined {
|
|
371
|
+
if (baselinePromptTokens <= 0) return undefined;
|
|
372
|
+
const dropped = baselinePromptTokens - cacheRead;
|
|
373
|
+
if (dropped <= DROP_NOISE_FLOOR_TOKENS || cacheRead >= baselinePromptTokens / 2) return undefined;
|
|
374
|
+
return dropped;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
export function formatNotification(summary: Summary, droppedTokens?: number): string {
|
|
378
|
+
if (summary.outcome === "miss") {
|
|
379
|
+
return `Cache miss, provider diagnostics reason: ${summary.reason ?? "unknown"}`;
|
|
380
|
+
}
|
|
381
|
+
if (summary.outcome === "expired") {
|
|
382
|
+
return "Cache miss, provider diagnostics: prompt unchanged, cache entry expired";
|
|
383
|
+
}
|
|
384
|
+
if (summary.outcome === "unavailable" && droppedTokens !== undefined) {
|
|
385
|
+
return "Cache miss, provider diagnostics reason: unavailable";
|
|
386
|
+
}
|
|
387
|
+
switch (summary.outcome) {
|
|
388
|
+
case "hit":
|
|
389
|
+
return "Provider diagnostics result: cache hit";
|
|
390
|
+
case "pending":
|
|
391
|
+
return "Provider diagnostics result: comparison pending";
|
|
392
|
+
case "not_found":
|
|
393
|
+
return "Provider diagnostics result: comparison response not found";
|
|
394
|
+
case "unavailable":
|
|
395
|
+
return "Provider diagnostics reason: unavailable";
|
|
396
|
+
case "none":
|
|
397
|
+
return "Provider diagnostics result: no diagnostics returned";
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Follow-up line for Pi's native cache-miss notice, which already says "Cache miss" and how many
|
|
403
|
+
* tokens were re-billed. Only the provider's explanation is added.
|
|
404
|
+
*/
|
|
405
|
+
export function formatComplement(summary: Summary, droppedTokens?: number): string {
|
|
406
|
+
const text = formatNotification(summary, droppedTokens).replace(/^Cache miss, provider/, "Provider");
|
|
407
|
+
return `↳ ${text}`;
|
|
408
|
+
}
|