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 CHANGED
@@ -41,12 +41,14 @@ This installs offgrid-ai and anything else it needs. Then open a new terminal wi
41
41
  offgrid-ai
42
42
  ```
43
43
 
44
- If you already have Node.js installed, you can also use:
44
+ If you already have Node.js installed, you can also install with npm:
45
45
 
46
46
  ```bash
47
47
  npm install -g offgrid-ai@latest --prefer-online
48
48
  ```
49
49
 
50
+ The curl installer is recommended for first-time setup because it also verifies the global npm bin directory is on your PATH. The npm package itself does not run install scripts or mutate shell config during `npm install`.
51
+
50
52
  ### 2. Pick a model
51
53
 
52
54
  The first time you run offgrid-ai, it looks for models already on your machine. If it does not find any, it tells you how to get one.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "offgrid-ai",
3
- "version": "0.8.14",
3
+ "version": "0.9.0",
4
4
  "description": "Privacy-first CLI for running local LLMs — discover, configure, run, benchmark",
5
5
  "author": "Eeshan Srivastava (https://eeshans.com)",
6
6
  "type": "module",
@@ -11,6 +11,7 @@
11
11
  "bin/*.mjs",
12
12
  "src/*.mjs",
13
13
  "src/commands/*.mjs",
14
+ "src/benchmark/*.mjs",
14
15
  "install.sh"
15
16
  ],
16
17
  "publishConfig": {
@@ -31,12 +32,11 @@
31
32
  "start": "node bin/offgrid-ai.mjs",
32
33
  "test": "node --test test/*.mjs",
33
34
  "test:integration": "OFFGRID_INTEGRATION=1 node --test test/integration/*.mjs",
34
- "lint": "eslint src/*.mjs src/commands/*.mjs bin/*.mjs",
35
+ "lint": "eslint src/*.mjs src/commands/*.mjs src/benchmark/*.mjs bin/*.mjs",
35
36
  "check:privacy": "node scripts/privacy-gate.mjs",
36
37
  "release:check": "bash scripts/release-check.sh",
37
38
  "release:check:fast": "bash scripts/release-check.sh --skip-install --skip-manual",
38
39
  "prepack": "npm run check:privacy",
39
- "postinstall": "node src/postinstall.mjs",
40
40
  "pretest": "npm run lint"
41
41
  },
42
42
  "dependencies": {
package/src/backends.mjs CHANGED
@@ -87,51 +87,47 @@ export function defaultFlagsForBackend(backendId) {
87
87
  // ── Ollama model discovery ──────────────────────────────────────────────
88
88
 
89
89
  async function scanOllamaModels() {
90
- try {
91
- const response = await fetch(`${BACKENDS.ollama.apiBaseUrl}/api/tags`, { signal: AbortSignal.timeout(3000) });
92
- if (!response.ok) return [];
93
- const body = await response.json();
94
- if (!Array.isArray(body?.models)) return [];
95
- return body.models
96
- .filter((model) => isLocalOllamaModel(model))
97
- .map((model) => ({
98
- id: model.name,
99
- label: ollamaLabel(model.name),
100
- aliasSuggestion: model.name,
101
- sizeBytes: model.size ?? 0,
102
- quant: model.details?.quantization_level,
103
- family: model.details?.family,
104
- backend: "ollama",
105
- source: "ollama",
106
- })).sort((a, b) => a.label.localeCompare(b.label));
107
- } catch {
108
- return [];
90
+ const response = await fetch(`${BACKENDS.ollama.apiBaseUrl}/api/tags`, { signal: AbortSignal.timeout(3000) });
91
+ if (!response.ok) {
92
+ throw new Error(`Ollama /api/tags returned ${response.status} ${response.statusText}`);
109
93
  }
94
+ const body = await response.json();
95
+ if (!Array.isArray(body?.models)) return [];
96
+ return body.models
97
+ .filter((model) => isLocalOllamaModel(model))
98
+ .map((model) => ({
99
+ id: model.name,
100
+ label: ollamaLabel(model.name),
101
+ aliasSuggestion: model.name,
102
+ sizeBytes: model.size ?? 0,
103
+ quant: model.details?.quantization_level,
104
+ family: model.details?.family,
105
+ backend: "ollama",
106
+ source: "ollama",
107
+ })).sort((a, b) => a.label.localeCompare(b.label));
110
108
  }
111
109
 
112
110
  // ── oMLX model discovery ───────────────────────────────────────────────
113
111
 
114
112
  async function scanOmlxModels() {
115
- try {
116
- const response = await fetch(`${BACKENDS.omlx.defaultBaseUrl}/models`, { signal: AbortSignal.timeout(3000) });
117
- if (!response.ok) return [];
118
- const body = await response.json();
119
- if (!Array.isArray(body?.data)) return [];
120
- return body.data
121
- .filter((model) => isChatOmlxModel(model))
122
- .map((model) => ({
123
- id: model.id,
124
- label: omlxLabel(model.id),
125
- aliasSuggestion: model.id,
126
- sizeBytes: 0,
127
- quant: null,
128
- family: null,
129
- backend: "omlx",
130
- source: "omlx",
131
- })).sort((a, b) => a.label.localeCompare(b.label));
132
- } catch {
133
- return [];
113
+ const response = await fetch(`${BACKENDS.omlx.defaultBaseUrl}/models`, { signal: AbortSignal.timeout(3000) });
114
+ if (!response.ok) {
115
+ throw new Error(`oMLX /models returned ${response.status} ${response.statusText}`);
134
116
  }
117
+ const body = await response.json();
118
+ if (!Array.isArray(body?.data)) return [];
119
+ return body.data
120
+ .filter((model) => isChatOmlxModel(model))
121
+ .map((model) => ({
122
+ id: model.id,
123
+ label: omlxLabel(model.id),
124
+ aliasSuggestion: model.id,
125
+ sizeBytes: 0,
126
+ quant: null,
127
+ family: null,
128
+ backend: "omlx",
129
+ source: "omlx",
130
+ })).sort((a, b) => a.label.localeCompare(b.label));
135
131
  }
136
132
 
137
133
  // ── Labels ──────────────────────────────────────────────────────────────
@@ -0,0 +1,198 @@
1
+ // ── Unload model from server memory after benchmark ────────────────────────────
2
+
3
+ import { backendFor } from "../backends.mjs";
4
+ import { apiRootUrl } from "../process.mjs";
5
+ import { existsSync } from "node:fs";
6
+ import { readFile, writeFile } from "node:fs/promises";
7
+ import { join } from "node:path";
8
+ import { pc, renderRows, renderSection } from "../ui.mjs";
9
+
10
+ export async function unloadModelFromServer(profile) {
11
+ const backend = backendFor(profile.backend);
12
+
13
+ if (backend.id === "ollama") {
14
+ const apiBaseUrl = apiRootUrl(profile.baseUrl || backend.apiBaseUrl || "");
15
+
16
+ try {
17
+ await fetch(`${apiBaseUrl}/api/generate`, {
18
+ method: "POST",
19
+ headers: { "Content-Type": "application/json" },
20
+ body: JSON.stringify({ model: profile.modelAlias, prompt: "", stream: false, keep_alive: 0 }),
21
+ signal: AbortSignal.timeout(10000),
22
+ });
23
+ return { unloaded: true, backend: backend.id };
24
+ } catch (err) {
25
+ return { unloaded: false, backend: backend.id, error: err.message };
26
+ }
27
+ }
28
+
29
+ if (backend.id === "llama-cpp" || backend.id === "llama-cpp-mtp") {
30
+ // llama.cpp unloads when the server process exits; no HTTP unload API exists.
31
+ // If offgrid-ai started the server, stopProfile already handled it.
32
+ return { unloaded: false, backend: backend.id, reason: "stop server to unload" };
33
+ }
34
+
35
+ if (backend.id === "omlx") {
36
+ // oMLX does not expose a model-unload endpoint. The model stays resident
37
+ // until the oMLX server process is stopped.
38
+ return { unloaded: false, backend: backend.id, reason: "no unload API available" };
39
+ }
40
+
41
+ return { unloaded: false, backend: backend.id, reason: "unsupported backend" };
42
+ }
43
+
44
+ export async function finalizeBenchmarkRun(runDirectory, runResult, speedMetrics) {
45
+ const metadataPath = join(runDirectory, "metadata.json");
46
+ const metadata = JSON.parse(await readFile(metadataPath, "utf8"));
47
+ const now = new Date();
48
+ const timestamp = now.toISOString();
49
+
50
+ const kind = metadata.kind ?? "visual";
51
+ const isDs = kind === "data-science";
52
+ const requiredFile = isDs ? "analysis.ipynb" : "index.html";
53
+ const requiredPath = join(runDirectory, requiredFile);
54
+
55
+ const outputFiles = [];
56
+ for (const candidate of [requiredFile, isDs ? "summary.json" : "preview.png", isDs ? "chart-distribution.png" : "preview.webm", "preview.mp4"]) {
57
+ if (existsSync(join(runDirectory, candidate))) {
58
+ outputFiles.push(candidate);
59
+ }
60
+ }
61
+
62
+ const success = existsSync(requiredPath) && (await readFile(requiredPath, "utf8")).trim().length > 0;
63
+ const hasTurns = runResult.agentTurns > 0;
64
+
65
+ let failureReason = null;
66
+ if (runResult.error) {
67
+ failureReason = typeof runResult.error === "string" ? runResult.error : (runResult.error.message ?? "Unknown error");
68
+ } else if (!hasTurns) {
69
+ failureReason = "The model did not produce any response turns.";
70
+ } else if (!success) {
71
+ if (runResult.toolCalls === 0) {
72
+ 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.`;
73
+ } else {
74
+ failureReason = `The required output file (${requiredFile}) was missing or empty after the run.`;
75
+ }
76
+ }
77
+
78
+ const failed = failureReason !== null;
79
+
80
+ metadata.status = failed ? "failed" : "completed";
81
+ metadata.updatedAt = timestamp;
82
+ if (failed) {
83
+ metadata.failedAt = timestamp;
84
+ } else {
85
+ metadata.completedAt = timestamp;
86
+ }
87
+
88
+ const totalTokens = runResult.promptTokens + runResult.completionTokens;
89
+
90
+ metadata.runner.tokenMetrics = {
91
+ reported: hasTurns,
92
+ promptTokens: runResult.promptTokens,
93
+ completionTokens: runResult.completionTokens,
94
+ totalTokens,
95
+ };
96
+
97
+ metadata.runner.speedMetrics = speedMetrics;
98
+ metadata.runner.metricSource = speedMetrics?.metricSource ?? null;
99
+
100
+ metadata.results = {
101
+ wallClockMs: runResult.wallClockMs,
102
+ agentTurns: runResult.agentTurns,
103
+ toolCalls: runResult.toolCalls,
104
+ toolResults: runResult.toolResults,
105
+ success,
106
+ outputFiles,
107
+ perTurn: runResult.perTurn,
108
+ };
109
+
110
+ if (failureReason) {
111
+ metadata.error = { message: failureReason, ...(typeof runResult.error === "object" && runResult.error?.stack ? { stack: runResult.error.stack } : {}) };
112
+ } else if (runResult.error) {
113
+ metadata.error = typeof runResult.error === "string"
114
+ ? { message: runResult.error }
115
+ : { message: runResult.error.message ?? "Unknown error", ...(runResult.error.stack ? { stack: runResult.error.stack } : {}) };
116
+ }
117
+
118
+ await writeFile(metadataPath, JSON.stringify(metadata, null, 2) + "\n", "utf8");
119
+ return metadata;
120
+ }
121
+
122
+ function formatMetric(value, formatter) {
123
+ if (value === null || value === undefined || !Number.isFinite(value)) return pc.dim("—");
124
+ return formatter(value);
125
+ }
126
+
127
+ function formatMs(ms) {
128
+ return formatMetric(ms, (n) => (n < 1000 ? `${Math.round(n)} ms` : `${(n / 1000).toFixed(1)} s`));
129
+ }
130
+
131
+ function formatNumber(n) {
132
+ return formatMetric(n, (v) => v.toLocaleString());
133
+ }
134
+
135
+ function formatTokPerSec(n) {
136
+ return formatMetric(n, (v) => `${v.toFixed(1)} tok/s`);
137
+ }
138
+
139
+ function formatPercent(n) {
140
+ return formatMetric(n, (v) => `${(v * 100).toFixed(0)} %`);
141
+ }
142
+
143
+ export function renderBenchmarkSummary(metadata) {
144
+ const { status, results, runner, error } = metadata;
145
+
146
+ const agentRows = [
147
+ ["Status", status === "completed" ? pc.green("completed") : pc.red(status ?? "failed")],
148
+ ["Duration", formatMs(results?.wallClockMs)],
149
+ ["Agent turns", formatNumber(results?.agentTurns)],
150
+ ["Input tokens", formatNumber(runner?.tokenMetrics?.promptTokens)],
151
+ ["Output tokens", formatNumber(runner?.tokenMetrics?.completionTokens)],
152
+ ["Total tokens", formatNumber(runner?.tokenMetrics?.totalTokens)],
153
+ ["Tool calls", formatNumber(results?.toolCalls)],
154
+ ["Tool results", formatNumber(results?.toolResults)],
155
+ ["Output files", (results?.outputFiles?.length ?? 0) > 0 ? results.outputFiles.join(", ") : pc.dim("—")],
156
+ ];
157
+
158
+ console.log("");
159
+ console.log(renderSection("Benchmark Result", renderRows(agentRows)));
160
+
161
+ if (status === "completed" && runner?.speedMetrics) {
162
+ const speed = runner.speedMetrics;
163
+ const speedRows = [
164
+ ["Prefill tok/s", formatTokPerSec(speed.prefillTokensPerSecond)],
165
+ ["Generation tok/s", formatTokPerSec(speed.generationTokensPerSecond)],
166
+ ["TTFT", formatMs(speed.ttftMs)],
167
+ ["Speculative decode", formatPercent(speed.speculativeDecodeAcceptance)],
168
+ ["KV cache tokens", formatNumber(speed.kvCacheTokens)],
169
+ ["Model load time", formatMs(speed.modelLoadMs)],
170
+ ["Metric source", speed.metricSource ?? pc.dim("—")],
171
+ ];
172
+ console.log(renderSection("Speed Metrics", renderRows(speedRows)));
173
+ } else if (error) {
174
+ const wrappedError = wrapText(error.message ?? "Unknown error");
175
+ console.log(renderSection("Error", pc.red(wrappedError)));
176
+ if (error.message?.includes("write tool") || error.message?.includes("required output file")) {
177
+ 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);
178
+ console.log(pc.dim("\n" + tip));
179
+ }
180
+ }
181
+ }
182
+
183
+ function wrapText(text, width = 64) {
184
+ if (!text) return "";
185
+ const words = text.split(/\s+/);
186
+ const lines = [];
187
+ let current = "";
188
+ for (const word of words) {
189
+ if ((current + " " + word).trim().length > width) {
190
+ if (current) lines.push(current.trim());
191
+ current = word;
192
+ } else {
193
+ current = current ? `${current} ${word}` : word;
194
+ }
195
+ }
196
+ if (current) lines.push(current.trim());
197
+ return lines.join("\n");
198
+ }
@@ -0,0 +1,237 @@
1
+ // ── Benchmark command flows ───────────────────────────────────────────────────
2
+
3
+ import { join } from "node:path";
4
+ import { ensureDirs } from "../config.mjs";
5
+ import { backendFor } from "../backends.mjs";
6
+ import { hasPi, hasPiModel, syncPiConfig } from "../harness-pi.mjs";
7
+ import { serverReady, startServer, waitForReady, stopProfile } from "../process.mjs";
8
+ import { loadProfiles } from "../profiles.mjs";
9
+ import { pc, createPrompt } from "../ui.mjs";
10
+ import { linkBenchmarkRepo } from "./repo.mjs";
11
+ import { loadBenchmarks } from "./shared.mjs";
12
+ import { prepareBenchmarkRun } from "./prepare.mjs";
13
+ import { runBenchmarkInPi } from "./pi-runner.mjs";
14
+ import { queryServerMetrics } from "./metrics.mjs";
15
+ import { unloadModelFromServer } from "./finalize.mjs";
16
+ import { finalizeBenchmarkRun, renderBenchmarkSummary } from "./finalize.mjs";
17
+
18
+ function benchmarkModelSource(profile) {
19
+ if (!profile) return "cloud";
20
+ return profile.providerId === "llama-cpp-mtp" ? "llama-cpp-mtp" : profile.backend === "ollama" ? "ollama" : profile.backend === "omlx" ? "omlx" : "llama-cpp";
21
+ }
22
+
23
+ async function chooseBenchmarkAction(prompt, canRun) {
24
+ const choices = [
25
+ { value: "run", label: "Run Benchmark", hint: "Automated with Pi" },
26
+ { value: "prepare", label: "Prepare Benchmark (manual)", hint: "Copy prompt and run yourself" },
27
+ ];
28
+ return await prompt.choice("Action", canRun ? choices : choices.filter((c) => c.value === "prepare"), canRun ? "run" : "prepare");
29
+ }
30
+
31
+ async function ensureServerForBenchmark(profile) {
32
+ const backend = backendFor(profile.backend);
33
+ if (await serverReady(profile.baseUrl)) {
34
+ console.log(pc.green(`[ready] ${backend.label} at ${profile.baseUrl}`));
35
+ return { started: false };
36
+ }
37
+
38
+ if (backend.type === "managed-server") {
39
+ throw new Error(`${backend.label} is not running at ${profile.baseUrl}. Start it and try again.`);
40
+ }
41
+
42
+ console.log(pc.dim(`Starting ${backend.label} for ${profile.label}...`));
43
+ const state = await startServer(profile);
44
+ await waitForReady(profile, state?.pid, state?.rawLogPath);
45
+ console.log(pc.green(`[ready] ${profile.baseUrl}/models`));
46
+ return { started: true, state };
47
+ }
48
+
49
+ export async function runPreparedBenchmark(profile, runDirectory, options = {}) {
50
+ const controller = new AbortController();
51
+ if (options.signal) {
52
+ options.signal.addEventListener("abort", () => controller.abort(), { once: true });
53
+ }
54
+ let serverStarted = false;
55
+ let metadata = null;
56
+
57
+ const onSigint = () => {
58
+ controller.abort();
59
+ };
60
+ process.on("SIGINT", onSigint);
61
+
62
+ try {
63
+ if (!(await hasPi())) {
64
+ console.log(pc.yellow("\nPi is not installed. Run prepared for manual execution."));
65
+ return metadata;
66
+ }
67
+
68
+ const serverState = await ensureServerForBenchmark(profile);
69
+ serverStarted = serverState.started;
70
+
71
+ if (!(await hasPiModel(profile))) {
72
+ await syncPiConfig(profile);
73
+ }
74
+
75
+ const runResult = await runBenchmarkInPi(profile, runDirectory, { signal: controller.signal });
76
+
77
+ let speedMetrics = null;
78
+ if (!runResult.error) {
79
+ try {
80
+ speedMetrics = await queryServerMetrics(profile);
81
+ } catch (err) {
82
+ runResult.error = { message: `Speed metrics query failed: ${err.message}` };
83
+ }
84
+ }
85
+
86
+ metadata = await finalizeBenchmarkRun(runDirectory, runResult, speedMetrics);
87
+ renderBenchmarkSummary(metadata);
88
+ } catch (err) {
89
+ const failedResult = {
90
+ error: { message: err.message },
91
+ wallClockMs: null,
92
+ agentTurns: 0,
93
+ promptTokens: 0,
94
+ completionTokens: 0,
95
+ totalTokens: 0,
96
+ cacheRead: 0,
97
+ cacheWrite: 0,
98
+ toolCalls: 0,
99
+ toolResults: 0,
100
+ perTurn: [],
101
+ };
102
+ metadata = await finalizeBenchmarkRun(runDirectory, failedResult, null);
103
+ renderBenchmarkSummary(metadata);
104
+ } finally {
105
+ process.removeListener("SIGINT", onSigint);
106
+ if (serverStarted && !options.keepServer) {
107
+ const backend = backendFor(profile.backend);
108
+ if (backend.type !== "managed-server") {
109
+ const result = await stopProfile(profile);
110
+ console.log(result.stopped ? pc.green(`[stop] ${result.message}`) : pc.dim(`[stop] ${result.message}`));
111
+ }
112
+ }
113
+ const unloadResult = await unloadModelFromServer(profile);
114
+ if (!unloadResult.unloaded && unloadResult.error) {
115
+ console.log(pc.yellow(`[unload] ${unloadResult.backend}: ${unloadResult.error}`));
116
+ } else if (!unloadResult.unloaded && unloadResult.reason) {
117
+ console.log(pc.dim(`[unload] ${unloadResult.backend}: ${unloadResult.reason}`));
118
+ }
119
+ }
120
+
121
+ return metadata;
122
+ }
123
+
124
+ // ── Benchmark from a selected profile (from model picker) ────────────────
125
+
126
+ export async function benchmarkForProfile(profile) {
127
+ await ensureDirs();
128
+ const prompt = createPrompt();
129
+ try {
130
+ const repoPath = await linkBenchmarkRepo(prompt);
131
+ if (!repoPath) return;
132
+
133
+ const kind = await prompt.choice("Benchmark category", [
134
+ { value: "visual", label: "Visual Benchmark", hint: "HTML/CSS/JS animation benchmarks" },
135
+ { value: "data-science", label: "Data Science", hint: "Analysis and charting benchmarks" },
136
+ ], "visual");
137
+
138
+ const benchDir = join(repoPath, "benchmarks");
139
+ const benchmarks = (await loadBenchmarks(benchDir)).filter((b) => b.kind === kind);
140
+ if (benchmarks.length === 0) {
141
+ console.log(pc.yellow(`No ${kind} benchmarks found in ${benchDir}`));
142
+ return;
143
+ }
144
+ const benchmarkId = await prompt.choice("Prompt", benchmarks.map((b) => ({
145
+ value: b.id, label: b.title, hint: b.description || b.id,
146
+ })), benchmarks[0].id);
147
+ const selectedBenchmark = benchmarks.find((b) => b.id === benchmarkId);
148
+ if (!selectedBenchmark) return;
149
+
150
+ const modelId = profile.modelAlias;
151
+ const modelSource = benchmarkModelSource(profile);
152
+ const backendLabel = backendFor(profile.backend).label;
153
+
154
+ const canRun = (await hasPi()) && modelSource !== "cloud";
155
+ const action = await chooseBenchmarkAction(prompt, canRun);
156
+
157
+ const runDirectory = await prepareBenchmarkRun({ repoPath, benchmark: selectedBenchmark, kind, modelId, modelSource, backendLabel, profile, showNextSteps: action === "prepare" });
158
+
159
+ if (action === "run") {
160
+ return await runPreparedBenchmark(profile, runDirectory);
161
+ }
162
+
163
+ return runDirectory;
164
+ } finally {
165
+ prompt.close();
166
+ }
167
+ }
168
+
169
+ // ── Standalone benchmark flow (offgrid-ai benchmark) ──────────────────────
170
+
171
+ export async function benchmarkFlow() {
172
+ await ensureDirs();
173
+
174
+ const prompt = createPrompt();
175
+ try {
176
+ const repoPath = await linkBenchmarkRepo(prompt);
177
+ if (!repoPath) return;
178
+
179
+ const kind = await prompt.choice("Benchmark category", [
180
+ { value: "visual", label: "Visual Benchmark", hint: "HTML/CSS/JS animation benchmarks" },
181
+ { value: "data-science", label: "Data Science", hint: "Analysis and charting benchmarks" },
182
+ ], "visual");
183
+
184
+ const benchDir = join(repoPath, "benchmarks");
185
+ const benchmarks = (await loadBenchmarks(benchDir)).filter((b) => b.kind === kind);
186
+ if (benchmarks.length === 0) {
187
+ console.log(pc.yellow(`No ${kind} benchmarks found in ${benchDir}`));
188
+ return;
189
+ }
190
+ const benchmarkId = await prompt.choice("Prompt", benchmarks.map((b) => ({
191
+ value: b.id, label: b.title, hint: b.description || b.id,
192
+ })), benchmarks[0].id);
193
+ const selectedBenchmark = benchmarks.find((b) => b.id === benchmarkId);
194
+ if (!selectedBenchmark) return;
195
+
196
+ const profiles = await loadProfiles();
197
+ const source = await prompt.choice("Model source", [
198
+ { value: "profile", label: "Use existing profile", hint: "Pick a saved offgrid-ai profile" },
199
+ { value: "cloud", label: "Custom / cloud", hint: "Free-form model label for cloud runs" },
200
+ ], "profile");
201
+
202
+ let modelId, modelSource, backendLabel, profile;
203
+
204
+ if (source === "profile") {
205
+ if (profiles.length === 0) {
206
+ console.log(pc.yellow("No profiles yet. Run: offgrid-ai models"));
207
+ return;
208
+ }
209
+ const profileId = await prompt.choice("Profile", profiles.map((p) => ({
210
+ value: p.id, label: p.label, hint: `${backendFor(p.backend).label} · ${p.modelAlias}`,
211
+ })), profiles[0].id);
212
+ profile = profiles.find((p) => p.id === profileId);
213
+ if (!profile) return;
214
+ modelId = profile.modelAlias;
215
+ modelSource = benchmarkModelSource(profile);
216
+ backendLabel = backendFor(profile.backend).label;
217
+ } else {
218
+ backendLabel = await prompt.text("Backend label", "cloud");
219
+ modelId = await prompt.text("Model name", "");
220
+ if (!modelId) { console.log(pc.yellow("Model name is required.")); return; }
221
+ modelSource = "cloud";
222
+ }
223
+
224
+ const canRun = (await hasPi()) && modelSource !== "cloud" && profile != null;
225
+ const action = await chooseBenchmarkAction(prompt, canRun);
226
+
227
+ const runDirectory = await prepareBenchmarkRun({ repoPath, benchmark: selectedBenchmark, kind, modelId, modelSource, backendLabel, profile, showNextSteps: action === "prepare" });
228
+
229
+ if (action === "run" && profile) {
230
+ return await runPreparedBenchmark(profile, runDirectory);
231
+ }
232
+
233
+ return runDirectory;
234
+ } finally {
235
+ prompt.close();
236
+ }
237
+ }