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/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
|
-
|
|
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
|
|
33
|
-
model
|
|
34
|
-
model
|
|
35
|
-
model
|
|
36
|
-
|
|
37
|
-
provider
|
|
38
|
-
|
|
39
|
-
provider
|
|
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
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
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
|
|
100
|
-
~/.config/mohdel/curated.json
|
|
101
|
-
~/.config/mohdel/
|
|
102
|
-
~/.config/mohdel/
|
|
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
|
+
}
|
package/src/cli/local.js
ADDED
|
@@ -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
|
+
}
|