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.
- package/README.md +154 -29
- package/config/curated.schema.json +32 -10
- 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/_chat_completions.js +3 -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/js/session/run_image.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* @module session/run_image
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import {
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
".":
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
"./
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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.
|
|
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.
|
|
139
|
+
"mohdel-thin-gate-linux-x64-gnu": "1.0.0"
|
|
112
140
|
},
|
|
113
141
|
"dependencies": {
|
|
114
|
-
"@anthropic-ai/sdk": "^0.
|
|
142
|
+
"@anthropic-ai/sdk": "^0.125.0",
|
|
115
143
|
"@cerebras/cerebras_cloud_sdk": "^1.91.0",
|
|
116
|
-
"@google/genai": "^2.
|
|
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.
|
|
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.
|
|
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:
|
|
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
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
|
|
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),
|
|
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
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
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
|
|
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 } =
|
|
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
|
+
}
|
package/src/cli/default.js
CHANGED
|
@@ -1,11 +1,37 @@
|
|
|
1
1
|
import { intro, outro, select, isCancel, cancel } from '@clack/prompts'
|
|
2
|
-
import {
|
|
3
|
-
import providers from '../lib/providers.js'
|
|
2
|
+
import { getConfig, getCuratedModels, saveConfig, catalogEntries } from '../lib/common.js'
|
|
4
3
|
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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)
|