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
@@ -1,97 +1,11 @@
1
- import { intro, outro, select, text, isCancel, cancel, note } from '@clack/prompts'
1
+ import { intro, outro, select, text, isCancel, cancel, note, spinner } from '@clack/prompts'
2
2
  import { id, label, meta, ok } from './colors.js'
3
- import { chmodSync, existsSync } from 'fs'
3
+ import { chmodSync, existsSync, readFileSync } from 'fs'
4
4
  import { readFile, writeFile, mkdir } from 'fs/promises'
5
- import { dirname } from 'path'
6
- import { loadDefaultEnv, getAPIKey, ENV_PATH } from '../lib/common.js'
5
+ import { dirname, resolve } from 'path'
6
+ import { loadDefaultEnv, getAPIKey, getConfig, saveConfig, getCuratedModels, catalogEntries, ENV_PATH } from '../lib/common.js'
7
7
  import providers from '../lib/providers.js'
8
-
9
- const PROVIDER_INFO = {
10
- gemini: {
11
- label: 'Google Gemini',
12
- description: 'Gemini 2.5/3 — long context, vision, video. Free tier, no card required.',
13
- url: 'https://aistudio.google.com/apikey',
14
- hint: 'Create an API key at aistudio.google.com → Get API Key',
15
- free: true
16
- },
17
- groq: {
18
- label: 'Groq',
19
- description: 'Llama 4 — fastest inference available. Free tier, no card required.',
20
- url: 'https://console.groq.com/keys',
21
- hint: 'Create an API key at console.groq.com → API Keys',
22
- free: true
23
- },
24
- cerebras: {
25
- label: 'Cerebras',
26
- description: 'Llama, Qwen — fast inference on custom hardware. Free tier available.',
27
- url: 'https://cloud.cerebras.ai/platform',
28
- hint: 'Create an API key at cloud.cerebras.ai → Platform → API Keys',
29
- free: true
30
- },
31
- anthropic: {
32
- label: 'Anthropic',
33
- description: 'Claude Opus, Sonnet, Haiku — reasoning, coding, vision, tool use.',
34
- url: 'https://console.anthropic.com/settings/keys',
35
- hint: 'Create an API key at console.anthropic.com → Settings → API Keys',
36
- free: false
37
- },
38
- openai: {
39
- label: 'OpenAI',
40
- description: 'GPT-5, o-series — reasoning, vision, image generation.',
41
- url: 'https://platform.openai.com/api-keys',
42
- hint: 'Create an API key at platform.openai.com → API Keys',
43
- free: false
44
- },
45
- xai: {
46
- label: 'xAI',
47
- description: 'Grok — reasoning and tool use.',
48
- url: 'https://console.x.ai',
49
- hint: 'Create an API key at console.x.ai',
50
- free: false
51
- },
52
- mistral: {
53
- label: 'Mistral',
54
- description: 'Mistral Large, Codestral, Pixtral — coding, reasoning, vision. Free tier available.',
55
- url: 'https://console.mistral.ai/api-keys',
56
- hint: 'Create an API key at console.mistral.ai → API Keys',
57
- free: true
58
- },
59
- deepseek: {
60
- label: 'DeepSeek',
61
- description: 'DeepSeek R1/V3 — reasoning, coding. Low cost.',
62
- url: 'https://platform.deepseek.com/api_keys',
63
- hint: 'Create an API key at platform.deepseek.com → API Keys',
64
- free: false
65
- },
66
- fireworks: {
67
- label: 'Fireworks',
68
- description: 'Llama, Qwen, DeepSeek — serverless inference with reasoning.',
69
- url: 'https://fireworks.ai/account/api-keys',
70
- hint: 'Create an API key at fireworks.ai → Account → API Keys',
71
- free: false
72
- },
73
- openrouter: {
74
- label: 'OpenRouter',
75
- description: 'Multi-provider router — access 200+ models with one key.',
76
- url: 'https://openrouter.ai/settings/keys',
77
- hint: 'Create an API key at openrouter.ai → Settings → Keys',
78
- free: false
79
- },
80
- novita: {
81
- label: 'Novita',
82
- description: 'Image generation — Flux, SDXL.',
83
- url: 'https://novita.ai/dashboard/key',
84
- hint: 'Create an API key at novita.ai → Dashboard → API Key',
85
- free: false
86
- },
87
- qwen: {
88
- label: 'Qwen Cloud',
89
- description: 'Qwen 3.7 Max/Plus, 3.6 Flash — reasoning, coding, long context. Free quota for new users.',
90
- url: 'https://home.qwencloud.com/api-keys',
91
- hint: 'Create an API key at home.qwencloud.com → API Keys',
92
- free: false
93
- }
94
- }
8
+ import PROVIDER_INFO from '../lib/provider-info.js'
95
9
 
96
10
  export { PROVIDER_INFO, appendToEnvFile }
97
11
 
@@ -138,26 +52,54 @@ export async function runOnboard () {
138
52
  loadDefaultEnv()
139
53
  const { configured, unconfigured } = getConfiguredProviders()
140
54
 
141
- // Has providers configured — show status
55
+ // Already set up: a status line and the step that follows from it, not a
56
+ // menu. `mo --help` is the menu.
142
57
  if (configured.length > 0) {
143
- console.log(label('mohdel') + meta(` — ${configured.length} provider${configured.length > 1 ? 's' : ''} configured\n`))
144
- for (const name of configured) {
145
- console.log(` ${ok('●')} ${id(name)}`)
58
+ const curated = await getCuratedModels()
59
+ const active = catalogEntries(curated).filter(([, s]) => !s.deprecated)
60
+ const unpriced = active.filter(([, s]) => s.inputPrice == null).length
61
+
62
+ const catalog = active.length === 0
63
+ ? 'catalog empty'
64
+ : `${active.length} model${active.length > 1 ? 's' : ''}` +
65
+ (unpriced ? `, ${unpriced} without prices` : '')
66
+ const shown = configured.slice(0, 4).map(n => id(n)).join(meta(', '))
67
+ const more = configured.length > 4 ? meta(` +${configured.length - 4} more`) : ''
68
+ console.log(`${label('mohdel')} ${meta('—')} ${shown}${more} ${meta('·')} ${meta(catalog)}\n`)
69
+
70
+ const first = configured[0]
71
+ const next = []
72
+ let handOver = null
73
+ if (active.length === 0) {
74
+ next.push([`mo curate ${first}`, 'add the models this key can reach'])
75
+ // An interrupted run leaves the brief behind. Telling someone to write
76
+ // one that is already sitting in front of them is the wrong step.
77
+ const { BRIEF_FILE, BRIEF_HEADING } = await import('./instructions.js')
78
+ const briefPath = resolve(process.cwd(), BRIEF_FILE)
79
+ const briefReady = existsSync(briefPath) &&
80
+ readFileSync(briefPath, 'utf8').startsWith(BRIEF_HEADING)
81
+ if (briefReady) {
82
+ const { detectAssistants, preferredAgent, briefPrompt } = await import('../lib/assistants.js')
83
+ const agent = preferredAgent((await getConfig()).assistant, detectAssistants())
84
+ handOver = { file: BRIEF_FILE, line: agent.start(`"${briefPrompt(BRIEF_FILE, first)}"`) }
85
+ } else {
86
+ next.push([`mo model instructions ${first}`, 'let your agent fill in the prices'])
87
+ }
88
+ } else {
89
+ if (unpriced) next.push([`mo model instructions ${first}`, 'fill in the missing prices'])
90
+ next.push(['mo ask <model> "..."', 'one-shot inference'])
91
+ next.push(['mo ls', 'browse the catalog'])
146
92
  }
147
93
  if (unconfigured.length) {
148
- console.log('')
149
- for (const name of unconfigured) {
150
- console.log(` ${meta('○')} ${meta(name)}`)
151
- }
94
+ next.push(['mo providers', `${unconfigured.length} more providers available`])
95
+ }
96
+ next.push(['mo --help', 'everything else'])
97
+
98
+ const width = Math.max(...next.map(([cmd]) => cmd.length))
99
+ for (const [cmd, why] of next) console.log(` ${id(cmd.padEnd(width))} ${meta(why)}`)
100
+ if (handOver) {
101
+ console.log(`\n ${meta(`${handOver.file} is written — hand it over:`)}\n ${id(handOver.line)}`)
152
102
  }
153
- console.log(`\n${meta('Commands:')}
154
- mo ask <model> "..." One-shot inference (pipeable)
155
- mo doctor Check install health
156
- mo model list Browse curated models
157
- mo model show <model> Model details
158
- mo default Set default model
159
- mo provider setup <p> Add another provider
160
- mo --help All commands`)
161
103
  return
162
104
  }
163
105
 
@@ -221,27 +163,150 @@ export async function runOnboard () {
221
163
 
222
164
  note(`${ok('✓')} Saved ${envVar} to ${meta(ENV_PATH)}`, 'Done')
223
165
 
224
- // Reload env so the new key is visible, then offer to curate models
166
+ // Reload env so the new key is visible, then fill the catalog.
225
167
  loadDefaultEnv()
226
- const { confirm } = await import('@clack/prompts')
227
- const shouldCurate = await confirm({
228
- message: `Fetch and curate models from ${info.label}?`,
229
- initialValue: true
230
- })
231
168
 
232
- if (isCancel(shouldCurate) || !shouldCurate) {
233
- outro(`Run ${id('mo model curate ' + selected)} later to browse available models.`)
169
+ // OpenRouter's model list carries prices, so picking by hand is complete
170
+ // there and needs no agent at all.
171
+ const selfPricing = !!providers[selected]?.pricesFromApi
172
+
173
+ // Where the prices are published, the models that cost nothing are the whole
174
+ // offer for someone with no coding agent. The count belongs on the menu: put
175
+ // it behind a search over hundreds of ids and nobody finds it.
176
+ let api = null
177
+ let free = []
178
+ if (selfPricing) {
179
+ const { providerApi, freeModels } = await import('../lib/select.js')
180
+ api = await providerApi(selected)
181
+ if (api?.listModels) {
182
+ const s = spinner()
183
+ s.start(`Reading ${info.label}'s model list...`)
184
+ try {
185
+ free = freeModels(await api.listModels())
186
+ s.stop(`${info.label}: ${free.length} of its models cost nothing`)
187
+ } catch (e) {
188
+ s.stop(`Could not read ${info.label}'s model list: ${e.message}`)
189
+ }
190
+ }
191
+ }
192
+
193
+ const how = await select({
194
+ message: 'How do you want to fill your catalog?',
195
+ initialValue: free.length ? 'free' : selfPricing ? 'hand' : 'agent',
196
+ options: selfPricing
197
+ ? [
198
+ ...(free.length
199
+ ? [{ value: 'free', label: `Add the free models (${free.length})`, hint: 'nothing to pay, nothing to type' }]
200
+ : []),
201
+ { value: 'hand', label: 'Pick models by hand', hint: `${info.label} publishes prices — nothing else needed` },
202
+ { value: 'agent', label: 'Let my coding agent do it', hint: 'for anything the model list omits' },
203
+ { value: 'later', label: 'Later' }
204
+ ]
205
+ : [
206
+ { value: 'agent', label: 'Let my coding agent do it', hint: 'fetches the model list and the prices' },
207
+ { value: 'hand', label: 'Pick models by hand', hint: 'ids only — prices left for later' },
208
+ { value: 'later', label: 'Later' }
209
+ ]
210
+ })
211
+ if (isCancel(how) || how === 'later') {
212
+ outro(`Later: ${id('mo model instructions ' + selected)} ${meta('│')} ${id('mo curate ' + selected)}`)
234
213
  return
235
214
  }
236
215
 
237
- const { initializeAPIs, processModels } = await import('../lib/select.js')
238
- const { api } = await initializeAPIs()
216
+ if (how === 'free') {
217
+ const { addModels } = await import('../lib/select.js')
218
+ const s = spinner()
219
+ s.start(`Adding ${free.length} model${free.length > 1 ? 's' : ''}...`)
220
+ await addModels(selected, api, free)
221
+ s.stop(`Added ${free.length} model${free.length > 1 ? 's' : ''}, priced at $0`)
222
+ outro(`Ready — ${id('mo ls')} lists them, then ${id('mo ask <model> "…"')}. ` +
223
+ `${id('mo curate ' + selected)} adds the paid ones.`)
224
+ return
225
+ }
239
226
 
240
- if (!api[selected]) {
241
- outro(`Could not initialize ${info.label}. Run ${id('mo model curate ' + selected)} to retry.`)
227
+ if (how === 'hand') {
228
+ const { providerApi, processModels } = await import('../lib/select.js')
229
+ api = api || await providerApi(selected)
230
+ if (!api) {
231
+ outro(`Could not reach ${info.label}. Run ${id('mo curate ' + selected)} to retry.`)
232
+ return
233
+ }
234
+ if (!await processModels(selected, api)) {
235
+ outro(`Nothing curated. Retry with ${id('mo curate ' + selected)}.`)
236
+ return
237
+ }
238
+ outro(selfPricing
239
+ ? `Catalog complete — ${id('mo ls')} to see it, then ${id('mo ask <model> "…"')}.`
240
+ : `Prices are still missing — ${id('mo model instructions ' + selected)} fills them in.`)
242
241
  return
243
242
  }
244
243
 
245
- await processModels(selected, api[selected])
246
- outro(`Run ${id('mo model list')} to see your curated models.`)
244
+ await writeCatalogBrief(selected)
245
+ }
246
+
247
+ // The provider API returns model ids, never prices — entries land unpriced and
248
+ // `cost` stays 0 on every result until the numbers are filled in. This is the
249
+ // moment the user is still in the flow.
250
+ async function writeCatalogBrief (selected) {
251
+ const { AGENTS, detectAssistants, preferredAgent, briefPrompt } = await import('../lib/assistants.js')
252
+ const installed = detectAssistants()
253
+
254
+ const stored = (await getConfig()).assistant
255
+ if (!installed.length && !stored) {
256
+ note(
257
+ `Nothing on your PATH looks like a coding agent. Mohdel does not ship
258
+ one — Claude Code, Codex CLI and opencode all install from npm.
259
+
260
+ Whichever you use has to be able to fetch a web page: it is being sent to
261
+ read the provider's pricing page.
262
+
263
+ To skip the agent entirely, use OpenRouter: it publishes prices in its own
264
+ model list, so "mo curate openrouter" writes a complete catalog by itself.`,
265
+ 'You will need an agent'
266
+ )
267
+ }
268
+ const chosen = await select({
269
+ message: 'Which coding agent do you use?',
270
+ initialValue: stored || installed[0] || AGENTS[0].bin,
271
+ options: [
272
+ ...AGENTS.map(a => ({
273
+ value: a.bin,
274
+ label: a.label,
275
+ hint: installed.includes(a.bin) ? 'found on your PATH' : undefined
276
+ })),
277
+ { value: '__other', label: 'Other…', hint: 'any command that takes a prompt' }
278
+ ]
279
+ })
280
+ if (isCancel(chosen)) return
281
+
282
+ let bin = chosen
283
+ if (chosen === '__other') {
284
+ const typed = await text({
285
+ message: 'Command that starts it:',
286
+ placeholder: 'my-agent',
287
+ validate: (v) => v?.trim() ? undefined : 'A command is required'
288
+ })
289
+ if (isCancel(typed)) return
290
+ bin = typed.trim()
291
+ }
292
+
293
+ const config = await getConfig()
294
+ await saveConfig({ ...config, assistant: bin })
295
+
296
+ const { writeBrief } = await import('./instructions.js')
297
+ const name = await writeBrief(selected)
298
+
299
+ const agent = preferredAgent(bin, installed)
300
+ const prompt = `"${briefPrompt(name, selected)}"`
301
+ note(
302
+ `${ok('✓')} Wrote ${meta(name)} in this directory.
303
+
304
+ ${agent.start(prompt)}
305
+
306
+ The agent drafts mohdel-candidate.json and checks it with
307
+ ${meta('mo model check --entry mohdel-candidate.json')}; you apply it with
308
+ ${meta('mo model apply mohdel-candidate.json')}, which shows the diff first.`,
309
+ `Hand it to ${agent.label}`
310
+ )
311
+ outro(`Launch lines for other agents: ${id('mo model instructions --help')}`)
247
312
  }
package/src/cli/rank.js CHANGED
@@ -15,7 +15,8 @@ Usage:
15
15
  model rank [options]
16
16
 
17
17
  Options:
18
- --use-case <name> Weight preset: ${USE_CASES.join(', ')} (default: balanced)
18
+ --use-case <name> Weight preset (default: balanced)
19
+ ${USE_CASES.join(', ')}
19
20
  --top N Number of results (default: 20)
20
21
  --breakdown, -b Show per-group sub-scores
21
22
  --all Include all upstream models (default: curated only)
@@ -19,9 +19,9 @@ Usage:
19
19
  ratelimit provider rm <provider> Remove provider-level limits
20
20
 
21
21
  Examples:
22
- ratelimit show anthropic Show provider limits
23
- ratelimit show gemini/gemini-2.0-flash Show model limits (with provider fallback)
24
- ratelimit set gemini/gemini-2.0-flash 15 1000000
22
+ ratelimit show anthropic Provider limits
23
+ ratelimit show gemini/gemini-flash-latest Model limits, then provider
24
+ ratelimit set gemini/gemini-flash-latest 15 1000000
25
25
  ratelimit provider set anthropic 60 100000
26
26
 
27
27
  Aliases:
package/src/cli/tag.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import mohdel, { silent } from '../lib/index.js'
2
2
  import { parseJsonFlag, jsonOutput } from './json-output.js'
3
3
  import { id, label, tag, meta, err } from './colors.js'
4
+ import { EMPTY_CATALOG } from './model.js'
4
5
 
5
6
  // CLI logger: silent for noisy levels, console.error for errors and fatals.
6
7
  const cliLogger = { ...silent, error: console.error, fatal: console.error }
@@ -52,6 +53,7 @@ Usage:
52
53
  jsonOutput(sorted.map(t => ({ tag: t })), jsonFlag.fields)
53
54
  return
54
55
  }
56
+ if (!sorted.length) { console.log(meta(EMPTY_CATALOG)); return }
55
57
  for (const t of sorted) console.log(tag(t))
56
58
  return
57
59
  }
@@ -0,0 +1,93 @@
1
+ import { statSync } from 'node:fs'
2
+ import { join, delimiter } from 'node:path'
3
+
4
+ // Launch forms are the vendor-documented ones for starting a session on an
5
+ // initial prompt — not the one-shot/print flags, since picking a model is a
6
+ // conversation.
7
+ const AGENTS = [
8
+ { bin: 'claude', label: 'Claude Code', start: p => `claude ${p}` },
9
+ { bin: 'codex', label: 'Codex CLI', start: p => `codex ${p}` },
10
+ { bin: 'gemini', label: 'Gemini CLI', start: p => `gemini -i ${p}` },
11
+ { bin: 'opencode', label: 'opencode', start: p => `opencode --prompt ${p}` },
12
+ { bin: 'cursor-agent', label: 'Cursor CLI', start: p => `cursor-agent ${p}`, note: "installs as 'agent' on some platforms" },
13
+ { bin: 'aider', label: 'Aider', start: p => `aider --message ${p}`, note: 'sends one message, then exits' }
14
+ ]
15
+
16
+ const isExecutable = (path) => {
17
+ try {
18
+ const stat = statSync(path)
19
+ return stat.isFile() && (stat.mode & 0o111) !== 0
20
+ } catch {
21
+ return false
22
+ }
23
+ }
24
+
25
+ export const detectAssistants = () => {
26
+ const dirs = (process.env.PATH || '').split(delimiter).filter(Boolean)
27
+ return AGENTS.map(a => a.bin).filter(bin => dirs.some(dir => isExecutable(join(dir, bin))))
28
+ }
29
+
30
+ const TOP = AGENTS.slice(0, 5)
31
+
32
+ export const launchLines = (installed = detectAssistants(), chosen = null, indent = ' ') => {
33
+ const rows = chosen && !AGENTS.some(a => a.bin === chosen) ? [custom(chosen), ...TOP] : TOP
34
+ const width = Math.max(...rows.map(a => a.start('"<prompt>"').length))
35
+ return rows.map(a => {
36
+ const mark = a.bin === chosen
37
+ ? '← yours'
38
+ : installed.includes(a.bin) ? '← on your PATH' : null
39
+ const marks = [mark, a.note].filter(Boolean)
40
+ const line = indent + a.start('"<prompt>"')
41
+ return marks.length ? `${line.padEnd(indent.length + width + 3)}${marks.join('; ')}` : line
42
+ })
43
+ }
44
+
45
+ // A binary the user named that mohdel has no entry for. Passing the prompt as
46
+ // one argument is the form every agent CLI accepts.
47
+ const custom = (bin) => ({ bin, label: bin, start: p => `${bin} ${p}` })
48
+
49
+ // Which agent the recipe should name. A stated preference beats detection —
50
+ // PATH cannot see which of two installed agents someone actually uses, nor an
51
+ // agent that lives in an editor.
52
+ export const preferredAgent = (chosen, installed = detectAssistants()) => {
53
+ if (chosen) return AGENTS.find(a => a.bin === chosen) || custom(chosen)
54
+ return TOP.find(a => installed.includes(a.bin)) || TOP[0]
55
+ }
56
+
57
+ export const handoff = (provider, installed = detectAssistants(), chosen = null) => {
58
+ const scope = provider ? ` ${provider}` : ''
59
+
60
+ const none = installed.length === 0 && !chosen
61
+ ? `
62
+
63
+ Nothing on your PATH looks like a coding agent. Mohdel does not ship one;
64
+ Claude Code, Codex CLI and opencode all install from npm.
65
+
66
+ You can also skip the agent entirely: OpenRouter publishes prices in its own
67
+ model list, so 'mo curate openrouter' writes a complete catalog with no brief
68
+ and no pricing page. It has a free tier and needs no card.`
69
+ : ''
70
+
71
+ return `This brief is written for a coding agent, not for you.${none}
72
+
73
+ 1. mo model instructions${scope} > mohdel-brief.md
74
+
75
+ 2. start your coding agent on <prompt>:
76
+
77
+ ${briefPrompt('mohdel-brief.md', provider)}
78
+
79
+ ${launchLines(installed, chosen).join('\n')}
80
+
81
+ Any other agent works too, with one requirement: it must be able to fetch a
82
+ web page. The brief sends it to the provider's pricing page, and an agent
83
+ that cannot read one will either stop or guess.
84
+
85
+ 3. it drafts mohdel-candidate.json and checks it with
86
+ 'mo model check --entry mohdel-candidate.json', then hands you
87
+ 'mo model apply mohdel-candidate.json' — which shows the diff before writing.`
88
+ }
89
+
90
+ export const briefPrompt = (file, provider) =>
91
+ `read ${file} and add ${provider ? provider + ' ' : ''}models to my mohdel catalog`
92
+
93
+ export { AGENTS }
@@ -21,7 +21,12 @@ export default (sdkConfig) => {
21
21
  return cache
22
22
  }
23
23
  return {
24
- listModels: async () => (await load()).map(m => ({ id: m.id, label: m.name || m.id })),
24
+ listModels: async () => (await load()).map(m => ({
25
+ id: m.id,
26
+ label: m.name || m.id,
27
+ inputPrice: toPerMillion(m.pricing?.prompt),
28
+ outputPrice: toPerMillion(m.pricing?.completion)
29
+ })),
25
30
  getModelInfo: async (id) => {
26
31
  const m = (await load()).find(x => x.id === id)
27
32
  if (!m) return null
@@ -0,0 +1,195 @@
1
+ import providers from './providers.js'
2
+ import { validate, isValidTag, stripComputed } from './schema.js'
3
+ import { isMetaKey, catalogEntries } from './common.js'
4
+ import { SPEED_LANES } from '../../js/session/adapters/_registry.js'
5
+ import { catalogKey } from '../../js/core/model-id.js'
6
+
7
+ const sortDeep = (value) => {
8
+ if (Array.isArray(value)) return value.map(sortDeep)
9
+ if (value && typeof value === 'object') {
10
+ const sorted = {}
11
+ for (const key of Object.keys(value).sort()) sorted[key] = sortDeep(value[key])
12
+ return sorted
13
+ }
14
+ return value
15
+ }
16
+
17
+ const same = (a, b) => JSON.stringify(sortDeep(a)) === JSON.stringify(sortDeep(b))
18
+
19
+ const TYPE_OF = (value) => Array.isArray(value) ? 'array' : typeof value
20
+
21
+ const reviewLocal = (key, spec, local) => {
22
+ const errors = []
23
+ const warnings = []
24
+
25
+ for (const [field, def] of Object.entries(local.fields)) {
26
+ const value = spec[field]
27
+ if (value === undefined) continue
28
+ if (TYPE_OF(value) !== def.type) {
29
+ errors.push(`${key}: ${field} — expected ${def.type}, got ${TYPE_OF(value)} (local field)`)
30
+ }
31
+ }
32
+
33
+ for (const tag of spec.tags || []) {
34
+ const def = local.tags[tag]
35
+ if (!def) continue
36
+ const missing = def.requires.filter(field => spec[field] === undefined)
37
+ if (!missing.length) continue
38
+ const message = `${key}: tag '${tag}' requires ${missing.join(', ')} — ${def.description}`
39
+ if (def.severity === 'error') errors.push(message)
40
+ else warnings.push(message)
41
+ }
42
+
43
+ return { errors, warnings }
44
+ }
45
+
46
+ export const reviewEntry = (key, spec, catalog, { strict = false, local = null } = {}) => {
47
+ const errors = []
48
+ const warnings = []
49
+ const [keyProvider] = key.split('/')
50
+
51
+ if (catalogKey(key) !== key) {
52
+ errors.push(`${key}: catalog key carries a ':effort' or '@speed' suffix — those are call-time, not entry keys`)
53
+ }
54
+
55
+ if (spec.deprecated) {
56
+ if (!catalog[spec.deprecated]) {
57
+ errors.push(`${key}: deprecated target '${spec.deprecated}' not in curated`)
58
+ }
59
+ return { errors, warnings }
60
+ }
61
+
62
+ for (const issue of validate(spec, key, { strict })) {
63
+ // Custom fields are namespaced by convention (docs/CATALOG.md) and
64
+ // round-trip untouched, so they are not drift.
65
+ if (issue.message === 'unknown field' && issue.field.includes(':')) continue
66
+ if (issue.message === 'unknown field' && local?.fields[issue.field]) continue
67
+ if (issue.severity === 'error') errors.push(`${key}: ${issue.field} — ${issue.message}`)
68
+ else warnings.push(`${key}: ${issue.field} — ${issue.message}`)
69
+ }
70
+
71
+ const providerConfig = providers[keyProvider]
72
+ if (!providerConfig) {
73
+ errors.push(`${key}: provider '${keyProvider}' not in providers.js`)
74
+ }
75
+ if (spec.provider && spec.provider !== keyProvider) {
76
+ errors.push(`${key}: spec.provider '${spec.provider}' doesn't match key prefix '${keyProvider}'`)
77
+ }
78
+ if (providerConfig && spec.sdk && spec.sdk !== providerConfig.sdk) {
79
+ errors.push(`${key}: spec.sdk '${spec.sdk}' doesn't match provider sdk '${providerConfig.sdk}'`)
80
+ }
81
+
82
+ if (!spec.label) warnings.push(`${key}: missing label`)
83
+
84
+ for (const priceField of ['inputPrice', 'outputPrice', 'thinkingPrice']) {
85
+ const val = spec[priceField]
86
+ if (val != null && typeof val === 'object' && val.default == null) {
87
+ errors.push(`${key}: ${priceField} is tiered but missing 'default' key`)
88
+ }
89
+ }
90
+
91
+ if (spec.thinkingEffortLevels && !spec.defaultThinkingEffort) {
92
+ warnings.push(`${key}: has thinkingEffortLevels but no defaultThinkingEffort`)
93
+ }
94
+
95
+ const lanes = SPEED_LANES[keyProvider]
96
+ for (const [lane, overlay] of Object.entries(spec.speeds || {})) {
97
+ if (!lanes?.has(lane)) {
98
+ const detail = lanes ? `accepts: ${[...lanes].join(', ')}` : 'implements no speed lanes'
99
+ errors.push(`${key}: speeds.${lane} — provider '${keyProvider}' ${detail}; calls on that lane would fail at dispatch`)
100
+ }
101
+ for (const priceField of ['inputPrice', 'outputPrice', 'thinkingPrice']) {
102
+ const val = overlay[priceField]
103
+ if (val != null && typeof val === 'object' && val.default == null) {
104
+ errors.push(`${key}: speeds.${lane}.${priceField} is tiered but missing 'default' key`)
105
+ }
106
+ }
107
+ const priced = ['inputPrice', 'outputPrice'].some(f => overlay[f] != null)
108
+ if (!priced) {
109
+ warnings.push(`${key}: speeds.${lane} restates no prices — the lane will bill at base rates`)
110
+ }
111
+ }
112
+
113
+ if (Array.isArray(spec.tags)) {
114
+ for (const t of spec.tags) {
115
+ if (!isValidTag(t)) warnings.push(`${key}: invalid tag "${t}" — must match /^[a-zA-Z][a-zA-Z0-9._-]{0,31}$/`)
116
+ }
117
+ }
118
+
119
+ if (local) {
120
+ const localIssues = reviewLocal(key, spec, local)
121
+ errors.push(...localIssues.errors)
122
+ warnings.push(...localIssues.warnings)
123
+ }
124
+
125
+ return { errors, warnings }
126
+ }
127
+
128
+ export const reviewCatalog = (catalog, { local = null } = {}) => {
129
+ const errors = []
130
+ const warnings = []
131
+ for (const [key, spec] of catalogEntries(catalog)) {
132
+ const entry = reviewEntry(key, spec, catalog, { local })
133
+ errors.push(...entry.errors)
134
+ warnings.push(...entry.warnings)
135
+ }
136
+ return { errors, warnings }
137
+ }
138
+
139
+ export const diffEntry = (before, after) => {
140
+ const from = before ? stripComputed(before) : {}
141
+ const to = stripComputed(after)
142
+ const fields = [...new Set([...Object.keys(from), ...Object.keys(to)])].sort()
143
+ const changes = []
144
+ for (const field of fields) {
145
+ if (same(from[field], to[field])) continue
146
+ changes.push({ field, from: from[field], to: to[field] })
147
+ }
148
+ return changes
149
+ }
150
+
151
+ export const parseCandidates = (text) => {
152
+ let doc
153
+ try {
154
+ doc = JSON.parse(text)
155
+ } catch (e) {
156
+ throw new Error(`candidate is not valid JSON: ${e.message}`)
157
+ }
158
+ if (!doc || typeof doc !== 'object' || Array.isArray(doc)) {
159
+ throw new Error('candidate must be a JSON object keyed by model id: { "<provider>/<model>": { … } }')
160
+ }
161
+
162
+ const entries = {}
163
+ const ignored = []
164
+ for (const [key, value] of Object.entries(doc)) {
165
+ if (isMetaKey(key)) {
166
+ ignored.push(key)
167
+ continue
168
+ }
169
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
170
+ const got = Array.isArray(value) ? 'array' : typeof value
171
+ throw new Error(`'${key}' maps to a ${got}, not an entry object — the shape is { "<provider>/<model>": { … } }`)
172
+ }
173
+ entries[key] = value
174
+ }
175
+ if (!Object.keys(entries).length) {
176
+ throw new Error('candidate contains no entries')
177
+ }
178
+ return { entries, ignored }
179
+ }
180
+
181
+ export const reviewCandidates = (catalog, entries, { local = null } = {}) => {
182
+ const merged = { ...catalog, ...entries }
183
+ return Object.entries(entries).map(([key, entry]) => {
184
+ const { errors, warnings } = reviewEntry(key, entry, merged, { strict: true, local })
185
+ const existing = catalog[key]
186
+ const changes = diffEntry(existing, entry)
187
+ return {
188
+ key,
189
+ status: existing ? (changes.length ? 'changed' : 'unchanged') : 'new',
190
+ errors,
191
+ warnings,
192
+ changes
193
+ }
194
+ })
195
+ }