mohdel 0.124.0 → 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.
Files changed (45) hide show
  1. package/README.md +155 -30
  2. package/config/curated.schema.json +32 -10
  3. package/js/client/call.js +76 -19
  4. package/js/client/gate-binary.js +5 -0
  5. package/js/client/index.js +1 -0
  6. package/js/core/envelope.js +5 -1
  7. package/js/factory/bridge.js +2 -2
  8. package/js/session/adapters/_cancelled.js +0 -6
  9. package/js/session/adapters/_chat_completions.js +25 -0
  10. package/js/session/adapters/_output_cap.js +30 -0
  11. package/js/session/adapters/_registry.js +44 -0
  12. package/js/session/adapters/anthropic.js +8 -5
  13. package/js/session/adapters/gemini.js +4 -0
  14. package/js/session/adapters/openai.js +2 -1
  15. package/js/session/run.js +14 -4
  16. package/js/session/run_image.js +12 -3
  17. package/package.json +49 -19
  18. package/src/cli/aliases.js +20 -0
  19. package/src/cli/ask.js +58 -12
  20. package/src/cli/backup.js +2 -1
  21. package/src/cli/check.js +15 -86
  22. package/src/cli/complete.js +130 -0
  23. package/src/cli/default.js +34 -13
  24. package/src/cli/doctor.js +33 -14
  25. package/src/cli/entry.js +173 -0
  26. package/src/cli/index.js +77 -66
  27. package/src/cli/instructions.js +349 -0
  28. package/src/cli/local.js +14 -0
  29. package/src/cli/model.js +184 -37
  30. package/src/cli/onboard.js +186 -121
  31. package/src/cli/rank.js +2 -1
  32. package/src/cli/ratelimit.js +3 -3
  33. package/src/cli/tag.js +2 -0
  34. package/src/lib/assistants.js +93 -0
  35. package/src/lib/catalog/openrouter.js +6 -1
  36. package/src/lib/catalog-review.js +195 -0
  37. package/src/lib/common.js +14 -1
  38. package/src/lib/creators.js +35 -0
  39. package/src/lib/index.js +17 -3
  40. package/src/lib/local-conventions.js +120 -0
  41. package/src/lib/provider-info.js +98 -0
  42. package/src/lib/providers.js +69 -14
  43. package/src/lib/schema.js +15 -3
  44. package/src/lib/select.js +125 -67
  45. package/js/session/adapters/image/index.js +0 -40
package/src/cli/model.js CHANGED
@@ -2,8 +2,14 @@ import mohdel, { silent } from '../lib/index.js'
2
2
  import providerDefs from '../lib/providers.js'
3
3
  import { loadDefaultEnv, getAPIKey, getCuratedModels, saveCuratedModels } from '../lib/common.js'
4
4
  import { fieldDefs } from '../lib/schema.js'
5
+ import { providerOf } from '#core/model-id.js'
5
6
  import { parseJsonFlag, printAvailableFields, jsonOutput, jsonOutputOne } from './json-output.js'
6
- import { id, label, tag, price, meta, err, ok } from './colors.js'
7
+ import { id, label, tag, price, meta, err, warn, ok } from './colors.js'
8
+
9
+ // An empty catalog is the fresh-install state, not an error — but printing
10
+ // nothing at all reads as a broken command.
11
+ export const EMPTY_CATALOG = 'Catalog is empty. "mo curate <provider>" adds a provider\'s models, ' +
12
+ 'then "mo model instructions <provider>" fills in the prices.'
7
13
 
8
14
  // Fields available for --json on model list/show
9
15
  const MODEL_FIELDS = [
@@ -17,11 +23,13 @@ const MODEL_FIELDS = [
17
23
  ]
18
24
 
19
25
  export async function runModel (args) {
26
+ // parseJsonFlag consumes --json in place; delegated actions parse their own.
27
+ const rawArgs = [...args]
20
28
  const jsonFlag = parseJsonFlag(args)
21
29
  const [action, arg1] = args
22
30
 
23
31
  if (!action || action === '-h' || action === '--help') {
24
- console.log(`mohdel model — browse models
32
+ console.log(`mohdel model — browse and maintain the catalog
25
33
 
26
34
  Usage:
27
35
  model list [--json [fields]] List all curated models
@@ -33,17 +41,31 @@ Usage:
33
41
  model set <model> <key> <value> Set a field (custom or reserved)
34
42
  model rm <model> <key> Remove a field
35
43
  model add <provider>/<model-id> Add a model manually (interactive)
36
- model backup list|restore|diff Manage catalog backups (prev/daily/weekly)
37
- model check [--local] [--json] Validate catalog (schema + upstream drift)
44
+ model instructions [provider] Print a brief for your coding agent
45
+ model check [--entry <file|->] Validate catalog or candidate entries
46
+ model apply <file|-> Write reviewed entries to the catalog
47
+ model backup list|restore|diff Catalog backups: prev, daily, weekly
38
48
  model rank [options] Rank models by benchmark performance
39
49
  model bench <model> [options] Benchmark a model with live inference
40
50
  model bench --tag <tag> [options] Benchmark all models with a tag
41
- model curate [provider] Add upstream models to catalog (interactive)
51
+ model curate [provider] Add a provider's models to the catalog
42
52
 
43
53
  Flags:
44
54
  --json List available JSON fields
45
55
  --json f1,f2,f3 Output only selected fields as JSON
46
56
 
57
+ Catalog work with a coding agent:
58
+ mo model instructions openai > mohdel-brief.md
59
+ then start claude / codex / gemini / opencode / cursor-agent on:
60
+ "read mohdel-brief.md, then add gpt-5.6 to my mohdel catalog"
61
+
62
+ The agent drafts mohdel-candidate.json and checks it; you apply it:
63
+
64
+ mo model check --entry mohdel-candidate.json
65
+ mo model apply mohdel-candidate.json ← prints the diff first
66
+
67
+ Launch lines for each agent: mo model instructions --help
68
+
47
69
  Aliases:
48
70
  mo ls model list
49
71
  mo show <model> model show <model>`)
@@ -84,6 +106,7 @@ Aliases:
84
106
  jsonOutput(items.map(m => ({ id: m.id, ...m.info })), jsonFlag.fields)
85
107
  return
86
108
  }
109
+ if (!items.length) { console.log(meta(EMPTY_CATALOG)); return }
87
110
  for (const m of items) {
88
111
  const tags = (m.info.tags || []).map(t => meta(t)).join(meta(', '))
89
112
  const p = formatPrice(m.info)
@@ -182,7 +205,7 @@ ${meta('tags:')} ${(info.tags || []).map(t => tag(t)).join(', ') || meta
182
205
  const curated = await getCuratedModels()
183
206
  if (!curated[resolvedId]) {
184
207
  // Model resolved via fallback but not in curated
185
- console.error(err(`Model '${modelId}' is not in the curated catalog. Use "mo model curate" to add it.`))
208
+ console.error(err(`Model '${modelId}' is not in your catalog. "mo model add ${modelId}" adds it, "mo curate ${providerOf(modelId)}" walks that provider's models.`))
186
209
  process.exit(1)
187
210
  }
188
211
 
@@ -227,7 +250,7 @@ ${meta('tags:')} ${(info.tags || []).map(t => tag(t)).join(', ') || meta
227
250
  const resolvedId = resolved.id
228
251
  const curated = await getCuratedModels()
229
252
  if (!curated[resolvedId]) {
230
- console.error(err(`Model '${modelId}' is not in the curated catalog.`))
253
+ console.error(err(`Model '${modelId}' is not in your catalog.`))
231
254
  process.exit(1)
232
255
  }
233
256
 
@@ -244,7 +267,7 @@ ${meta('tags:')} ${(info.tags || []).map(t => tag(t)).join(', ') || meta
244
267
 
245
268
  if (action === 'backup') {
246
269
  const { runBackup } = await import('./backup.js')
247
- await runBackup(args.slice(1))
270
+ await runBackup(rawArgs.slice(1))
248
271
  return
249
272
  }
250
273
 
@@ -259,7 +282,8 @@ Usage:
259
282
  What it does:
260
283
  1. Resolves <provider> against the known provider list (anthropic, openai, …)
261
284
  2. Pre-fills 'model', 'provider', 'sdk' from that resolution
262
- 3. If your API key is set, fetches upstream model metadata (context, pricing, …)
285
+ 3. Fetches upstream model metadata where the API key is set
286
+ (context, pricing, …)
263
287
  4. Prompts for any missing required field
264
288
 
265
289
  Examples:
@@ -308,7 +332,7 @@ config/curated.example.json for ready-to-copy entries.`)
308
332
  if (apiKey && providerConfig.catalog !== false) {
309
333
  try {
310
334
  const sdkConfig = providerConfig.createConfiguration(apiKey)
311
- const { default: API } = await import(`../lib/sdk/${providerConfig.sdk}.js`)
335
+ const { default: API } = await import(`../lib/catalog/${providerConfig.sdk}.js`)
312
336
  const noop = () => {}
313
337
  const api = API(sdkConfig, {}, { trace: noop, debug: noop, info: noop, warn: noop, error: noop, fatal: noop })
314
338
  if (api.getModelInfo) {
@@ -318,7 +342,19 @@ config/curated.example.json for ready-to-copy entries.`)
318
342
  console.log(meta('Fetched model info from upstream'))
319
343
  }
320
344
  }
321
- } catch {}
345
+ } catch (e) {
346
+ console.log(warn(`upstream lookup failed: ${e.message}`))
347
+ }
348
+ }
349
+
350
+ if (providerConfig.pricesFromApi) {
351
+ console.log(meta(`${providerName} publishes prices in its model list, so they are filled in already.`))
352
+ } else {
353
+ const refs = providerConfig.references
354
+ if (refs) {
355
+ console.log(meta(`Prices and limits are in no provider API — read them at ${refs.pricing || refs.models}`))
356
+ }
357
+ console.log(meta(`Or hand the job to your coding agent: mo model instructions ${providerName}`))
322
358
  }
323
359
 
324
360
  // Interactive prompts for missing fields
@@ -333,19 +369,31 @@ config/curated.example.json for ready-to-copy entries.`)
333
369
 
334
370
  if (action === 'check') {
335
371
  const { runCheck } = await import('./check.js')
336
- await runCheck(args.slice(1))
372
+ await runCheck(rawArgs.slice(1))
373
+ return
374
+ }
375
+
376
+ if (action === 'apply') {
377
+ const { runApply } = await import('./entry.js')
378
+ await runApply(rawArgs.slice(1))
379
+ return
380
+ }
381
+
382
+ if (action === 'instructions') {
383
+ const { runInstructions } = await import('./instructions.js')
384
+ await runInstructions(rawArgs.slice(1))
337
385
  return
338
386
  }
339
387
 
340
388
  if (action === 'rank') {
341
389
  const { runRank } = await import('./rank.js')
342
- await runRank(args.slice(1))
390
+ await runRank(rawArgs.slice(1))
343
391
  return
344
392
  }
345
393
 
346
394
  if (action === 'bench') {
347
395
  const { runBench } = await import('./bench.js')
348
- await runBench(args.slice(1))
396
+ await runBench(rawArgs.slice(1))
349
397
  return
350
398
  }
351
399
 
@@ -367,29 +415,31 @@ Examples:
367
415
  mo curate anthropic
368
416
  mo curate openai
369
417
 
370
- Tip: after curating, fill in the things only you know — prices, contextTokenLimit,
371
- tags, thinkingEffortLevels — with 'mo model set <id> <key> <value>' or by editing
372
- ~/.config/mohdel/curated.json directly. See docs/CATALOG.md for the field reference
373
- and config/curated.example.json for ready-to-copy entries.
418
+ A provider's model list carries ids, not prices. Fill the rest in with
419
+ 'mo model instructions <provider>', which briefs your coding agent, or by hand
420
+ with 'mo model set <id> <key> <value>'. See docs/CATALOG.md for the field
421
+ reference and config/curated.example.json for ready-to-copy entries.
374
422
 
375
423
  Requires an API key for the chosen provider — run 'mo' to configure one.`)
376
424
  process.exit(0)
377
425
  }
378
- const { initializeAPIs, processModels } = await import('../lib/select.js')
379
- const { api, providersWithKeys } = await initializeAPIs()
426
+ const { providerApi, providersWithKeys, processModels } = await import('../lib/select.js')
427
+ const withKeys = providersWithKeys()
380
428
 
381
- if (!providersWithKeys.length) {
429
+ if (!withKeys.length) {
382
430
  console.error(err('No providers with API keys configured. Run "mo" to set up.'))
383
431
  process.exit(1)
384
432
  }
385
433
 
386
- // mo model curate <provider> — curate specific provider
434
+ // mo model curate <provider> — only that provider's client is built
387
435
  if (arg1) {
388
- if (!api[arg1]) {
389
- console.error(err(`Provider "${arg1}" not found or no API key. Available: ${providersWithKeys.join(', ')}`))
436
+ const api = await providerApi(arg1)
437
+ if (!api) {
438
+ console.error(err(`Provider "${arg1}" not found or no API key. Available: ${withKeys.join(', ')}`))
390
439
  process.exit(1)
391
440
  }
392
- await processModels(arg1, api[arg1])
441
+ if (!await processModels(arg1, api)) process.exit(1)
442
+ printCurateNext(arg1)
393
443
  return
394
444
  }
395
445
 
@@ -397,10 +447,11 @@ Requires an API key for the chosen provider — run 'mo' to configure one.`)
397
447
  const { select, isCancel } = await import('@clack/prompts')
398
448
  const selected = await select({
399
449
  message: 'Select a provider to curate:',
400
- options: providersWithKeys.map(name => ({ value: name, label: name }))
450
+ options: withKeys.map(name => ({ value: name, label: name }))
401
451
  })
402
452
  if (isCancel(selected)) return
403
- await processModels(selected, api[selected])
453
+ if (!await processModels(selected, await providerApi(selected))) process.exit(1)
454
+ printCurateNext(selected)
404
455
  return
405
456
  }
406
457
 
@@ -412,25 +463,48 @@ export async function runProvider (args) {
412
463
  const jsonFlag = parseJsonFlag(args)
413
464
  const [action, arg1] = args
414
465
 
466
+ if (action === '-h' || action === '--help') {
467
+ console.log(`mohdel provider — providers and their API keys
468
+
469
+ Usage:
470
+ provider list [--json] List every provider mohdel can route to
471
+ provider list <provider> List your catalog entries for one provider
472
+ provider models <provider> List the models your key reaches upstream
473
+ provider setup <provider> Paste an API key (interactive)
474
+ provider rm <provider> Remove an API key
475
+
476
+ A provider is who serves the call. Who trained the model is its creator —
477
+ see "mo creator --help".`)
478
+ process.exit(0)
479
+ }
480
+
415
481
  const mo = await mohdel({ logger: silent })
416
482
  const all = mo.list()
417
483
 
418
484
  if ((!action || action === 'list') && !arg1) {
419
485
  loadDefaultEnv()
420
- const providerMap = new Map()
486
+ // Every provider mohdel can route to, not just the ones the catalog
487
+ // happens to mention — on a fresh install the catalog is empty and this
488
+ // list is how you find out what there is to curate.
489
+ const counts = new Map()
421
490
  for (const m of all) {
422
491
  const info = mo.use(m.value).info()
423
492
  if (!info.provider) continue
424
- if (!providerMap.has(info.provider)) providerMap.set(info.provider, 0)
425
- providerMap.set(info.provider, providerMap.get(info.provider) + 1)
493
+ counts.set(info.provider, (counts.get(info.provider) || 0) + 1)
426
494
  }
427
- const rows = [...providerMap.entries()]
428
- .sort((a, b) => a[0].localeCompare(b[0]))
429
- .map(([provider, count]) => {
495
+ const rows = Object.keys(providerDefs)
496
+ .sort((a, b) => a.localeCompare(b))
497
+ .map(provider => {
430
498
  const def = providerDefs[provider]
431
- const hasKey = def?.apiKeyEnv ? !!getAPIKey(def.apiKeyEnv) : null
499
+ const hasKey = def.apiKeyEnv ? !!getAPIKey(def.apiKeyEnv) : null
432
500
  const rl = mo.getProviderRateLimit(provider)
433
- return { provider, count, hasKey, rpmLimit: rl?.rpmLimit || null, tpmLimit: rl?.tpmLimit || null }
501
+ return {
502
+ provider,
503
+ count: counts.get(provider) || 0,
504
+ hasKey,
505
+ rpmLimit: rl?.rpmLimit || null,
506
+ tpmLimit: rl?.tpmLimit || null
507
+ }
434
508
  })
435
509
 
436
510
  if (jsonFlag.json && !jsonFlag.fields) {
@@ -441,13 +515,17 @@ export async function runProvider (args) {
441
515
  jsonOutput(rows, jsonFlag.fields)
442
516
  return
443
517
  }
518
+ const nameWidth = Math.max(...rows.map(r => r.provider.length))
519
+ const countWidth = Math.max(...rows.map(r => String(r.count).length))
444
520
  for (const row of rows) {
445
521
  const dot = row.hasKey === null ? ' ' : row.hasKey ? ok('●') : meta('○')
446
522
  const rl = []
447
523
  if (row.rpmLimit) rl.push(`rpm=${row.rpmLimit}`)
448
524
  if (row.tpmLimit) rl.push(`tpm=${row.tpmLimit}`)
449
- const rlStr = rl.length ? meta(rl.join(' ')) : ''
450
- console.log(` ${dot} ${id(row.provider)} ${meta(`(${row.count} models)`)} ${rlStr}`)
525
+ const models = `${String(row.count).padStart(countWidth)} model${row.count === 1 ? '' : 's'}`
526
+ const cells = [id(row.provider.padEnd(nameWidth)), meta(models)]
527
+ if (rl.length) cells.push(meta(rl.join(' ')))
528
+ console.log(` ${dot} ${cells.join(' ')}`)
451
529
  }
452
530
  const hasUnconfigured = rows.some(r => r.hasKey === false)
453
531
  console.log(`\n${meta('Next:')} mo provider show <name> ${meta('│')} mo curate <name>` +
@@ -478,6 +556,48 @@ export async function runProvider (args) {
478
556
  return
479
557
  }
480
558
 
559
+ if (action === 'models') {
560
+ if (!arg1) { console.error('Usage: provider models <provider> [--json]'); process.exit(1) }
561
+ const providerConfig = providerDefs[arg1]
562
+ if (!providerConfig) {
563
+ console.error(err(`Unknown provider: ${arg1}. Known: ${Object.keys(providerDefs).join(', ')}`))
564
+ process.exit(1)
565
+ }
566
+ loadDefaultEnv()
567
+ const { providerApi } = await import('../lib/select.js')
568
+ const api = await providerApi(arg1)
569
+ if (!api?.listModels) {
570
+ const why = providerConfig.catalog === false || !providerConfig.apiKeyEnv
571
+ ? `${arg1} publishes no model list`
572
+ : `no API key for ${arg1} — run "mo provider setup ${arg1}"`
573
+ console.error(err(why))
574
+ process.exit(1)
575
+ }
576
+
577
+ let upstream
578
+ try {
579
+ upstream = await api.listModels()
580
+ } catch (e) {
581
+ console.error(err(`${arg1}: ${e.message}`))
582
+ process.exit(1)
583
+ }
584
+
585
+ const curated = await getCuratedModels()
586
+ const rows = upstream.map(m => ({ id: m.id, label: m.label, curated: !!curated[`${arg1}/${m.id}`] }))
587
+
588
+ if (jsonFlag.json && !jsonFlag.fields) { printAvailableFields(['id', 'label', 'curated']); return }
589
+ if (jsonFlag.json) { jsonOutput(rows, jsonFlag.fields); return }
590
+
591
+ const width = Math.max(...rows.map(r => r.id.length))
592
+ for (const r of rows) {
593
+ console.log(` ${r.curated ? ok('●') : meta('○')} ${id(r.id.padEnd(width))} ${label(r.label)}`)
594
+ }
595
+ const fresh = rows.filter(r => !r.curated).length
596
+ console.log(`\n${meta(`${rows.length} upstream, ${fresh} not in your catalog`)}`)
597
+ console.log(`${meta('Next:')} mo model instructions ${arg1} ${meta('│')} mo curate ${arg1}`)
598
+ return
599
+ }
600
+
481
601
  if (action === 'setup') {
482
602
  if (!arg1) { console.error('Usage: provider setup <provider>'); process.exit(1) }
483
603
  const providerConfig = providerDefs[arg1]
@@ -535,6 +655,19 @@ export async function runCreator (args) {
535
655
  const jsonFlag = parseJsonFlag(args)
536
656
  const [action, arg1] = args
537
657
 
658
+ if (action === '-h' || action === '--help') {
659
+ console.log(`mohdel creator — who trained the model
660
+
661
+ Usage:
662
+ creator list [--json] List every creator in your catalog
663
+ creator list <creator> List that creator's models
664
+ creator show <creator> Same, by name
665
+
666
+ One creator is hosted by many providers: Cerebras and Groq both serve
667
+ Alibaba's Qwen. Routing follows the provider — see "mo provider --help".`)
668
+ process.exit(0)
669
+ }
670
+
538
671
  const mo = await mohdel({ logger: silent })
539
672
  const all = mo.list()
540
673
 
@@ -557,6 +690,7 @@ export async function runCreator (args) {
557
690
  jsonOutput(items, jsonFlag.fields)
558
691
  return
559
692
  }
693
+ if (!creators.size) { console.log(meta(EMPTY_CATALOG)); return }
560
694
  for (const [name, count] of [...creators.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
561
695
  console.log(`${id(name)} ${meta(`(${count} models)`)}`)
562
696
  }
@@ -590,6 +724,19 @@ export async function runCreator (args) {
590
724
  process.exit(1)
591
725
  }
592
726
 
727
+ // The provider API returns ids, never prices — curated entries land unpriced.
728
+ function printCurateNext (providerName) {
729
+ const def = providerDefs[providerName]
730
+ if (def?.pricesFromApi) {
731
+ console.log(`\n${meta(`${providerName} publishes prices in its model list — the entries are complete.`)}`)
732
+ console.log(`${meta('Check them:')} mo ls ${meta('│')} mo check`)
733
+ return
734
+ }
735
+ const refs = def?.references
736
+ if (refs) console.log(`\n${meta('Prices and limits are in no provider API — read them at')} ${refs.pricing || refs.models}`)
737
+ console.log(`${meta('Or hand the job to your coding agent:')} mo model instructions ${providerName}`)
738
+ }
739
+
593
740
  // Auto-detect value type from string input
594
741
  function coerceValue (key, raw) {
595
742
  if (raw === 'true') return true