offgrid-ai 0.8.14 → 0.9.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/README.md +3 -1
- package/package.json +3 -3
- package/src/backends.mjs +34 -38
- package/src/benchmark/finalize.mjs +198 -0
- package/src/benchmark/flow.mjs +237 -0
- package/src/benchmark/metrics.mjs +152 -0
- package/src/benchmark/pi-runner.mjs +252 -0
- package/src/benchmark/prepare.mjs +120 -0
- package/src/benchmark/repo.mjs +77 -0
- package/src/benchmark/shared.mjs +54 -0
- package/src/benchmark/stream-renderer.mjs +274 -0
- package/src/benchmark.mjs +10 -1222
- package/src/cli.mjs +2 -2
- package/src/commands/main.mjs +2 -2
- package/src/commands/onboard.mjs +6 -2
- package/src/config.mjs +8 -2
- package/src/harness-pi.mjs +1 -1
- package/src/managed.mjs +3 -3
- package/src/model-catalog.mjs +2 -1
- package/src/process.mjs +29 -21
- package/src/runtime.mjs +11 -0
- package/src/postinstall.mjs +0 -106
package/src/benchmark.mjs
CHANGED
|
@@ -1,1222 +1,10 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
import { serverReady, startServer, waitForReady, stopProfile } from "./process.mjs";
|
|
12
|
-
import { pc, createPrompt, renderRows, renderSection } from "./ui.mjs";
|
|
13
|
-
|
|
14
|
-
const execFileAsync = promisify(execFile);
|
|
15
|
-
|
|
16
|
-
// ── Shared utilities (matches local-llm-visual-benchmark) ──────────────────
|
|
17
|
-
|
|
18
|
-
export function slugModelId(modelId, maxLength = 80) {
|
|
19
|
-
const hash = createHash("sha256").update(modelId).digest("hex").slice(0, 10);
|
|
20
|
-
const normalized = modelId.normalize("NFKD").replace(/[\u0300-\u036f]/gu, "").toLowerCase();
|
|
21
|
-
const slug = normalized.replace(/[^a-z0-9]+/gu, "-").replace(/^-+|-+$/gu, "").replace(/-{2,}/gu, "-");
|
|
22
|
-
if (slug.length > 0 && slug.length <= maxLength && slug === normalized) return slug;
|
|
23
|
-
const baseMaxLength = Math.max(1, maxLength - 11);
|
|
24
|
-
const base = slug.slice(0, baseMaxLength).replace(/^-+|-+$/gu, "") || "model";
|
|
25
|
-
return `${base}-${hash}`;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export function createRunId(date = new Date()) {
|
|
29
|
-
return date.toISOString().replace(/:/gu, "-").replace(/\./gu, "-");
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export function buildToolPrompt(benchmark) {
|
|
33
|
-
return benchmark.prompt;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export async function loadBenchmarks(benchDir) {
|
|
37
|
-
const entries = await readdir(benchDir);
|
|
38
|
-
const markdownFiles = entries.filter((f) => f.endsWith(".md")).sort();
|
|
39
|
-
const benchmarks = [];
|
|
40
|
-
for (const filename of markdownFiles) {
|
|
41
|
-
const raw = await readFile(join(benchDir, filename), "utf8");
|
|
42
|
-
const match = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
|
|
43
|
-
const frontmatter = match ? match[1] : "";
|
|
44
|
-
const content = match ? match[2].trim() : raw.trim();
|
|
45
|
-
let id = filename.replace(/\.md$/u, "");
|
|
46
|
-
let title = id;
|
|
47
|
-
let description = "";
|
|
48
|
-
for (const line of frontmatter.split("\n")) {
|
|
49
|
-
const kv = line.match(/^(\w+):\s*(.+)$/);
|
|
50
|
-
if (kv) {
|
|
51
|
-
const [, key, val] = kv;
|
|
52
|
-
if (key === "id") id = val.trim();
|
|
53
|
-
if (key === "title") title = val.trim();
|
|
54
|
-
if (key === "description") description = val.trim();
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
const kind = id === "ab-test-analysis" ? "data-science" : "visual";
|
|
58
|
-
benchmarks.push({ id, title, description, prompt: content, kind });
|
|
59
|
-
}
|
|
60
|
-
return benchmarks;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
// ── Benchmark repo linking ────────────────────────────────────────────────
|
|
64
|
-
|
|
65
|
-
const BENCHMARK_REPO = "https://github.com/eeshansrivastava89/local-llm-visual-benchmark.git";
|
|
66
|
-
|
|
67
|
-
export async function findBenchmarkRepo() {
|
|
68
|
-
const config = await loadConfig();
|
|
69
|
-
if (config.benchmarkRepoPath && existsSync(join(config.benchmarkRepoPath, "benchmarks"))) {
|
|
70
|
-
return config.benchmarkRepoPath;
|
|
71
|
-
}
|
|
72
|
-
return null;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
export async function linkBenchmarkRepo(prompt) {
|
|
76
|
-
const existing = await findBenchmarkRepo();
|
|
77
|
-
if (existing) return existing;
|
|
78
|
-
|
|
79
|
-
const candidates = [
|
|
80
|
-
join(homedir(), "dev", "local-llm-visual-benchmark"),
|
|
81
|
-
join(homedir(), "projects", "local-llm-visual-benchmark"),
|
|
82
|
-
join(homedir(), "local-llm-visual-benchmark"),
|
|
83
|
-
];
|
|
84
|
-
for (const candidate of candidates) {
|
|
85
|
-
if (existsSync(join(candidate, "benchmarks"))) {
|
|
86
|
-
const config = await loadConfig();
|
|
87
|
-
config.benchmarkRepoPath = candidate;
|
|
88
|
-
await saveConfig(config);
|
|
89
|
-
return candidate;
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
console.log(pc.dim("\nThe benchmark gallery needs to be linked to offgrid-ai."));
|
|
94
|
-
console.log(pc.dim("This is the local-llm-visual-benchmark repo that stores prompts and run results.\n"));
|
|
95
|
-
|
|
96
|
-
const choice = await prompt.choice("Link benchmark gallery", [
|
|
97
|
-
{ value: "clone", label: "Clone from GitHub", hint: "git clone into ~/dev" },
|
|
98
|
-
{ value: "manual", label: "Enter path manually", hint: "If you already have it cloned" },
|
|
99
|
-
], "clone");
|
|
100
|
-
|
|
101
|
-
if (choice === "clone") {
|
|
102
|
-
const targetDir = join(homedir(), "dev", "local-llm-visual-benchmark");
|
|
103
|
-
console.log(pc.dim(`\nCloning ${BENCHMARK_REPO}...`));
|
|
104
|
-
try {
|
|
105
|
-
await execFileAsync("git", ["clone", BENCHMARK_REPO, targetDir], { stdio: "pipe" });
|
|
106
|
-
const config = await loadConfig();
|
|
107
|
-
config.benchmarkRepoPath = targetDir;
|
|
108
|
-
await saveConfig(config);
|
|
109
|
-
console.log(pc.green(`✓ Cloned to ${targetDir}`));
|
|
110
|
-
return targetDir;
|
|
111
|
-
} catch (err) {
|
|
112
|
-
console.log(pc.red(`Clone failed: ${err.message}`));
|
|
113
|
-
return null;
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
const path = await prompt.text("Path to local-llm-visual-benchmark", "");
|
|
118
|
-
if (!path) return null;
|
|
119
|
-
const resolved = resolve(path.replace(/^~/, homedir()));
|
|
120
|
-
if (!existsSync(join(resolved, "benchmarks"))) {
|
|
121
|
-
console.log(pc.red(`No benchmarks/ directory found at ${resolved}`));
|
|
122
|
-
return null;
|
|
123
|
-
}
|
|
124
|
-
const config = await loadConfig();
|
|
125
|
-
config.benchmarkRepoPath = resolved;
|
|
126
|
-
await saveConfig(config);
|
|
127
|
-
console.log(pc.green(`✓ Linked to ${resolved}`));
|
|
128
|
-
return resolved;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
// ── Create a benchmark run directory ──────────────────────────────────────
|
|
132
|
-
|
|
133
|
-
function harnessDisplayName(id) {
|
|
134
|
-
if (id === "pi") return "Pi";
|
|
135
|
-
return String(id).replace(/[-_]+/gu, " ").replace(/\b\w/gu, (char) => char.toUpperCase());
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
function intendedRunnerForProfile(profile) {
|
|
139
|
-
if (!profile) return "your tool";
|
|
140
|
-
const harnessEntries = Object.entries(profile.harnesses ?? {}).filter(([, config]) => config?.enabled !== false);
|
|
141
|
-
const [id] = harnessEntries.find(([key]) => key === "pi") ?? harnessEntries[0] ?? ["pi"];
|
|
142
|
-
return harnessDisplayName(id);
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
function printBenchmarkNextSteps({ repoPath, runDirectory, profile, modelId, runnerLabel }) {
|
|
146
|
-
const runCommand = profile ? `offgrid-ai run ${profile.id}` : null;
|
|
147
|
-
const runnerCommand = runCommand ?? `Open ${runnerLabel} for ${modelId}`;
|
|
148
|
-
|
|
149
|
-
console.log("");
|
|
150
|
-
console.log(pc.bold("Next steps"));
|
|
151
|
-
console.log(` 1. Open the gallery. If it is not running: ${pc.cyan(`cd ${repoPath} && npm run dev`)}`);
|
|
152
|
-
console.log(` 2. ${pc.cyan(`cd ${runDirectory}`)}`);
|
|
153
|
-
console.log(` 3. ${pc.cyan(runnerCommand)}, then copy this run's prompt from the gallery and paste it into ${runnerLabel}`);
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
export async function prepareBenchmarkRun({ repoPath, benchmark, kind, modelId, modelSource, backendLabel, profile, showNextSteps = true }) {
|
|
157
|
-
const toolPrompt = buildToolPrompt(benchmark);
|
|
158
|
-
const now = new Date();
|
|
159
|
-
const runId = createRunId(now);
|
|
160
|
-
const modelSlug = slugModelId(modelId);
|
|
161
|
-
const runnerLabel = intendedRunnerForProfile(profile);
|
|
162
|
-
const runsDir = join(repoPath, "runs");
|
|
163
|
-
const benchmarkDirectory = join(runsDir, benchmark.id);
|
|
164
|
-
const modelDirectory = join(benchmarkDirectory, modelSlug);
|
|
165
|
-
const runDirectory = join(modelDirectory, runId);
|
|
166
|
-
|
|
167
|
-
await mkdir(runDirectory, { recursive: true });
|
|
168
|
-
|
|
169
|
-
const isDs = kind === "data-science";
|
|
170
|
-
const baseAssets = {
|
|
171
|
-
metadata: "metadata.json",
|
|
172
|
-
prompt: "prompt.md",
|
|
173
|
-
rawResponse: "response.raw.txt",
|
|
174
|
-
stream: "stream.ndjson",
|
|
175
|
-
stderr: "stderr.log",
|
|
176
|
-
};
|
|
177
|
-
const metadata = {
|
|
178
|
-
schemaVersion: 1,
|
|
179
|
-
kind,
|
|
180
|
-
runId,
|
|
181
|
-
benchmark: { id: benchmark.id, title: benchmark.title, description: benchmark.description, prompt: benchmark.prompt },
|
|
182
|
-
model: { id: modelId, slug: modelSlug },
|
|
183
|
-
status: "prepared",
|
|
184
|
-
createdAt: now.toISOString(),
|
|
185
|
-
updatedAt: now.toISOString(),
|
|
186
|
-
preparedAt: now.toISOString(),
|
|
187
|
-
runDirectory,
|
|
188
|
-
assets: isDs
|
|
189
|
-
? { ...baseAssets, ds: { notebook: "analysis.ipynb", summary: "summary.json", chartDistribution: "chart-distribution.png", chartTreatmentEffect: "chart-treatment-effect.png", chartCompletionRates: "chart-completion-rates.png" } }
|
|
190
|
-
: { ...baseAssets, html: "index.html", preview: "preview.png", video: "preview.webm" },
|
|
191
|
-
runner: {
|
|
192
|
-
mode: modelSource === "cloud" ? "manual" : "external",
|
|
193
|
-
intendedRunner: profile ? runnerLabel : undefined,
|
|
194
|
-
...(profile?.harnesses?.pi || runnerLabel === "Pi" ? { tool: "pi" } : {}),
|
|
195
|
-
...(modelSource ? { modelSource } : {}),
|
|
196
|
-
...(backendLabel ? { backendLabel } : {}),
|
|
197
|
-
...(profile?.baseUrl ? { baseUrl: profile.baseUrl } : {}),
|
|
198
|
-
model: modelId,
|
|
199
|
-
retries: 0,
|
|
200
|
-
tokenMetrics: {
|
|
201
|
-
reported: false,
|
|
202
|
-
promptTokens: 0,
|
|
203
|
-
completionTokens: 0,
|
|
204
|
-
totalTokens: 0,
|
|
205
|
-
},
|
|
206
|
-
speedMetrics: {
|
|
207
|
-
prefillTokensPerSecond: null,
|
|
208
|
-
generationTokensPerSecond: null,
|
|
209
|
-
ttftMs: null,
|
|
210
|
-
modelLoadMs: null,
|
|
211
|
-
speculativeDecodeAcceptance: null,
|
|
212
|
-
kvCacheTokens: null,
|
|
213
|
-
},
|
|
214
|
-
metricSource: null,
|
|
215
|
-
},
|
|
216
|
-
results: {
|
|
217
|
-
wallClockMs: null,
|
|
218
|
-
agentTurns: 0,
|
|
219
|
-
toolCalls: 0,
|
|
220
|
-
toolResults: 0,
|
|
221
|
-
success: false,
|
|
222
|
-
outputFiles: [],
|
|
223
|
-
perTurn: [],
|
|
224
|
-
},
|
|
225
|
-
};
|
|
226
|
-
|
|
227
|
-
await writeFile(join(runDirectory, "metadata.json"), JSON.stringify(metadata, null, 2) + "\n", "utf8");
|
|
228
|
-
await writeFile(join(runDirectory, "prompt.md"), toolPrompt + "\n", "utf8");
|
|
229
|
-
|
|
230
|
-
console.log("");
|
|
231
|
-
console.log(pc.green("✓ Run slot prepared"));
|
|
232
|
-
console.log(renderSection("Run", renderRows([
|
|
233
|
-
["Directory", pc.cyan(runDirectory)],
|
|
234
|
-
["Benchmark", benchmark.title],
|
|
235
|
-
["Kind", kind],
|
|
236
|
-
["Model", pc.bold(modelId)],
|
|
237
|
-
["Source", backendLabel || modelSource],
|
|
238
|
-
])));
|
|
239
|
-
|
|
240
|
-
if (showNextSteps) {
|
|
241
|
-
printBenchmarkNextSteps({ repoPath, runDirectory, profile, modelId, runnerLabel });
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
return runDirectory;
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
// ── Run benchmark in Pi (non-interactive JSON mode) ───────────────────────
|
|
248
|
-
|
|
249
|
-
const BENCH_COLORS = {
|
|
250
|
-
thinking: pc.magenta,
|
|
251
|
-
text: pc.green,
|
|
252
|
-
tool: pc.yellow,
|
|
253
|
-
toolOutput: pc.dim,
|
|
254
|
-
error: pc.red,
|
|
255
|
-
info: pc.cyan,
|
|
256
|
-
dim: pc.dim,
|
|
257
|
-
};
|
|
258
|
-
|
|
259
|
-
function formatToolCall(toolCall) {
|
|
260
|
-
const path = toolCall.arguments?.path || toolCall.arguments?.file_path || toolCall.arguments?.filename || "";
|
|
261
|
-
const summary = path ? ` → ${path}` : "";
|
|
262
|
-
return `[toolCall] ${toolCall.name}${summary}`;
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
function formatTokens(n) {
|
|
266
|
-
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
|
267
|
-
if (n >= 1_000) return `${Math.round(n / 1_000)}k`;
|
|
268
|
-
return String(Math.round(n));
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
function estimatedTokensFromText(text) {
|
|
272
|
-
// Simple heuristic: ~4 chars per token for code/English.
|
|
273
|
-
return Math.max(1, Math.ceil(text.length / 4));
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
function clearStatusLine() {
|
|
277
|
-
if (process.stdout.isTTY) {
|
|
278
|
-
process.stdout.write("\r\x1b[K");
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
function printStatusLine(text) {
|
|
283
|
-
if (process.stdout.isTTY) {
|
|
284
|
-
process.stdout.write(`\r\x1b[K${text}`);
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
function printFinalLine(text) {
|
|
289
|
-
clearStatusLine();
|
|
290
|
-
console.log(text);
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
function renderStreamEvent(parsed, state, opts = {}) {
|
|
294
|
-
const verbose = Boolean(opts.verbose);
|
|
295
|
-
const type = parsed.type;
|
|
296
|
-
|
|
297
|
-
switch (type) {
|
|
298
|
-
case "session":
|
|
299
|
-
console.log(BENCH_COLORS.dim(`[session] ${parsed.id}`));
|
|
300
|
-
break;
|
|
301
|
-
case "agent_start":
|
|
302
|
-
console.log(BENCH_COLORS.dim("[agent_start]"));
|
|
303
|
-
break;
|
|
304
|
-
case "turn_start": {
|
|
305
|
-
state.turn += 1;
|
|
306
|
-
state.status.mode = "thinking";
|
|
307
|
-
state.status.toolName = null;
|
|
308
|
-
state.status.bytes = 0;
|
|
309
|
-
state.status.text = "";
|
|
310
|
-
printFinalLine(BENCH_COLORS.info(`[turn ${state.turn}]`));
|
|
311
|
-
break;
|
|
312
|
-
}
|
|
313
|
-
case "message_start": {
|
|
314
|
-
const msg = parsed.message;
|
|
315
|
-
if (msg?.role === "assistant" && msg.provider && msg.model) {
|
|
316
|
-
console.log(BENCH_COLORS.info(`[assistant] ${msg.provider}/${msg.model}`));
|
|
317
|
-
}
|
|
318
|
-
break;
|
|
319
|
-
}
|
|
320
|
-
case "message_update": {
|
|
321
|
-
const evt = parsed.assistantMessageEvent;
|
|
322
|
-
if (!evt) return;
|
|
323
|
-
const subtype = String(evt.type ?? "").replace(/_/gu, "");
|
|
324
|
-
if (subtype === "thinkingstart" || subtype === "thinkingdelta") {
|
|
325
|
-
if (verbose) process.stdout.write(BENCH_COLORS.thinking(evt.delta || ""));
|
|
326
|
-
state.status.mode = "thinking";
|
|
327
|
-
updateStatusFromDelta(state, evt.delta);
|
|
328
|
-
} else if (subtype === "textstart" || subtype === "textdelta") {
|
|
329
|
-
if (verbose) process.stdout.write(BENCH_COLORS.text(evt.delta || ""));
|
|
330
|
-
state.status.mode = "text";
|
|
331
|
-
updateStatusFromDelta(state, evt.delta);
|
|
332
|
-
} else if (subtype === "toolcallstart") {
|
|
333
|
-
if (!verbose) printFinalLine(BENCH_COLORS.tool("[tool_call_start]"));
|
|
334
|
-
} else if (subtype === "toolcalldelta") {
|
|
335
|
-
if (verbose) process.stdout.write(BENCH_COLORS.tool(evt.delta || ""));
|
|
336
|
-
state.status.mode = "tool";
|
|
337
|
-
updateStatusFromDelta(state, evt.delta);
|
|
338
|
-
} else if (subtype === "toolcallend") {
|
|
339
|
-
if (!verbose) printFinalLine(BENCH_COLORS.tool("[tool_call_end]"));
|
|
340
|
-
}
|
|
341
|
-
break;
|
|
342
|
-
}
|
|
343
|
-
case "message_end": {
|
|
344
|
-
const msg = parsed.message;
|
|
345
|
-
if (msg?.role === "assistant" && Array.isArray(msg.content)) {
|
|
346
|
-
for (const item of msg.content) {
|
|
347
|
-
if (item.type === "toolCall") {
|
|
348
|
-
const toolLine = formatToolCall(item);
|
|
349
|
-
state.status.toolName = item.name;
|
|
350
|
-
if (!verbose) printFinalLine(BENCH_COLORS.tool(toolLine));
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
break;
|
|
355
|
-
}
|
|
356
|
-
case "tool_execution_start":
|
|
357
|
-
state.status.mode = "exec";
|
|
358
|
-
state.status.toolName = parsed.toolName;
|
|
359
|
-
state.status.bytes = 0;
|
|
360
|
-
state.status.text = "";
|
|
361
|
-
printFinalLine(BENCH_COLORS.tool(`[exec] ${parsed.toolName}`));
|
|
362
|
-
break;
|
|
363
|
-
case "tool_execution_update": {
|
|
364
|
-
if (parsed.content) {
|
|
365
|
-
if (verbose) process.stdout.write(BENCH_COLORS.toolOutput(parsed.content));
|
|
366
|
-
state.status.mode = "exec";
|
|
367
|
-
updateStatusFromDelta(state, parsed.content);
|
|
368
|
-
}
|
|
369
|
-
break;
|
|
370
|
-
}
|
|
371
|
-
case "tool_execution_end":
|
|
372
|
-
printFinalLine(BENCH_COLORS.tool(`[exec done] ${state.status.toolName || parsed.toolName}`));
|
|
373
|
-
break;
|
|
374
|
-
case "toolResult": {
|
|
375
|
-
const errorFlag = parsed.isError ? BENCH_COLORS.error(" error") : "";
|
|
376
|
-
printFinalLine(BENCH_COLORS.tool(`[result] ${parsed.toolName}${errorFlag}`));
|
|
377
|
-
break;
|
|
378
|
-
}
|
|
379
|
-
case "turn_end": {
|
|
380
|
-
const usage = parsed.message?.usage;
|
|
381
|
-
if (usage) {
|
|
382
|
-
const exact = usage.output ?? usage.totalTokens ?? 0;
|
|
383
|
-
printFinalLine(BENCH_COLORS.info(`[turn ${state.turn}] completed · ${formatTokens(exact)} tokens`));
|
|
384
|
-
} else {
|
|
385
|
-
printFinalLine(BENCH_COLORS.info(`[turn ${state.turn}] completed`));
|
|
386
|
-
}
|
|
387
|
-
break;
|
|
388
|
-
}
|
|
389
|
-
case "agent_end":
|
|
390
|
-
clearStatusLine();
|
|
391
|
-
console.log(BENCH_COLORS.dim("[agent_end]"));
|
|
392
|
-
break;
|
|
393
|
-
default:
|
|
394
|
-
break;
|
|
395
|
-
}
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
function updateStatusFromDelta(state, delta) {
|
|
399
|
-
if (!delta) return;
|
|
400
|
-
state.status.bytes += Buffer.byteLength(delta, "utf8");
|
|
401
|
-
state.status.text = (state.status.text || "") + delta;
|
|
402
|
-
state.status.tokens = estimatedTokensFromText(state.status.text);
|
|
403
|
-
const label = state.status.toolName ? ` · ${state.status.toolName}` : "";
|
|
404
|
-
const modeLabel = state.status.mode === "thinking" ? "thinking" : state.status.mode === "text" ? "text" : state.status.mode === "tool" ? "tool" : "exec";
|
|
405
|
-
const bytes = formatBytes(state.status.bytes);
|
|
406
|
-
const tokens = formatTokens(state.status.tokens);
|
|
407
|
-
printStatusLine(BENCH_COLORS.dim(`[turn ${state.turn}] ${modeLabel}${label} · ${bytes} (~${tokens} tokens)`));
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
function formatBytes(bytes) {
|
|
411
|
-
if (!Number.isFinite(bytes)) return "unknown";
|
|
412
|
-
const units = ["B", "KB", "MB", "GB", "TB"];
|
|
413
|
-
let size = bytes;
|
|
414
|
-
let unit = 0;
|
|
415
|
-
while (size >= 1024 && unit < units.length - 1) { size /= 1024; unit += 1; }
|
|
416
|
-
return `${size.toFixed(unit === 0 ? 0 : 2)} ${units[unit]}`;
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
export function piModelString(profile) {
|
|
420
|
-
return profile.harnesses?.pi?.model ?? `${profile.providerId}/${profile.modelAlias}`;
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
export async function runBenchmarkInPi(profile, runDirectory, { signal } = {}) {
|
|
424
|
-
const model = piModelString(profile);
|
|
425
|
-
const args = ["--model", model, "--mode", "json", "-p", "@prompt.md"];
|
|
426
|
-
|
|
427
|
-
const child = spawn("pi", args, {
|
|
428
|
-
cwd: runDirectory,
|
|
429
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
430
|
-
});
|
|
431
|
-
|
|
432
|
-
const runResult = {
|
|
433
|
-
model,
|
|
434
|
-
exitCode: null,
|
|
435
|
-
wallClockMs: null,
|
|
436
|
-
agentTurns: 0,
|
|
437
|
-
promptTokens: 0,
|
|
438
|
-
completionTokens: 0,
|
|
439
|
-
totalTokens: 0,
|
|
440
|
-
cacheRead: 0,
|
|
441
|
-
cacheWrite: 0,
|
|
442
|
-
toolCalls: 0,
|
|
443
|
-
toolResults: 0,
|
|
444
|
-
perTurn: [],
|
|
445
|
-
rawResponseLines: [],
|
|
446
|
-
error: null,
|
|
447
|
-
};
|
|
448
|
-
|
|
449
|
-
let streamBuffer = "";
|
|
450
|
-
let responseBuffer = "";
|
|
451
|
-
let currentTurnStartMs = null;
|
|
452
|
-
let lastTurnEndMs = null;
|
|
453
|
-
let runStartMs = null;
|
|
454
|
-
let firstEventMs = null;
|
|
455
|
-
let lastEventMs = null;
|
|
456
|
-
let cancelled = false;
|
|
457
|
-
|
|
458
|
-
const streamPath = join(runDirectory, "stream.ndjson");
|
|
459
|
-
const stderrPath = join(runDirectory, "stderr.log");
|
|
460
|
-
const responsePath = join(runDirectory, "response.raw.txt");
|
|
461
|
-
|
|
462
|
-
const streamHandle = await openFileHandle(streamPath, "w");
|
|
463
|
-
const stderrHandle = await openFileHandle(stderrPath, "w");
|
|
464
|
-
|
|
465
|
-
const verbose = Boolean(process.env.OFFGRID_BENCHMARK_VERBOSE);
|
|
466
|
-
const renderState = { turn: 0, status: { mode: "idle", toolName: null, bytes: 0, text: "", tokens: 0 } };
|
|
467
|
-
|
|
468
|
-
function appendResponse(text) {
|
|
469
|
-
responseBuffer += text;
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
function flushResponse() {
|
|
473
|
-
if (responseBuffer) {
|
|
474
|
-
runResult.rawResponseLines.push(responseBuffer);
|
|
475
|
-
responseBuffer = "";
|
|
476
|
-
}
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
function updateTimeBounds(timestamp) {
|
|
480
|
-
if (!timestamp) return;
|
|
481
|
-
if (firstEventMs === null) firstEventMs = timestamp;
|
|
482
|
-
lastEventMs = timestamp;
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
function beginTurn() {
|
|
486
|
-
runResult.agentTurns += 1;
|
|
487
|
-
currentTurnStartMs = lastTurnEndMs ?? runStartMs ?? null;
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
function endTurn(usage, timestamp) {
|
|
491
|
-
const turnEndMs = timestamp ?? null;
|
|
492
|
-
const wallClockMs = currentTurnStartMs && turnEndMs ? turnEndMs - currentTurnStartMs : null;
|
|
493
|
-
runResult.perTurn.push({
|
|
494
|
-
turn: runResult.agentTurns,
|
|
495
|
-
inputTokens: usage?.input ?? 0,
|
|
496
|
-
outputTokens: usage?.output ?? 0,
|
|
497
|
-
cacheRead: usage?.cacheRead ?? 0,
|
|
498
|
-
cacheWrite: usage?.cacheWrite ?? 0,
|
|
499
|
-
wallClockMs,
|
|
500
|
-
toolCalls: 0,
|
|
501
|
-
});
|
|
502
|
-
if (turnEndMs) lastTurnEndMs = turnEndMs;
|
|
503
|
-
currentTurnStartMs = null;
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
function processLine(line) {
|
|
507
|
-
if (!line.trim()) return;
|
|
508
|
-
streamHandle.write(line + "\n");
|
|
509
|
-
let parsed;
|
|
510
|
-
try {
|
|
511
|
-
parsed = JSON.parse(line);
|
|
512
|
-
} catch (err) {
|
|
513
|
-
console.log(BENCH_COLORS.error(`[parse error] ${err.message}`));
|
|
514
|
-
return;
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
const timestamp = extractTimestamp(parsed);
|
|
518
|
-
updateTimeBounds(timestamp);
|
|
519
|
-
|
|
520
|
-
renderStreamEvent(parsed, renderState, { verbose });
|
|
521
|
-
|
|
522
|
-
if (parsed.type === "session" || parsed.type === "agent_start") {
|
|
523
|
-
if (timestamp && runStartMs === null) runStartMs = timestamp;
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
if (parsed.type === "turn_start") {
|
|
527
|
-
beginTurn();
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
if (parsed.type === "turn_end" && parsed.message?.usage) {
|
|
531
|
-
const usage = parsed.message.usage;
|
|
532
|
-
runResult.promptTokens += usage.input ?? 0;
|
|
533
|
-
runResult.completionTokens += usage.output ?? 0;
|
|
534
|
-
runResult.cacheRead += usage.cacheRead ?? 0;
|
|
535
|
-
runResult.cacheWrite += usage.cacheWrite ?? 0;
|
|
536
|
-
endTurn(usage, timestamp);
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
if (parsed.type === "message_update" && parsed.assistantMessageEvent) {
|
|
540
|
-
const evt = parsed.assistantMessageEvent;
|
|
541
|
-
const subtype = String(evt.type ?? "").replace(/_/gu, "");
|
|
542
|
-
if (subtype === "thinkingdelta" || subtype === "textdelta") {
|
|
543
|
-
appendResponse(evt.delta || "");
|
|
544
|
-
}
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
if (parsed.type === "message_end" && parsed.message?.role === "assistant") {
|
|
548
|
-
flushResponse();
|
|
549
|
-
const content = parsed.message.content ?? [];
|
|
550
|
-
for (const item of content) {
|
|
551
|
-
if (item.type === "toolCall") {
|
|
552
|
-
runResult.toolCalls += 1;
|
|
553
|
-
appendResponse(`\n${formatToolCall(item)}\n`);
|
|
554
|
-
const currentTurn = runResult.perTurn[runResult.perTurn.length - 1];
|
|
555
|
-
if (currentTurn) currentTurn.toolCalls += 1;
|
|
556
|
-
}
|
|
557
|
-
}
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
if (parsed.type === "toolResult") {
|
|
561
|
-
runResult.toolResults += 1;
|
|
562
|
-
const status = parsed.isError ? "error" : "ok";
|
|
563
|
-
appendResponse(`\n[toolResult] ${parsed.toolName} (${status})\n`);
|
|
564
|
-
}
|
|
565
|
-
|
|
566
|
-
if (parsed.type === "agent_end") {
|
|
567
|
-
flushResponse();
|
|
568
|
-
}
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
child.stdout.setEncoding("utf8");
|
|
572
|
-
child.stdout.on("data", (chunk) => {
|
|
573
|
-
streamBuffer += chunk;
|
|
574
|
-
const lines = streamBuffer.split("\n");
|
|
575
|
-
streamBuffer = lines.pop();
|
|
576
|
-
for (const line of lines) {
|
|
577
|
-
processLine(line);
|
|
578
|
-
}
|
|
579
|
-
});
|
|
580
|
-
|
|
581
|
-
child.stderr.setEncoding("utf8");
|
|
582
|
-
child.stderr.on("data", (chunk) => {
|
|
583
|
-
stderrHandle.write(chunk);
|
|
584
|
-
});
|
|
585
|
-
|
|
586
|
-
const abortListener = () => {
|
|
587
|
-
if (cancelled) return;
|
|
588
|
-
cancelled = true;
|
|
589
|
-
console.log(BENCH_COLORS.error("\n\n[Cancelled by user]"));
|
|
590
|
-
child.kill("SIGTERM");
|
|
591
|
-
};
|
|
592
|
-
|
|
593
|
-
if (signal) {
|
|
594
|
-
signal.addEventListener("abort", abortListener);
|
|
595
|
-
}
|
|
596
|
-
|
|
597
|
-
return new Promise((resolve) => {
|
|
598
|
-
child.on("exit", async (code) => {
|
|
599
|
-
if (signal) signal.removeEventListener("abort", abortListener);
|
|
600
|
-
if (streamBuffer.trim()) {
|
|
601
|
-
processLine(streamBuffer);
|
|
602
|
-
}
|
|
603
|
-
flushResponse();
|
|
604
|
-
await streamHandle.close();
|
|
605
|
-
await stderrHandle.close();
|
|
606
|
-
await writeFile(responsePath, runResult.rawResponseLines.join(""), "utf8");
|
|
607
|
-
|
|
608
|
-
runResult.exitCode = code ?? 0;
|
|
609
|
-
if (firstEventMs !== null && lastEventMs !== null) {
|
|
610
|
-
runResult.wallClockMs = lastEventMs - firstEventMs;
|
|
611
|
-
}
|
|
612
|
-
|
|
613
|
-
if (cancelled) {
|
|
614
|
-
runResult.error = { message: "Cancelled by user" };
|
|
615
|
-
resolve(runResult);
|
|
616
|
-
return;
|
|
617
|
-
}
|
|
618
|
-
|
|
619
|
-
if (runResult.exitCode !== 0) {
|
|
620
|
-
runResult.error = { message: `Pi exited with code ${runResult.exitCode}` };
|
|
621
|
-
resolve(runResult);
|
|
622
|
-
return;
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
resolve(runResult);
|
|
626
|
-
});
|
|
627
|
-
|
|
628
|
-
child.on("error", async (err) => {
|
|
629
|
-
if (signal) signal.removeEventListener("abort", abortListener);
|
|
630
|
-
await streamHandle.close();
|
|
631
|
-
await stderrHandle.close();
|
|
632
|
-
runResult.error = { message: err.message };
|
|
633
|
-
resolve(runResult);
|
|
634
|
-
});
|
|
635
|
-
});
|
|
636
|
-
}
|
|
637
|
-
|
|
638
|
-
function extractTimestamp(event) {
|
|
639
|
-
const raw = event?.message?.timestamp ?? event?.timestamp ?? event?.assistantMessageEvent?.partial?.timestamp;
|
|
640
|
-
if (typeof raw === "number") return raw;
|
|
641
|
-
if (typeof raw === "string") {
|
|
642
|
-
const parsed = Date.parse(raw);
|
|
643
|
-
if (Number.isFinite(parsed)) return parsed;
|
|
644
|
-
}
|
|
645
|
-
const iso = event?.message?.createdAt ?? event?.createdAt ?? event?.created_at;
|
|
646
|
-
if (typeof iso === "string") {
|
|
647
|
-
const parsed = Date.parse(iso);
|
|
648
|
-
if (Number.isFinite(parsed)) return parsed;
|
|
649
|
-
}
|
|
650
|
-
return null;
|
|
651
|
-
}
|
|
652
|
-
|
|
653
|
-
async function openFileHandle(path, flags) {
|
|
654
|
-
const { open } = await import("node:fs/promises");
|
|
655
|
-
return open(path, flags);
|
|
656
|
-
}
|
|
657
|
-
|
|
658
|
-
// ── Backend-aware server speed metrics ───────────────────────────────────
|
|
659
|
-
|
|
660
|
-
const BENCH_SPEED_PROMPT = "Write a one-sentence summary of machine learning.";
|
|
661
|
-
|
|
662
|
-
export async function queryServerMetrics(profile) {
|
|
663
|
-
const backend = backendFor(profile.backend);
|
|
664
|
-
|
|
665
|
-
if (backend.id === "llama-cpp" || backend.id === "llama-cpp-mtp") {
|
|
666
|
-
return await queryLlamaCppMetrics(profile);
|
|
667
|
-
}
|
|
668
|
-
if (backend.id === "omlx") {
|
|
669
|
-
return await queryOmlxMetrics(profile);
|
|
670
|
-
}
|
|
671
|
-
if (backend.id === "ollama") {
|
|
672
|
-
return await queryOllamaMetrics(profile);
|
|
673
|
-
}
|
|
674
|
-
|
|
675
|
-
throw new Error(`Unsupported backend for benchmark speed metrics: ${backend.id}`);
|
|
676
|
-
}
|
|
677
|
-
|
|
678
|
-
async function queryLlamaCppMetrics(profile) {
|
|
679
|
-
const body = {
|
|
680
|
-
model: profile.modelAlias,
|
|
681
|
-
messages: [{ role: "user", content: BENCH_SPEED_PROMPT }],
|
|
682
|
-
stream: false,
|
|
683
|
-
};
|
|
684
|
-
|
|
685
|
-
const response = await fetch(profile.baseUrl.replace(/\/$/u, "") + "/chat/completions", {
|
|
686
|
-
method: "POST",
|
|
687
|
-
headers: { "Content-Type": "application/json" },
|
|
688
|
-
body: JSON.stringify(body),
|
|
689
|
-
signal: AbortSignal.timeout(60000),
|
|
690
|
-
});
|
|
691
|
-
|
|
692
|
-
if (!response.ok) {
|
|
693
|
-
throw new Error(`llama.cpp speed query failed: ${response.status} ${response.statusText}`);
|
|
694
|
-
}
|
|
695
|
-
|
|
696
|
-
const data = await response.json();
|
|
697
|
-
const timings = data.timings;
|
|
698
|
-
if (!timings || typeof timings.prompt_per_second !== "number" || typeof timings.predicted_per_second !== "number") {
|
|
699
|
-
throw new Error("llama.cpp response did not include usable timings object");
|
|
700
|
-
}
|
|
701
|
-
const draftN = timings.draft_n;
|
|
702
|
-
const draftAccepted = timings.draft_n_accepted;
|
|
703
|
-
|
|
704
|
-
return {
|
|
705
|
-
prefillTokensPerSecond: timings.prompt_per_second ?? null,
|
|
706
|
-
generationTokensPerSecond: timings.predicted_per_second ?? null,
|
|
707
|
-
ttftMs: timings.prompt_ms ?? null,
|
|
708
|
-
modelLoadMs: null,
|
|
709
|
-
speculativeDecodeAcceptance: (draftN && Number.isFinite(draftAccepted) && Number.isFinite(draftN) && draftN > 0)
|
|
710
|
-
? draftAccepted / draftN
|
|
711
|
-
: null,
|
|
712
|
-
kvCacheTokens: timings.cache_n ?? null,
|
|
713
|
-
metricSource: "llama.cpp /v1/chat/completions timings",
|
|
714
|
-
};
|
|
715
|
-
}
|
|
716
|
-
|
|
717
|
-
async function queryOmlxMetrics(profile) {
|
|
718
|
-
const body = {
|
|
719
|
-
model: profile.modelAlias,
|
|
720
|
-
messages: [{ role: "user", content: BENCH_SPEED_PROMPT }],
|
|
721
|
-
stream: true,
|
|
722
|
-
stream_options: { include_usage: true },
|
|
723
|
-
};
|
|
724
|
-
|
|
725
|
-
const response = await fetch(profile.baseUrl.replace(/\/$/u, "") + "/chat/completions", {
|
|
726
|
-
method: "POST",
|
|
727
|
-
headers: { "Content-Type": "application/json" },
|
|
728
|
-
body: JSON.stringify(body),
|
|
729
|
-
signal: AbortSignal.timeout(60000),
|
|
730
|
-
});
|
|
731
|
-
|
|
732
|
-
if (!response.ok) {
|
|
733
|
-
throw new Error(`oMLX speed query failed: ${response.status} ${response.statusText}`);
|
|
734
|
-
}
|
|
735
|
-
|
|
736
|
-
const text = await response.text();
|
|
737
|
-
let usage = null;
|
|
738
|
-
for (const line of text.split("\n").reverse()) {
|
|
739
|
-
const trimmed = line.trim();
|
|
740
|
-
if (!trimmed || !trimmed.startsWith("data:")) continue;
|
|
741
|
-
const payload = trimmed.slice(5).trim();
|
|
742
|
-
if (payload === "[DONE]") continue;
|
|
743
|
-
try {
|
|
744
|
-
const chunk = JSON.parse(payload);
|
|
745
|
-
if (chunk.usage) {
|
|
746
|
-
usage = chunk.usage;
|
|
747
|
-
break;
|
|
748
|
-
}
|
|
749
|
-
} catch {
|
|
750
|
-
// Ignore malformed SSE chunks.
|
|
751
|
-
}
|
|
752
|
-
}
|
|
753
|
-
|
|
754
|
-
if (!usage) {
|
|
755
|
-
throw new Error("oMLX speed query did not return usage in streaming response");
|
|
756
|
-
}
|
|
757
|
-
|
|
758
|
-
return {
|
|
759
|
-
prefillTokensPerSecond: usage.prompt_tokens_per_second ?? null,
|
|
760
|
-
generationTokensPerSecond: usage.generation_tokens_per_second ?? null,
|
|
761
|
-
ttftMs: usage.time_to_first_token != null ? usage.time_to_first_token * 1000 : null,
|
|
762
|
-
modelLoadMs: null,
|
|
763
|
-
speculativeDecodeAcceptance: null,
|
|
764
|
-
kvCacheTokens: usage.prompt_tokens_details?.cached_tokens ?? null,
|
|
765
|
-
metricSource: "oMLX /v1/chat/completions streaming include_usage",
|
|
766
|
-
};
|
|
767
|
-
}
|
|
768
|
-
|
|
769
|
-
async function queryOllamaMetrics(profile) {
|
|
770
|
-
const body = {
|
|
771
|
-
model: profile.modelAlias,
|
|
772
|
-
prompt: BENCH_SPEED_PROMPT,
|
|
773
|
-
stream: false,
|
|
774
|
-
};
|
|
775
|
-
|
|
776
|
-
const apiBaseUrl = (profile.baseUrl
|
|
777
|
-
? profile.baseUrl.replace(/\/v1\/?$/u, "")
|
|
778
|
-
: backendFor(profile.backend).apiBaseUrl).replace(/\/$/u, "");
|
|
779
|
-
|
|
780
|
-
const response = await fetch(`${apiBaseUrl}/api/generate`, {
|
|
781
|
-
method: "POST",
|
|
782
|
-
headers: { "Content-Type": "application/json" },
|
|
783
|
-
body: JSON.stringify(body),
|
|
784
|
-
signal: AbortSignal.timeout(60000),
|
|
785
|
-
});
|
|
786
|
-
|
|
787
|
-
if (!response.ok) {
|
|
788
|
-
throw new Error(`Ollama speed query failed: ${response.status} ${response.statusText}`);
|
|
789
|
-
}
|
|
790
|
-
|
|
791
|
-
const data = await response.json();
|
|
792
|
-
const promptEvalNs = data.prompt_eval_duration ?? 0;
|
|
793
|
-
const evalNs = data.eval_duration ?? 0;
|
|
794
|
-
const loadNs = data.load_duration ?? 0;
|
|
795
|
-
|
|
796
|
-
const promptEvalCount = data.prompt_eval_count ?? 0;
|
|
797
|
-
const evalCount = data.eval_count ?? 0;
|
|
798
|
-
|
|
799
|
-
return {
|
|
800
|
-
prefillTokensPerSecond: promptEvalNs > 0 ? (promptEvalCount / (promptEvalNs / 1e9)) : null,
|
|
801
|
-
generationTokensPerSecond: evalNs > 0 ? (evalCount / (evalNs / 1e9)) : null,
|
|
802
|
-
ttftMs: promptEvalNs / 1e6,
|
|
803
|
-
modelLoadMs: loadNs / 1e6,
|
|
804
|
-
speculativeDecodeAcceptance: null,
|
|
805
|
-
kvCacheTokens: null,
|
|
806
|
-
metricSource: "Ollama /api/generate",
|
|
807
|
-
};
|
|
808
|
-
}
|
|
809
|
-
|
|
810
|
-
// ── Unload model from server memory after benchmark ────────────────────────
|
|
811
|
-
|
|
812
|
-
export async function unloadModelFromServer(profile) {
|
|
813
|
-
const backend = backendFor(profile.backend);
|
|
814
|
-
|
|
815
|
-
if (backend.id === "ollama") {
|
|
816
|
-
const apiBaseUrl = (profile.baseUrl
|
|
817
|
-
? profile.baseUrl.replace(/\/v1\/?$/u, "")
|
|
818
|
-
: backend.apiBaseUrl).replace(/\/$/u, "");
|
|
819
|
-
|
|
820
|
-
try {
|
|
821
|
-
await fetch(`${apiBaseUrl}/api/generate`, {
|
|
822
|
-
method: "POST",
|
|
823
|
-
headers: { "Content-Type": "application/json" },
|
|
824
|
-
body: JSON.stringify({ model: profile.modelAlias, prompt: "", stream: false, keep_alive: 0 }),
|
|
825
|
-
signal: AbortSignal.timeout(10000),
|
|
826
|
-
});
|
|
827
|
-
return { unloaded: true, backend: backend.id };
|
|
828
|
-
} catch (err) {
|
|
829
|
-
return { unloaded: false, backend: backend.id, error: err.message };
|
|
830
|
-
}
|
|
831
|
-
}
|
|
832
|
-
|
|
833
|
-
if (backend.id === "llama-cpp" || backend.id === "llama-cpp-mtp") {
|
|
834
|
-
// llama.cpp unloads when the server process exits; no HTTP unload API exists.
|
|
835
|
-
// If offgrid-ai started the server, stopProfile already handled it.
|
|
836
|
-
return { unloaded: false, backend: backend.id, reason: "stop server to unload" };
|
|
837
|
-
}
|
|
838
|
-
|
|
839
|
-
if (backend.id === "omlx") {
|
|
840
|
-
// oMLX does not expose a model-unload endpoint. The model stays resident
|
|
841
|
-
// until the oMLX server process is stopped.
|
|
842
|
-
return { unloaded: false, backend: backend.id, reason: "no unload API available" };
|
|
843
|
-
}
|
|
844
|
-
|
|
845
|
-
return { unloaded: false, backend: backend.id, reason: "unsupported backend" };
|
|
846
|
-
}
|
|
847
|
-
|
|
848
|
-
// ── Finalize benchmark run metadata ──────────────────────────────────────
|
|
849
|
-
|
|
850
|
-
export async function finalizeBenchmarkRun(runDirectory, runResult, speedMetrics) {
|
|
851
|
-
const metadataPath = join(runDirectory, "metadata.json");
|
|
852
|
-
const metadata = JSON.parse(await readFile(metadataPath, "utf8"));
|
|
853
|
-
const now = new Date();
|
|
854
|
-
const timestamp = now.toISOString();
|
|
855
|
-
|
|
856
|
-
const kind = metadata.kind ?? "visual";
|
|
857
|
-
const isDs = kind === "data-science";
|
|
858
|
-
const requiredFile = isDs ? "analysis.ipynb" : "index.html";
|
|
859
|
-
const requiredPath = join(runDirectory, requiredFile);
|
|
860
|
-
|
|
861
|
-
const outputFiles = [];
|
|
862
|
-
for (const candidate of [requiredFile, isDs ? "summary.json" : "preview.png", isDs ? "chart-distribution.png" : "preview.webm", "preview.mp4"]) {
|
|
863
|
-
if (existsSync(join(runDirectory, candidate))) {
|
|
864
|
-
outputFiles.push(candidate);
|
|
865
|
-
}
|
|
866
|
-
}
|
|
867
|
-
|
|
868
|
-
const success = existsSync(requiredPath) && (await readFile(requiredPath, "utf8")).trim().length > 0;
|
|
869
|
-
const hasTurns = runResult.agentTurns > 0;
|
|
870
|
-
|
|
871
|
-
let failureReason = null;
|
|
872
|
-
if (runResult.error) {
|
|
873
|
-
failureReason = typeof runResult.error === "string" ? runResult.error : (runResult.error.message ?? "Unknown error");
|
|
874
|
-
} else if (!hasTurns) {
|
|
875
|
-
failureReason = "The model did not produce any response turns.";
|
|
876
|
-
} else if (!success) {
|
|
877
|
-
if (runResult.toolCalls === 0) {
|
|
878
|
-
failureReason = `The model finished without writing the required output file (${requiredFile}). It may have returned the response as chat text instead of using the write tool.`;
|
|
879
|
-
} else {
|
|
880
|
-
failureReason = `The required output file (${requiredFile}) was missing or empty after the run.`;
|
|
881
|
-
}
|
|
882
|
-
}
|
|
883
|
-
|
|
884
|
-
const failed = failureReason !== null;
|
|
885
|
-
|
|
886
|
-
metadata.status = failed ? "failed" : "completed";
|
|
887
|
-
metadata.updatedAt = timestamp;
|
|
888
|
-
if (failed) {
|
|
889
|
-
metadata.failedAt = timestamp;
|
|
890
|
-
} else {
|
|
891
|
-
metadata.completedAt = timestamp;
|
|
892
|
-
}
|
|
893
|
-
|
|
894
|
-
const totalTokens = runResult.promptTokens + runResult.completionTokens;
|
|
895
|
-
|
|
896
|
-
metadata.runner.tokenMetrics = {
|
|
897
|
-
reported: hasTurns,
|
|
898
|
-
promptTokens: runResult.promptTokens,
|
|
899
|
-
completionTokens: runResult.completionTokens,
|
|
900
|
-
totalTokens,
|
|
901
|
-
};
|
|
902
|
-
|
|
903
|
-
metadata.runner.speedMetrics = speedMetrics;
|
|
904
|
-
metadata.runner.metricSource = speedMetrics?.metricSource ?? null;
|
|
905
|
-
|
|
906
|
-
metadata.results = {
|
|
907
|
-
wallClockMs: runResult.wallClockMs,
|
|
908
|
-
agentTurns: runResult.agentTurns,
|
|
909
|
-
toolCalls: runResult.toolCalls,
|
|
910
|
-
toolResults: runResult.toolResults,
|
|
911
|
-
success,
|
|
912
|
-
outputFiles,
|
|
913
|
-
perTurn: runResult.perTurn,
|
|
914
|
-
};
|
|
915
|
-
|
|
916
|
-
if (failureReason) {
|
|
917
|
-
metadata.error = { message: failureReason, ...(typeof runResult.error === "object" && runResult.error?.stack ? { stack: runResult.error.stack } : {}) };
|
|
918
|
-
} else if (runResult.error) {
|
|
919
|
-
metadata.error = typeof runResult.error === "string"
|
|
920
|
-
? { message: runResult.error }
|
|
921
|
-
: { message: runResult.error.message ?? "Unknown error", ...(runResult.error.stack ? { stack: runResult.error.stack } : {}) };
|
|
922
|
-
}
|
|
923
|
-
|
|
924
|
-
await writeFile(metadataPath, JSON.stringify(metadata, null, 2) + "\n", "utf8");
|
|
925
|
-
return metadata;
|
|
926
|
-
}
|
|
927
|
-
|
|
928
|
-
async function ensureServerForBenchmark(profile) {
|
|
929
|
-
const backend = backendFor(profile.backend);
|
|
930
|
-
if (await serverReady(profile.baseUrl)) {
|
|
931
|
-
console.log(pc.green(`[ready] ${backend.label} at ${profile.baseUrl}`));
|
|
932
|
-
return { started: false };
|
|
933
|
-
}
|
|
934
|
-
|
|
935
|
-
if (backend.type === "managed-server") {
|
|
936
|
-
throw new Error(`${backend.label} is not running at ${profile.baseUrl}. Start it and try again.`);
|
|
937
|
-
}
|
|
938
|
-
|
|
939
|
-
console.log(pc.dim(`Starting ${backend.label} for ${profile.label}...`));
|
|
940
|
-
const state = await startServer(profile);
|
|
941
|
-
await waitForReady(profile, state?.pid, state?.rawLogPath);
|
|
942
|
-
console.log(pc.green(`[ready] ${profile.baseUrl}/models`));
|
|
943
|
-
return { started: true, state };
|
|
944
|
-
}
|
|
945
|
-
|
|
946
|
-
export async function runPreparedBenchmark(profile, runDirectory, options = {}) {
|
|
947
|
-
const controller = new AbortController();
|
|
948
|
-
if (options.signal) {
|
|
949
|
-
options.signal.addEventListener("abort", () => controller.abort(), { once: true });
|
|
950
|
-
}
|
|
951
|
-
let serverStarted = false;
|
|
952
|
-
let metadata = null;
|
|
953
|
-
|
|
954
|
-
const onSigint = () => {
|
|
955
|
-
controller.abort();
|
|
956
|
-
};
|
|
957
|
-
process.on("SIGINT", onSigint);
|
|
958
|
-
|
|
959
|
-
try {
|
|
960
|
-
if (!(await hasPi())) {
|
|
961
|
-
console.log(pc.yellow("\nPi is not installed. Run prepared for manual execution."));
|
|
962
|
-
return metadata;
|
|
963
|
-
}
|
|
964
|
-
|
|
965
|
-
const serverState = await ensureServerForBenchmark(profile);
|
|
966
|
-
serverStarted = serverState.started;
|
|
967
|
-
|
|
968
|
-
if (!(await hasPiModel(profile))) {
|
|
969
|
-
await syncPiConfig(profile);
|
|
970
|
-
}
|
|
971
|
-
|
|
972
|
-
const runResult = await runBenchmarkInPi(profile, runDirectory, { signal: controller.signal });
|
|
973
|
-
|
|
974
|
-
let speedMetrics = null;
|
|
975
|
-
if (!runResult.error) {
|
|
976
|
-
try {
|
|
977
|
-
speedMetrics = await queryServerMetrics(profile);
|
|
978
|
-
} catch (err) {
|
|
979
|
-
runResult.error = { message: `Speed metrics query failed: ${err.message}` };
|
|
980
|
-
}
|
|
981
|
-
}
|
|
982
|
-
|
|
983
|
-
metadata = await finalizeBenchmarkRun(runDirectory, runResult, speedMetrics);
|
|
984
|
-
renderBenchmarkSummary(metadata);
|
|
985
|
-
} catch (err) {
|
|
986
|
-
const failedResult = {
|
|
987
|
-
error: { message: err.message },
|
|
988
|
-
wallClockMs: null,
|
|
989
|
-
agentTurns: 0,
|
|
990
|
-
promptTokens: 0,
|
|
991
|
-
completionTokens: 0,
|
|
992
|
-
totalTokens: 0,
|
|
993
|
-
cacheRead: 0,
|
|
994
|
-
cacheWrite: 0,
|
|
995
|
-
toolCalls: 0,
|
|
996
|
-
toolResults: 0,
|
|
997
|
-
perTurn: [],
|
|
998
|
-
};
|
|
999
|
-
metadata = await finalizeBenchmarkRun(runDirectory, failedResult, null);
|
|
1000
|
-
renderBenchmarkSummary(metadata);
|
|
1001
|
-
} finally {
|
|
1002
|
-
process.removeListener("SIGINT", onSigint);
|
|
1003
|
-
if (serverStarted && !options.keepServer) {
|
|
1004
|
-
const backend = backendFor(profile.backend);
|
|
1005
|
-
if (backend.type !== "managed-server") {
|
|
1006
|
-
const result = await stopProfile(profile);
|
|
1007
|
-
console.log(result.stopped ? pc.green(`[stop] ${result.message}`) : pc.dim(`[stop] ${result.message}`));
|
|
1008
|
-
}
|
|
1009
|
-
}
|
|
1010
|
-
await unloadModelFromServer(profile).catch(() => {});
|
|
1011
|
-
}
|
|
1012
|
-
|
|
1013
|
-
return metadata;
|
|
1014
|
-
}
|
|
1015
|
-
|
|
1016
|
-
function formatMetric(value, formatter) {
|
|
1017
|
-
if (value === null || value === undefined || !Number.isFinite(value)) return pc.dim("—");
|
|
1018
|
-
return formatter(value);
|
|
1019
|
-
}
|
|
1020
|
-
|
|
1021
|
-
function formatMs(ms) {
|
|
1022
|
-
return formatMetric(ms, (n) => (n < 1000 ? `${Math.round(n)} ms` : `${(n / 1000).toFixed(1)} s`));
|
|
1023
|
-
}
|
|
1024
|
-
|
|
1025
|
-
function formatNumber(n) {
|
|
1026
|
-
return formatMetric(n, (v) => v.toLocaleString());
|
|
1027
|
-
}
|
|
1028
|
-
|
|
1029
|
-
function formatTokPerSec(n) {
|
|
1030
|
-
return formatMetric(n, (v) => `${v.toFixed(1)} tok/s`);
|
|
1031
|
-
}
|
|
1032
|
-
|
|
1033
|
-
function formatPercent(n) {
|
|
1034
|
-
return formatMetric(n, (v) => `${(v * 100).toFixed(0)} %`);
|
|
1035
|
-
}
|
|
1036
|
-
|
|
1037
|
-
export function renderBenchmarkSummary(metadata) {
|
|
1038
|
-
const { status, results, runner, error } = metadata;
|
|
1039
|
-
|
|
1040
|
-
const agentRows = [
|
|
1041
|
-
["Status", status === "completed" ? pc.green("completed") : pc.red(status ?? "failed")],
|
|
1042
|
-
["Duration", formatMs(results?.wallClockMs)],
|
|
1043
|
-
["Agent turns", formatNumber(results?.agentTurns)],
|
|
1044
|
-
["Input tokens", formatNumber(runner?.tokenMetrics?.promptTokens)],
|
|
1045
|
-
["Output tokens", formatNumber(runner?.tokenMetrics?.completionTokens)],
|
|
1046
|
-
["Total tokens", formatNumber(runner?.tokenMetrics?.totalTokens)],
|
|
1047
|
-
["Tool calls", formatNumber(results?.toolCalls)],
|
|
1048
|
-
["Tool results", formatNumber(results?.toolResults)],
|
|
1049
|
-
["Output files", (results?.outputFiles?.length ?? 0) > 0 ? results.outputFiles.join(", ") : pc.dim("—")],
|
|
1050
|
-
];
|
|
1051
|
-
|
|
1052
|
-
console.log("");
|
|
1053
|
-
console.log(renderSection("Benchmark Result", renderRows(agentRows)));
|
|
1054
|
-
|
|
1055
|
-
if (status === "completed" && runner?.speedMetrics) {
|
|
1056
|
-
const speed = runner.speedMetrics;
|
|
1057
|
-
const speedRows = [
|
|
1058
|
-
["Prefill tok/s", formatTokPerSec(speed.prefillTokensPerSecond)],
|
|
1059
|
-
["Generation tok/s", formatTokPerSec(speed.generationTokensPerSecond)],
|
|
1060
|
-
["TTFT", formatMs(speed.ttftMs)],
|
|
1061
|
-
["Speculative decode", formatPercent(speed.speculativeDecodeAcceptance)],
|
|
1062
|
-
["KV cache tokens", formatNumber(speed.kvCacheTokens)],
|
|
1063
|
-
["Model load time", formatMs(speed.modelLoadMs)],
|
|
1064
|
-
["Metric source", speed.metricSource ?? pc.dim("—")],
|
|
1065
|
-
];
|
|
1066
|
-
console.log(renderSection("Speed Metrics", renderRows(speedRows)));
|
|
1067
|
-
} else if (error) {
|
|
1068
|
-
const wrappedError = wrapText(error.message ?? "Unknown error");
|
|
1069
|
-
console.log(renderSection("Error", pc.red(wrappedError)));
|
|
1070
|
-
if (error.message?.includes("write tool") || error.message?.includes("required output file")) {
|
|
1071
|
-
const tip = wrapText("Tip: This usually means the model returned the answer as chat text instead of writing the file. Try a model with stronger tool-use support, or run the prompt manually.", 64);
|
|
1072
|
-
console.log(pc.dim("\n" + tip));
|
|
1073
|
-
}
|
|
1074
|
-
}
|
|
1075
|
-
}
|
|
1076
|
-
|
|
1077
|
-
function wrapText(text, width = 64) {
|
|
1078
|
-
if (!text) return "";
|
|
1079
|
-
const words = text.split(/\s+/);
|
|
1080
|
-
const lines = [];
|
|
1081
|
-
let current = "";
|
|
1082
|
-
for (const word of words) {
|
|
1083
|
-
if ((current + " " + word).trim().length > width) {
|
|
1084
|
-
if (current) lines.push(current.trim());
|
|
1085
|
-
current = word;
|
|
1086
|
-
} else {
|
|
1087
|
-
current = current ? `${current} ${word}` : word;
|
|
1088
|
-
}
|
|
1089
|
-
}
|
|
1090
|
-
if (current) lines.push(current.trim());
|
|
1091
|
-
return lines.join("\n");
|
|
1092
|
-
}
|
|
1093
|
-
|
|
1094
|
-
function benchmarkModelSource(profile) {
|
|
1095
|
-
if (!profile) return "cloud";
|
|
1096
|
-
return profile.providerId === "llama-cpp-mtp" ? "llama-cpp-mtp" : profile.backend === "ollama" ? "ollama" : profile.backend === "omlx" ? "omlx" : "llama-cpp";
|
|
1097
|
-
}
|
|
1098
|
-
|
|
1099
|
-
async function chooseBenchmarkAction(prompt, canRun) {
|
|
1100
|
-
const choices = [
|
|
1101
|
-
{ value: "run", label: "Run Benchmark", hint: "Automated with Pi" },
|
|
1102
|
-
{ value: "prepare", label: "Prepare Benchmark (manual)", hint: "Copy prompt and run yourself" },
|
|
1103
|
-
];
|
|
1104
|
-
return await prompt.choice("Action", canRun ? choices : choices.filter((c) => c.value === "prepare"), canRun ? "run" : "prepare");
|
|
1105
|
-
}
|
|
1106
|
-
|
|
1107
|
-
// ── Benchmark from a selected profile (from model picker) ────────────────
|
|
1108
|
-
|
|
1109
|
-
export async function benchmarkForProfile(profile) {
|
|
1110
|
-
await ensureDirs();
|
|
1111
|
-
const prompt = createPrompt();
|
|
1112
|
-
try {
|
|
1113
|
-
const repoPath = await linkBenchmarkRepo(prompt);
|
|
1114
|
-
if (!repoPath) return;
|
|
1115
|
-
|
|
1116
|
-
const kind = await prompt.choice("Benchmark category", [
|
|
1117
|
-
{ value: "visual", label: "Visual Benchmark", hint: "HTML/CSS/JS animation benchmarks" },
|
|
1118
|
-
{ value: "data-science", label: "Data Science", hint: "Analysis and charting benchmarks" },
|
|
1119
|
-
], "visual");
|
|
1120
|
-
|
|
1121
|
-
const benchDir = join(repoPath, "benchmarks");
|
|
1122
|
-
const benchmarks = (await loadBenchmarks(benchDir)).filter((b) => b.kind === kind);
|
|
1123
|
-
if (benchmarks.length === 0) {
|
|
1124
|
-
console.log(pc.yellow(`No ${kind} benchmarks found in ${benchDir}`));
|
|
1125
|
-
return;
|
|
1126
|
-
}
|
|
1127
|
-
const benchmarkId = await prompt.choice("Prompt", benchmarks.map((b) => ({
|
|
1128
|
-
value: b.id, label: b.title, hint: b.description || b.id,
|
|
1129
|
-
})), benchmarks[0].id);
|
|
1130
|
-
const selectedBenchmark = benchmarks.find((b) => b.id === benchmarkId);
|
|
1131
|
-
if (!selectedBenchmark) return;
|
|
1132
|
-
|
|
1133
|
-
const modelId = profile.modelAlias;
|
|
1134
|
-
const modelSource = benchmarkModelSource(profile);
|
|
1135
|
-
const backendLabel = backendFor(profile.backend).label;
|
|
1136
|
-
|
|
1137
|
-
const canRun = (await hasPi()) && modelSource !== "cloud";
|
|
1138
|
-
const action = await chooseBenchmarkAction(prompt, canRun);
|
|
1139
|
-
|
|
1140
|
-
const runDirectory = await prepareBenchmarkRun({ repoPath, benchmark: selectedBenchmark, kind, modelId, modelSource, backendLabel, profile, showNextSteps: action === "prepare" });
|
|
1141
|
-
|
|
1142
|
-
if (action === "run") {
|
|
1143
|
-
return await runPreparedBenchmark(profile, runDirectory);
|
|
1144
|
-
}
|
|
1145
|
-
|
|
1146
|
-
return runDirectory;
|
|
1147
|
-
} finally {
|
|
1148
|
-
prompt.close();
|
|
1149
|
-
}
|
|
1150
|
-
}
|
|
1151
|
-
|
|
1152
|
-
// ── Standalone benchmark flow (offgrid-ai benchmark) ──────────────────────
|
|
1153
|
-
|
|
1154
|
-
export async function benchmarkFlow() {
|
|
1155
|
-
await ensureDirs();
|
|
1156
|
-
|
|
1157
|
-
const prompt = createPrompt();
|
|
1158
|
-
try {
|
|
1159
|
-
const repoPath = await linkBenchmarkRepo(prompt);
|
|
1160
|
-
if (!repoPath) return;
|
|
1161
|
-
|
|
1162
|
-
const kind = await prompt.choice("Benchmark category", [
|
|
1163
|
-
{ value: "visual", label: "Visual Benchmark", hint: "HTML/CSS/JS animation benchmarks" },
|
|
1164
|
-
{ value: "data-science", label: "Data Science", hint: "Analysis and charting benchmarks" },
|
|
1165
|
-
], "visual");
|
|
1166
|
-
|
|
1167
|
-
const benchDir = join(repoPath, "benchmarks");
|
|
1168
|
-
const benchmarks = (await loadBenchmarks(benchDir)).filter((b) => b.kind === kind);
|
|
1169
|
-
if (benchmarks.length === 0) {
|
|
1170
|
-
console.log(pc.yellow(`No ${kind} benchmarks found in ${benchDir}`));
|
|
1171
|
-
return;
|
|
1172
|
-
}
|
|
1173
|
-
const benchmarkId = await prompt.choice("Prompt", benchmarks.map((b) => ({
|
|
1174
|
-
value: b.id, label: b.title, hint: b.description || b.id,
|
|
1175
|
-
})), benchmarks[0].id);
|
|
1176
|
-
const selectedBenchmark = benchmarks.find((b) => b.id === benchmarkId);
|
|
1177
|
-
if (!selectedBenchmark) return;
|
|
1178
|
-
|
|
1179
|
-
const { loadProfiles } = await import("./profiles.mjs");
|
|
1180
|
-
|
|
1181
|
-
const profiles = await loadProfiles();
|
|
1182
|
-
const source = await prompt.choice("Model source", [
|
|
1183
|
-
{ value: "profile", label: "Use existing profile", hint: "Pick a saved offgrid-ai profile" },
|
|
1184
|
-
{ value: "cloud", label: "Custom / cloud", hint: "Free-form model label for cloud runs" },
|
|
1185
|
-
], "profile");
|
|
1186
|
-
|
|
1187
|
-
let modelId, modelSource, backendLabel, profile;
|
|
1188
|
-
|
|
1189
|
-
if (source === "profile") {
|
|
1190
|
-
if (profiles.length === 0) {
|
|
1191
|
-
console.log(pc.yellow("No profiles yet. Run: offgrid-ai models"));
|
|
1192
|
-
return;
|
|
1193
|
-
}
|
|
1194
|
-
const profileId = await prompt.choice("Profile", profiles.map((p) => ({
|
|
1195
|
-
value: p.id, label: p.label, hint: `${backendFor(p.backend).label} · ${p.modelAlias}`,
|
|
1196
|
-
})), profiles[0].id);
|
|
1197
|
-
profile = profiles.find((p) => p.id === profileId);
|
|
1198
|
-
if (!profile) return;
|
|
1199
|
-
modelId = profile.modelAlias;
|
|
1200
|
-
modelSource = benchmarkModelSource(profile);
|
|
1201
|
-
backendLabel = backendFor(profile.backend).label;
|
|
1202
|
-
} else {
|
|
1203
|
-
backendLabel = await prompt.text("Backend label", "cloud");
|
|
1204
|
-
modelId = await prompt.text("Model name", "");
|
|
1205
|
-
if (!modelId) { console.log(pc.yellow("Model name is required.")); return; }
|
|
1206
|
-
modelSource = "cloud";
|
|
1207
|
-
}
|
|
1208
|
-
|
|
1209
|
-
const canRun = (await hasPi()) && modelSource !== "cloud" && profile != null;
|
|
1210
|
-
const action = await chooseBenchmarkAction(prompt, canRun);
|
|
1211
|
-
|
|
1212
|
-
const runDirectory = await prepareBenchmarkRun({ repoPath, benchmark: selectedBenchmark, kind, modelId, modelSource, backendLabel, profile, showNextSteps: action === "prepare" });
|
|
1213
|
-
|
|
1214
|
-
if (action === "run" && profile) {
|
|
1215
|
-
return await runPreparedBenchmark(profile, runDirectory);
|
|
1216
|
-
}
|
|
1217
|
-
|
|
1218
|
-
return runDirectory;
|
|
1219
|
-
} finally {
|
|
1220
|
-
prompt.close();
|
|
1221
|
-
}
|
|
1222
|
-
}
|
|
1
|
+
// ── Benchmark module (thin facade) ──────────────────────────────────────────
|
|
2
|
+
// Submodules handle the actual logic. This file re-exports for backward compatibility.
|
|
3
|
+
|
|
4
|
+
export { slugModelId, createRunId, buildToolPrompt, loadBenchmarks, piModelString } from "./benchmark/shared.mjs";
|
|
5
|
+
export { findBenchmarkRepo, linkBenchmarkRepo } from "./benchmark/repo.mjs";
|
|
6
|
+
export { prepareBenchmarkRun } from "./benchmark/prepare.mjs";
|
|
7
|
+
export { runBenchmarkInPi } from "./benchmark/pi-runner.mjs";
|
|
8
|
+
export { queryServerMetrics } from "./benchmark/metrics.mjs";
|
|
9
|
+
export { unloadModelFromServer, finalizeBenchmarkRun, renderBenchmarkSummary } from "./benchmark/finalize.mjs";
|
|
10
|
+
export { benchmarkForProfile, benchmarkFlow } from "./benchmark/flow.mjs";
|