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
@@ -13,7 +13,7 @@
13
13
  * @module session/run_image
14
14
  */
15
15
 
16
- import { getImageAdapter } from './adapters/image/index.js'
16
+ import { IMAGE_ADAPTER_NAMES } from './adapters/_registry.js'
17
17
  import { classifyProviderError } from './adapters/_errors.js'
18
18
  import { providerOf } from '#core/model-id.js'
19
19
 
@@ -31,10 +31,19 @@ import { providerOf } from '#core/model-id.js'
31
31
  * | {ok: false, error: import('#core/errors.js').TypedError}
32
32
  * >}
33
33
  */
34
- export async function runImage (envelope, { resolveAdapter = getImageAdapter, spec } = {}) {
34
+ // Loaded per provider rather than as a registry: the image adapters pull the
35
+ // OpenAI SDK, which a text-only caller never needs. Checked against the known
36
+ // list first — the name comes off the envelope.
37
+ const loadImageAdapter = async (provider) => {
38
+ if (!IMAGE_ADAPTER_NAMES.includes(provider)) throw new Error(`no image adapter for provider: ${provider}`)
39
+ const module = await import(`./adapters/image/${provider}.js`)
40
+ return module[`${provider}Image`]
41
+ }
42
+
43
+ export async function runImage (envelope, { resolveAdapter = loadImageAdapter, spec } = {}) {
35
44
  let adapter
36
45
  try {
37
- adapter = resolveAdapter(providerOf(envelope.model))
46
+ adapter = await resolveAdapter(providerOf(envelope.model))
38
47
  } catch (e) {
39
48
  return {
40
49
  ok: false,
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "mohdel",
3
- "version": "0.125.0",
3
+ "version": "1.0.0",
4
4
  "license": "MIT",
5
5
  "author": {
6
6
  "name": "Christophe Le Bars",
7
7
  "email": "clb@toort.net"
8
8
  },
9
- "description": "Self-hosted LLM gateway and SDK for Node — a LiteLLM-style unified API for 11 providers (Anthropic, OpenAI, Gemini, Mistral, Groq, xAI, DeepSeek, OpenRouter, …) with per-call USD cost tracking, streaming, tool calls, vision, speech-to-text, and built-in OpenTelemetry. Run in-process, or behind the process-isolated thin-gate for fault containment.",
9
+ "description": "Self-hosted LLM gateway and SDK for Node — a LiteLLM-style unified API for 13 providers (Anthropic, OpenAI, Gemini, Mistral, Groq, xAI, DeepSeek, OpenRouter, …) with per-call USD cost tracking, streaming, tool calls, vision, speech-to-text, and built-in OpenTelemetry. Run in-process, or behind the process-isolated thin-gate for fault containment.",
10
10
  "type": "module",
11
11
  "keywords": [
12
12
  "llm",
@@ -42,14 +42,38 @@
42
42
  },
43
43
  "main": "src/lib/index.js",
44
44
  "exports": {
45
- ".": "./src/lib/index.js",
46
- "./providers": "./src/lib/providers.js",
47
- "./creators": "./src/lib/creators.js",
48
- "./utils": "./src/lib/utils.js",
49
- "./errors": "./js/core/errors.js",
50
- "./client": "./js/client/index.js",
51
- "./session": "./js/session/index.js",
52
- "./session/bin": "./js/session/bin.js"
45
+ ".": {
46
+ "types": "./src/lib/index.d.ts",
47
+ "default": "./src/lib/index.js"
48
+ },
49
+ "./providers": {
50
+ "types": "./src/lib/providers.d.ts",
51
+ "default": "./src/lib/providers.js"
52
+ },
53
+ "./creators": {
54
+ "types": "./src/lib/creators.d.ts",
55
+ "default": "./src/lib/creators.js"
56
+ },
57
+ "./utils": {
58
+ "types": "./src/lib/utils.d.ts",
59
+ "default": "./src/lib/utils.js"
60
+ },
61
+ "./errors": {
62
+ "types": "./js/core/errors.d.ts",
63
+ "default": "./js/core/errors.js"
64
+ },
65
+ "./client": {
66
+ "types": "./js/client/index.d.ts",
67
+ "default": "./js/client/index.js"
68
+ },
69
+ "./session": {
70
+ "types": "./js/session/index.d.ts",
71
+ "default": "./js/session/index.js"
72
+ },
73
+ "./session/bin": {
74
+ "types": "./js/session/bin.d.ts",
75
+ "default": "./js/session/bin.js"
76
+ }
53
77
  },
54
78
  "imports": {
55
79
  "#core": "./js/core/index.js",
@@ -64,7 +88,9 @@
64
88
  "src/cli",
65
89
  "config",
66
90
  "README.md",
67
- "LICENSE"
91
+ "LICENSE",
92
+ "js/**/*.d.ts",
93
+ "src/lib/**/*.d.ts"
68
94
  ],
69
95
  "publishConfig": {
70
96
  "registry": "https://registry.npmjs.org",
@@ -73,10 +99,12 @@
73
99
  },
74
100
  "scripts": {
75
101
  "lint": "standard",
102
+ "clean:types": "find js src/lib -name '*.d.ts' -delete",
103
+ "build:types": "npm run clean:types && tsc -p tsconfig.build.json",
76
104
  "test": "npm run test:js && npm run test:rust",
77
105
  "test:js": "vitest run test/unit",
78
106
  "test:rust": "cargo test --manifest-path rust/thin-gate/Cargo.toml && cargo test --manifest-path rust/napi-addon/Cargo.toml",
79
- "prerelease": "npm run lint && npm run test",
107
+ "prerelease": "npm run lint && npm run build:types && npm run test",
80
108
  "release": "release-it",
81
109
  "test:provider": "vitest run test/integration/provider.test.js",
82
110
  "test:multiturn": "vitest run test/integration/multiturn.test.js",
@@ -104,20 +132,20 @@
104
132
  }
105
133
  },
106
134
  "optionalDependencies": {
107
- "@clack/prompts": "^1.7.0",
135
+ "@clack/prompts": "^1.8.8",
108
136
  "@opentelemetry/exporter-trace-otlp-grpc": "^0.222.0",
109
137
  "@opentelemetry/sdk-node": "^0.222.0",
110
138
  "chalk": "^6.0.0",
111
- "mohdel-thin-gate-linux-x64-gnu": "0.125.0"
139
+ "mohdel-thin-gate-linux-x64-gnu": "1.0.0"
112
140
  },
113
141
  "dependencies": {
114
- "@anthropic-ai/sdk": "^0.123.0",
142
+ "@anthropic-ai/sdk": "^0.125.0",
115
143
  "@cerebras/cerebras_cloud_sdk": "^1.91.0",
116
- "@google/genai": "^2.21.0",
144
+ "@google/genai": "^2.22.0",
117
145
  "@opentelemetry/api": "^1.9.1",
118
146
  "env-paths": "^4.0.0",
119
147
  "groq-sdk": "^1.6.0",
120
- "openai": "^7.9.0",
148
+ "openai": "^7.15.0",
121
149
  "undici": "^7.29.0"
122
150
  },
123
151
  "lint-staged": {
@@ -125,9 +153,11 @@
125
153
  },
126
154
  "devDependencies": {
127
155
  "gpt-tokenizer": "^4.0.0",
128
- "lint-staged": "^17.4.1",
156
+ "lint-staged": "^17.5.1",
129
157
  "release-it": "^21.0.2",
130
158
  "standard": "^17.1.2",
159
+ "typescript": "^7.0.2",
131
160
  "vitest": "^4.1.11"
132
- }
161
+ },
162
+ "types": "./src/lib/index.d.ts"
133
163
  }
@@ -0,0 +1,20 @@
1
+ // Short commands → noun + verb. Shared by dispatch and by completion, which
2
+ // must resolve the same chain the router would.
3
+ export const ALIASES = {
4
+ models: { noun: 'model', inject: ['list'] },
5
+ providers: { noun: 'provider', inject: ['list'] },
6
+ creators: { noun: 'creator', inject: ['list'] },
7
+ tags: { noun: 'tag', inject: ['list'] },
8
+ ls: { noun: 'model', inject: ['list'] },
9
+ show: { noun: 'model', inject: ['show'] },
10
+ search: { noun: 'model', inject: ['search'] },
11
+ stats: { noun: 'model', inject: ['stats'] },
12
+ check: { noun: 'model', inject: ['check'] },
13
+ setup: { noun: 'provider', inject: ['setup'] },
14
+ rank: { noun: 'model', inject: ['rank'] },
15
+ bench: { noun: 'model', inject: ['bench'] },
16
+ curate: { noun: 'model', inject: ['curate'] },
17
+ apply: { noun: 'model', inject: ['apply'] },
18
+ instructions: { noun: 'model', inject: ['instructions'] },
19
+ rl: { noun: 'ratelimit', inject: [] }
20
+ }
package/src/cli/ask.js CHANGED
@@ -1,11 +1,28 @@
1
1
  import mohdel, { silent } from '../lib/index.js'
2
- import { loadDefaultEnv } from '../lib/common.js'
2
+ import { getConfig, loadDefaultEnv } from '../lib/common.js'
3
3
 
4
4
  const noop = () => {}
5
5
 
6
6
  // Friendly next-step hints for common ask-time failures. Pure pattern match on
7
7
  // err.message — keeps the lib layer neutral, but gives CLI users a copy-pasteable
8
8
  // command instead of just an error. Shared with `mo transcribe`.
9
+ // Waiting on a model is the one place mohdel has nothing to show for several
10
+ // seconds. The frames carry the resolved id, so the id is visible while it is
11
+ // useful and gone afterwards. stderr only, so pipes see nothing.
12
+ const startSpinner = (text) => {
13
+ if (!process.stderr.isTTY) return null
14
+ const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
15
+ let i = 0
16
+ const timer = setInterval(() => {
17
+ process.stderr.write(`\r${frames[i++ % frames.length]} ${text}`)
18
+ }, 80)
19
+ timer.unref?.()
20
+ return () => {
21
+ clearInterval(timer)
22
+ process.stderr.write('\r\u001b[K')
23
+ }
24
+ }
25
+
9
26
  export const hintsForError = (err, modelId) => {
10
27
  const msg = String(err?.message || '')
11
28
  const detail = String(err?.detail || '')
@@ -16,6 +33,7 @@ export const hintsForError = (err, modelId) => {
16
33
  if (/not found in catalog/i.test(both)) {
17
34
  if (provider) {
18
35
  hints.push(`→ run: mo curate ${provider} # add upstream models from this provider`)
36
+ hints.push(`→ then: mo model instructions ${provider} # let your coding agent fill in the prices`)
19
37
  hints.push(`→ or: mo model add ${modelId} # add this one manually`)
20
38
  } else {
21
39
  hints.push('→ run: mo ls # list available models')
@@ -49,18 +67,21 @@ Usage:
49
67
  mo ask <model> "question" < file Combined: args + stdin
50
68
 
51
69
  Options:
52
- --effort <level> Thinking effort: high, medium, low, none
70
+ --effort <level> Thinking effort: none, low, medium, high, xhigh, max
53
71
  --budget <tokens> Output token budget
54
72
  --json Output full result as JSON
55
73
  --stream Stream output to stdout in real time
56
- -v, --verbose Show debug info on stderr (cooldown, rate limit, SDK calls)
74
+ -v, --verbose Show debug info on stderr (cooldown, rate limits)
75
+ -q, --quiet Nothing on stderr but errors — no usage summary
57
76
 
58
77
  Output:
59
78
  stdout: model output text (raw, no formatting — or JSON with --json)
60
- stderr: model name + token usage summary
79
+ stderr: token usage summary, and errors
80
+ --json omits the summary (the same numbers are in the payload);
81
+ --quiet omits it too, leaving stderr for failures alone
61
82
 
62
83
  Examples:
63
- mo ask gemini/gemini-3-flash-preview "why is the sky blue"
84
+ mo ask openai/gpt-5.6-luna "why is the sky blue"
64
85
  cat article.txt | mo ask anthropic/claude-sonnet-4-6 "summarize this"
65
86
  mo ask openai/gpt-5.4 --effort high "explain monads" --json | jq .cost`)
66
87
  process.exit(0)
@@ -86,18 +107,23 @@ Examples:
86
107
  const json = flag('--json')
87
108
  const stream = flag('--stream')
88
109
  const verbose = flag('--verbose') || flag('-v')
110
+ const quiet = flag('--quiet') || flag('-q')
89
111
  const effort = flagVal('--effort')
90
112
  const budget = flagVal('--budget')
91
113
 
92
114
  // First remaining arg is model
93
- const modelId = args[0]
115
+ // A model id always carries a provider segment, so a first argument without
116
+ // a slash is prompt text and the configured default model applies. Typing a
117
+ // full id on every call is the first thing that wears out.
118
+ const given = args[0]?.includes('/') ? args[0] : null
119
+ const modelId = given ?? (await getConfig()).defaultModel
94
120
  if (!modelId) {
95
121
  console.error('Usage: mo ask <model> [prompt]')
122
+ console.error('→ or: mo default # pick one, then "mo ask" needs no model')
96
123
  process.exit(1)
97
124
  }
98
125
 
99
- // Remaining args form the prompt
100
- const promptArgs = args.slice(1).join(' ').trim()
126
+ const promptArgs = args.slice(given ? 1 : 0).join(' ').trim()
101
127
 
102
128
  // Read stdin if piped
103
129
  let stdinContent = ''
@@ -140,15 +166,32 @@ Examples:
140
166
  const options = {}
141
167
  if (effort) options.outputEffort = effort
142
168
  if (budget) options.outputBudget = parseInt(budget, 10)
169
+
170
+ // --verbose puts log lines on stderr on purpose; a spinner would be
171
+ // overwritten by them and leave its last frame behind.
172
+ const stopSpinner = (json || verbose || quiet) ? null : startSpinner(model.id)
173
+ let stopped = false
174
+ const stop = () => {
175
+ if (stopped) return
176
+ stopped = true
177
+ stopSpinner?.()
178
+ }
179
+
143
180
  if (stream && !json) {
144
- options.realtimeHandler = (delta) => process.stdout.write(delta)
181
+ options.realtimeHandler = (delta) => {
182
+ stop()
183
+ process.stdout.write(delta)
184
+ }
145
185
  options.bufferOpts = { maxChars: 1, maxMs: 0 }
146
186
  }
147
187
 
148
- process.stderr.write(`${model.id}\n`)
188
+ // Without a terminal the id is worth echoing only when it is not what was
189
+ // typed — an alias or a partial that resolved to something else.
190
+ if (!stopSpinner && !quiet && model.id !== modelId) process.stderr.write(`${model.id}\n`)
149
191
 
150
192
  try {
151
193
  const result = await model.answer(prompt, options)
194
+ stop()
152
195
  const output = typeof result === 'string' ? result : result?.output || ''
153
196
  const tokens = typeof result === 'object' ? result : {}
154
197
 
@@ -170,7 +213,7 @@ Examples:
170
213
  if (output && !output.endsWith('\n')) process.stdout.write('\n')
171
214
  }
172
215
 
173
- // Token + timing summary to stderr
216
+ // Token + timing summary to stderr.
174
217
  const summary = []
175
218
  if (tokens.inputTokens) summary.push(`${tokens.inputTokens} in`)
176
219
  if (tokens.outputTokens) summary.push(`${tokens.outputTokens} out`)
@@ -195,8 +238,11 @@ Examples:
195
238
  if (ttft != null) summary.push(`${Math.round(ttft)}ms ttft`)
196
239
  if (total != null) summary.push(`${Math.round(total)}ms total`)
197
240
  }
198
- if (summary.length) process.stderr.write(`${summary.join(', ')}\n`)
241
+ // --json already carries these numbers in the payload; --quiet keeps
242
+ // stderr free so a caller can treat anything on it as a failure.
243
+ if (summary.length && !json && !quiet) process.stderr.write(`${summary.join(', ')}\n`)
199
244
  } catch (err) {
245
+ stop()
200
246
  console.error(`Error: ${err.detail || err.message}`)
201
247
  for (const h of hintsForError(err, modelId)) console.error(h)
202
248
  process.exit(1)
package/src/cli/backup.js CHANGED
@@ -14,7 +14,8 @@ Usage:
14
14
  model backup restore <slot> Restore from a backup slot
15
15
  model backup diff <slot> Show changes between current and slot
16
16
 
17
- Slots: prev (last save), daily (first save of the day), weekly (first save of the week)`)
17
+ Slots: prev (last save), daily (first save of the day),
18
+ weekly (first save of the week)`)
18
19
  process.exit(0)
19
20
  }
20
21
 
package/src/cli/check.js CHANGED
@@ -1,106 +1,35 @@
1
1
  import { label, err, warn, ok } from './colors.js'
2
- import providers from '../lib/providers.js'
3
- import { validate, isValidTag } from '../lib/schema.js'
4
- import { adapters } from '../../js/session/adapters/index.js'
5
- import { getCuratedModels, loadDefaultEnv, catalogEntries, catalogValues } from '../lib/common.js'
6
-
7
- // --- Local validation ---
8
-
9
- const checkLocal = (curated) => {
10
- const errors = []
11
- const warnings = []
12
- const knownProviders = new Set(Object.keys(providers))
13
-
14
- for (const [key, spec] of catalogEntries(curated)) {
15
- const [keyProvider] = key.split('/')
16
-
17
- if (spec.deprecated) {
18
- if (!curated[spec.deprecated]) {
19
- errors.push(`${key}: deprecated target '${spec.deprecated}' not in curated`)
20
- }
21
- continue
22
- }
23
-
24
- for (const issue of validate(spec, key)) {
25
- if (issue.severity === 'error') errors.push(`${key}: ${issue.field} — ${issue.message}`)
26
- else warnings.push(`${key}: ${issue.field} — ${issue.message}`)
27
- }
28
-
29
- if (!knownProviders.has(keyProvider)) {
30
- errors.push(`${key}: provider '${keyProvider}' not in providers.js`)
31
- }
32
- if (spec.provider && spec.provider !== keyProvider) {
33
- errors.push(`${key}: spec.provider '${spec.provider}' doesn't match key prefix '${keyProvider}'`)
34
- }
35
-
36
- const providerConfig = providers[keyProvider]
37
- if (providerConfig && spec.sdk && spec.sdk !== providerConfig.sdk) {
38
- errors.push(`${key}: spec.sdk '${spec.sdk}' doesn't match provider sdk '${providerConfig.sdk}'`)
39
- }
40
-
41
- if (!spec.label) warnings.push(`${key}: missing label`)
42
-
43
- for (const priceField of ['inputPrice', 'outputPrice', 'thinkingPrice']) {
44
- const val = spec[priceField]
45
- if (val != null && typeof val === 'object' && val.default == null) {
46
- errors.push(`${key}: ${priceField} is tiered but missing 'default' key`)
47
- }
48
- }
49
-
50
- if (spec.thinkingEffortLevels && !spec.defaultThinkingEffort) {
51
- warnings.push(`${key}: has thinkingEffortLevels but no defaultThinkingEffort`)
52
- }
53
-
54
- const lanes = adapters[keyProvider]?.speedLanes
55
- for (const [lane, overlay] of Object.entries(spec.speeds || {})) {
56
- if (!lanes?.has(lane)) {
57
- const detail = lanes ? `accepts: ${[...lanes].join(', ')}` : 'implements no speed lanes'
58
- errors.push(`${key}: speeds.${lane} — provider '${keyProvider}' ${detail}; calls on that lane would fail at dispatch`)
59
- }
60
- for (const priceField of ['inputPrice', 'outputPrice', 'thinkingPrice']) {
61
- const val = overlay[priceField]
62
- if (val != null && typeof val === 'object' && val.default == null) {
63
- errors.push(`${key}: speeds.${lane}.${priceField} is tiered but missing 'default' key`)
64
- }
65
- }
66
- const priced = ['inputPrice', 'outputPrice'].some(f => overlay[f] != null)
67
- if (!priced) {
68
- warnings.push(`${key}: speeds.${lane} restates no prices — the lane will bill at base rates`)
69
- }
70
- }
71
-
72
- if (Array.isArray(spec.tags)) {
73
- for (const t of spec.tags) {
74
- if (!isValidTag(t)) warnings.push(`${key}: invalid tag "${t}" — must match /^[a-zA-Z][a-zA-Z0-9._-]{0,31}$/`)
75
- }
76
- }
77
- }
78
-
79
- return { errors, warnings }
80
- }
2
+ import { reviewCatalog } from '../lib/catalog-review.js'
3
+ import { localConventionsOrExit } from './local.js'
4
+ import { getCuratedModels, loadDefaultEnv, catalogValues } from '../lib/common.js'
81
5
 
82
6
  // --- CLI ---
83
7
 
84
8
  export async function runCheck (args) {
85
9
  if (args.includes('-h') || args.includes('--help')) {
86
- console.log(`mohdel model check — validate curated catalog
10
+ console.log(`mohdel model check — validate the catalog
87
11
 
88
12
  Usage:
89
13
  model check [options]
14
+ model check --entry <file|-> Validate entries not yet in the catalog
90
15
 
91
16
  Options:
92
17
  --json Output as JSON
18
+ --entry <file|-> Read entries in curated.json shape and report what they
19
+ would change, without writing. 'mo model apply' writes.
93
20
 
94
21
  Checks:
95
22
  Schema types, required fields, deprecated targets, provider/sdk
96
- consistency, tiered pricing, thinking config.
97
-
98
- Note: 0.90 drops the upstream-drift check that piggybacked on the
99
- legacy per-provider SDK factory. If you need upstream drift
100
- detection, file an issue — it'll be rebuilt on the /session stack.`)
23
+ consistency, tiered pricing, thinking config.`)
101
24
  process.exit(0)
102
25
  }
103
26
 
27
+ if (args.includes('--entry')) {
28
+ const { runCheckEntry } = await import('./entry.js')
29
+ await runCheckEntry(args)
30
+ return
31
+ }
32
+
104
33
  loadDefaultEnv()
105
34
 
106
35
  const json = args.includes('--json')
@@ -114,7 +43,7 @@ detection, file an issue — it'll be rebuilt on the /session stack.`)
114
43
  console.log(`${label('Catalog:')} ${active} active, ${deprecated} deprecated\n`)
115
44
  }
116
45
 
117
- const { errors, warnings: localWarnings } = checkLocal(curated)
46
+ const { errors, warnings: localWarnings } = reviewCatalog(curated, { local: await localConventionsOrExit() })
118
47
 
119
48
  if (!json) {
120
49
  if (errors.length) {
@@ -0,0 +1,130 @@
1
+ import providerDefs from '../lib/providers.js'
2
+ import { fieldDefs } from '../lib/schema.js'
3
+ import { getCuratedModels, catalogEntries } from '../lib/common.js'
4
+ import { ALIASES } from './aliases.js'
5
+
6
+ // Completion must never import the factory (`src/lib/index.js`), which pulls
7
+ // every adapter, provider SDK and the OTel stack — 350ms that would be paid on
8
+ // every press of the tab key. Reading the catalog costs about 30ms.
9
+
10
+ const NOUNS = ['model', 'provider', 'creator', 'tag', 'ratelimit', 'ask', 'transcribe', 'default', 'doctor']
11
+
12
+ const VERBS = {
13
+ model: ['list', 'search', 'stats', 'show', 'get', 'set', 'rm', 'add', 'instructions',
14
+ 'check', 'apply', 'rank', 'bench', 'curate', 'backup'],
15
+ provider: ['list', 'show', 'models', 'setup', 'rm'],
16
+ creator: ['list', 'show'],
17
+ tag: ['list', 'show', 'add', 'rm'],
18
+ ratelimit: ['show', 'set', 'rm', 'provider']
19
+ }
20
+
21
+ // What the argument at a given offset after `<noun> <verb>` names. `model` is
22
+ // an id already in the catalog, so `model add` — which takes a new one — is
23
+ // deliberately absent.
24
+ const ARGUMENT = {
25
+ ask: ['model'],
26
+ transcribe: ['model'],
27
+ 'model show': ['model'],
28
+ 'model get': ['model', 'field'],
29
+ 'model set': ['model', 'field'],
30
+ 'model rm': ['model', 'field'],
31
+ 'model bench': ['model'],
32
+ 'model curate': ['provider'],
33
+ 'model instructions': ['provider'],
34
+ 'provider list': ['provider'],
35
+ 'provider show': ['provider'],
36
+ 'provider models': ['provider'],
37
+ 'provider setup': ['provider'],
38
+ 'provider rm': ['provider'],
39
+ 'creator show': ['creator'],
40
+ 'tag list': ['model'],
41
+ 'tag show': ['tag'],
42
+ 'tag add': ['model', 'tag'],
43
+ 'tag rm': ['model', 'tag'],
44
+ 'ratelimit show': ['model'],
45
+ 'ratelimit set': ['model'],
46
+ 'ratelimit rm': ['model']
47
+ }
48
+
49
+ const fromCatalog = async (pick) => {
50
+ const catalog = await getCuratedModels()
51
+ const out = new Set()
52
+ for (const [key, spec] of catalogEntries(catalog)) {
53
+ if (spec.deprecated) continue
54
+ pick(key, spec, out)
55
+ }
56
+ return [...out]
57
+ }
58
+
59
+ const kinds = {
60
+ model: () => fromCatalog((key, _spec, out) => out.add(key)),
61
+ creator: () => fromCatalog((_key, spec, out) => spec.creator && out.add(spec.creator)),
62
+ tag: () => fromCatalog((_key, spec, out) => (spec.tags || []).forEach(t => out.add(t))),
63
+ provider: async () => Object.keys(providerDefs),
64
+ field: async () => Object.keys(fieldDefs)
65
+ }
66
+
67
+ /**
68
+ * @param {number} cword Index of the word being completed (bash COMP_CWORD).
69
+ * @param {string[]} words The full command line, split (bash COMP_WORDS).
70
+ * @returns {Promise<string[]>}
71
+ */
72
+ export const candidates = async (cword, words) => {
73
+ const current = words[cword] ?? ''
74
+ const before = words.slice(1, cword).filter(w => !w.startsWith('-'))
75
+
76
+ const offer = (list) => list.filter(c => c.startsWith(current)).sort()
77
+
78
+ if (!before.length) {
79
+ return offer([...NOUNS, ...Object.keys(ALIASES)])
80
+ }
81
+
82
+ // Resolve the alias exactly as the router does, so completion and dispatch
83
+ // never disagree about what a word means.
84
+ const alias = ALIASES[before[0]]
85
+ const chain = alias ? [alias.noun, ...alias.inject, ...before.slice(1)] : before
86
+ const [noun, verb, ...rest] = chain
87
+
88
+ if (chain.length === 1 && VERBS[noun]) return offer(VERBS[noun])
89
+
90
+ // `model show <id>` keys on two words; `ask <id>` on one. Whichever matched
91
+ // decides where the argument list starts.
92
+ const paired = ARGUMENT[`${noun} ${verb}`]
93
+ const shape = paired ?? ARGUMENT[noun]
94
+ if (!shape) return []
95
+ const typed = paired ? rest : [verb, ...rest].filter(Boolean)
96
+
97
+ const kind = shape[typed.length]
98
+ if (!kind || !kinds[kind]) return []
99
+ return offer(await kinds[kind]())
100
+ }
101
+
102
+ export const BASH_SCRIPT = `# mohdel completion for bash. Add to ~/.bashrc:
103
+ # source <(mo completion bash)
104
+ _mo_completion() {
105
+ local IFS=$'\\n'
106
+ COMPREPLY=($(mo __complete "$COMP_CWORD" "\${COMP_WORDS[@]}" 2>/dev/null))
107
+ }
108
+ complete -o default -F _mo_completion mo
109
+ `
110
+
111
+ export async function runComplete (args) {
112
+ // A crash here would break the user's tab key, so an unreadable or corrupt
113
+ // catalog offers nothing rather than an error.
114
+ try {
115
+ const cword = Number.parseInt(args[0], 10)
116
+ if (!Number.isFinite(cword)) return
117
+ const list = await candidates(cword, args.slice(1))
118
+ if (list.length) process.stdout.write(`${list.join('\n')}\n`)
119
+ } catch {}
120
+ }
121
+
122
+ export function runCompletionScript (args) {
123
+ const shell = args[0]
124
+ if (shell === 'bash') {
125
+ process.stdout.write(BASH_SCRIPT)
126
+ return
127
+ }
128
+ console.error(`Usage: mo completion bash${shell ? ` (got "${shell}")` : ''}`)
129
+ process.exit(1)
130
+ }
@@ -1,11 +1,37 @@
1
1
  import { intro, outro, select, isCancel, cancel } from '@clack/prompts'
2
- import { getCuratedModels, CONFIG_PATH, saveConfig, catalogEntries } from '../lib/common.js'
3
- import providers from '../lib/providers.js'
2
+ import { getConfig, getCuratedModels, saveConfig, catalogEntries } from '../lib/common.js'
4
3
 
5
- export async function runDefault () {
6
- intro('Mohdel — Set Default Model')
4
+ // CLI-only. The default model is a convenience for someone typing `mo ask`;
5
+ // the library and the gate never read it. A program picks a model by id or by
6
+ // tag, where the choice is explicit and reviewable — a machine inheriting a
7
+ // human's terminal preference is how a call ends up on a model nobody chose.
8
+ export async function runDefault (args = []) {
9
+ if (args.includes('-h') || args.includes('--help')) {
10
+ console.log(`mohdel default — the model "mo ask" uses when you name none
11
+
12
+ Usage:
13
+ default Pick one from your catalog (interactive)
14
+ default <model> Set it directly
15
+
16
+ Stored in ~/.config/mohdel/default.json. The library and the gate never
17
+ read it: a program names its model, or selects one by tag.`)
18
+ process.exit(0)
19
+ }
7
20
 
8
21
  const curated = await getCuratedModels()
22
+
23
+ const named = args.find(a => !a.startsWith('-'))
24
+ if (named) {
25
+ if (!curated[named] || curated[named].deprecated) {
26
+ console.error(`'${named}' is not a model in your catalog. "mo ls" lists them.`)
27
+ process.exit(1)
28
+ }
29
+ await saveConfig({ ...(await getConfig()), defaultModel: named })
30
+ console.log(`Default set to ${named} — "mo ask" uses it when you give no model.`)
31
+ return
32
+ }
33
+
34
+ intro('mohdel — set the default model')
9
35
  const modelOptions = catalogEntries(curated).map(([modelId, info]) => ({
10
36
  value: modelId,
11
37
  label: `${info.label} (${modelId})`
@@ -22,16 +48,11 @@ export async function runDefault () {
22
48
  process.exit(0)
23
49
  }
24
50
 
25
- const [providerName] = selectedModelId.split('/')
26
-
27
51
  try {
28
- const config = { defaultModel: selectedModelId }
29
- const apiKeyEnv = providers[providerName]?.apiKeyEnv
30
- if (apiKeyEnv) {
31
- config.apiKeyInfo = `Set ${apiKeyEnv} environment variable for this provider`
32
- }
33
- await saveConfig(config)
34
- outro(`Default set to ${selectedModelId} — saved to ${CONFIG_PATH}`)
52
+ // Merge: the file also holds the chosen coding agent, and replacing the
53
+ // object wholesale would drop it.
54
+ await saveConfig({ ...(await getConfig()), defaultModel: selectedModelId })
55
+ outro(`Default set to ${selectedModelId} — "mo ask" uses it when you give no model.`)
35
56
  } catch (err) {
36
57
  cancel(`Error: ${err.message}`)
37
58
  process.exit(1)