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/index.js CHANGED
@@ -9,6 +9,10 @@
9
9
  * Aliases: ls → model list, rl → ratelimit
10
10
  */
11
11
 
12
+ import { detectAssistants, launchLines } from '../lib/assistants.js'
13
+ import { getConfig } from '../lib/common.js'
14
+ import { ALIASES } from './aliases.js'
15
+
12
16
  const [command, ...args] = process.argv.slice(2)
13
17
 
14
18
  if (!command) {
@@ -17,38 +21,65 @@ if (!command) {
17
21
  process.exit(0)
18
22
  }
19
23
 
24
+ // Before anything else: a tab press must not pay for the factory import.
25
+ if (command === '__complete') {
26
+ const { runComplete } = await import('./complete.js')
27
+ await runComplete(args)
28
+ process.exit(0)
29
+ }
30
+
31
+ if (command === 'completion') {
32
+ const { runCompletionScript } = await import('./complete.js')
33
+ runCompletionScript(args)
34
+ process.exit(0)
35
+ }
36
+
20
37
  if (command === '-h' || command === '--help') {
21
- console.log(`mohdel — model catalog management
38
+ const assistant = (await getConfig()).assistant || null
39
+ const { default: PROVIDER_INFO } = await import('../lib/provider-info.js')
40
+ const { default: providerDefs } = await import('../lib/providers.js')
41
+ const keyRows = Object.keys(PROVIDER_INFO)
42
+ .map(name => [providerDefs[name].apiKeyEnv, `${PROVIDER_INFO[name].label} API key`])
43
+ .concat([['MOHDEL_LOCAL_API_SK', 'Bearer token for a local server, if it wants one']])
44
+ const keyWidth = Math.max(...keyRows.map(([k]) => k.length))
45
+ const keys = keyRows.map(([k, d]) => ` ${k.padEnd(keyWidth + 2)}${d}`).join('\n')
46
+ console.log(`mohdel — self-hosted LLM gateway and SDK for Node
47
+
48
+ Run a model, see what the call cost, and curate the catalog those prices
49
+ come from. Your keys, your infra, no SaaS in the path.
22
50
 
23
51
  Commands:
24
- model list [--sort price|context|name] List all curated models
25
- model search <term> Filter models by name/label
26
- model stats Catalog summary
27
- model show <model> Show model details
52
+ model list [--sort price|context|name] List all curated models (mo ls, mo models)
53
+ model search <term> Filter models by name/label (mo search)
54
+ model stats Catalog summary (mo stats)
55
+ model show <model> Show model details (mo show)
28
56
  model get <model> <key> Get a field value
29
57
  model set <model> <key> <value> Set a field
30
58
  model rm <model> <key> Remove a field
31
59
  model add <provider>/<model-id> Add a model manually
32
- model check [--local] Validate catalog
33
- model rank [--use-case <name>] Rank models by benchmarks
34
- model bench <model> Benchmark with live inference
35
- model curate [provider] Add upstream models to catalog
36
-
37
- provider list List all providers
38
- provider list <provider> List models from a provider
39
- provider setup <provider> Configure API key (interactive)
60
+ model instructions [provider] Brief for your coding agent (mo instructions)
61
+ model check [--entry <file|->] Validate catalog, or candidate entries (mo check)
62
+ model apply <file|-> Write reviewed entries to the catalog (mo apply)
63
+ model rank [--use-case <name>] Rank models by benchmarks (mo rank)
64
+ model bench <model> Benchmark with live inference (mo bench)
65
+ model curate [provider] Add upstream models to catalog (mo curate)
66
+
67
+ provider list List all providers (mo providers)
68
+ provider list <provider> List curated models for a provider
69
+ provider models <provider> List models the key can reach upstream
70
+ provider setup <provider> Configure API key (interactive) (mo setup)
40
71
  provider rm <provider> Remove API key
41
72
 
42
- creator list List all creators
73
+ creator list List all creators (mo creators)
43
74
  creator list <creator> List models by a creator
44
75
 
45
- tag list List all unique tags
76
+ tag list List all unique tags (mo tags)
46
77
  tag list <model> Show tags on a model
47
78
  tag show <tag> List models with a tag
48
79
  tag add <model> <tag> Add a tag
49
80
  tag rm <model> <tag> Remove a tag
50
81
 
51
- ratelimit show <model|provider> Show effective limits
82
+ ratelimit show <model|provider> Show effective limits (mo rl show)
52
83
  ratelimit set <model> [rpm] [tpm] Set model-level limits
53
84
  ratelimit rm <model> Remove model-level limits
54
85
  ratelimit provider set <p> [rpm] [tpm] Set provider-level limits
@@ -57,24 +88,30 @@ Commands:
57
88
  ask <provider/model> [prompt] One-shot inference (pipeable)
58
89
  transcribe <provider/model> <file> Speech → text from an audio file
59
90
 
60
- default Set default model (interactive)
91
+ default [model] Set the model "mo ask" uses by default
61
92
  doctor Check that your install is wired up
93
+ completion bash Shell completion — source <(mo completion bash)
94
+
95
+ Catalog work with a coding agent:
96
+ Prices, context limits and cache rates are in no provider API — they live on
97
+ docs pages. Put the brief in front of the coding agent you already use:
62
98
 
63
- Aliases:
64
- models model list
65
- providers provider list
66
- creators creator list
67
- tags tag list
68
- ls model list
69
- show <model> model show <model>
70
- search <term> model search <term>
71
- stats model stats
72
- check model check
73
- setup <provider> provider setup <provider>
74
- rank model rank
75
- bench <model> model bench <model>
76
- curate [provider] model curate [provider]
77
- rl ratelimit
99
+ mo model instructions openai > mohdel-brief.md
100
+
101
+ then start it on <prompt>:
102
+
103
+ read mohdel-brief.md, then add gpt-5.6 to my mohdel catalog
104
+
105
+ ${launchLines(detectAssistants(), assistant).join('\n')}
106
+
107
+ The agent needs to be able to fetch a web page — that is where the prices
108
+ are. It drafts mohdel-candidate.json and checks it; you apply it:
109
+
110
+ mo model check --entry mohdel-candidate.json
111
+ mo model apply mohdel-candidate.json ← prints the diff first
112
+
113
+ Aliases appear in brackets above. "mo rl" stands in for "mo ratelimit" on
114
+ every subcommand, not only its list form.
78
115
 
79
116
  Global flags:
80
117
  --json [fields] Output as JSON (omit fields to list available)
@@ -83,51 +120,25 @@ Environment:
83
120
  API keys are loaded from ~/.config/mohdel/environment (KEY=value format).
84
121
  Run "mo" with no arguments to configure interactively.
85
122
 
86
- ANTHROPIC_API_SK Anthropic API key
87
- OPENAI_API_SK OpenAI API key
88
- GEMINI_API_SK Google Gemini API key
89
- GROQ_API_SK Groq API key
90
- CEREBRAS_API_SK Cerebras API key
91
- XAI_API_SK xAI API key
92
- MISTRAL_API_SK Mistral API key
93
- DEEPSEEK_API_SK DeepSeek API key
94
- FIREWORKS_API_SK Fireworks API key
95
- OPENROUTER_API_SK OpenRouter API key
96
- NOVITA_API_SK Novita API key
123
+ ${keys}
97
124
 
98
125
  Configuration:
99
- ~/.config/mohdel/environment API keys (loaded automatically)
100
- ~/.config/mohdel/curated.json Model catalog
101
- ~/.config/mohdel/providers.json Provider-level rate limits
102
- ~/.config/mohdel/default.json Default model selection`)
126
+ ~/.config/mohdel/environment API keys (loaded automatically)
127
+ ~/.config/mohdel/curated.json Model catalog
128
+ ~/.config/mohdel/catalog.local.json This installation's own fields and tags
129
+ ~/.config/mohdel/providers.json Provider-level rate limits
130
+ ~/.config/mohdel/excluded.json Models to hide from list and curate
131
+ ~/.config/mohdel/default.json Default model, and your coding agent`)
103
132
  process.exit(0)
104
133
  }
105
134
 
106
- // Alias resolution: short commands → noun + verb
107
- const ALIASES = {
108
- models: { noun: 'model', inject: ['list'] },
109
- providers: { noun: 'provider', inject: ['list'] },
110
- creators: { noun: 'creator', inject: ['list'] },
111
- tags: { noun: 'tag', inject: ['list'] },
112
- ls: { noun: 'model', inject: ['list'] },
113
- show: { noun: 'model', inject: ['show'] },
114
- search: { noun: 'model', inject: ['search'] },
115
- stats: { noun: 'model', inject: ['stats'] },
116
- check: { noun: 'model', inject: ['check'] },
117
- setup: { noun: 'provider', inject: ['setup'] },
118
- rank: { noun: 'model', inject: ['rank'] },
119
- bench: { noun: 'model', inject: ['bench'] },
120
- curate: { noun: 'model', inject: ['curate'] },
121
- rl: { noun: 'ratelimit', inject: [] }
122
- }
123
-
124
135
  const alias = ALIASES[command]
125
136
  const resolved = alias ? alias.noun : command
126
137
  const resolvedArgs = alias ? [...alias.inject, ...args] : args
127
138
 
128
139
  if (resolved === 'default') {
129
140
  const { runDefault } = await import('./default.js')
130
- await runDefault()
141
+ await runDefault(resolvedArgs)
131
142
  } else if (resolved === 'doctor') {
132
143
  const { runDoctor } = await import('./doctor.js')
133
144
  await runDoctor(resolvedArgs)
@@ -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
+ }