pi-nebius 0.3.2 → 0.4.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/CHANGELOG.md +8 -0
- package/README.md +4 -0
- package/dist/benchmark/context-observation.js +59 -0
- package/dist/benchmark/instrumentation.js +30 -2
- package/dist/benchmark/report.js +8 -0
- package/dist/benchmark/request-report.js +118 -0
- package/docs/benchmarking.md +41 -0
- package/package.json +1 -1
- package/src/benchmark/context-observation.ts +68 -0
- package/src/benchmark/instrumentation.ts +37 -2
- package/src/benchmark/report.ts +8 -0
- package/src/benchmark/request-report.ts +130 -0
- package/src/benchmark/types.ts +20 -0
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,14 @@
|
|
|
3
3
|
Versions use Semantic Versioning. Entries describe changes included in the named version;
|
|
4
4
|
a version is released only when its matching Git tag and GitHub release are published.
|
|
5
5
|
|
|
6
|
+
## [0.4.0](https://github.com/PeterHdd/pi-nebius/compare/v0.3.2...v0.4.0) (2026-09-23)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
### Features
|
|
10
|
+
|
|
11
|
+
* add request-level benchmark token observability ([33385c2](https://github.com/PeterHdd/pi-nebius/commit/33385c2364bf958607d0c8d0a2ac8d6bf6899663))
|
|
12
|
+
* add request-level benchmark token observability ([5ecdaea](https://github.com/PeterHdd/pi-nebius/commit/5ecdaea0bda25b4df3fcfe50d5b9e0e15883350c))
|
|
13
|
+
|
|
6
14
|
## [0.3.2](https://github.com/PeterHdd/pi-nebius/compare/v0.3.1...v0.3.2) (2026-09-17)
|
|
7
15
|
|
|
8
16
|
|
package/README.md
CHANGED
|
@@ -109,6 +109,10 @@ middle values for an even number of runs). With one run, both show that run's me
|
|
|
109
109
|
Failed runs contribute to the performance metrics; missing measurements appear as `n/a`.
|
|
110
110
|
Custom prompts have no automatic correctness check, so a completed run does not establish success.
|
|
111
111
|
|
|
112
|
+
After the summary, a per-request trace shows token usage, context sizes in bytes, tool-output sizes,
|
|
113
|
+
and repeated operations. These observations help identify where context grows without changing
|
|
114
|
+
the agent. Byte counts are not token estimates, and repeated calls are not necessarily wasteful.
|
|
115
|
+
|
|
112
116
|
Detailed results, request settings, and resulting files are saved under `benchmark-results/`.
|
|
113
117
|
Benchmarks use your saved model settings at the start; later changes do not affect an active comparison.
|
|
114
118
|
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { isRecord } from "../models.js";
|
|
2
|
+
export function textSize(content) {
|
|
3
|
+
const text = typeof content === "string"
|
|
4
|
+
? content
|
|
5
|
+
: Array.isArray(content)
|
|
6
|
+
? content
|
|
7
|
+
.filter((block) => isRecord(block) && block.type === "text" && typeof block.text === "string")
|
|
8
|
+
.map((block) => block.text)
|
|
9
|
+
.join("\n")
|
|
10
|
+
: "";
|
|
11
|
+
return {
|
|
12
|
+
bytes: Buffer.byteLength(text, "utf8"),
|
|
13
|
+
lines: text ? text.split("\n").length - Number(text.endsWith("\n")) : 0,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
/** Counts serialized JSON bytes, not model tokens. Never retains request content. */
|
|
17
|
+
export function observeContext(body) {
|
|
18
|
+
if (typeof body !== "string")
|
|
19
|
+
return null; // Do not consume Request/stream bodies for observation.
|
|
20
|
+
try {
|
|
21
|
+
const payload = JSON.parse(body);
|
|
22
|
+
if (!isRecord(payload) || !Array.isArray(payload.messages))
|
|
23
|
+
return null;
|
|
24
|
+
const bytes = { system: 0, tools: 0, user: 0, assistant: 0, toolResults: 0, other: 0 };
|
|
25
|
+
const toolResults = [];
|
|
26
|
+
for (const message of payload.messages) {
|
|
27
|
+
const role = isRecord(message) ? message.role : undefined;
|
|
28
|
+
const category = role === "system" || role === "developer"
|
|
29
|
+
? "system"
|
|
30
|
+
: role === "user"
|
|
31
|
+
? "user"
|
|
32
|
+
: role === "assistant"
|
|
33
|
+
? "assistant"
|
|
34
|
+
: role === "tool"
|
|
35
|
+
? "toolResults"
|
|
36
|
+
: "other";
|
|
37
|
+
bytes[category] += Buffer.byteLength(JSON.stringify(message), "utf8");
|
|
38
|
+
if (isRecord(message) && role === "tool" && typeof message.tool_call_id === "string")
|
|
39
|
+
toolResults.push({ id: message.tool_call_id, ...textSize(message.content) });
|
|
40
|
+
}
|
|
41
|
+
if (payload.tools !== undefined)
|
|
42
|
+
bytes.tools = Buffer.byteLength(JSON.stringify(payload.tools), "utf8");
|
|
43
|
+
return {
|
|
44
|
+
measurement: "serialized-json-bytes",
|
|
45
|
+
bytes,
|
|
46
|
+
messageCount: payload.messages.length,
|
|
47
|
+
toolResults,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/** Stable object-key order lets equivalent argument objects compare within a run. */
|
|
55
|
+
export function canonical(value) {
|
|
56
|
+
return (JSON.stringify(value, (_key, item) => isRecord(item)
|
|
57
|
+
? Object.fromEntries(Object.entries(item).sort(([a], [b]) => a.localeCompare(b)))
|
|
58
|
+
: item) ?? "null");
|
|
59
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
1
|
+
import { createHash, createHmac, randomBytes } from "node:crypto";
|
|
2
2
|
import { isRecord } from "../models.js";
|
|
3
|
+
import { canonical, observeContext, textSize } from "./context-observation.js";
|
|
3
4
|
export const hash = (text) => createHash("sha256").update(text).digest("hex");
|
|
4
5
|
const count = (value) => typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : null;
|
|
5
6
|
export function reportedUsage(value) {
|
|
@@ -46,6 +47,11 @@ export function redactor(secrets) {
|
|
|
46
47
|
export class Instrumentation {
|
|
47
48
|
state = emptyObservation();
|
|
48
49
|
compacting = false;
|
|
50
|
+
// Per-run HMAC keys are never persisted: compare values without retaining commands or file text.
|
|
51
|
+
fingerprintKey = randomBytes(32);
|
|
52
|
+
fingerprint(value) {
|
|
53
|
+
return createHmac("sha256", this.fingerprintKey).update(canonical(value)).digest("hex");
|
|
54
|
+
}
|
|
49
55
|
update;
|
|
50
56
|
now;
|
|
51
57
|
redact;
|
|
@@ -83,8 +89,14 @@ export class Instrumentation {
|
|
|
83
89
|
case "auto_retry_start":
|
|
84
90
|
this.state.retries++;
|
|
85
91
|
break;
|
|
86
|
-
case "tool_execution_start":
|
|
92
|
+
case "tool_execution_start": {
|
|
93
|
+
const fingerprint = this.fingerprint(event.args);
|
|
94
|
+
const previous = this.state.tools.find((tool) => tool.name === event.toolName && tool.argumentsFingerprint === fingerprint);
|
|
95
|
+
const origin = this.state.requests.find((request) => request.generatedToolCalls?.some((tool) => tool.id === event.toolCallId));
|
|
87
96
|
this.state.tools.push({
|
|
97
|
+
request: origin?.request ?? null,
|
|
98
|
+
argumentsFingerprint: fingerprint,
|
|
99
|
+
repeatedArgumentsOf: previous?.id ?? null,
|
|
88
100
|
id: event.toolCallId,
|
|
89
101
|
name: event.toolName,
|
|
90
102
|
startedAtMs: at,
|
|
@@ -93,6 +105,7 @@ export class Instrumentation {
|
|
|
93
105
|
error: null,
|
|
94
106
|
});
|
|
95
107
|
break;
|
|
108
|
+
}
|
|
96
109
|
case "tool_execution_end": {
|
|
97
110
|
const tool = this.state.tools.find((item) => item.id === event.toolCallId);
|
|
98
111
|
if (tool) {
|
|
@@ -104,6 +117,11 @@ export class Instrumentation {
|
|
|
104
117
|
case "message_end": {
|
|
105
118
|
const message = event.message;
|
|
106
119
|
if (message.role === "assistant") {
|
|
120
|
+
const request = this.state.requests.filter((item) => item.purpose === "agent").at(-1);
|
|
121
|
+
if (request)
|
|
122
|
+
request.generatedToolCalls = message.content
|
|
123
|
+
.filter((block) => block.type === "toolCall")
|
|
124
|
+
.map((block) => ({ id: block.id, name: block.name }));
|
|
107
125
|
this.state.assistantMessages++;
|
|
108
126
|
this.state.lastAssistantStopReason = message.stopReason;
|
|
109
127
|
if (message.errorMessage)
|
|
@@ -129,6 +147,14 @@ export class Instrumentation {
|
|
|
129
147
|
this.state.tools.push(tool);
|
|
130
148
|
}
|
|
131
149
|
tool.isError = message.isError;
|
|
150
|
+
const fingerprint = this.fingerprint(message.content);
|
|
151
|
+
tool.output = { ...textSize(message.content), fingerprint };
|
|
152
|
+
const previous = this.state.tools.find((item) => item !== tool &&
|
|
153
|
+
item.name === tool.name &&
|
|
154
|
+
item.argumentsFingerprint !== undefined &&
|
|
155
|
+
item.argumentsFingerprint === tool.argumentsFingerprint &&
|
|
156
|
+
item.output?.fingerprint === fingerprint);
|
|
157
|
+
tool.repeatedOutputOf = previous?.id ?? null;
|
|
132
158
|
if (message.isError)
|
|
133
159
|
tool.error = this.redact(message.content
|
|
134
160
|
.filter((item) => item.type === "text")
|
|
@@ -149,6 +175,8 @@ export class Instrumentation {
|
|
|
149
175
|
return fetcher(input, init);
|
|
150
176
|
const trace = {
|
|
151
177
|
request: this.state.requests.length + 1,
|
|
178
|
+
context: observeContext(init?.body),
|
|
179
|
+
generatedToolCalls: [],
|
|
152
180
|
purpose: this.compacting ? "compaction" : "agent",
|
|
153
181
|
startedAtMs: this.now(),
|
|
154
182
|
endedAtMs: null,
|
package/dist/benchmark/report.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { distribution } from "./metrics.js";
|
|
2
|
+
import { requestReport } from "./request-report.js";
|
|
2
3
|
/** Client-observed first generated content on the first agent request, not worker startup. */
|
|
3
4
|
export function observedTtftMs(run) {
|
|
4
5
|
const first = run.observation.requests.find((request) => request.purpose === "agent");
|
|
@@ -89,6 +90,13 @@ export function terminalReport(results) {
|
|
|
89
90
|
]
|
|
90
91
|
: []),
|
|
91
92
|
"",
|
|
93
|
+
...results.runs.flatMap(requestReport),
|
|
94
|
+
"",
|
|
95
|
+
"Request tokens are provider-reported per HTTP attempt, including retries and compaction when observed.",
|
|
96
|
+
"Δ input is the change from the preceding request, not tokens attributable to its tools.",
|
|
97
|
+
"Context sizes are measured UTF-8 JSON bytes including message wrappers; category token counts are unavailable.",
|
|
98
|
+
"Tool text sizes exclude images and may already be truncated by Pi. Commands, arguments, and successful output text are not stored.",
|
|
99
|
+
"Input amplification = cumulative input / last agent request input; not a waste score or final context size.",
|
|
92
100
|
"Detailed per-run measurements and validator output: results.json.",
|
|
93
101
|
].join("\n");
|
|
94
102
|
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
const number = (value) => value == null ? "n/a" : value.toLocaleString("en-US");
|
|
2
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: remove terminal control characters from labels
|
|
3
|
+
const label = (value) => value.replace(/[\x00-\x1f\x7f-\x9f]/g, " ").slice(0, 80);
|
|
4
|
+
const table = (rows) => {
|
|
5
|
+
const widths = rows[0]?.map((_, index) => Math.max(...rows.map((row) => row[index]?.length ?? 0))) ?? [];
|
|
6
|
+
return rows.map((row) => row
|
|
7
|
+
.map((cell, index) => cell.padEnd(widths[index] ?? 0))
|
|
8
|
+
.join(" │ ")
|
|
9
|
+
.trimEnd());
|
|
10
|
+
};
|
|
11
|
+
export function requestReport(run) {
|
|
12
|
+
const requests = run.observation.requests;
|
|
13
|
+
const tools = run.observation.tools ?? [];
|
|
14
|
+
const rows = [["Request", "Kind", "Input", "Δ input", "Output", "Cached", "Tools produced"]];
|
|
15
|
+
const sizes = [
|
|
16
|
+
["Request", "System B", "Schemas B", "User B", "Assistant B", "Tool results B", "Other B"],
|
|
17
|
+
];
|
|
18
|
+
for (const [index, request] of requests.entries()) {
|
|
19
|
+
const input = request.usage?.inputTokens;
|
|
20
|
+
const previous = requests[index - 1]?.usage?.inputTokens;
|
|
21
|
+
const delta = input != null && previous != null ? input - previous : null;
|
|
22
|
+
rows.push([
|
|
23
|
+
String(request.request ?? index + 1),
|
|
24
|
+
request.purpose,
|
|
25
|
+
number(input),
|
|
26
|
+
delta !== null && delta > 0 ? `+${number(delta)}` : number(delta),
|
|
27
|
+
number(request.usage?.outputTokens),
|
|
28
|
+
number(request.usage?.cachedInputTokens),
|
|
29
|
+
request.generatedToolCalls?.length
|
|
30
|
+
? request.generatedToolCalls.map((tool) => label(tool.name)).join(", ")
|
|
31
|
+
: "—",
|
|
32
|
+
]);
|
|
33
|
+
const bytes = request.context?.bytes;
|
|
34
|
+
sizes.push([
|
|
35
|
+
String(request.request ?? index + 1),
|
|
36
|
+
...[
|
|
37
|
+
bytes?.system,
|
|
38
|
+
bytes?.tools,
|
|
39
|
+
bytes?.user,
|
|
40
|
+
bytes?.assistant,
|
|
41
|
+
bytes?.toolResults,
|
|
42
|
+
bytes?.other,
|
|
43
|
+
].map(number),
|
|
44
|
+
]);
|
|
45
|
+
}
|
|
46
|
+
const input = run.tokens.cumulativeInputTokens;
|
|
47
|
+
const output = run.tokens.cumulativeOutputTokens;
|
|
48
|
+
rows.push([
|
|
49
|
+
"TOTAL",
|
|
50
|
+
"all attempts",
|
|
51
|
+
number(input),
|
|
52
|
+
"—",
|
|
53
|
+
number(output),
|
|
54
|
+
number(run.tokens.cachedInputTokens),
|
|
55
|
+
"",
|
|
56
|
+
]);
|
|
57
|
+
const last = run.tokens.lastRequestInputTokens;
|
|
58
|
+
const amplification = run.tokens.inputAmplificationVsLastRequest;
|
|
59
|
+
const total = input != null && output != null ? input + output : null;
|
|
60
|
+
const consumption = run.success && run.validation.checked
|
|
61
|
+
? "Tokens to validated solution"
|
|
62
|
+
: run.failure !== null
|
|
63
|
+
? "Tokens consumed before failure"
|
|
64
|
+
: "Tokens consumed (correctness not checked)";
|
|
65
|
+
const lines = [
|
|
66
|
+
"",
|
|
67
|
+
`Request trace: ${label(run.model)} #${run.run}`,
|
|
68
|
+
...table(rows),
|
|
69
|
+
`${consumption}: ${number(total)} (input ${number(input)} + output ${number(output)}).`,
|
|
70
|
+
`Last agent request input: ${number(last)}; input amplification: ${amplification == null ? "n/a" : `${amplification.toFixed(2)}x`}.`,
|
|
71
|
+
"",
|
|
72
|
+
"Context composition — measured JSON bytes (B), NOT token attribution:",
|
|
73
|
+
...table(sizes),
|
|
74
|
+
];
|
|
75
|
+
if (tools.length) {
|
|
76
|
+
lines.push("", "Tool results — model-facing text after Pi processing:");
|
|
77
|
+
const toolRows = [
|
|
78
|
+
[
|
|
79
|
+
"Tool",
|
|
80
|
+
"From request",
|
|
81
|
+
"Text bytes",
|
|
82
|
+
"Lines",
|
|
83
|
+
"Included in requests",
|
|
84
|
+
"Repeated arguments",
|
|
85
|
+
"Same result",
|
|
86
|
+
],
|
|
87
|
+
];
|
|
88
|
+
for (const [index, tool] of tools.entries()) {
|
|
89
|
+
const included = requests
|
|
90
|
+
.filter((request) => request.context?.toolResults.some((result) => result.id === tool.id))
|
|
91
|
+
.map((request) => request.request);
|
|
92
|
+
const reference = (id) => {
|
|
93
|
+
const at = tools.findIndex((item) => item.id === id);
|
|
94
|
+
return at < 0 ? "—" : `T${at + 1}`;
|
|
95
|
+
};
|
|
96
|
+
toolRows.push([
|
|
97
|
+
`T${index + 1} ${label(tool.name)}`,
|
|
98
|
+
number(tool.request),
|
|
99
|
+
number(tool.output?.bytes),
|
|
100
|
+
number(tool.output?.lines),
|
|
101
|
+
requests.every((request) => request.context != null)
|
|
102
|
+
? included.join(", ") || "none"
|
|
103
|
+
: `${included.join(", ") || "none observed"} (partial)`,
|
|
104
|
+
reference(tool.repeatedArgumentsOf),
|
|
105
|
+
reference(tool.repeatedOutputOf),
|
|
106
|
+
]);
|
|
107
|
+
}
|
|
108
|
+
lines.push(...table(toolRows));
|
|
109
|
+
const large = tools
|
|
110
|
+
.map((tool, index) => ({ tool, index }))
|
|
111
|
+
.filter(({ tool }) => (tool.output?.bytes ?? 0) >= 16384);
|
|
112
|
+
for (const { tool, index } of large)
|
|
113
|
+
lines.push(`Observation: T${index + 1} returned ${number(tool.output?.bytes)} text bytes (large-output threshold: 16 KiB).`);
|
|
114
|
+
if (tools.some((tool) => tool.repeatedArgumentsOf))
|
|
115
|
+
lines.push("Repeated arguments/results are candidates to inspect, not proof of unnecessary work or unchanged files.");
|
|
116
|
+
}
|
|
117
|
+
return lines;
|
|
118
|
+
}
|
package/docs/benchmarking.md
CHANGED
|
@@ -251,3 +251,44 @@ No live benchmark has been run in this environment: `NEBIUS_API_KEY` was unavail
|
|
|
251
251
|
See [benchmark-research.md](benchmark-research.md) for inspected source APIs and Nebius methodology.
|
|
252
252
|
|
|
253
253
|
Result schema version 2 removes monetary fields from version 1 (per-run estimates, pricing snapshots, aggregate costs, and the pricing hash). Existing saved results are not rewritten. Task definitions still use schema version 1.
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
## Request-level observability
|
|
257
|
+
|
|
258
|
+
After the comparison summary, each run shows its individual HTTP requests: provider-reported
|
|
259
|
+
input/output tokens, cached input tokens when available, change in input from the preceding request,
|
|
260
|
+
and tool calls produced by the response. A request can produce zero, one, or multiple tool calls.
|
|
261
|
+
Retries and compaction requests remain separate rows; a request is not necessarily an agent turn.
|
|
262
|
+
Missing usage stays `n/a`, including totals when any required usage is missing.
|
|
263
|
+
|
|
264
|
+
The context-composition table measures **UTF-8 serialized JSON bytes**, not tokens. Categories are
|
|
265
|
+
system/developer messages, tool schemas, user messages, assistant messages (including tool arguments),
|
|
266
|
+
tool-result messages, and other messages. Message wrapper fields are included; array separators and
|
|
267
|
+
other HTTP payload fields are not. File reads and shell output are subtypes of tool results, not
|
|
268
|
+
additional categories. Category token counts are unavailable; no tokenizer estimates are substituted.
|
|
269
|
+
Input-token differences are net changes, not causal attribution to the most recent tool.
|
|
270
|
+
|
|
271
|
+
Tool rows use local labels such as `T1 read` and `T2 bash`. They show model-facing text bytes/lines,
|
|
272
|
+
the originating request, and the subsequent request numbers containing that tool-call ID. Repeated
|
|
273
|
+
HTTP attempts count as repeated inclusions. These are observations after Pi's tool processing;
|
|
274
|
+
original shell stdout/stderr may already have been truncated. Text sizes exclude images. If a request
|
|
275
|
+
body cannot be inspected without consuming it, context measurements are unavailable and inclusion
|
|
276
|
+
lists are labelled partial.
|
|
277
|
+
|
|
278
|
+
Outputs of at least 16 KiB receive a large-output observation. Matching tool names and canonicalized
|
|
279
|
+
arguments flag repeated operations; matching result content also flags repeated results. This does
|
|
280
|
+
not establish that files or external state were unchanged, or that a call was avoidable. No tool calls
|
|
281
|
+
are blocked and no output or context is reduced. Only sizes, IDs, names, and per-run keyed fingerprints
|
|
282
|
+
are added to traces; successful output, commands, paths in arguments, and prompts are not retained.
|
|
283
|
+
Fingerprint keys are ephemeral and are not saved; fingerprints are not comparable across runs.
|
|
284
|
+
|
|
285
|
+
Input amplification is cumulative input over **all observed HTTP attempts** divided by the final
|
|
286
|
+
agent request's input tokens. It is unavailable with incomplete usage or a zero/missing denominator.
|
|
287
|
+
Compaction can decrease the denominator, so the ratio is not a waste score. Final-request input also
|
|
288
|
+
is not the context size after the response. Successful validated runs show input + output tokens as
|
|
289
|
+
**tokens to validated solution**; failures and unvalidated runs are labelled separately. Cached and
|
|
290
|
+
reasoning token counters are details of usage, not additional tokens to add to that sum.
|
|
291
|
+
|
|
292
|
+
These are additive optional fields in result schema 2 (`RequestTrace.context`,
|
|
293
|
+
`RequestTrace.generatedToolCalls`, and tool fingerprints/output sizes). Old reports remain readable;
|
|
294
|
+
absent observations are unavailable. Individual data remains in `results.json` and the live journals.
|
package/package.json
CHANGED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { isRecord } from "../models.ts";
|
|
2
|
+
import type { RequestContext } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
export function textSize(content: unknown): { bytes: number; lines: number } {
|
|
5
|
+
const text =
|
|
6
|
+
typeof content === "string"
|
|
7
|
+
? content
|
|
8
|
+
: Array.isArray(content)
|
|
9
|
+
? content
|
|
10
|
+
.filter(
|
|
11
|
+
(block) => isRecord(block) && block.type === "text" && typeof block.text === "string",
|
|
12
|
+
)
|
|
13
|
+
.map((block) => block.text)
|
|
14
|
+
.join("\n")
|
|
15
|
+
: "";
|
|
16
|
+
return {
|
|
17
|
+
bytes: Buffer.byteLength(text, "utf8"),
|
|
18
|
+
lines: text ? text.split("\n").length - Number(text.endsWith("\n")) : 0,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Counts serialized JSON bytes, not model tokens. Never retains request content. */
|
|
23
|
+
export function observeContext(body: unknown): RequestContext | null {
|
|
24
|
+
if (typeof body !== "string") return null; // Do not consume Request/stream bodies for observation.
|
|
25
|
+
try {
|
|
26
|
+
const payload: unknown = JSON.parse(body);
|
|
27
|
+
if (!isRecord(payload) || !Array.isArray(payload.messages)) return null;
|
|
28
|
+
const bytes = { system: 0, tools: 0, user: 0, assistant: 0, toolResults: 0, other: 0 };
|
|
29
|
+
const toolResults: RequestContext["toolResults"] = [];
|
|
30
|
+
for (const message of payload.messages) {
|
|
31
|
+
const role = isRecord(message) ? message.role : undefined;
|
|
32
|
+
const category =
|
|
33
|
+
role === "system" || role === "developer"
|
|
34
|
+
? "system"
|
|
35
|
+
: role === "user"
|
|
36
|
+
? "user"
|
|
37
|
+
: role === "assistant"
|
|
38
|
+
? "assistant"
|
|
39
|
+
: role === "tool"
|
|
40
|
+
? "toolResults"
|
|
41
|
+
: "other";
|
|
42
|
+
bytes[category] += Buffer.byteLength(JSON.stringify(message), "utf8");
|
|
43
|
+
if (isRecord(message) && role === "tool" && typeof message.tool_call_id === "string")
|
|
44
|
+
toolResults.push({ id: message.tool_call_id, ...textSize(message.content) });
|
|
45
|
+
}
|
|
46
|
+
if (payload.tools !== undefined)
|
|
47
|
+
bytes.tools = Buffer.byteLength(JSON.stringify(payload.tools), "utf8");
|
|
48
|
+
return {
|
|
49
|
+
measurement: "serialized-json-bytes",
|
|
50
|
+
bytes,
|
|
51
|
+
messageCount: payload.messages.length,
|
|
52
|
+
toolResults,
|
|
53
|
+
};
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Stable object-key order lets equivalent argument objects compare within a run. */
|
|
60
|
+
export function canonical(value: unknown): string {
|
|
61
|
+
return (
|
|
62
|
+
JSON.stringify(value, (_key, item) =>
|
|
63
|
+
isRecord(item)
|
|
64
|
+
? Object.fromEntries(Object.entries(item).sort(([a], [b]) => a.localeCompare(b)))
|
|
65
|
+
: item,
|
|
66
|
+
) ?? "null"
|
|
67
|
+
);
|
|
68
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
1
|
+
import { createHash, createHmac, randomBytes } from "node:crypto";
|
|
2
2
|
import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { isRecord } from "../models.ts";
|
|
4
|
+
import { canonical, observeContext, textSize } from "./context-observation.ts";
|
|
4
5
|
import type { Observation, ReportedUsage, RequestTrace } from "./types.ts";
|
|
5
6
|
|
|
6
7
|
export const hash = (text: string) => createHash("sha256").update(text).digest("hex");
|
|
@@ -51,6 +52,11 @@ export function redactor(secrets: string[]) {
|
|
|
51
52
|
export class Instrumentation {
|
|
52
53
|
readonly state = emptyObservation();
|
|
53
54
|
private compacting = false;
|
|
55
|
+
// Per-run HMAC keys are never persisted: compare values without retaining commands or file text.
|
|
56
|
+
private readonly fingerprintKey = randomBytes(32);
|
|
57
|
+
private fingerprint(value: unknown) {
|
|
58
|
+
return createHmac("sha256", this.fingerprintKey).update(canonical(value)).digest("hex");
|
|
59
|
+
}
|
|
54
60
|
private readonly update: (state: Observation) => void;
|
|
55
61
|
private readonly now: () => number;
|
|
56
62
|
private readonly redact: (text: string) => string;
|
|
@@ -93,8 +99,18 @@ export class Instrumentation {
|
|
|
93
99
|
case "auto_retry_start":
|
|
94
100
|
this.state.retries++;
|
|
95
101
|
break;
|
|
96
|
-
case "tool_execution_start":
|
|
102
|
+
case "tool_execution_start": {
|
|
103
|
+
const fingerprint = this.fingerprint(event.args);
|
|
104
|
+
const previous = this.state.tools.find(
|
|
105
|
+
(tool) => tool.name === event.toolName && tool.argumentsFingerprint === fingerprint,
|
|
106
|
+
);
|
|
107
|
+
const origin = this.state.requests.find((request) =>
|
|
108
|
+
request.generatedToolCalls?.some((tool) => tool.id === event.toolCallId),
|
|
109
|
+
);
|
|
97
110
|
this.state.tools.push({
|
|
111
|
+
request: origin?.request ?? null,
|
|
112
|
+
argumentsFingerprint: fingerprint,
|
|
113
|
+
repeatedArgumentsOf: previous?.id ?? null,
|
|
98
114
|
id: event.toolCallId,
|
|
99
115
|
name: event.toolName,
|
|
100
116
|
startedAtMs: at,
|
|
@@ -103,6 +119,7 @@ export class Instrumentation {
|
|
|
103
119
|
error: null,
|
|
104
120
|
});
|
|
105
121
|
break;
|
|
122
|
+
}
|
|
106
123
|
case "tool_execution_end": {
|
|
107
124
|
const tool = this.state.tools.find((item) => item.id === event.toolCallId);
|
|
108
125
|
if (tool) {
|
|
@@ -114,6 +131,11 @@ export class Instrumentation {
|
|
|
114
131
|
case "message_end": {
|
|
115
132
|
const message = event.message;
|
|
116
133
|
if (message.role === "assistant") {
|
|
134
|
+
const request = this.state.requests.filter((item) => item.purpose === "agent").at(-1);
|
|
135
|
+
if (request)
|
|
136
|
+
request.generatedToolCalls = message.content
|
|
137
|
+
.filter((block) => block.type === "toolCall")
|
|
138
|
+
.map((block) => ({ id: block.id, name: block.name }));
|
|
117
139
|
this.state.assistantMessages++;
|
|
118
140
|
this.state.lastAssistantStopReason = message.stopReason;
|
|
119
141
|
if (message.errorMessage)
|
|
@@ -138,6 +160,17 @@ export class Instrumentation {
|
|
|
138
160
|
this.state.tools.push(tool);
|
|
139
161
|
}
|
|
140
162
|
tool.isError = message.isError;
|
|
163
|
+
const fingerprint = this.fingerprint(message.content);
|
|
164
|
+
tool.output = { ...textSize(message.content), fingerprint };
|
|
165
|
+
const previous = this.state.tools.find(
|
|
166
|
+
(item) =>
|
|
167
|
+
item !== tool &&
|
|
168
|
+
item.name === tool.name &&
|
|
169
|
+
item.argumentsFingerprint !== undefined &&
|
|
170
|
+
item.argumentsFingerprint === tool.argumentsFingerprint &&
|
|
171
|
+
item.output?.fingerprint === fingerprint,
|
|
172
|
+
);
|
|
173
|
+
tool.repeatedOutputOf = previous?.id ?? null;
|
|
141
174
|
if (message.isError)
|
|
142
175
|
tool.error = this.redact(
|
|
143
176
|
message.content
|
|
@@ -160,6 +193,8 @@ export class Instrumentation {
|
|
|
160
193
|
if (!url.pathname.endsWith("/chat/completions")) return fetcher(input, init);
|
|
161
194
|
const trace: RequestTrace = {
|
|
162
195
|
request: this.state.requests.length + 1,
|
|
196
|
+
context: observeContext(init?.body),
|
|
197
|
+
generatedToolCalls: [],
|
|
163
198
|
purpose: this.compacting ? "compaction" : "agent",
|
|
164
199
|
startedAtMs: this.now(),
|
|
165
200
|
endedAtMs: null,
|
package/src/benchmark/report.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { distribution } from "./metrics.ts";
|
|
2
|
+
import { requestReport } from "./request-report.ts";
|
|
2
3
|
import type { Results, RunResult } from "./types.ts";
|
|
3
4
|
|
|
4
5
|
/** Client-observed first generated content on the first agent request, not worker startup. */
|
|
@@ -106,6 +107,13 @@ export function terminalReport(results: Results): string {
|
|
|
106
107
|
]
|
|
107
108
|
: []),
|
|
108
109
|
"",
|
|
110
|
+
...results.runs.flatMap(requestReport),
|
|
111
|
+
"",
|
|
112
|
+
"Request tokens are provider-reported per HTTP attempt, including retries and compaction when observed.",
|
|
113
|
+
"Δ input is the change from the preceding request, not tokens attributable to its tools.",
|
|
114
|
+
"Context sizes are measured UTF-8 JSON bytes including message wrappers; category token counts are unavailable.",
|
|
115
|
+
"Tool text sizes exclude images and may already be truncated by Pi. Commands, arguments, and successful output text are not stored.",
|
|
116
|
+
"Input amplification = cumulative input / last agent request input; not a waste score or final context size.",
|
|
109
117
|
"Detailed per-run measurements and validator output: results.json.",
|
|
110
118
|
].join("\n");
|
|
111
119
|
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import type { RunResult } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
const number = (value: number | null | undefined) =>
|
|
4
|
+
value == null ? "n/a" : value.toLocaleString("en-US");
|
|
5
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: remove terminal control characters from labels
|
|
6
|
+
const label = (value: string) => value.replace(/[\x00-\x1f\x7f-\x9f]/g, " ").slice(0, 80);
|
|
7
|
+
const table = (rows: string[][]) => {
|
|
8
|
+
const widths =
|
|
9
|
+
rows[0]?.map((_, index) => Math.max(...rows.map((row) => row[index]?.length ?? 0))) ?? [];
|
|
10
|
+
return rows.map((row) =>
|
|
11
|
+
row
|
|
12
|
+
.map((cell, index) => cell.padEnd(widths[index] ?? 0))
|
|
13
|
+
.join(" │ ")
|
|
14
|
+
.trimEnd(),
|
|
15
|
+
);
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export function requestReport(run: RunResult): string[] {
|
|
19
|
+
const requests = run.observation.requests;
|
|
20
|
+
const tools = run.observation.tools ?? [];
|
|
21
|
+
const rows = [["Request", "Kind", "Input", "Δ input", "Output", "Cached", "Tools produced"]];
|
|
22
|
+
const sizes = [
|
|
23
|
+
["Request", "System B", "Schemas B", "User B", "Assistant B", "Tool results B", "Other B"],
|
|
24
|
+
];
|
|
25
|
+
for (const [index, request] of requests.entries()) {
|
|
26
|
+
const input = request.usage?.inputTokens;
|
|
27
|
+
const previous = requests[index - 1]?.usage?.inputTokens;
|
|
28
|
+
const delta = input != null && previous != null ? input - previous : null;
|
|
29
|
+
rows.push([
|
|
30
|
+
String(request.request ?? index + 1),
|
|
31
|
+
request.purpose,
|
|
32
|
+
number(input),
|
|
33
|
+
delta !== null && delta > 0 ? `+${number(delta)}` : number(delta),
|
|
34
|
+
number(request.usage?.outputTokens),
|
|
35
|
+
number(request.usage?.cachedInputTokens),
|
|
36
|
+
request.generatedToolCalls?.length
|
|
37
|
+
? request.generatedToolCalls.map((tool) => label(tool.name)).join(", ")
|
|
38
|
+
: "—",
|
|
39
|
+
]);
|
|
40
|
+
const bytes = request.context?.bytes;
|
|
41
|
+
sizes.push([
|
|
42
|
+
String(request.request ?? index + 1),
|
|
43
|
+
...[
|
|
44
|
+
bytes?.system,
|
|
45
|
+
bytes?.tools,
|
|
46
|
+
bytes?.user,
|
|
47
|
+
bytes?.assistant,
|
|
48
|
+
bytes?.toolResults,
|
|
49
|
+
bytes?.other,
|
|
50
|
+
].map(number),
|
|
51
|
+
]);
|
|
52
|
+
}
|
|
53
|
+
const input = run.tokens.cumulativeInputTokens;
|
|
54
|
+
const output = run.tokens.cumulativeOutputTokens;
|
|
55
|
+
rows.push([
|
|
56
|
+
"TOTAL",
|
|
57
|
+
"all attempts",
|
|
58
|
+
number(input),
|
|
59
|
+
"—",
|
|
60
|
+
number(output),
|
|
61
|
+
number(run.tokens.cachedInputTokens),
|
|
62
|
+
"",
|
|
63
|
+
]);
|
|
64
|
+
const last = run.tokens.lastRequestInputTokens;
|
|
65
|
+
const amplification = run.tokens.inputAmplificationVsLastRequest;
|
|
66
|
+
const total = input != null && output != null ? input + output : null;
|
|
67
|
+
const consumption =
|
|
68
|
+
run.success && run.validation.checked
|
|
69
|
+
? "Tokens to validated solution"
|
|
70
|
+
: run.failure !== null
|
|
71
|
+
? "Tokens consumed before failure"
|
|
72
|
+
: "Tokens consumed (correctness not checked)";
|
|
73
|
+
const lines = [
|
|
74
|
+
"",
|
|
75
|
+
`Request trace: ${label(run.model)} #${run.run}`,
|
|
76
|
+
...table(rows),
|
|
77
|
+
`${consumption}: ${number(total)} (input ${number(input)} + output ${number(output)}).`,
|
|
78
|
+
`Last agent request input: ${number(last)}; input amplification: ${amplification == null ? "n/a" : `${amplification.toFixed(2)}x`}.`,
|
|
79
|
+
"",
|
|
80
|
+
"Context composition — measured JSON bytes (B), NOT token attribution:",
|
|
81
|
+
...table(sizes),
|
|
82
|
+
];
|
|
83
|
+
if (tools.length) {
|
|
84
|
+
lines.push("", "Tool results — model-facing text after Pi processing:");
|
|
85
|
+
const toolRows = [
|
|
86
|
+
[
|
|
87
|
+
"Tool",
|
|
88
|
+
"From request",
|
|
89
|
+
"Text bytes",
|
|
90
|
+
"Lines",
|
|
91
|
+
"Included in requests",
|
|
92
|
+
"Repeated arguments",
|
|
93
|
+
"Same result",
|
|
94
|
+
],
|
|
95
|
+
];
|
|
96
|
+
for (const [index, tool] of tools.entries()) {
|
|
97
|
+
const included = requests
|
|
98
|
+
.filter((request) => request.context?.toolResults.some((result) => result.id === tool.id))
|
|
99
|
+
.map((request) => request.request);
|
|
100
|
+
const reference = (id: string | null | undefined) => {
|
|
101
|
+
const at = tools.findIndex((item) => item.id === id);
|
|
102
|
+
return at < 0 ? "—" : `T${at + 1}`;
|
|
103
|
+
};
|
|
104
|
+
toolRows.push([
|
|
105
|
+
`T${index + 1} ${label(tool.name)}`,
|
|
106
|
+
number(tool.request),
|
|
107
|
+
number(tool.output?.bytes),
|
|
108
|
+
number(tool.output?.lines),
|
|
109
|
+
requests.every((request) => request.context != null)
|
|
110
|
+
? included.join(", ") || "none"
|
|
111
|
+
: `${included.join(", ") || "none observed"} (partial)`,
|
|
112
|
+
reference(tool.repeatedArgumentsOf),
|
|
113
|
+
reference(tool.repeatedOutputOf),
|
|
114
|
+
]);
|
|
115
|
+
}
|
|
116
|
+
lines.push(...table(toolRows));
|
|
117
|
+
const large = tools
|
|
118
|
+
.map((tool, index) => ({ tool, index }))
|
|
119
|
+
.filter(({ tool }) => (tool.output?.bytes ?? 0) >= 16384);
|
|
120
|
+
for (const { tool, index } of large)
|
|
121
|
+
lines.push(
|
|
122
|
+
`Observation: T${index + 1} returned ${number(tool.output?.bytes)} text bytes (large-output threshold: 16 KiB).`,
|
|
123
|
+
);
|
|
124
|
+
if (tools.some((tool) => tool.repeatedArgumentsOf))
|
|
125
|
+
lines.push(
|
|
126
|
+
"Repeated arguments/results are candidates to inspect, not proof of unnecessary work or unchanged files.",
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
return lines;
|
|
130
|
+
}
|
package/src/benchmark/types.ts
CHANGED
|
@@ -26,7 +26,22 @@ export interface ReportedUsage {
|
|
|
26
26
|
reasoningTokens: number | null;
|
|
27
27
|
totalTokens: number | null;
|
|
28
28
|
}
|
|
29
|
+
export interface RequestContext {
|
|
30
|
+
measurement: "serialized-json-bytes";
|
|
31
|
+
bytes: {
|
|
32
|
+
system: number;
|
|
33
|
+
tools: number;
|
|
34
|
+
user: number;
|
|
35
|
+
assistant: number;
|
|
36
|
+
toolResults: number;
|
|
37
|
+
other: number;
|
|
38
|
+
};
|
|
39
|
+
messageCount: number;
|
|
40
|
+
toolResults: Array<{ id: string; bytes: number; lines: number }>;
|
|
41
|
+
}
|
|
29
42
|
export interface RequestTrace {
|
|
43
|
+
context?: RequestContext | null;
|
|
44
|
+
generatedToolCalls?: Array<{ id: string; name: string }>;
|
|
30
45
|
request: number;
|
|
31
46
|
purpose: "agent" | "compaction";
|
|
32
47
|
startedAtMs: number;
|
|
@@ -42,6 +57,11 @@ export interface RequestTrace {
|
|
|
42
57
|
streamComplete: boolean;
|
|
43
58
|
}
|
|
44
59
|
export interface ToolTrace {
|
|
60
|
+
request?: number | null;
|
|
61
|
+
argumentsFingerprint?: string;
|
|
62
|
+
repeatedArgumentsOf?: string | null;
|
|
63
|
+
output?: { bytes: number; lines: number; fingerprint: string };
|
|
64
|
+
repeatedOutputOf?: string | null;
|
|
45
65
|
id: string;
|
|
46
66
|
name: string;
|
|
47
67
|
startedAtMs: number | null;
|