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.
- package/README.md +155 -30
- package/config/curated.schema.json +32 -10
- package/js/client/call.js +76 -19
- package/js/client/gate-binary.js +5 -0
- package/js/client/index.js +1 -0
- package/js/core/envelope.js +5 -1
- package/js/factory/bridge.js +2 -2
- package/js/session/adapters/_cancelled.js +0 -6
- package/js/session/adapters/_chat_completions.js +25 -0
- package/js/session/adapters/_output_cap.js +30 -0
- package/js/session/adapters/_registry.js +44 -0
- package/js/session/adapters/anthropic.js +8 -5
- package/js/session/adapters/gemini.js +4 -0
- package/js/session/adapters/openai.js +2 -1
- package/js/session/run.js +14 -4
- package/js/session/run_image.js +12 -3
- package/package.json +49 -19
- package/src/cli/aliases.js +20 -0
- package/src/cli/ask.js +58 -12
- package/src/cli/backup.js +2 -1
- package/src/cli/check.js +15 -86
- package/src/cli/complete.js +130 -0
- package/src/cli/default.js +34 -13
- package/src/cli/doctor.js +33 -14
- package/src/cli/entry.js +173 -0
- package/src/cli/index.js +77 -66
- package/src/cli/instructions.js +349 -0
- package/src/cli/local.js +14 -0
- package/src/cli/model.js +184 -37
- package/src/cli/onboard.js +186 -121
- package/src/cli/rank.js +2 -1
- package/src/cli/ratelimit.js +3 -3
- package/src/cli/tag.js +2 -0
- package/src/lib/assistants.js +93 -0
- package/src/lib/catalog/openrouter.js +6 -1
- package/src/lib/catalog-review.js +195 -0
- package/src/lib/common.js +14 -1
- package/src/lib/creators.js +35 -0
- package/src/lib/index.js +17 -3
- package/src/lib/local-conventions.js +120 -0
- package/src/lib/provider-info.js +98 -0
- package/src/lib/providers.js +69 -14
- package/src/lib/schema.js +15 -3
- package/src/lib/select.js +125 -67
- package/js/session/adapters/image/index.js +0 -40
package/src/cli/onboard.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
|
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
|
-
|
|
233
|
-
|
|
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
|
-
|
|
238
|
-
|
|
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 (
|
|
241
|
-
|
|
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
|
|
246
|
-
|
|
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
|
|
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)
|
package/src/cli/ratelimit.js
CHANGED
|
@@ -19,9 +19,9 @@ Usage:
|
|
|
19
19
|
ratelimit provider rm <provider> Remove provider-level limits
|
|
20
20
|
|
|
21
21
|
Examples:
|
|
22
|
-
ratelimit show anthropic
|
|
23
|
-
ratelimit show gemini/gemini-
|
|
24
|
-
ratelimit set gemini/gemini-
|
|
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 => ({
|
|
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
|
+
}
|