omo-recommend-models 1.0.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 +78 -0
- package/bin/omo-recommend-models +3948 -0
- package/bin/omo-validate-config +449 -0
- package/lib/omo-shared.js +734 -0
- package/lib/recommend/apply.js +43 -0
- package/package.json +30 -0
|
@@ -0,0 +1,734 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* omo-shared — Shared helpers for omo-* tooling.
|
|
4
|
+
*
|
|
5
|
+
* AI helpers, config path resolution, config loading, provider
|
|
6
|
+
* infrastructure, and model application utilities used by:
|
|
7
|
+
* - omo-validate-config
|
|
8
|
+
* - omo-recommend-cloud
|
|
9
|
+
* - omo-recommend-local
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import fs from "node:fs";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import readline from "node:readline";
|
|
15
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
import { dirname } from "node:path";
|
|
18
|
+
|
|
19
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
20
|
+
const __dirname = dirname(__filename);
|
|
21
|
+
|
|
22
|
+
// =========================================================================
|
|
23
|
+
// Config paths
|
|
24
|
+
// =========================================================================
|
|
25
|
+
|
|
26
|
+
function resolveConfigPath() {
|
|
27
|
+
let dir = process.cwd();
|
|
28
|
+
while (true) {
|
|
29
|
+
const jsoncFile = path.join(dir, ".opencode", "oh-my-openagent.jsonc");
|
|
30
|
+
const jsonFile = path.join(dir, ".opencode", "oh-my-openagent.json");
|
|
31
|
+
if (fs.existsSync(jsoncFile)) {
|
|
32
|
+
return jsoncFile;
|
|
33
|
+
}
|
|
34
|
+
if (fs.existsSync(jsonFile)) {
|
|
35
|
+
return jsonFile;
|
|
36
|
+
}
|
|
37
|
+
const workshopYaml = path.join(dir, "workshop.yaml");
|
|
38
|
+
const gitignore = path.join(dir, ".gitignore");
|
|
39
|
+
if (fs.existsSync(workshopYaml) || fs.existsSync(gitignore)) {
|
|
40
|
+
return jsoncFile;
|
|
41
|
+
}
|
|
42
|
+
const parent = path.dirname(dir);
|
|
43
|
+
if (parent === dir) {
|
|
44
|
+
break;
|
|
45
|
+
}
|
|
46
|
+
dir = parent;
|
|
47
|
+
}
|
|
48
|
+
return path.join(process.cwd(), ".opencode", "oh-my-openagent.jsonc");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const CONFIG_PATH = resolveConfigPath();
|
|
52
|
+
const CONFIG_DIR = path.dirname(CONFIG_PATH);
|
|
53
|
+
|
|
54
|
+
// Ensure the config directory exists
|
|
55
|
+
try {
|
|
56
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
57
|
+
} catch (e) {}
|
|
58
|
+
|
|
59
|
+
const CACHE_DIR = path.join(
|
|
60
|
+
process.env.HOME || "/home/workshop",
|
|
61
|
+
".cache",
|
|
62
|
+
"oh-my-opencode",
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const CACHE_PATH = path.join(CACHE_DIR, "provider-models.json");
|
|
66
|
+
const BACKUP_PATH = CONFIG_PATH + ".pre-rebalance";
|
|
67
|
+
|
|
68
|
+
// =========================================================================
|
|
69
|
+
// Interactive prompt helper
|
|
70
|
+
// =========================================================================
|
|
71
|
+
|
|
72
|
+
let stdinBuffer = [];
|
|
73
|
+
let stdinWaiters = [];
|
|
74
|
+
let stdinInitialized = false;
|
|
75
|
+
|
|
76
|
+
function initStdin() {
|
|
77
|
+
if (stdinInitialized) return;
|
|
78
|
+
stdinInitialized = true;
|
|
79
|
+
process.stdin.setEncoding("utf-8");
|
|
80
|
+
process.stdin.resume();
|
|
81
|
+
|
|
82
|
+
let currentLine = "";
|
|
83
|
+
process.stdin.on("data", (chunk) => {
|
|
84
|
+
currentLine += chunk;
|
|
85
|
+
let idx;
|
|
86
|
+
while ((idx = currentLine.indexOf("\n")) !== -1) {
|
|
87
|
+
const line = currentLine.slice(0, idx);
|
|
88
|
+
currentLine = currentLine.slice(idx + 1);
|
|
89
|
+
if (stdinWaiters.length > 0) {
|
|
90
|
+
const resolve = stdinWaiters.shift();
|
|
91
|
+
resolve(line);
|
|
92
|
+
} else {
|
|
93
|
+
stdinBuffer.push(line);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
process.stdin.on("end", () => {
|
|
99
|
+
if (currentLine) {
|
|
100
|
+
if (stdinWaiters.length > 0) {
|
|
101
|
+
const resolve = stdinWaiters.shift();
|
|
102
|
+
resolve(currentLine);
|
|
103
|
+
} else {
|
|
104
|
+
stdinBuffer.push(currentLine);
|
|
105
|
+
}
|
|
106
|
+
currentLine = "";
|
|
107
|
+
}
|
|
108
|
+
while (stdinWaiters.length > 0) {
|
|
109
|
+
const resolve = stdinWaiters.shift();
|
|
110
|
+
resolve("");
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function isStdinEnded() {
|
|
116
|
+
return (
|
|
117
|
+
process.stdin.readableEnded ||
|
|
118
|
+
!process.stdin.readable ||
|
|
119
|
+
Boolean(process.stdin._readableState && process.stdin._readableState.ended)
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function readLineFromStdin() {
|
|
124
|
+
initStdin();
|
|
125
|
+
if (stdinBuffer.length > 0) {
|
|
126
|
+
return Promise.resolve(stdinBuffer.shift());
|
|
127
|
+
}
|
|
128
|
+
if (isStdinEnded()) {
|
|
129
|
+
return Promise.resolve("");
|
|
130
|
+
}
|
|
131
|
+
return new Promise((resolve) => {
|
|
132
|
+
stdinWaiters.push(resolve);
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function confirm(question) {
|
|
137
|
+
if (isStdinEnded()) {
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
process.stdout.write(question);
|
|
141
|
+
const answer = await readLineFromStdin();
|
|
142
|
+
return answer.toLowerCase().trim() === "y";
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function promptUser(question) {
|
|
146
|
+
if (isStdinEnded()) {
|
|
147
|
+
return "";
|
|
148
|
+
}
|
|
149
|
+
process.stdout.write(question);
|
|
150
|
+
const answer = await readLineFromStdin();
|
|
151
|
+
return answer.trim();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// =========================================================================
|
|
155
|
+
// AI helpers — OpenCode free provider chat
|
|
156
|
+
// =========================================================================
|
|
157
|
+
|
|
158
|
+
function pickFreeModel() {
|
|
159
|
+
const models = discoverFreeModels();
|
|
160
|
+
if (models.length === 0) throw new Error("No free model found in `opencode models`");
|
|
161
|
+
const preferred = [
|
|
162
|
+
"north-mini-code-free",
|
|
163
|
+
"deepseek-v4-flash-free",
|
|
164
|
+
"nemotron-3-ultra-free",
|
|
165
|
+
"mimo-v2.5-free",
|
|
166
|
+
];
|
|
167
|
+
for (const p of preferred) {
|
|
168
|
+
const found = models.find((m) => m.includes(p));
|
|
169
|
+
if (found) return found;
|
|
170
|
+
}
|
|
171
|
+
return models[0];
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function discoverFreeModels() {
|
|
175
|
+
try {
|
|
176
|
+
const raw = execFileSync("opencode", ["models"], {
|
|
177
|
+
encoding: "utf-8",
|
|
178
|
+
timeout: 15000,
|
|
179
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
180
|
+
env: { ...process.env, TERM: "dumb" },
|
|
181
|
+
});
|
|
182
|
+
const lines = raw.trim().split("\n").filter(Boolean);
|
|
183
|
+
return lines.map((m) => m.trim()).filter(Boolean);
|
|
184
|
+
} catch (_) {
|
|
185
|
+
return [];
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function callOpencodeChat(model, prompt) {
|
|
190
|
+
process.stderr.write(`→ AI analysis: ${model}...`);
|
|
191
|
+
try {
|
|
192
|
+
const raw = execFileSync("opencode", [
|
|
193
|
+
"run",
|
|
194
|
+
"--pure",
|
|
195
|
+
"--format",
|
|
196
|
+
"json",
|
|
197
|
+
"--model",
|
|
198
|
+
model,
|
|
199
|
+
"--dangerously-skip-permissions",
|
|
200
|
+
prompt,
|
|
201
|
+
], {
|
|
202
|
+
encoding: "utf-8",
|
|
203
|
+
timeout: 60000,
|
|
204
|
+
maxBuffer: 1024 * 1024,
|
|
205
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
206
|
+
env: { ...process.env, TERM: "dumb" },
|
|
207
|
+
});
|
|
208
|
+
process.stderr.write("\x1b[2K\r→ AI analysis: done\n");
|
|
209
|
+
const lines = raw.trim().split("\n");
|
|
210
|
+
const texts = [];
|
|
211
|
+
for (const line of lines) {
|
|
212
|
+
try {
|
|
213
|
+
const evt = JSON.parse(line);
|
|
214
|
+
if (evt.type === "text" && evt.part && evt.part.text) {
|
|
215
|
+
texts.push(evt.part.text);
|
|
216
|
+
}
|
|
217
|
+
} catch (_) {}
|
|
218
|
+
}
|
|
219
|
+
return texts.join("") || null;
|
|
220
|
+
} catch (_) {
|
|
221
|
+
process.stderr.write("\x1b[2K\r→ AI analysis: failed\n");
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function callOpencodeChatAsync(model, prompt, signal, statusRef) {
|
|
227
|
+
return new Promise((resolve) => {
|
|
228
|
+
const child = spawn("opencode", [
|
|
229
|
+
"run", "--pure", "--format", "json",
|
|
230
|
+
"--model", model,
|
|
231
|
+
"--dangerously-skip-permissions",
|
|
232
|
+
prompt,
|
|
233
|
+
], {
|
|
234
|
+
env: { ...process.env, TERM: "dumb" },
|
|
235
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
let stdout = "";
|
|
239
|
+
let timersCleaned = false;
|
|
240
|
+
let timeoutReason = null;
|
|
241
|
+
function cleanupTimers() {
|
|
242
|
+
if (timersCleaned) return;
|
|
243
|
+
timersCleaned = true;
|
|
244
|
+
clearTimeout(firstByteTimer);
|
|
245
|
+
clearTimeout(totalTimer);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// First-byte timeout: if no stdout data after 45s the model is hung.
|
|
249
|
+
// The real prompt (agents + models + instructions) is 4-6KB so models
|
|
250
|
+
// need time to ingest before producing a byte.
|
|
251
|
+
const firstByteTimer = setTimeout(() => {
|
|
252
|
+
timeoutReason = "first-byte-timeout";
|
|
253
|
+
if (statusRef) statusRef.failReason = timeoutReason;
|
|
254
|
+
if (!child.killed) child.kill("SIGTERM");
|
|
255
|
+
setTimeout(() => {
|
|
256
|
+
if (!child.killed) child.kill("SIGKILL");
|
|
257
|
+
}, 5000);
|
|
258
|
+
}, 45000);
|
|
259
|
+
|
|
260
|
+
// Total timeout: kill after 120s regardless of output
|
|
261
|
+
const totalTimer = setTimeout(() => {
|
|
262
|
+
timeoutReason = "total-timeout";
|
|
263
|
+
if (statusRef) statusRef.failReason = timeoutReason;
|
|
264
|
+
if (!child.killed) child.kill("SIGTERM");
|
|
265
|
+
setTimeout(() => {
|
|
266
|
+
if (!child.killed) child.kill("SIGKILL");
|
|
267
|
+
}, 5000);
|
|
268
|
+
}, 120000);
|
|
269
|
+
|
|
270
|
+
let firstByteReceived = false;
|
|
271
|
+
child.stdout.on("data", (data) => {
|
|
272
|
+
if (!firstByteReceived) {
|
|
273
|
+
firstByteReceived = true;
|
|
274
|
+
clearTimeout(firstByteTimer);
|
|
275
|
+
}
|
|
276
|
+
stdout += data.toString();
|
|
277
|
+
if (statusRef) {
|
|
278
|
+
statusRef.phase = "receiving";
|
|
279
|
+
statusRef.bytes = stdout.length;
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
child.on("error", () => {
|
|
284
|
+
cleanupTimers();
|
|
285
|
+
resolve(null);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
child.on("close", () => {
|
|
289
|
+
cleanupTimers();
|
|
290
|
+
if (signal && signal.aborted) {
|
|
291
|
+
if (statusRef) statusRef.failReason = "aborted";
|
|
292
|
+
return resolve(null);
|
|
293
|
+
}
|
|
294
|
+
if (!stdout) return resolve(null);
|
|
295
|
+
const lines = stdout.trim().split("\n");
|
|
296
|
+
const texts = [];
|
|
297
|
+
for (const line of lines) {
|
|
298
|
+
try {
|
|
299
|
+
const evt = JSON.parse(line);
|
|
300
|
+
if (evt.type === "text" && evt.part && evt.part.text) {
|
|
301
|
+
texts.push(evt.part.text);
|
|
302
|
+
}
|
|
303
|
+
} catch (_) {}
|
|
304
|
+
}
|
|
305
|
+
resolve(texts.join("") || null);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
// Hook external abort signal
|
|
309
|
+
if (signal) {
|
|
310
|
+
signal.addEventListener("abort", () => {
|
|
311
|
+
cleanupTimers();
|
|
312
|
+
if (statusRef) statusRef.failReason = "aborted";
|
|
313
|
+
if (!child.killed) child.kill("SIGTERM");
|
|
314
|
+
}, { once: true });
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function parseAiJson(raw) {
|
|
320
|
+
if (!raw) throw new Error("AI returned no output. Is OpenCode running?");
|
|
321
|
+
const clean = raw
|
|
322
|
+
.replace(/^\s*```(?:json)?\s*\n?/gm, "")
|
|
323
|
+
.replace(/\s*```\s*$/gm, "")
|
|
324
|
+
.trim();
|
|
325
|
+
return JSON.parse(clean);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// =========================================================================
|
|
329
|
+
// Config loading
|
|
330
|
+
// =========================================================================
|
|
331
|
+
|
|
332
|
+
function stripJsoncComments(text) {
|
|
333
|
+
let out = "";
|
|
334
|
+
let inString = false;
|
|
335
|
+
let escaped = false;
|
|
336
|
+
let quote = "";
|
|
337
|
+
|
|
338
|
+
for (let i = 0; i < text.length; i++) {
|
|
339
|
+
const ch = text[i];
|
|
340
|
+
const next = text[i + 1];
|
|
341
|
+
|
|
342
|
+
if (inString) {
|
|
343
|
+
out += ch;
|
|
344
|
+
if (escaped) {
|
|
345
|
+
escaped = false;
|
|
346
|
+
} else if (ch === "\\") {
|
|
347
|
+
escaped = true;
|
|
348
|
+
} else if (ch === quote) {
|
|
349
|
+
inString = false;
|
|
350
|
+
quote = "";
|
|
351
|
+
}
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (ch === "\"" || ch === "'") {
|
|
356
|
+
inString = true;
|
|
357
|
+
quote = ch;
|
|
358
|
+
out += ch;
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (ch === "/" && next === "/") {
|
|
363
|
+
i += 2;
|
|
364
|
+
while (i < text.length && text[i] !== "\n" && text[i] !== "\r") i++;
|
|
365
|
+
if (i < text.length) out += text[i];
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
if (ch === "/" && next === "*") {
|
|
370
|
+
i += 2;
|
|
371
|
+
while (i < text.length) {
|
|
372
|
+
if (text[i] === "\n" || text[i] === "\r") out += text[i];
|
|
373
|
+
if (text[i] === "*" && text[i + 1] === "/") {
|
|
374
|
+
i++;
|
|
375
|
+
break;
|
|
376
|
+
}
|
|
377
|
+
i++;
|
|
378
|
+
}
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
out += ch;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
return out;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function stripTrailingCommas(text) {
|
|
389
|
+
let out = "";
|
|
390
|
+
let inString = false;
|
|
391
|
+
let escaped = false;
|
|
392
|
+
let quote = "";
|
|
393
|
+
|
|
394
|
+
for (let i = 0; i < text.length; i++) {
|
|
395
|
+
const ch = text[i];
|
|
396
|
+
|
|
397
|
+
if (inString) {
|
|
398
|
+
out += ch;
|
|
399
|
+
if (escaped) {
|
|
400
|
+
escaped = false;
|
|
401
|
+
} else if (ch === "\\") {
|
|
402
|
+
escaped = true;
|
|
403
|
+
} else if (ch === quote) {
|
|
404
|
+
inString = false;
|
|
405
|
+
quote = "";
|
|
406
|
+
}
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
if (ch === "\"" || ch === "'") {
|
|
411
|
+
inString = true;
|
|
412
|
+
quote = ch;
|
|
413
|
+
out += ch;
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
if (ch === ",") {
|
|
418
|
+
let j = i + 1;
|
|
419
|
+
while (j < text.length && /\s/.test(text[j])) j++;
|
|
420
|
+
if (text[j] === "}" || text[j] === "]") continue;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
out += ch;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
return out;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function jsoncParse(text) {
|
|
430
|
+
return JSON.parse(stripTrailingCommas(stripJsoncComments(text)));
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function loadConfig() {
|
|
434
|
+
if (!fs.existsSync(CONFIG_PATH)) {
|
|
435
|
+
return {
|
|
436
|
+
$schema: "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
|
|
437
|
+
runtime_fallback: true,
|
|
438
|
+
agents: {
|
|
439
|
+
sisyphus: { model_quality: "high", description: "Primary orchestrator and architectural planner" },
|
|
440
|
+
hephaestus: { model_quality: "balanced", description: "Autonomous deep worker for writing large code files and refactoring" },
|
|
441
|
+
oracle: { model_quality: "high", description: "High-IQ consultation agent for complex architecture and debugging" },
|
|
442
|
+
librarian: { model_quality: "balanced", description: "Reads local documentation, markdown files, and generates summaries" },
|
|
443
|
+
explore: { model_quality: "balanced", description: "Fast codebase exploration and pattern matching" },
|
|
444
|
+
"multimodal-looker": { model_quality: "balanced", description: "Analyzes images, PDFs, and other media files" },
|
|
445
|
+
prometheus: { model_quality: "high", description: "Generates, runs, and evaluates comprehensive unit tests" },
|
|
446
|
+
metis: { model_quality: "high", description: "Pre-planning consultant for ambiguous requirements" },
|
|
447
|
+
momus: { model_quality: "xhigh", description: "Expert reviewer for work plans and quality assurance" },
|
|
448
|
+
atlas: { model_quality: "balanced", description: "Codebase exploration and structural analysis" },
|
|
449
|
+
"sisyphus-junior": { model_quality: "balanced", description: "Focused task executor under orchestration" },
|
|
450
|
+
scout: { model_quality: "balanced", description: "Fast context gathering and file search" },
|
|
451
|
+
sysadmin: { model_quality: "balanced", description: "Scripting, automation, and system configuration" },
|
|
452
|
+
},
|
|
453
|
+
categories: {
|
|
454
|
+
"visual-engineering": { model_quality: "balanced", description: "Frontend, UI/UX, design, styling, animation" },
|
|
455
|
+
ultrabrain: { model_quality: "xhigh", description: "Hard logic, architecture decisions, algorithms" },
|
|
456
|
+
deep: { model_quality: "balanced", description: "Goal-oriented autonomous problem-solving" },
|
|
457
|
+
artistry: { model_quality: "balanced", description: "Complex problem-solving with creative approaches" },
|
|
458
|
+
quick: { model_quality: "balanced", description: "Single file changes, typo fixes, simple modifications" },
|
|
459
|
+
"unspecified-low": { model_quality: "balanced", description: "Low-effort tasks that don't fit other categories" },
|
|
460
|
+
"unspecified-high": { model_quality: "balanced", description: "High-effort tasks that don't fit other categories" },
|
|
461
|
+
writing: { model_quality: "balanced", description: "Documentation, prose, technical writing" },
|
|
462
|
+
},
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
return jsoncParse(fs.readFileSync(CONFIG_PATH, "utf-8"));
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function getAccessibleModels() {
|
|
469
|
+
try {
|
|
470
|
+
const output = execFileSync("opencode", ["models", "--pure"], {
|
|
471
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
472
|
+
timeout: 10000,
|
|
473
|
+
env: { ...process.env, TERM: "dumb" },
|
|
474
|
+
encoding: "utf8",
|
|
475
|
+
});
|
|
476
|
+
const set = new Set();
|
|
477
|
+
for (const line of output.split("\n")) {
|
|
478
|
+
const trimmed = line.trim();
|
|
479
|
+
if (trimmed) set.add(trimmed);
|
|
480
|
+
}
|
|
481
|
+
return set;
|
|
482
|
+
} catch (err) {
|
|
483
|
+
if (err && err.code === "ENOENT") {
|
|
484
|
+
return null;
|
|
485
|
+
}
|
|
486
|
+
console.error("getAccessibleModels failed:", err.message, err.stderr);
|
|
487
|
+
return null;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function loadProviderModels(options = {}) {
|
|
492
|
+
const refresh = options.refresh !== false;
|
|
493
|
+
const quiet = options.quiet === true;
|
|
494
|
+
|
|
495
|
+
let cache = null;
|
|
496
|
+
|
|
497
|
+
if (fs.existsSync(CACHE_PATH)) {
|
|
498
|
+
try {
|
|
499
|
+
cache = JSON.parse(fs.readFileSync(CACHE_PATH, "utf-8"));
|
|
500
|
+
} catch (e) {
|
|
501
|
+
if (!quiet) console.error(" ✗ Failed to read provider-models cache: " + e.message);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
if (!cache) {
|
|
506
|
+
const opencodeModelsPath = path.join(
|
|
507
|
+
process.env.HOME || "/home/workshop",
|
|
508
|
+
".cache",
|
|
509
|
+
"opencode",
|
|
510
|
+
"models.json"
|
|
511
|
+
);
|
|
512
|
+
|
|
513
|
+
if (!fs.existsSync(opencodeModelsPath)) {
|
|
514
|
+
if (!refresh) return null;
|
|
515
|
+
if (!quiet) {
|
|
516
|
+
console.log("⚠ Provider-models cache not found. Refreshing...");
|
|
517
|
+
}
|
|
518
|
+
try {
|
|
519
|
+
execFileSync("opencode", ["models", "--refresh", "--pure"], {
|
|
520
|
+
stdio: ["ignore", "pipe", "inherit"],
|
|
521
|
+
timeout: 60000,
|
|
522
|
+
env: { ...process.env, TERM: "dumb" },
|
|
523
|
+
});
|
|
524
|
+
if (!quiet) console.log(" ✓ Cache populated.");
|
|
525
|
+
} catch (e) {
|
|
526
|
+
if (!quiet) console.error(" ✗ Failed to refresh models cache. Run OpenCode once to populate it.");
|
|
527
|
+
return null;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
if (fs.existsSync(opencodeModelsPath)) {
|
|
532
|
+
try {
|
|
533
|
+
const rawModels = JSON.parse(fs.readFileSync(opencodeModelsPath, "utf-8"));
|
|
534
|
+
const convertedModels = {};
|
|
535
|
+
for (const [providerId, providerObj] of Object.entries(rawModels)) {
|
|
536
|
+
if (providerObj && providerObj.models) {
|
|
537
|
+
const modelsArray = Object.values(providerObj.models);
|
|
538
|
+
convertedModels[providerId] = modelsArray;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
cache = { models: convertedModels };
|
|
542
|
+
} catch (e) {
|
|
543
|
+
if (!quiet) console.error(" ✗ Failed to process models.json: " + e.message);
|
|
544
|
+
return null;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
if (cache && cache.models) {
|
|
550
|
+
const accessible = getAccessibleModels();
|
|
551
|
+
if (accessible) {
|
|
552
|
+
const convertedModels = {};
|
|
553
|
+
for (const [providerId, modelsArray] of Object.entries(cache.models)) {
|
|
554
|
+
const filtered = modelsArray.filter((m) => {
|
|
555
|
+
const id = typeof m === "string" ? m : m.id;
|
|
556
|
+
return accessible.has(`${providerId}/${id}`) || accessible.has(id);
|
|
557
|
+
});
|
|
558
|
+
if (filtered.length > 0) {
|
|
559
|
+
convertedModels[providerId] = filtered;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
cache.models = convertedModels;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
return cache;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// =========================================================================
|
|
570
|
+
// Provider alias resolution
|
|
571
|
+
// =========================================================================
|
|
572
|
+
|
|
573
|
+
const CANONICAL_PROVIDERS = new Set(["local", "openai", "opencode"]);
|
|
574
|
+
|
|
575
|
+
function buildProviderAliases(config) {
|
|
576
|
+
const aliases = {};
|
|
577
|
+
for (const [key, entry] of Object.entries(config.providers || {})) {
|
|
578
|
+
if (entry.type && entry.type !== key && !CANONICAL_PROVIDERS.has(key)) {
|
|
579
|
+
aliases[key] = entry.type;
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
return aliases;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function resolveProvider(providerKey, aliases) {
|
|
586
|
+
return aliases[providerKey] || providerKey;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function normalizeLocalModelName(modelName) {
|
|
590
|
+
const trimmed = String(modelName || "").trim();
|
|
591
|
+
const withoutProvider = trimmed.replace(/^(?:local|ollama)\//, "");
|
|
592
|
+
if (!withoutProvider) return "";
|
|
593
|
+
return withoutProvider.includes(":") ? withoutProvider : `${withoutProvider}:latest`;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function formatModelRef(provider, modelName) {
|
|
597
|
+
return `${provider}/${provider === "local" ? normalizeLocalModelName(modelName) : String(modelName || "")}`;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// =========================================================================
|
|
601
|
+
// Model lookup
|
|
602
|
+
// =========================================================================
|
|
603
|
+
|
|
604
|
+
function buildRichModelLookup(cache) {
|
|
605
|
+
const byId = {};
|
|
606
|
+
const sets = {};
|
|
607
|
+
if (!cache || !cache.models) return { byId, sets };
|
|
608
|
+
for (const [provider, models] of Object.entries(cache.models)) {
|
|
609
|
+
const modelMap = new Map();
|
|
610
|
+
const modelSet = new Set();
|
|
611
|
+
for (const m of Array.isArray(models) ? models : []) {
|
|
612
|
+
const id = typeof m === "string" ? m : m.id;
|
|
613
|
+
modelSet.add(id);
|
|
614
|
+
if (typeof m === "object" && m !== null) {
|
|
615
|
+
modelMap.set(id, m);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
byId[provider] = modelMap;
|
|
619
|
+
sets[provider] = modelSet;
|
|
620
|
+
}
|
|
621
|
+
return { byId, sets };
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function collectModelRefs(config, pathPrefix) {
|
|
625
|
+
const refs = [];
|
|
626
|
+
|
|
627
|
+
function walk(obj, context) {
|
|
628
|
+
if (!obj || typeof obj !== "object") return;
|
|
629
|
+
|
|
630
|
+
if (Array.isArray(obj)) {
|
|
631
|
+
obj.forEach((item, i) => walk(item, { ...context, index: i }));
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
if (obj.model && typeof obj.model === "string") {
|
|
636
|
+
const slash = obj.model.indexOf("/");
|
|
637
|
+
if (slash !== -1) {
|
|
638
|
+
const providerID = obj.model.slice(0, slash).trim();
|
|
639
|
+
const modelID = obj.model.slice(slash + 1).trim();
|
|
640
|
+
refs.push({
|
|
641
|
+
location: context.path || pathPrefix,
|
|
642
|
+
providerID,
|
|
643
|
+
modelID,
|
|
644
|
+
variant: obj.variant,
|
|
645
|
+
raw: obj.model,
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
for (const [key, val] of Object.entries(obj)) {
|
|
651
|
+
walk(val, {
|
|
652
|
+
...context,
|
|
653
|
+
path: context.path ? `${context.path}.${key}` : key,
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
walk(config, { path: "" });
|
|
659
|
+
return refs;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// =========================================================================
|
|
663
|
+
// Local model discovery
|
|
664
|
+
// =========================================================================
|
|
665
|
+
|
|
666
|
+
function discoverLocalModels() {
|
|
667
|
+
try {
|
|
668
|
+
const raw = execFileSync("omo-recommend-local", ["--json"], {
|
|
669
|
+
encoding: "utf-8",
|
|
670
|
+
timeout: 10_000,
|
|
671
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
672
|
+
env: { ...process.env, TERM: "dumb" },
|
|
673
|
+
});
|
|
674
|
+
const data = JSON.parse(raw);
|
|
675
|
+
// Only return models that are actually installed on disk
|
|
676
|
+
return (data.ollama.models || [])
|
|
677
|
+
.filter((m) => m.name)
|
|
678
|
+
.map((m) => m.name);
|
|
679
|
+
} catch {
|
|
680
|
+
return [];
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// =========================================================================
|
|
685
|
+
// Config application
|
|
686
|
+
// =========================================================================
|
|
687
|
+
|
|
688
|
+
function applyAiRecommendations(section, recommendations) {
|
|
689
|
+
const models = [];
|
|
690
|
+
for (const rec of recommendations) {
|
|
691
|
+
if (rec.model && rec.provider) {
|
|
692
|
+
models.push(formatModelRef(rec.provider, rec.model));
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
if (models.length > 0) {
|
|
696
|
+
section.model = models[0];
|
|
697
|
+
const fallbacks = models.slice(1);
|
|
698
|
+
if (fallbacks.length > 0) {
|
|
699
|
+
section.fallback_models = fallbacks;
|
|
700
|
+
} else if (section.fallback_models) {
|
|
701
|
+
delete section.fallback_models;
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// =========================================================================
|
|
707
|
+
// Exports
|
|
708
|
+
// =========================================================================
|
|
709
|
+
|
|
710
|
+
export {
|
|
711
|
+
confirm,
|
|
712
|
+
promptUser,
|
|
713
|
+
pickFreeModel,
|
|
714
|
+
discoverFreeModels,
|
|
715
|
+
callOpencodeChat,
|
|
716
|
+
callOpencodeChatAsync,
|
|
717
|
+
parseAiJson,
|
|
718
|
+
CONFIG_DIR,
|
|
719
|
+
CONFIG_PATH,
|
|
720
|
+
CACHE_DIR,
|
|
721
|
+
CACHE_PATH,
|
|
722
|
+
BACKUP_PATH,
|
|
723
|
+
jsoncParse,
|
|
724
|
+
loadConfig,
|
|
725
|
+
loadProviderModels,
|
|
726
|
+
buildProviderAliases,
|
|
727
|
+
resolveProvider,
|
|
728
|
+
normalizeLocalModelName,
|
|
729
|
+
formatModelRef,
|
|
730
|
+
buildRichModelLookup,
|
|
731
|
+
collectModelRefs,
|
|
732
|
+
discoverLocalModels,
|
|
733
|
+
applyAiRecommendations
|
|
734
|
+
};
|