mohdel 0.125.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 (43) hide show
  1. package/README.md +154 -29
  2. package/config/curated.schema.json +32 -10
  3. package/js/client/gate-binary.js +5 -0
  4. package/js/client/index.js +1 -0
  5. package/js/core/envelope.js +5 -1
  6. package/js/factory/bridge.js +2 -2
  7. package/js/session/adapters/_chat_completions.js +3 -0
  8. package/js/session/adapters/_output_cap.js +30 -0
  9. package/js/session/adapters/_registry.js +44 -0
  10. package/js/session/adapters/anthropic.js +8 -5
  11. package/js/session/adapters/gemini.js +4 -0
  12. package/js/session/adapters/openai.js +2 -1
  13. package/js/session/run.js +14 -4
  14. package/js/session/run_image.js +12 -3
  15. package/package.json +49 -19
  16. package/src/cli/aliases.js +20 -0
  17. package/src/cli/ask.js +58 -12
  18. package/src/cli/backup.js +2 -1
  19. package/src/cli/check.js +15 -86
  20. package/src/cli/complete.js +130 -0
  21. package/src/cli/default.js +34 -13
  22. package/src/cli/doctor.js +33 -14
  23. package/src/cli/entry.js +173 -0
  24. package/src/cli/index.js +77 -66
  25. package/src/cli/instructions.js +349 -0
  26. package/src/cli/local.js +14 -0
  27. package/src/cli/model.js +184 -37
  28. package/src/cli/onboard.js +186 -121
  29. package/src/cli/rank.js +2 -1
  30. package/src/cli/ratelimit.js +3 -3
  31. package/src/cli/tag.js +2 -0
  32. package/src/lib/assistants.js +93 -0
  33. package/src/lib/catalog/openrouter.js +6 -1
  34. package/src/lib/catalog-review.js +195 -0
  35. package/src/lib/common.js +14 -1
  36. package/src/lib/creators.js +35 -0
  37. package/src/lib/index.js +17 -3
  38. package/src/lib/local-conventions.js +120 -0
  39. package/src/lib/provider-info.js +98 -0
  40. package/src/lib/providers.js +69 -14
  41. package/src/lib/schema.js +15 -3
  42. package/src/lib/select.js +125 -67
  43. package/js/session/adapters/image/index.js +0 -40
@@ -0,0 +1,349 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { readFile, writeFile, mkdir } from 'node:fs/promises'
3
+ import { dirname, resolve } from 'node:path'
4
+ import providerDefs from '../lib/providers.js'
5
+ import PROVIDER_INFO from '../lib/provider-info.js'
6
+ import { fieldDefs } from '../lib/schema.js'
7
+ import { CURATED_PATH, getConfig, getCuratedModels, catalogEntries, portablePath } from '../lib/common.js'
8
+ import { detectAssistants, handoff, launchLines } from '../lib/assistants.js'
9
+ import { LOCAL_PATH, TEMPLATE } from '../lib/local-conventions.js'
10
+ import { localConventionsOrExit } from './local.js'
11
+
12
+ const CATALOG_DOC = 'https://github.com/clbrge/mohdel/blob/main/docs/CATALOG.md'
13
+
14
+ export const BRIEF_FILE = 'mohdel-brief.md'
15
+ export const BRIEF_HEADING = '# mohdel catalog entry'
16
+
17
+ // A brief mohdel wrote before is stale, not precious — replace it. Anything
18
+ // else owning the name is the user's, so write alongside rather than over it.
19
+ export const writeBrief = async (provider) => {
20
+ const brief = await buildBrief(provider)
21
+ let name = BRIEF_FILE
22
+ let path = resolve(process.cwd(), name)
23
+ if (existsSync(path) && !readFileSync(path, 'utf8').startsWith(BRIEF_HEADING)) {
24
+ let n = 2
25
+ while (existsSync(resolve(process.cwd(), `mohdel-brief-${n}.md`))) n++
26
+ name = `mohdel-brief-${n}.md`
27
+ path = resolve(process.cwd(), name)
28
+ }
29
+ await writeFile(path, brief)
30
+ return name
31
+ }
32
+
33
+ const loadDescriptions = async () => {
34
+ const raw = await readFile(new URL('../../config/curated.schema.json', import.meta.url), 'utf8')
35
+ const props = JSON.parse(raw).$defs.modelEntry.properties
36
+ return Object.fromEntries(Object.entries(props).map(([field, def]) => [field, def.description]))
37
+ }
38
+
39
+ const typeOf = (def) => {
40
+ const types = [def.type, def.altType].filter(Boolean)
41
+ if (def.nullable) types.push('null')
42
+ return types.join(' \\| ')
43
+ }
44
+
45
+ const fieldTable = (descriptions) => {
46
+ const rows = Object.entries(fieldDefs)
47
+ .sort(([, a], [, b]) => Number(!!b.required) - Number(!!a.required))
48
+ .map(([field, def]) => {
49
+ const note = def.deprecated ? ` **deprecated — ${def.deprecated}**` : ''
50
+ return `| \`${field}\` | ${typeOf(def)} | ${def.required ? 'yes' : ''} | ${descriptions[field]}${note} |`
51
+ })
52
+ return ['| field | type | required | meaning |', '|---|---|---|---|', ...rows].join('\n')
53
+ }
54
+
55
+ const referenceList = (name) => {
56
+ const refs = providerDefs[name]?.references
57
+ const free = PROVIDER_INFO[name]?.free ? '\n - free tier: yes — see *A free tier is not a price* below' : ''
58
+ if (!refs) return `- \`${name}\` — no reference links shipped. Ask the user where this provider publishes prices.${free}`
59
+ const links = Object.entries(refs).map(([kind, url]) => `${kind}: ${url}`).join('\n - ')
60
+ return `- \`${name}\`\n - ${links}${free}`
61
+ }
62
+
63
+ const sampleEntry = (curated, name) => {
64
+ const match = catalogEntries(curated).find(([key, spec]) => key.startsWith(`${name}/`) && !spec.deprecated)
65
+ if (!match) return null
66
+ const [key, spec] = match
67
+ const { upstreamIds, ...entry } = spec
68
+ return JSON.stringify({ [key]: entry }, null, 2)
69
+ }
70
+
71
+ const localSection = (local) => {
72
+ const parts = ['## Local conventions', '',
73
+ `This installation adds the following to its catalog. None of it is part of
74
+ mohdel and no vendor docs page describes it — ${portablePath(LOCAL_PATH)} is the only
75
+ source. Treat everything here as binding for this catalog.`]
76
+
77
+ if (local.notes) parts.push('', local.notes)
78
+
79
+ const fields = Object.entries(local.fields)
80
+ if (fields.length) {
81
+ parts.push('', '### Local fields', '', '| field | type | meaning |', '|---|---|---|')
82
+ for (const [name, def] of fields) parts.push(`| \`${name}\` | ${def.type} | ${def.description} |`)
83
+ const measured = fields.filter(([, def]) => def.measured)
84
+ if (measured.length) {
85
+ parts.push('', 'These are **measured, not published**. There is no page to read them off.',
86
+ 'Run the command, or leave the field out and say which one you could not obtain:', '')
87
+ for (const [name, def] of measured) parts.push(`- \`${name}\` — \`${def.measured}\``)
88
+ }
89
+ const read = fields.filter(([, def]) => def.readBy)
90
+ if (read.length) {
91
+ parts.push('', 'Read by:', '')
92
+ for (const [name, def] of read) parts.push(`- \`${name}\` — ${def.readBy}`)
93
+ }
94
+ }
95
+
96
+ const tags = Object.entries(local.tags)
97
+ if (tags.length) {
98
+ parts.push('', '### Local tags', '',
99
+ 'A tag here is not a label — it routes the model into something.', '',
100
+ '| tag | what it does | required alongside |', '|---|---|---|')
101
+ for (const [name, def] of tags) {
102
+ parts.push(`| \`${name}\` | ${def.description} | ${def.requires.length ? def.requires.map(f => `\`${f}\``).join(', ') : '—'} |`)
103
+ }
104
+ }
105
+
106
+ if (local.adding.field) parts.push('', '### Adding a new field', '', local.adding.field)
107
+ if (local.adding.tag) parts.push('', '### Adding a new tag', '', local.adding.tag)
108
+
109
+ return parts.join('\n')
110
+ }
111
+
112
+ export async function runInstructions (args) {
113
+ if (args.includes('-h') || args.includes('--help')) {
114
+ console.log(`mohdel model instructions — brief for the coding agent that edits your catalog
115
+
116
+ Usage:
117
+ model instructions [provider] Write mohdel-brief.md and show how to
118
+ hand it over. Redirected or piped, the
119
+ brief goes to stdout instead.
120
+ model instructions --print Always write the brief to stdout
121
+ model instructions --init-local Scaffold this installation's own field
122
+ and tag declarations, then edit them
123
+
124
+ Redirected or piped, the brief goes to stdout and the hand-off recipe to
125
+ stderr, so the file keeps only the brief:
126
+
127
+ mo model instructions anthropic > mohdel-brief.md
128
+
129
+ Then start your coding agent on <prompt>:
130
+
131
+ read mohdel-brief.md, then add claude-haiku-5 to my mohdel catalog
132
+
133
+ ${launchLines().join('\n')}
134
+
135
+ One-shot instead of a session, where the agent supports it:
136
+
137
+ mo model instructions anthropic | claude -p "add claude-haiku-5 to my catalog"
138
+ mo model instructions anthropic | codex exec -`)
139
+ process.exit(0)
140
+ }
141
+
142
+ if (args.includes('--init-local')) {
143
+ const { err, ok, meta } = await import('./colors.js')
144
+ if (existsSync(LOCAL_PATH)) {
145
+ console.error(err(`${LOCAL_PATH} already exists — edit it, or move it aside first.`))
146
+ process.exit(1)
147
+ }
148
+ await mkdir(dirname(LOCAL_PATH), { recursive: true })
149
+ await writeFile(LOCAL_PATH, TEMPLATE)
150
+ console.log(`${ok('✓')} wrote ${LOCAL_PATH}`)
151
+ console.log(meta('Edit it, then "mo check" enforces it and "mo model instructions" hands it to your agent.'))
152
+ return
153
+ }
154
+
155
+ const only = args.find(a => !a.startsWith('--'))
156
+ if (only && !providerDefs[only]) {
157
+ const { err } = await import('./colors.js')
158
+ console.error(err(`Unknown provider: ${only}. Known: ${Object.keys(providerDefs).join(', ')}`))
159
+ process.exit(1)
160
+ }
161
+
162
+ const local = await localConventionsOrExit()
163
+
164
+ const assistant = (await getConfig()).assistant || null
165
+
166
+ // 150 lines of agent-facing markdown is not what someone at a terminal
167
+ // wants. Redirected or piped — how the recipe and agents use it — stdout
168
+ // still carries the brief.
169
+ if (process.stdout.isTTY && !args.includes('--print')) {
170
+ const { preferredAgent, briefPrompt } = await import('../lib/assistants.js')
171
+ const { ok, meta, id: cmd } = await import('./colors.js')
172
+ const name = await writeBrief(only)
173
+ const agent = preferredAgent(assistant, detectAssistants())
174
+ console.log(`${ok('✓')} wrote ${cmd(name)}\n`)
175
+ console.log(` ${cmd(agent.start(`"${briefPrompt(name, only)}"`))}\n`)
176
+ console.log(meta('It drafts mohdel-candidate.json and checks it with "mo model check --entry mohdel-candidate.json";'))
177
+ console.log(meta('you apply it with "mo model apply mohdel-candidate.json", which shows the diff first.'))
178
+ console.log(meta(`\nThe brief itself: mo model instructions${only ? ' ' + only : ''} --print`))
179
+ return
180
+ }
181
+
182
+ console.error(handoff(only, detectAssistants(), assistant))
183
+ if (!local) {
184
+ console.error(`
185
+ No local conventions declared. If your own services read catalog fields or tags
186
+ that mohdel does not know about, declare them once so the agent is told:
187
+
188
+ mo model instructions --init-local`)
189
+ }
190
+ console.error('')
191
+
192
+ console.log(await buildBrief(only, local))
193
+ }
194
+
195
+ export const buildBrief = async (only = null, local = undefined) => {
196
+ if (local === undefined) local = await localConventionsOrExit()
197
+ const names = only ? [only] : Object.keys(providerDefs)
198
+ const descriptions = await loadDescriptions()
199
+ const curated = await getCuratedModels()
200
+ const sample = only ? sampleEntry(curated, only) : null
201
+ const size = catalogEntries(curated).filter(([, spec]) => !spec.deprecated).length
202
+
203
+ return `# mohdel catalog entry — brief for a coding agent
204
+
205
+ You are filling in one or more entries for a mohdel model catalog. Mohdel
206
+ computes real USD cost from these numbers on every call, so a wrong price is
207
+ a silent billing error, not a crash. Accuracy matters more than completeness.
208
+
209
+ ## Hard rules
210
+
211
+ 1. **Do not edit ${portablePath(CURATED_PATH)}.** Write your entries to a separate JSON
212
+ file. The user applies it themselves.
213
+ 2. **Do not invent a number.** Every price and limit must come from a page you
214
+ actually read. If you cannot find one, leave the field out and say which
215
+ ones you left out and why. If you have no way to fetch a web page at all,
216
+ stop and say so — an entry of plausible-looking prices is worse than no
217
+ entry, because nothing downstream can tell the difference.
218
+ 3. **Prices are USD per 1,000,000 tokens.** \`"inputPrice": 3\` means $3 per
219
+ million input tokens. \`imagePrice\` is per image; \`transcriptionPrice\` is
220
+ per audio minute.
221
+ 4. **The catalog key is \`<provider>/<model>\`** and carries no \`:effort\` or
222
+ \`@speed\` suffix — those are call-time. The key may differ from the
223
+ provider's literal id; the literal id goes in \`model\`.
224
+ 5. **Record where the numbers came from**: \`source\` (the URL you read) and
225
+ \`sourcedAt\` (YYYY-MM-DD). This is what makes a later price re-check possible.
226
+ 6. **An entry replaces the existing one wholesale** — a field you leave out is a
227
+ field removed. Editing an existing model? Read its current entry out of the
228
+ catalog file first and change only what you mean to. Step 2 below prints
229
+ every removal, so check the diff before handing it over.
230
+ ${local
231
+ ? `7. **This installation has its own fields and tags** — see *Local conventions*
232
+ below, and do not treat the field table as the whole story. A field marked
233
+ *measured* has no page to read it off: run the command named for it. Never
234
+ apply a tag whose required fields you cannot supply — leave the tag off and
235
+ say which one you skipped and why. \`mo model check --entry\` enforces both.
236
+ `
237
+ : ''}
238
+ ${size === 0
239
+ ? `## This catalog is empty
240
+
241
+ Nothing is curated yet, so the person asking probably cannot name the models
242
+ they want — do not ask them to. Start from what their key can actually reach:
243
+
244
+ \`\`\`bash
245
+ mo provider models ${only || '<provider>'} --json
246
+ \`\`\`
247
+
248
+ Read the pricing page, then propose a short starter set — three or four
249
+ entries, not everything on offer. A cheap fast model for routine work, one
250
+ strong model for hard work, and whatever else the list clearly justifies.
251
+ Show the prices you found and let them confirm before you write anything.
252
+ Prefer current models over older ones where the ids make the generation
253
+ obvious, and leave out anything you cannot price.
254
+
255
+ `
256
+ : ''}## Output shape
257
+
258
+ A JSON object keyed by model id, same shape as the catalog itself:
259
+
260
+ \`\`\`json
261
+ {
262
+ "openai/gpt-5.4-mini": {
263
+ "model": "gpt-5.4-mini-2026-01-15",
264
+ "creator": "openai",
265
+ "provider": "openai",
266
+ "sdk": "openai",
267
+ "label": "GPT-5.4 mini",
268
+ "inputFormat": ["text", "image"],
269
+ "inputPrice": 0.25,
270
+ "outputPrice": 2,
271
+ "contextTokenLimit": 400000,
272
+ "outputTokenLimit": 128000,
273
+ "source": "https://developers.openai.com/api/docs/pricing",
274
+ "sourcedAt": "2026-09-10"
275
+ }
276
+ }
277
+ \`\`\`
278
+
279
+ Retiring an id instead? A deprecated stub is a one-field redirect:
280
+ \`{ "openai/gpt-4o": { "deprecated": "openai/gpt-5.4-mini" } }\`.
281
+
282
+ ## Workflow
283
+
284
+ \`\`\`bash
285
+ # 1. ask the provider which models this key can actually reach
286
+ mo provider models ${only || '<provider>'} --json
287
+
288
+ # 2. you write the candidate
289
+ $EDITOR mohdel-candidate.json
290
+
291
+ # 3. you check it — repeat until 0 errors
292
+ mo model check --entry mohdel-candidate.json --json
293
+
294
+ # 4. the user applies it (this is the step that writes)
295
+ mo model apply mohdel-candidate.json
296
+ \`\`\`
297
+
298
+ Step 1 is the authority on which ids exist and how they are spelled: a docs
299
+ page may list models this key cannot reach, miss ones shipped last week, or
300
+ spell them differently from the API. Take the id from there and the prices
301
+ from the docs page. Steps 1 and 3 only read, so run them as often as you need.
302
+ Step 4 writes to the user's catalog and shows them the diff first — hand them
303
+ the command, do not run it for them.
304
+
305
+ ## Where to read the numbers
306
+
307
+ ${names.map(referenceList).join('\n')}
308
+
309
+ ${only && providerDefs[only]?.pricesFromApi
310
+ ? `${only} is the exception among providers: its model list carries per-token
311
+ prices, so step 1 gives you the ids *and* the prices, and \`mo curate ${only}\`
312
+ writes complete entries on its own. Check what it produced rather than
313
+ transcribing anything, and use the pages below only for what the list omits.`
314
+ : `Provider APIs return model *ids*, not prices. Step 1 above gets you the ids;
315
+ the pages below carry everything the API does not expose: prices, context and
316
+ output limits, thinking budgets, cache rates.`}
317
+
318
+ ### A free tier is not a price
319
+
320
+ Some of these providers let you call them for nothing up to a quota. Record
321
+ the **paid** rates anyway — a quota is not a price, mohdel bills against these
322
+ numbers, and they are what \`mo rank\` compares. But say plainly that the tier
323
+ exists when the provider has one, especially if that is why they chose it:
324
+ someone who picked a provider *because* it was free should not be shown a
325
+ table of dollar figures with no explanation. The rates apply once the free
326
+ quota is gone.
327
+
328
+ ## Entry kinds
329
+
330
+ - **Text/vision model** — the default. \`inputFormat\` lists what it accepts.
331
+ - **Image generation** — \`"type": "image"\` plus \`imagePrice\`, \`imageEndpoint\`,
332
+ \`imageDefaultSize\`.
333
+ - **Transcription** — \`"type": "transcription"\` plus \`transcriptionPrice\` (USD
334
+ per audio minute).
335
+ - **Self-hosted** — a \`local/\` key. \`baseURL\` is required and rejected on every
336
+ other provider. A server tag like \`llama3.1:8b\` goes in \`model\`; the key
337
+ uses a dash.
338
+
339
+ ## Fields
340
+
341
+ ${fieldTable(descriptions)}
342
+
343
+ Fields mohdel does not know are preserved untouched — namespace your own with
344
+ a prefix (\`myapp:tier\`) so they stay distinct.
345
+
346
+ Full reference, including thinking-effort mapping and service speed lanes:
347
+ ${CATALOG_DOC}
348
+ ${local ? `\n${localSection(local)}\n` : ''}${sample ? `\n## An existing ${only} entry, for shape\n\n\`\`\`json\n${sample}\n\`\`\`\n` : ''}`
349
+ }
@@ -0,0 +1,14 @@
1
+ import { loadLocalConventions } from '../lib/local-conventions.js'
2
+ import { err } from './colors.js'
3
+
4
+ // A declaration file that exists but does not parse is broken policy. Reading
5
+ // it as "no conventions" would let every local rule silently stop applying.
6
+ export const localConventionsOrExit = async () => {
7
+ try {
8
+ return await loadLocalConventions()
9
+ } catch (e) {
10
+ console.error(err(e.message))
11
+ console.error(err('Fix it or move it aside; mohdel will not carry on as if no conventions were declared.'))
12
+ process.exit(1)
13
+ }
14
+ }