pi-nebius 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.
Files changed (73) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/CONTRIBUTING.md +39 -0
  3. package/LICENSE +21 -0
  4. package/README.md +239 -0
  5. package/SECURITY.md +47 -0
  6. package/benchmarks/add-api-endpoint/benchmark.yaml +13 -0
  7. package/benchmarks/add-api-endpoint/fixture/app.mjs +6 -0
  8. package/benchmarks/add-api-endpoint/fixture/app.test.mjs +8 -0
  9. package/benchmarks/add-api-endpoint/fixture/package.json +8 -0
  10. package/benchmarks/add-api-endpoint/validation/check.test.mjs +37 -0
  11. package/benchmarks/fix-auth-bug/benchmark.yaml +14 -0
  12. package/benchmarks/fix-auth-bug/fixture/auth.mjs +4 -0
  13. package/benchmarks/fix-auth-bug/fixture/auth.test.mjs +26 -0
  14. package/benchmarks/fix-auth-bug/fixture/package.json +8 -0
  15. package/benchmarks/fix-auth-bug/validation/check.test.mjs +25 -0
  16. package/benchmarks/multi-file-feature/benchmark.yaml +15 -0
  17. package/benchmarks/multi-file-feature/fixture/package.json +8 -0
  18. package/benchmarks/multi-file-feature/fixture/routes.mjs +11 -0
  19. package/benchmarks/multi-file-feature/fixture/routes.test.mjs +14 -0
  20. package/benchmarks/multi-file-feature/fixture/serialize.mjs +3 -0
  21. package/benchmarks/multi-file-feature/fixture/store.mjs +10 -0
  22. package/benchmarks/multi-file-feature/validation/check.test.mjs +52 -0
  23. package/benchmarks/refactor-module/benchmark.yaml +12 -0
  24. package/benchmarks/refactor-module/fixture/invoice.mjs +8 -0
  25. package/benchmarks/refactor-module/fixture/invoice.test.mjs +9 -0
  26. package/benchmarks/refactor-module/fixture/package.json +8 -0
  27. package/benchmarks/refactor-module/validation/check.test.mjs +36 -0
  28. package/dist/benchmark/cli.js +112 -0
  29. package/dist/benchmark/command.js +194 -0
  30. package/dist/benchmark/definition.js +109 -0
  31. package/dist/benchmark/host-worker.js +14 -0
  32. package/dist/benchmark/instrumentation.js +296 -0
  33. package/dist/benchmark/metrics.js +78 -0
  34. package/dist/benchmark/process.js +122 -0
  35. package/dist/benchmark/project.js +70 -0
  36. package/dist/benchmark/report.js +94 -0
  37. package/dist/benchmark/runner.js +376 -0
  38. package/dist/benchmark/types.js +1 -0
  39. package/dist/benchmark/worker.js +134 -0
  40. package/dist/benchmark/workspace.js +55 -0
  41. package/dist/discovery.js +154 -0
  42. package/dist/errors.js +32 -0
  43. package/dist/index.js +86 -0
  44. package/dist/model-settings-command.js +130 -0
  45. package/dist/model-settings.js +101 -0
  46. package/dist/models.js +62 -0
  47. package/dist/provider.js +48 -0
  48. package/docs/benchmark-research.md +35 -0
  49. package/docs/benchmarking.md +253 -0
  50. package/docs/security-review.md +49 -0
  51. package/docs/validation.md +51 -0
  52. package/examples/models.json +31 -0
  53. package/package.json +74 -0
  54. package/src/benchmark/cli.ts +118 -0
  55. package/src/benchmark/command.ts +218 -0
  56. package/src/benchmark/definition.ts +112 -0
  57. package/src/benchmark/host-worker.ts +14 -0
  58. package/src/benchmark/instrumentation.ts +298 -0
  59. package/src/benchmark/metrics.ts +101 -0
  60. package/src/benchmark/process.ts +120 -0
  61. package/src/benchmark/project.ts +71 -0
  62. package/src/benchmark/report.ts +111 -0
  63. package/src/benchmark/runner.ts +458 -0
  64. package/src/benchmark/types.ts +180 -0
  65. package/src/benchmark/worker.ts +150 -0
  66. package/src/benchmark/workspace.ts +64 -0
  67. package/src/discovery.ts +176 -0
  68. package/src/errors.ts +32 -0
  69. package/src/index.ts +96 -0
  70. package/src/model-settings-command.ts +151 -0
  71. package/src/model-settings.ts +129 -0
  72. package/src/models.ts +73 -0
  73. package/src/provider.ts +63 -0
@@ -0,0 +1,218 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdir, mkdtemp, readFile, realpath, rm } from "node:fs/promises";
3
+ import { findPackageJSON } from "node:module";
4
+ import { tmpdir } from "node:os";
5
+ import { dirname, join } from "node:path";
6
+ import { fileURLToPath, pathToFileURL } from "node:url";
7
+ import { parseArgs } from "node:util";
8
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
9
+ import { MISSING_KEY } from "../discovery.ts";
10
+ import { applyModelSettings, type ModelSettingsMap } from "../model-settings.ts";
11
+ import type { NebiusModel } from "../models.ts";
12
+ import { loadDefinition, positive } from "./definition.ts";
13
+ import { redactor } from "./instrumentation.ts";
14
+ import { snapshotProject } from "./project.ts";
15
+ import { terminalReport } from "./report.ts";
16
+ import { runBenchmark } from "./runner.ts";
17
+ import type { BenchmarkDefinition } from "./types.ts";
18
+
19
+ const tasks = ["fix-auth-bug", "add-api-endpoint", "refactor-module", "multi-file-feature"];
20
+ const help = `Usage: /nebius-benchmark [--models ID,ID] [--runs N]
21
+ Enter your task prompt in the editor. Each run gets a fresh copy of the current project.
22
+ Without --models, uses the selected Nebius model. Default: 1 run per model.
23
+ Optional: --task NAME uses a bundled task instead (${tasks.join(", ")}).
24
+ /nebius-benchmark cancel stops the active benchmark.
25
+ Results appear here and in benchmark-results/. Custom prompts have no correctness check.
26
+ Copies exclude Git-ignored files, dependencies, build output, and known credential files.
27
+ Runs use paid inference and tools with normal host access; copies are not a security sandbox.`;
28
+
29
+ export async function hostPiEntry(): Promise<string> {
30
+ // The running Pi CLI can be a symlink into a global installation.
31
+ const base = process.argv[1]
32
+ ? pathToFileURL(await realpath(process.argv[1])).href
33
+ : import.meta.url;
34
+ const manifestPath = findPackageJSON("@earendil-works/pi-coding-agent", base);
35
+ if (!manifestPath)
36
+ throw new Error("Cannot locate the host Pi SDK. Use the Node.js Pi installation.");
37
+ const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
38
+ return join(dirname(manifestPath), manifest.main);
39
+ }
40
+
41
+ export function registerBenchmarkCommand(
42
+ pi: ExtensionAPI,
43
+ getSettings: () => ModelSettingsMap = () => ({}),
44
+ ) {
45
+ let starting = false;
46
+ let active: { controller: AbortController; done: Promise<void> } | undefined;
47
+ const show = (content: string) =>
48
+ pi.sendMessage(
49
+ { customType: "nebius-benchmark", content, display: true },
50
+ { triggerTurn: false },
51
+ );
52
+ pi.registerCommand("nebius-benchmark", {
53
+ description: "Compare models on your prompt: --models ID,ID --runs N; help or cancel",
54
+ handler: async (args, ctx) => {
55
+ const tokens = args.trim().split(/\s+/).filter(Boolean);
56
+ const action = tokens[0];
57
+ if (action === "help" || action === "list") {
58
+ show(help);
59
+ return;
60
+ }
61
+ if (action === "cancel") {
62
+ if (active) {
63
+ active.controller.abort();
64
+ ctx.ui.notify("Stopping benchmark…", "info");
65
+ } else ctx.ui.notify("No benchmark is running.", "info");
66
+ return;
67
+ }
68
+ if (active || starting) {
69
+ ctx.ui.notify("A benchmark is already running. Use /nebius-benchmark cancel.", "warning");
70
+ return;
71
+ }
72
+ const key = process.env.NEBIUS_API_KEY?.trim();
73
+ const redact = redactor([key ?? ""]);
74
+ starting = true;
75
+ let scratch: string | undefined;
76
+ try {
77
+ const { values } = parseArgs({
78
+ args: tokens,
79
+ options: {
80
+ models: { type: "string" },
81
+ runs: { type: "string" },
82
+ task: { type: "string" },
83
+ },
84
+ });
85
+ const runs = positive(Number(values.runs ?? 1), "runs", 1000);
86
+ if (values.task && !tasks.includes(values.task)) throw new Error(help);
87
+ let selected = ctx.model ? [ctx.model] : [];
88
+ if (values.models !== undefined) {
89
+ const ids = values.models.split(",");
90
+ if (ids.some((id) => !id) || new Set(ids).size !== ids.length)
91
+ throw new Error("--models requires unique, comma-separated model IDs.");
92
+ const available = ctx.modelRegistry.getAll();
93
+ selected = ids.map((id) => {
94
+ const model = available.find(
95
+ (candidate) => candidate.provider === "nebius" && candidate.id === id,
96
+ );
97
+ if (!model)
98
+ throw new Error(`Unknown Nebius model: ${id}. Run /nebius-refresh or check /model.`);
99
+ return model;
100
+ });
101
+ }
102
+ if (
103
+ !selected.length ||
104
+ selected.some(
105
+ (model) => model.provider !== "nebius" || model.api !== "openai-completions",
106
+ )
107
+ )
108
+ throw new Error("Select a Nebius model with /model first.");
109
+ const total = runs * selected.length;
110
+ if (total > 10000) throw new Error("At most 10,000 runs per benchmark.");
111
+ if (!key) throw new Error(MISSING_KEY);
112
+ const piEntry = await hostPiEntry();
113
+ let definition: BenchmarkDefinition;
114
+ let directory: string;
115
+ const task = values.task ?? "custom-prompt";
116
+ if (values.task) {
117
+ const root = fileURLToPath(new URL("../../", import.meta.url));
118
+ ({ definition, directory } = await loadDefinition(join(root, "benchmarks", task)));
119
+ } else {
120
+ if (!ctx.hasUI)
121
+ throw new Error("Custom prompts need interactive Pi. Use --task for a bundled task.");
122
+ const prompt = await ctx.ui.editor("Benchmark task — what should each model do?");
123
+ if (!prompt?.trim()) return;
124
+ scratch = await mkdtemp(join(tmpdir(), "pi-nebius-project-"));
125
+ directory = scratch;
126
+ const copied = await snapshotProject(ctx.cwd, join(directory, "fixture"));
127
+ await mkdir(join(directory, "validation"));
128
+ definition = {
129
+ schemaVersion: 1,
130
+ name: task,
131
+ task: prompt.trim(),
132
+ fixture: "fixture",
133
+ validationDirectory: "validation",
134
+ validation: [],
135
+ validationMode: "none",
136
+ setup: [],
137
+ timeout: 600,
138
+ validationTimeout: 60,
139
+ tools: ["read", "bash", "edit", "write", "grep", "find", "ls"],
140
+ };
141
+ ctx.ui.notify(
142
+ `Copied ${copied} project files for comparison. Dependencies and ignored files are excluded.`,
143
+ "info",
144
+ );
145
+ }
146
+ // Snapshot selection; changing the interactive model does not change an active run.
147
+ const modelSettings = structuredClone(getSettings());
148
+ const models = (structuredClone(selected) as NebiusModel[]).map((model) =>
149
+ applyModelSettings(model, modelSettings[model.id]),
150
+ );
151
+ let completed = 0;
152
+ const output = join(ctx.cwd, "benchmark-results", `${task}-${randomUUID()}`);
153
+ const controller = new AbortController();
154
+ show(
155
+ `Starting ${task}: ${models.map((model) => model.id).join(", ")}, ${runs} run(s) each. Uses paid inference; tools have normal host access.\nUse /nebius-benchmark cancel to stop.`,
156
+ );
157
+ ctx.ui.setStatus("nebius-benchmark", `Benchmark: ${task} (0/${total})`);
158
+ const snapshot = scratch;
159
+ scratch = undefined; // The background run owns cleanup from here.
160
+ const done = runBenchmark({
161
+ definition,
162
+ directory,
163
+ models,
164
+ modelSettings,
165
+ runs,
166
+ concurrency: 1,
167
+ output,
168
+ apiKey: key,
169
+ signal: controller.signal,
170
+ piEntry,
171
+ workerPath: fileURLToPath(
172
+ new URL(
173
+ import.meta.url.endsWith(".ts") ? "./host-worker.ts" : "./host-worker.js",
174
+ import.meta.url,
175
+ ),
176
+ ),
177
+ workerArgs: [piEntry],
178
+ onRun: (run) => {
179
+ ctx.ui.setStatus("nebius-benchmark", `Benchmark: ${task} (${++completed}/${total})`);
180
+ ctx.ui.notify(
181
+ `${run.model} #${run.run}: ${run.failure ?? (run.validation.checked ? "PASS" : "FINISHED (not validated)")}`,
182
+ "info",
183
+ );
184
+ },
185
+ })
186
+ .then((results) => {
187
+ show(redact(`${terminalReport(results)}\nDetails: ${join(output, "results.json")}`));
188
+ })
189
+ .catch((error) => {
190
+ show(redact(`Benchmark failed: ${String(error)}`));
191
+ })
192
+ .finally(async () => {
193
+ try {
194
+ if (snapshot) await rm(snapshot, { recursive: true, force: true });
195
+ } catch (error) {
196
+ ctx.ui.notify(
197
+ redact(`Could not remove temporary snapshot: ${String(error)}`),
198
+ "warning",
199
+ );
200
+ } finally {
201
+ active = undefined;
202
+ ctx.ui.setStatus("nebius-benchmark", undefined);
203
+ }
204
+ });
205
+ active = { controller, done };
206
+ } catch (error) {
207
+ ctx.ui.notify(redact(String(error)), "error");
208
+ } finally {
209
+ if (scratch) await rm(scratch, { recursive: true, force: true });
210
+ starting = false;
211
+ }
212
+ },
213
+ });
214
+ pi.on("session_shutdown", async () => {
215
+ active?.controller.abort();
216
+ await active?.done;
217
+ });
218
+ }
@@ -0,0 +1,112 @@
1
+ import { readFile, realpath } from "node:fs/promises";
2
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
3
+ import { parse } from "yaml";
4
+ import { isRecord } from "../models.ts";
5
+ import type { BenchmarkDefinition, Command } from "./types.ts";
6
+
7
+ export function positive(value: unknown, name: string, maximum = 86400): number {
8
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1 || value > maximum)
9
+ throw new Error(`${name} must be an integer between 1 and ${maximum}`);
10
+ return value;
11
+ }
12
+ function text(value: unknown, name: string): string {
13
+ if (typeof value !== "string" || !value.trim()) throw new Error(`${name} must be nonempty text`);
14
+ return value;
15
+ }
16
+ function fields(value: Record<string, unknown>, allowed: string[]) {
17
+ for (const key of Object.keys(value))
18
+ if (!allowed.includes(key)) throw new Error(`Unknown field: ${key}`);
19
+ }
20
+ function commands(value: unknown, name: string): Command[] {
21
+ if (!Array.isArray(value)) throw new Error(`${name} must be an array`);
22
+ return value.map((item) => {
23
+ if (typeof item === "string") return { command: "/bin/sh", args: ["-c", text(item, name)] };
24
+ if (!isRecord(item)) throw new Error(`Invalid ${name} command`);
25
+ fields(item, ["command", "args"]);
26
+ if (
27
+ item.args !== undefined &&
28
+ (!Array.isArray(item.args) || item.args.some((arg) => typeof arg !== "string"))
29
+ )
30
+ throw new Error(`${name} args must be strings`);
31
+ return { command: text(item.command, "command"), args: (item.args ?? []) as string[] };
32
+ });
33
+ }
34
+ function localPath(value: unknown, fallback: string): string {
35
+ const path = value === undefined ? fallback : text(value, "path");
36
+ if (isAbsolute(path) || path.split(/[\\/]/).includes(".."))
37
+ throw new Error("Benchmark paths must be relative and inside its directory");
38
+ return path;
39
+ }
40
+ export function parseDefinition(source: string): BenchmarkDefinition {
41
+ const value: unknown = parse(source, { maxAliasCount: 0, uniqueKeys: true });
42
+ if (!isRecord(value)) throw new Error("Benchmark definition must be an object");
43
+ fields(value, [
44
+ "schemaVersion",
45
+ "name",
46
+ "task",
47
+ "fixture",
48
+ "validationDirectory",
49
+ "validation",
50
+ "setup",
51
+ "timeout",
52
+ "validationTimeout",
53
+ "tools",
54
+ "systemPrompt",
55
+ ]);
56
+ if (value.schemaVersion !== undefined && value.schemaVersion !== 1)
57
+ throw new Error("Unsupported benchmark schemaVersion");
58
+ const tools = value.tools ?? ["read", "bash", "edit", "write"];
59
+ const available = ["read", "bash", "edit", "write", "grep", "find", "ls"];
60
+ if (
61
+ !Array.isArray(tools) ||
62
+ !tools.length ||
63
+ tools.some((tool) => !available.includes(tool)) ||
64
+ new Set(tools).size !== tools.length
65
+ )
66
+ throw new Error("tools must be a nonempty, unique list of Pi built-in tools");
67
+ const validation = commands(value.validation, "validation");
68
+ if (!validation.length) throw new Error("At least one validation command is required");
69
+ return {
70
+ schemaVersion: 1,
71
+ name: text(value.name, "name"),
72
+ task: text(value.task, "task"),
73
+ fixture: localPath(value.fixture, "fixture"),
74
+ validationDirectory: localPath(value.validationDirectory, "validation"),
75
+ validation,
76
+ setup: commands(value.setup ?? [], "setup"),
77
+ timeout: positive(value.timeout ?? 600, "timeout"),
78
+ validationTimeout: positive(value.validationTimeout ?? 60, "validationTimeout"),
79
+ tools,
80
+ ...(value.systemPrompt === undefined
81
+ ? {}
82
+ : { systemPrompt: text(value.systemPrompt, "systemPrompt") }),
83
+ };
84
+ }
85
+ export async function loadDefinition(path: string) {
86
+ const definitionFile =
87
+ path.endsWith(".yaml") || path.endsWith(".yml") || path.endsWith(".json")
88
+ ? resolve(path)
89
+ : resolve(path, "benchmark.yaml");
90
+ const source = await readFile(definitionFile, "utf8");
91
+ if (Buffer.byteLength(source) > 1024 * 1024) throw new Error("Definition exceeds 1 MiB");
92
+ const definition = parseDefinition(source);
93
+ const directory = await realpath(dirname(definitionFile));
94
+ const directories: string[] = [];
95
+ for (const child of [definition.fixture, definition.validationDirectory]) {
96
+ const actual = await realpath(join(directory, child));
97
+ directories.push(actual);
98
+ const rel = relative(directory, actual);
99
+ if (!rel || rel.startsWith("..") || isAbsolute(rel))
100
+ throw new Error("Fixture/validation must be separate directories inside the benchmark");
101
+ }
102
+ const [fixture, validators] = directories;
103
+ if (
104
+ fixture &&
105
+ validators &&
106
+ [relative(fixture, validators), relative(validators, fixture)].some(
107
+ (path) => !isAbsolute(path) && path !== ".." && !path.startsWith(`..${sep}`),
108
+ )
109
+ )
110
+ throw new Error("Fixture and validation directories must be separate and must not overlap");
111
+ return { definition, directory, source };
112
+ }
@@ -0,0 +1,14 @@
1
+ import { registerHooks } from "node:module";
2
+ import { pathToFileURL } from "node:url";
3
+
4
+ // Reuse the installed host's SDK; Git installs intentionally omit Pi dev dependencies.
5
+ const hostEntry = process.argv[2];
6
+ if (!hostEntry) throw new Error("Missing host Pi entry");
7
+ registerHooks({
8
+ resolve(specifier, context, nextResolve) {
9
+ if (specifier.startsWith("@earendil-works/"))
10
+ return nextResolve(specifier, { ...context, parentURL: pathToFileURL(hostEntry).href });
11
+ return nextResolve(specifier, context);
12
+ },
13
+ });
14
+ await import("./worker.ts");
@@ -0,0 +1,298 @@
1
+ import { createHash } from "node:crypto";
2
+ import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
3
+ import { isRecord } from "../models.ts";
4
+ import type { Observation, ReportedUsage, RequestTrace } from "./types.ts";
5
+
6
+ export const hash = (text: string) => createHash("sha256").update(text).digest("hex");
7
+ const count = (value: unknown): number | null =>
8
+ typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : null;
9
+ export function reportedUsage(value: unknown): ReportedUsage | null {
10
+ if (!isRecord(value)) return null;
11
+ const prompt = isRecord(value.prompt_tokens_details) ? value.prompt_tokens_details : {};
12
+ const completion = isRecord(value.completion_tokens_details)
13
+ ? value.completion_tokens_details
14
+ : {};
15
+ return {
16
+ inputTokens: count(value.prompt_tokens),
17
+ outputTokens: count(value.completion_tokens),
18
+ cachedInputTokens: count(
19
+ prompt.cached_tokens ?? value.prompt_cache_hit_tokens ?? value.cached_tokens,
20
+ ),
21
+ reasoningTokens: count(completion.reasoning_tokens),
22
+ totalTokens: count(value.total_tokens),
23
+ };
24
+ }
25
+ export function emptyObservation(): Observation {
26
+ return {
27
+ requests: [],
28
+ tools: [],
29
+ agentTurns: 0,
30
+ assistantMessages: 0,
31
+ toolCalls: 0,
32
+ toolCallsByType: {},
33
+ compactions: 0,
34
+ retries: 0,
35
+ agentStartedAtMs: null,
36
+ agentEndedAtMs: null,
37
+ systemPromptHash: null,
38
+ lastAssistantStopReason: null,
39
+ errors: [],
40
+ };
41
+ }
42
+ export function redactor(secrets: string[]) {
43
+ return (value: string) => {
44
+ let safe = value;
45
+ for (const secret of secrets.filter(Boolean)) safe = safe.split(secret).join("[REDACTED]");
46
+ return safe.replace(/Bearer\s+[^\s"']+/gi, "Bearer [REDACTED]");
47
+ };
48
+ }
49
+
50
+ /** Observer only: never returns modified payloads, tools, messages, or stream bytes. */
51
+ export class Instrumentation {
52
+ readonly state = emptyObservation();
53
+ private compacting = false;
54
+ private readonly update: (state: Observation) => void;
55
+ private readonly now: () => number;
56
+ private readonly redact: (text: string) => string;
57
+ constructor(
58
+ update: (state: Observation) => void = () => {},
59
+ now: () => number = () => performance.now(),
60
+ redact: (text: string) => string = (text) => text,
61
+ ) {
62
+ this.update = update;
63
+ this.now = now;
64
+ this.redact = redact;
65
+ }
66
+
67
+ systemPrompt(prompt: string) {
68
+ this.state.systemPromptHash = hash(prompt);
69
+ this.publish();
70
+ }
71
+ private publish() {
72
+ this.update(this.state);
73
+ }
74
+ onEvent(event: AgentSessionEvent) {
75
+ const at = this.now();
76
+ switch (event.type) {
77
+ case "agent_start":
78
+ this.state.agentStartedAtMs ??= at;
79
+ break;
80
+ case "agent_settled":
81
+ this.state.agentEndedAtMs = at;
82
+ break;
83
+ case "turn_start":
84
+ this.state.agentTurns++;
85
+ break;
86
+ case "compaction_start":
87
+ this.compacting = true;
88
+ this.state.compactions++;
89
+ break;
90
+ case "compaction_end":
91
+ this.compacting = false;
92
+ break;
93
+ case "auto_retry_start":
94
+ this.state.retries++;
95
+ break;
96
+ case "tool_execution_start":
97
+ this.state.tools.push({
98
+ id: event.toolCallId,
99
+ name: event.toolName,
100
+ startedAtMs: at,
101
+ endedAtMs: null,
102
+ isError: null,
103
+ error: null,
104
+ });
105
+ break;
106
+ case "tool_execution_end": {
107
+ const tool = this.state.tools.find((item) => item.id === event.toolCallId);
108
+ if (tool) {
109
+ tool.endedAtMs = at;
110
+ tool.isError = event.isError;
111
+ }
112
+ break;
113
+ }
114
+ case "message_end": {
115
+ const message = event.message;
116
+ if (message.role === "assistant") {
117
+ this.state.assistantMessages++;
118
+ this.state.lastAssistantStopReason = message.stopReason;
119
+ if (message.errorMessage)
120
+ this.state.errors.push(this.redact(message.errorMessage).slice(0, 8000));
121
+ for (const block of message.content)
122
+ if (block.type === "toolCall") {
123
+ this.state.toolCalls++;
124
+ this.state.toolCallsByType[block.name] =
125
+ (this.state.toolCallsByType[block.name] ?? 0) + 1;
126
+ }
127
+ } else if (message.role === "toolResult") {
128
+ let tool = this.state.tools.find((item) => item.id === message.toolCallId);
129
+ if (!tool) {
130
+ tool = {
131
+ id: message.toolCallId,
132
+ name: message.toolName,
133
+ startedAtMs: null,
134
+ endedAtMs: at,
135
+ isError: message.isError,
136
+ error: null,
137
+ };
138
+ this.state.tools.push(tool);
139
+ }
140
+ tool.isError = message.isError;
141
+ if (message.isError)
142
+ tool.error = this.redact(
143
+ message.content
144
+ .filter((item) => item.type === "text")
145
+ .map((item) => item.text)
146
+ .join("\n"),
147
+ ).slice(0, 4000);
148
+ }
149
+ break;
150
+ }
151
+ default:
152
+ return;
153
+ }
154
+ this.publish();
155
+ }
156
+
157
+ observeFetch(fetcher: typeof fetch): typeof fetch {
158
+ return async (input, init) => {
159
+ const url = new URL(input instanceof Request ? input.url : input.toString());
160
+ if (!url.pathname.endsWith("/chat/completions")) return fetcher(input, init);
161
+ const trace: RequestTrace = {
162
+ request: this.state.requests.length + 1,
163
+ purpose: this.compacting ? "compaction" : "agent",
164
+ startedAtMs: this.now(),
165
+ endedAtMs: null,
166
+ firstContentAtMs: null,
167
+ status: null,
168
+ requestId: null,
169
+ servedModel: null,
170
+ systemFingerprint: null,
171
+ finishReason: null,
172
+ usage: null,
173
+ error: null,
174
+ streamComplete: false,
175
+ };
176
+ this.state.requests.push(trace);
177
+ this.publish();
178
+ try {
179
+ const response = await fetcher(input, init);
180
+ trace.status = response.status;
181
+ trace.requestId = response.headers.get("x-request-id");
182
+ this.publish();
183
+ if (!response.body) {
184
+ trace.endedAtMs = this.now();
185
+ this.publish();
186
+ return response;
187
+ }
188
+ const decoder = new TextDecoder();
189
+ let buffer = "";
190
+ let dropped = false;
191
+ const consume = (data: string) => {
192
+ if (data.trim() === "[DONE]") {
193
+ trace.streamComplete = true;
194
+ return;
195
+ }
196
+ let chunk: unknown;
197
+ try {
198
+ chunk = JSON.parse(data);
199
+ } catch {
200
+ return;
201
+ }
202
+ if (!isRecord(chunk)) return;
203
+ if (typeof chunk.model === "string") trace.servedModel = chunk.model;
204
+ if (typeof chunk.system_fingerprint === "string")
205
+ trace.systemFingerprint = chunk.system_fingerprint;
206
+ if (chunk.usage) trace.usage = reportedUsage(chunk.usage);
207
+ for (const choice of Array.isArray(chunk.choices) ? chunk.choices : []) {
208
+ if (!isRecord(choice)) continue;
209
+ if (typeof choice.finish_reason === "string") trace.finishReason = choice.finish_reason;
210
+ if (choice.usage) trace.usage = reportedUsage(choice.usage);
211
+ const delta = isRecord(choice.delta) ? choice.delta : {};
212
+ if (
213
+ trace.firstContentAtMs === null &&
214
+ ([delta.content, delta.reasoning_content, delta.reasoning].some(
215
+ (value) => typeof value === "string" && value.length > 0,
216
+ ) ||
217
+ (Array.isArray(delta.tool_calls) &&
218
+ delta.tool_calls.some((call: unknown) => {
219
+ if (!isRecord(call) || !isRecord(call.function)) return false;
220
+ return [call.function.name, call.function.arguments].some(
221
+ (value) => typeof value === "string" && value.length > 0,
222
+ );
223
+ })))
224
+ )
225
+ trace.firstContentAtMs = this.now();
226
+ }
227
+ };
228
+ const ingest = (bytes: Uint8Array) => {
229
+ if (dropped) return;
230
+ const previous = JSON.stringify([trace.usage, trace.firstContentAtMs]);
231
+ buffer += decoder.decode(bytes, { stream: true });
232
+ if (buffer.length > 1024 * 1024) {
233
+ dropped = true;
234
+ buffer = "";
235
+ trace.error = "Instrumentation frame exceeded 1 MiB; usage may be incomplete";
236
+ return;
237
+ }
238
+ // Line parser handles arbitrary TCP chunking, LF and CRLF. JSON payloads
239
+ // are contained in SSE data lines for Nebius's Chat Completions protocol.
240
+ let newline = buffer.indexOf("\n");
241
+ while (newline >= 0) {
242
+ const line = buffer.slice(0, newline).replace(/\r$/, "");
243
+ buffer = buffer.slice(newline + 1);
244
+ if (line.startsWith("data:")) consume(line.slice(5).trimStart());
245
+ newline = buffer.indexOf("\n");
246
+ }
247
+ if (JSON.stringify([trace.usage, trace.firstContentAtMs]) !== previous) this.publish();
248
+ };
249
+ const reader = response.body.getReader();
250
+ const finish = () => {
251
+ trace.endedAtMs = this.now();
252
+ this.publish();
253
+ };
254
+ const body = new ReadableStream<Uint8Array>({
255
+ pull: async (controller) => {
256
+ try {
257
+ const result = await reader.read();
258
+ if (result.done) {
259
+ buffer += decoder.decode();
260
+ if (buffer.startsWith("data:")) consume(buffer.slice(5).trim());
261
+ if (!response.ok)
262
+ trace.error = this.redact(`HTTP ${response.status}: ${buffer}`).slice(0, 8000);
263
+ finish();
264
+ controller.close();
265
+ return;
266
+ }
267
+ // Forward exactly the original bytes. Observation is best effort and cannot fail inference.
268
+ try {
269
+ ingest(result.value);
270
+ } catch {
271
+ trace.error = "Instrumentation could not decode response metadata";
272
+ }
273
+ controller.enqueue(result.value);
274
+ } catch (error) {
275
+ trace.error = this.redact(String(error)).slice(0, 8000);
276
+ finish();
277
+ controller.error(error);
278
+ }
279
+ },
280
+ cancel: async (reason) => {
281
+ finish();
282
+ await reader.cancel(reason);
283
+ },
284
+ });
285
+ return new Response(body, {
286
+ status: response.status,
287
+ statusText: response.statusText,
288
+ headers: response.headers,
289
+ });
290
+ } catch (error) {
291
+ trace.error = this.redact(String(error)).slice(0, 8000);
292
+ trace.endedAtMs = this.now();
293
+ this.publish();
294
+ throw error;
295
+ }
296
+ };
297
+ }
298
+ }