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.
@@ -0,0 +1,449 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { dirname } from 'node:path';
6
+ import {
7
+ CONFIG_PATH,
8
+ CACHE_DIR,
9
+ jsoncParse,
10
+ loadProviderModels,
11
+ buildProviderAliases,
12
+ resolveProvider,
13
+ buildRichModelLookup,
14
+ discoverLocalModels,
15
+ } from '../lib/omo-shared.js';
16
+
17
+ const __filename = fileURLToPath(import.meta.url);
18
+ const __dirname = dirname(__filename);
19
+
20
+
21
+ const DEFAULT_SCHEMA =
22
+ "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json";
23
+
24
+ const FALLBACK_OPTION_KEYS = new Set([
25
+ "model",
26
+ "variant",
27
+ "reasoningEffort",
28
+ "temperature",
29
+ "top_p",
30
+ "maxTokens",
31
+ "thinking",
32
+ ]);
33
+
34
+ const REASONING_EFFORTS = new Set(["none", "minimal", "low", "medium", "high", "xhigh", "max"]);
35
+
36
+ function usage() {
37
+ return [
38
+ "Usage: omo-validate-config [--config <path>] [--fix] [--help]",
39
+ "",
40
+ "Validate the local oh-my-openagent.jsonc subset written by OMO tooling.",
41
+ "",
42
+ "Options:",
43
+ " --config <path> Validate a specific JSONC config file",
44
+ " --fix Apply safe mechanical fixes after creating <path>.bak",
45
+ " --help Show this help",
46
+ ].join("\n");
47
+ }
48
+
49
+ function parseArgs(argv) {
50
+ const options = { configPath: CONFIG_PATH, fix: false, help: false };
51
+ for (let i = 0; i < argv.length; i++) {
52
+ const arg = argv[i];
53
+ if (arg === "--help" || arg === "-h") {
54
+ options.help = true;
55
+ } else if (arg === "--fix") {
56
+ options.fix = true;
57
+ } else if (arg === "--config") {
58
+ const value = argv[++i];
59
+ if (!value) throw new Error("--config requires a path");
60
+ options.configPath = path.resolve(value);
61
+ } else {
62
+ throw new Error(`Unknown argument: ${arg}`);
63
+ }
64
+ }
65
+ return options;
66
+ }
67
+
68
+ function isPlainObject(value) {
69
+ return value !== null && typeof value === "object" && !Array.isArray(value);
70
+ }
71
+
72
+ function parseConfigFile(configPath) {
73
+ let text;
74
+ try {
75
+ text = fs.readFileSync(configPath, "utf8");
76
+ } catch (err) {
77
+ throw new Error(`${configPath}: ${err.message}`);
78
+ }
79
+
80
+ try {
81
+ return { config: jsoncParse(text), text };
82
+ } catch (err) {
83
+ throw new Error(`JSONC parse error: ${err.message}`);
84
+ }
85
+ }
86
+
87
+ function refFromParts(provider, model) {
88
+ return `${provider}/${model}`;
89
+ }
90
+
91
+ function splitModelRef(ref) {
92
+ if (typeof ref !== "string") return null;
93
+ const slash = ref.indexOf("/");
94
+ if (slash <= 0 || slash === ref.length - 1) return null;
95
+ const provider = ref.slice(0, slash).trim();
96
+ const model = ref.slice(slash + 1).trim();
97
+ if (!provider || !model || /\s/.test(provider)) return null;
98
+ return { provider, model };
99
+ }
100
+
101
+ function canonicalizeModelRef(ref) {
102
+ const parts = splitModelRef(ref);
103
+ if (!parts) return ref;
104
+ if (parts.provider === "ollama") return refFromParts("local", parts.model);
105
+ return ref;
106
+ }
107
+
108
+ function loadLocalFacts() {
109
+ const names = new Set();
110
+ let available = false;
111
+ const catalogPath = path.join(CACHE_DIR, "ollama-models.json");
112
+
113
+ if (fs.existsSync(catalogPath)) {
114
+ available = true;
115
+ try {
116
+ const raw = JSON.parse(fs.readFileSync(catalogPath, "utf8"));
117
+ const models = Array.isArray(raw) ? raw : raw?.ollama?.models;
118
+ for (const item of Array.isArray(models) ? models : []) {
119
+ const name = typeof item === "string" ? item : item?.name;
120
+ if (name) names.add(name);
121
+ }
122
+ } catch {
123
+ // A corrupt optional local catalog should not hide config syntax errors.
124
+ }
125
+ }
126
+
127
+ const discovered = discoverLocalModels();
128
+ if (discovered.length > 0) available = true;
129
+ for (const name of discovered) names.add(name);
130
+
131
+ return { available, names };
132
+ }
133
+
134
+ function buildFacts(config) {
135
+ const cache = loadProviderModels({ refresh: false, quiet: true });
136
+ return {
137
+ aliases: buildProviderAliases(config),
138
+ modelLookup: buildRichModelLookup(cache),
139
+ hasProviderCache: Boolean(cache && cache.models),
140
+ local: loadLocalFacts(),
141
+ };
142
+ }
143
+
144
+ function addError(errors, location, message) {
145
+ errors.push(`${location}: ${message}`);
146
+ }
147
+
148
+ function validateModelRef(value, location, facts, errors) {
149
+ if (typeof value !== "string") {
150
+ addError(errors, location, "must be a provider/model string");
151
+ return;
152
+ }
153
+
154
+ const ref = splitModelRef(value);
155
+ if (!ref) {
156
+ addError(errors, location, "must use provider/model syntax");
157
+ return;
158
+ }
159
+
160
+ if (ref.provider === "ollama") {
161
+ addError(errors, location, "use local/<model> for local model references");
162
+ return;
163
+ }
164
+
165
+ if (ref.provider === "local") {
166
+ if (facts.local.available && !facts.local.names.has(ref.model)) {
167
+ addError(errors, location, `unknown local model ${ref.model}`);
168
+ }
169
+ return;
170
+ }
171
+
172
+ if (!facts.hasProviderCache) return;
173
+ const provider = resolveProvider(ref.provider, facts.aliases);
174
+ const providerSet = facts.modelLookup.sets[provider] || facts.modelLookup.sets[ref.provider];
175
+ if (!providerSet) {
176
+ addError(errors, location, `unknown provider ${ref.provider}`);
177
+ return;
178
+ }
179
+ if (!providerSet.has(ref.model) && !providerSet.has(`${ref.provider}/${ref.model}`) && !providerSet.has(`${provider}/${ref.model}`)) {
180
+ addError(errors, location, `unknown model ${ref.provider}/${ref.model}`);
181
+ }
182
+ }
183
+
184
+ function validateNumberRange(value, location, min, max, errors) {
185
+ if (typeof value !== "number" || !Number.isFinite(value)) {
186
+ addError(errors, location, "must be a finite number");
187
+ return;
188
+ }
189
+ if (value < min || value > max) addError(errors, location, `must be between ${min} and ${max}`);
190
+ }
191
+
192
+ function validateThinking(value, location, errors) {
193
+ if (!isPlainObject(value)) {
194
+ addError(errors, location, "must be an object");
195
+ return;
196
+ }
197
+ for (const key of Object.keys(value)) {
198
+ if (key !== "type" && key !== "budgetTokens") {
199
+ addError(errors, `${location}.${key}`, "unknown thinking option");
200
+ }
201
+ }
202
+ if (value.type !== "enabled" && value.type !== "disabled") {
203
+ addError(errors, `${location}.type`, "must be enabled or disabled");
204
+ }
205
+ if ("budgetTokens" in value && (typeof value.budgetTokens !== "number" || !Number.isFinite(value.budgetTokens))) {
206
+ addError(errors, `${location}.budgetTokens`, "must be a finite number");
207
+ }
208
+ }
209
+
210
+ function validatePlacementObject(value, location, facts, errors) {
211
+ if (!isPlainObject(value)) {
212
+ addError(errors, location, "must be a model reference string or object");
213
+ return;
214
+ }
215
+ for (const key of Object.keys(value)) {
216
+ if (!FALLBACK_OPTION_KEYS.has(key)) addError(errors, `${location}.${key}`, "unknown model placement option");
217
+ }
218
+ if (!("model" in value)) {
219
+ addError(errors, `${location}.model`, "is required");
220
+ } else {
221
+ validateModelRef(value.model, `${location}.model`, facts, errors);
222
+ }
223
+ if ("variant" in value && typeof value.variant !== "string") {
224
+ addError(errors, `${location}.variant`, "must be a string");
225
+ }
226
+ if ("reasoningEffort" in value && !REASONING_EFFORTS.has(value.reasoningEffort)) {
227
+ addError(errors, `${location}.reasoningEffort`, "must be a known reasoning effort");
228
+ }
229
+ if ("temperature" in value) validateNumberRange(value.temperature, `${location}.temperature`, 0, 2, errors);
230
+ if ("top_p" in value) validateNumberRange(value.top_p, `${location}.top_p`, 0, 1, errors);
231
+ if ("maxTokens" in value && (typeof value.maxTokens !== "number" || !Number.isFinite(value.maxTokens))) {
232
+ addError(errors, `${location}.maxTokens`, "must be a finite number");
233
+ }
234
+ if ("thinking" in value) validateThinking(value.thinking, `${location}.thinking`, errors);
235
+ }
236
+
237
+ function validatePlacementArray(value, location, facts, errors) {
238
+ if (!Array.isArray(value)) {
239
+ addError(errors, location, "must be an array");
240
+ return;
241
+ }
242
+ value.forEach((item, index) => {
243
+ const itemPath = `${location}.${index}`;
244
+ if (typeof item === "string") validateModelRef(item, itemPath, facts, errors);
245
+ else validatePlacementObject(item, itemPath, facts, errors);
246
+ });
247
+ }
248
+
249
+ function validateFallbacks(value, location, facts, errors) {
250
+ if (typeof value === "string") {
251
+ validateModelRef(value, location, facts, errors);
252
+ return;
253
+ }
254
+ validatePlacementArray(value, location, facts, errors);
255
+ }
256
+
257
+ function validateSection(section, location, facts, errors) {
258
+ if (!isPlainObject(section)) {
259
+ addError(errors, location, "must be an object");
260
+ return;
261
+ }
262
+
263
+ if ("model" in section) validateModelRef(section.model, `${location}.model`, facts, errors);
264
+ if ("variant" in section && typeof section.variant !== "string") {
265
+ addError(errors, `${location}.variant`, "must be a string");
266
+ }
267
+ if ("variant" in section && !("model" in section)) {
268
+ addError(errors, `${location}.variant`, "requires model");
269
+ }
270
+ if ("routing" in section) validatePlacementArray(section.routing, `${location}.routing`, facts, errors);
271
+ if ("fallback_models" in section) validateFallbacks(section.fallback_models, `${location}.fallback_models`, facts, errors);
272
+ }
273
+
274
+ function validateConfig(config) {
275
+ const errors = [];
276
+
277
+ if (!isPlainObject(config)) {
278
+ addError(errors, "$", "top-level config must be an object");
279
+ return errors;
280
+ }
281
+
282
+ const facts = buildFacts(config);
283
+
284
+ if (typeof config.$schema !== "string" || config.$schema.trim() === "") {
285
+ addError(errors, "$schema", "must be a non-empty string");
286
+ }
287
+ if (!isPlainObject(config.agents)) {
288
+ addError(errors, "agents", "must be an object");
289
+ } else {
290
+ for (const [name, section] of Object.entries(config.agents)) {
291
+ validateSection(section, `agents.${name}`, facts, errors);
292
+ }
293
+ }
294
+ if (!isPlainObject(config.categories)) {
295
+ addError(errors, "categories", "must be an object");
296
+ } else {
297
+ for (const [name, section] of Object.entries(config.categories)) {
298
+ validateSection(section, `categories.${name}`, facts, errors);
299
+ }
300
+ }
301
+
302
+ return errors;
303
+ }
304
+
305
+ function objectToRef(value) {
306
+ if (!isPlainObject(value)) return null;
307
+ const keys = Object.keys(value);
308
+ if (keys.length === 1 && typeof value.model === "string") return value.model;
309
+ if (
310
+ keys.length === 2 &&
311
+ typeof value.provider === "string" &&
312
+ typeof value.model === "string"
313
+ ) {
314
+ return refFromParts(value.provider, value.model);
315
+ }
316
+ return null;
317
+ }
318
+
319
+ function fixPlacementValue(value) {
320
+ if (typeof value === "string") return canonicalizeModelRef(value);
321
+ const asRef = objectToRef(value);
322
+ if (asRef) return canonicalizeModelRef(asRef);
323
+ if (isPlainObject(value) && typeof value.model === "string") {
324
+ value.model = canonicalizeModelRef(value.model);
325
+ }
326
+ return value;
327
+ }
328
+
329
+ function fixRoutingValue(value) {
330
+ if (typeof value === "string") return canonicalizeModelRef(value);
331
+ if (isPlainObject(value) && typeof value.model === "string") {
332
+ value.model = canonicalizeModelRef(value.model);
333
+ }
334
+ return value;
335
+ }
336
+
337
+ function fixSection(section) {
338
+ let changed = false;
339
+ if (!isPlainObject(section)) return changed;
340
+
341
+ if (typeof section.model === "string") {
342
+ const next = canonicalizeModelRef(section.model);
343
+ if (next !== section.model) {
344
+ section.model = next;
345
+ changed = true;
346
+ }
347
+ }
348
+
349
+ if (Array.isArray(section.routing)) {
350
+ const nextRouting = section.routing.map(fixRoutingValue);
351
+ if (JSON.stringify(nextRouting) !== JSON.stringify(section.routing)) {
352
+ section.routing = nextRouting;
353
+ changed = true;
354
+ }
355
+ if (section.routing.length === 0) {
356
+ delete section.routing;
357
+ changed = true;
358
+ }
359
+ }
360
+
361
+ if (Array.isArray(section.fallback_models)) {
362
+ const nextFallbacks = section.fallback_models.map(fixPlacementValue);
363
+ if (JSON.stringify(nextFallbacks) !== JSON.stringify(section.fallback_models)) {
364
+ section.fallback_models = nextFallbacks;
365
+ changed = true;
366
+ }
367
+ if (section.fallback_models.length === 0) {
368
+ delete section.fallback_models;
369
+ changed = true;
370
+ }
371
+ }
372
+
373
+ if (typeof section.fallback_models === "string") {
374
+ const next = canonicalizeModelRef(section.fallback_models);
375
+ if (next !== section.fallback_models) {
376
+ section.fallback_models = next;
377
+ changed = true;
378
+ }
379
+ }
380
+
381
+ return changed;
382
+ }
383
+
384
+ function applyFixes(config) {
385
+ let changed = false;
386
+ if (isPlainObject(config) && typeof config.$schema !== "string") {
387
+ config.$schema = DEFAULT_SCHEMA;
388
+ changed = true;
389
+ }
390
+ for (const sectionGroup of [config.agents, config.categories]) {
391
+ if (!isPlainObject(sectionGroup)) continue;
392
+ for (const section of Object.values(sectionGroup)) {
393
+ if (fixSection(section)) changed = true;
394
+ }
395
+ }
396
+ return changed;
397
+ }
398
+
399
+ function writeFixedConfig(configPath, config) {
400
+ const backupPath = `${configPath}.bak`;
401
+ fs.copyFileSync(configPath, backupPath);
402
+ fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
403
+ return backupPath;
404
+ }
405
+
406
+ function main() {
407
+ let options;
408
+ try {
409
+ options = parseArgs(process.argv.slice(2));
410
+ } catch (err) {
411
+ console.error(err.message);
412
+ console.error(usage());
413
+ return 2;
414
+ }
415
+
416
+ if (options.help) {
417
+ console.log(usage());
418
+ return 0;
419
+ }
420
+
421
+ let config;
422
+ try {
423
+ ({ config } = parseConfigFile(options.configPath));
424
+ } catch (err) {
425
+ console.error(err.message);
426
+ return 1;
427
+ }
428
+
429
+ let fixed = false;
430
+ if (options.fix) fixed = applyFixes(config);
431
+
432
+ const errors = validateConfig(config);
433
+ if (errors.length > 0) {
434
+ for (const error of errors) console.error(error);
435
+ return 1;
436
+ }
437
+
438
+ if (options.fix && fixed) {
439
+ const backupPath = writeFixedConfig(options.configPath, config);
440
+ console.log(`Config valid after fixes: ${options.configPath}`);
441
+ console.log(`Backup: ${backupPath}`);
442
+ return 0;
443
+ }
444
+
445
+ console.log(`Config valid: ${options.configPath}`);
446
+ return 0;
447
+ }
448
+
449
+ process.exitCode = main();