mohdel 0.122.0 → 0.124.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 CHANGED
@@ -154,7 +154,9 @@ const result = await mo.use('anthropic/claude-sonnet-4-6').answer('Hello')
154
154
  console.log(result.output, result.cost)
155
155
  ```
156
156
 
157
- No subprocess, no setup beyond your API key. Right for CLI tools (`mo ask`), scripts, tests, and single-process services — which is most projects.
157
+ No subprocess, no setup beyond your API key. Right for CLI tools (`mo ask`), scripts, tests, and single-process services — which is most projects. Pass an `AbortSignal` as `answer(prompt, { signal })` to cancel in flight.
158
+
159
+ The factory reads the catalog from `~/.config/mohdel/curated.json` and keys from the environment, the same defaults a gate session starts from. `mohdel({ models })` replaces the catalog for the process, factory and session runtime alike, the way `set_catalog` replaces it in a gate session. `mohdel({ configurations: { openai: { apiKey } } })` overrides the key per provider.
158
160
 
159
161
  ### Client — cross-process (the production gateway)
160
162
 
@@ -233,9 +235,26 @@ With no session-bin configured, thin-gate runs in demo mode: `POST /v1/call` ret
233
235
 
234
236
  The client snippet under [Library Usage](#library-usage) above is the full surface: `call(envelope, { socketPath, signal? })` returns an async iterable of events. Pass an `AbortSignal` to cancel in flight; thin-gate forwards a cancel control message to the session and reuses it on the pool. The envelope is the flat `answer(prompt, options)` surface plus transport metadata (`callId`, `authId`, `auth.key`, optional `traceparent`); see [`js/core/envelope.js`](js/core/envelope.js) for the full field list.
235
237
 
238
+ ### Other languages
239
+
240
+ The gate's HTTP surface is specified in [PROTOCOL.md §10](PROTOCOL.md#10-gate-http-surface-clients); `test/conformance/` holds the fixtures a client round-trips. Reference clients live under `clients/`:
241
+
242
+ - **Lua** — [`clients/lua`](clients/lua): Lua 5.1+ / LuaJIT, transports over LuaSocket or `curl`. `cd clients/lua && luarocks make`.
243
+ - **Gleam** — [`clients/gleam`](clients/gleam): Erlang target, typed events and results, `gen_tcp` over the unix socket. Path dependency until it is on Hex.
244
+ - **Rust** — [`clients/rust`](clients/rust): async (tokio), uses the gate's own wire types from the `mohdel-protocol` crate. Path dependency until it is on crates.io.
245
+ - **OCaml** — [`clients/ocaml`](clients/ocaml): synchronous, stdlib `Unix` transport, `yojson`; OCaml 4.14+. `opam pin` until it is on opam.
246
+
247
+ ```lua
248
+ local mohdel = require('mohdel')
249
+ local c = mohdel.connect{ socket = '/tmp/mohdel-data.sock' }
250
+ for ev in c:call(envelope):events() do
251
+ if ev.type == 'delta' then io.write(ev.delta.delta) end
252
+ end
253
+ ```
254
+
236
255
  ### Canonical types (frozen wire contract)
237
256
 
238
- Wire format is JSON over NDJSON frames, camelCase. Types are defined in `js/core/` (JSDoc) and mirrored in `rust/thin-gate/src/protocol.rs` (serde). Cross-language conformance tests enforce round-trip fidelity. The session-side protocol (envelopes in, events out, cancel control messages) is specified in [PROTOCOL.md](PROTOCOL.md) — read that to implement a session in another language.
257
+ Wire format is JSON over NDJSON frames, camelCase. Types are defined in `js/core/` (JSDoc) and mirrored in `rust/protocol/src/protocol.rs` (serde, the `mohdel-protocol` crate). Cross-language conformance tests enforce round-trip fidelity. The session-side protocol (envelopes in, events out, cancel control messages) is specified in [PROTOCOL.md](PROTOCOL.md) — read that to implement a session in another language.
239
258
 
240
259
  - **`CallEnvelope`** — flat `answer()` options plus transport metadata: `callId`, `authId`, `auth.key`, `traceparent?`, `baggage?`, `provider`, `model`, `prompt`, `outputBudget?`, `outputType?`, `outputStyle?`, `outputEffort?`, `images?`, `videos?`, `cache?`, `tools?`, `toolChoice?`, `parallelToolCalls?`, `identifier?`.
241
260
  - **`Event`** — three-variant union discriminated on `type`:
@@ -35,7 +35,6 @@ import { createRealtimeDeltaBuffer } from '../../src/lib/utils.js'
35
35
  * Hook that returns `{rpmLimit, tpmLimit}` for a given provider —
36
36
  * lets the factory keep its own `providersConfig` as source of
37
37
  * truth instead of the module-level `providers.json` loader.
38
- * @property {AbortSignal} [signal]
39
38
  */
40
39
 
41
40
  /**
@@ -58,6 +57,7 @@ import { createRealtimeDeltaBuffer } from '../../src/lib/utils.js'
58
57
  * | `providerOrder` / `providerAllow` / `providerDeny`| envelope.providerOptions.openrouter |
59
58
  * | `traceparent` / `baggage` | envelope transport metadata |
60
59
  * | `callId` / `authId` | envelope transport metadata |
60
+ * | `signal` | not on the wire — `run()` deps, aborts the call |
61
61
  * | `configuration.apiKey` | envelope.auth.key |
62
62
  * | **`parentSpan`** | **dropped** — use `traceparent` instead |
63
63
  * | **`maybeThrowHandler`** | **dropped** — no factory-side validation hook |
@@ -90,7 +90,7 @@ export async function runAnswer ({ provider, model, modelKey, configuration, pro
90
90
 
91
91
  let terminal
92
92
  try {
93
- for await (const ev of run(envelope, deps)) {
93
+ for await (const ev of run(envelope, { ...deps, signal: options.signal })) {
94
94
  if (ev.type === 'delta' && ev.delta) {
95
95
  if (deltaBuffer) deltaBuffer.push(ev.delta.type, ev.delta.delta)
96
96
  } else if (ev.type === 'done' || ev.type === 'error') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mohdel",
3
- "version": "0.122.0",
3
+ "version": "0.124.0",
4
4
  "license": "MIT",
5
5
  "author": {
6
6
  "name": "Christophe Le Bars",
@@ -108,16 +108,16 @@
108
108
  "@opentelemetry/exporter-trace-otlp-grpc": "^0.222.0",
109
109
  "@opentelemetry/sdk-node": "^0.222.0",
110
110
  "chalk": "^6.0.0",
111
- "mohdel-thin-gate-linux-x64-gnu": "0.122.0"
111
+ "mohdel-thin-gate-linux-x64-gnu": "0.124.0"
112
112
  },
113
113
  "dependencies": {
114
- "@anthropic-ai/sdk": "^0.122.0",
114
+ "@anthropic-ai/sdk": "^0.123.0",
115
115
  "@cerebras/cerebras_cloud_sdk": "^1.91.0",
116
116
  "@google/genai": "^2.20.0",
117
117
  "@opentelemetry/api": "^1.9.1",
118
118
  "env-paths": "^4.0.0",
119
119
  "groq-sdk": "^1.6.0",
120
- "openai": "^7.8.0",
120
+ "openai": "^7.9.0",
121
121
  "undici": "^7.29.0"
122
122
  },
123
123
  "lint-staged": {
package/src/cli/ask.js CHANGED
@@ -13,7 +13,7 @@ export const hintsForError = (err, modelId) => {
13
13
  const provider = modelId.includes('/') ? modelId.split('/')[0] : null
14
14
  const hints = []
15
15
 
16
- if (/not found in curated models/i.test(both)) {
16
+ if (/not found in catalog/i.test(both)) {
17
17
  if (provider) {
18
18
  hints.push(`→ run: mo curate ${provider} # add upstream models from this provider`)
19
19
  hints.push(`→ or: mo model add ${modelId} # add this one manually`)
package/src/lib/common.js CHANGED
@@ -112,12 +112,11 @@ const createFileOperation = (filePath, defaultValue = {}, operationType) => {
112
112
  const loadHandler = async () => {
113
113
  let loadedData
114
114
  try {
115
- if (!existsSync(CONFIG_DIR)) {
116
- await mkdir(CONFIG_DIR, { recursive: true })
117
- }
118
-
119
115
  if (!existsSync(filePath)) {
120
116
  if (defaultValue && Object.keys(defaultValue).length > 0) {
117
+ if (!existsSync(CONFIG_DIR)) {
118
+ await mkdir(CONFIG_DIR, { recursive: true })
119
+ }
121
120
  await writeFile(filePath, JSON.stringify(defaultValue, null, 2))
122
121
  loadedData = JSON.parse(JSON.stringify(defaultValue))
123
122
  } else {
@@ -69,6 +69,11 @@ const rebuildAliasMap = () => {
69
69
 
70
70
  export const loadCuratedCache = ensureCuratedCache
71
71
 
72
+ export const setCuratedCache = (table) => {
73
+ curatedCache = { ...table }
74
+ aliasMapCache = buildAliasMap(curatedCache)
75
+ }
76
+
72
77
  export const getCuratedCacheSnapshot = () => curatedCache
73
78
 
74
79
  export const getAliasMapSnapshot = () => aliasMapCache
package/src/lib/index.js CHANGED
@@ -7,6 +7,7 @@ import providers from './providers.js'
7
7
  import { getAPIKey, loadDefaultEnv, getProvidersConfig, saveProvidersConfig, setLogger as setCommonLogger } from './common.js'
8
8
  import {
9
9
  loadCuratedCache,
10
+ setCuratedCache,
10
11
  getCuratedCacheSnapshot,
11
12
  expandModelAliasSync,
12
13
  suggestModels,
@@ -14,6 +15,7 @@ import {
14
15
  } from './curated-cache.js'
15
16
  import { createRateLimiter } from '../../js/session/_rate_limiter.js'
16
17
  import { createCooldownTracker } from '../../js/session/_cooldown.js'
18
+ import { setCatalog } from '../../js/session/adapters/_catalog.js'
17
19
  import { runAnswer, runAnswerImage, runAnswerTranscription } from '../../js/factory/bridge.js'
18
20
  import { startSpan, endSpanOk, endSpanError } from './tracing.js'
19
21
  import { isValidTag } from './schema.js'
@@ -200,21 +202,27 @@ export { buildHandlers as _buildHandlersForTests }
200
202
  * @param {Function} [opts.onFailure] — fired after every failed answer call
201
203
  * @param {number} [opts.cooldownThreshold=3] — consecutive provider failures before cooldown
202
204
  * @param {number} [opts.cooldownDuration=60000] — cooldown duration in ms
203
- * @param {object} [opts.models] — model catalog (library mode — skips disk init)
204
- * @param {object} [opts.configurations] — provider configurations (library mode)
205
+ * @param {object} [opts.models] — catalog `{ [provider/model]: spec }`. Replaces
206
+ * `~/.config/mohdel/curated.json` for this process,
207
+ * factory and session runtime alike.
208
+ * @param {object} [opts.configurations] — `{ [provider]: { apiKey } }`. Overrides the env
209
+ * key per provider; env for the rest.
205
210
  */
206
211
  const mohdel = async ({ logger, verbosity: verbosityOpt, onSuccess, onFailure, cooldownThreshold, cooldownDuration, models, configurations } = {}) => {
207
- // When consumer provides models + configurations, skip disk-based init (library mode).
208
- // Otherwise load from ~/.config/mohdel/ (CLI / standalone mode).
209
- const libraryMode = !!(models && configurations)
210
-
211
- if (!libraryMode) {
212
- loadDefaultEnv()
212
+ loadDefaultEnv()
213
+
214
+ // The session runtime keeps its own catalog cache (the seam thin-gate's
215
+ // `set_catalog` uses) and would otherwise lazy-load curated.json, so a
216
+ // replacement has to reach both caches.
217
+ if (models) {
218
+ setCuratedCache(models)
219
+ setCatalog(models)
220
+ } else {
213
221
  await loadCuratedCache()
214
222
  }
215
223
 
216
224
  // Provider config: user-specific rate limits per provider (~/.config/mohdel/providers.json)
217
- const providersConfig = libraryMode ? {} : await getProvidersConfig()
225
+ const providersConfig = await getProvidersConfig()
218
226
 
219
227
  // Resolve verbosity tier once at init. Factory opt > env var > default.
220
228
  // Captured in the answer() closure below to gate per-call log lines.
@@ -248,7 +256,7 @@ const mohdel = async ({ logger, verbosity: verbosityOpt, onSuccess, onFailure, c
248
256
  get: (target, prop) => {
249
257
  if (prop === 'list') {
250
258
  return (tag) => { // Sync
251
- const catalog = libraryMode ? models : getCuratedCacheSnapshot()
259
+ const catalog = getCuratedCacheSnapshot()
252
260
  let modelEntries = Object.entries(catalog)
253
261
  .filter(([, metadata]) => !metadata.deprecated)
254
262
 
@@ -267,7 +275,7 @@ const mohdel = async ({ logger, verbosity: verbosityOpt, onSuccess, onFailure, c
267
275
 
268
276
  if (prop === 'use') {
269
277
  return (modelId) => { // Sync
270
- const catalog = libraryMode ? models : getCuratedCacheSnapshot()
278
+ const catalog = getCuratedCacheSnapshot()
271
279
 
272
280
  // Parse optional :outputEffort suffix (e.g. "claude-opus:max").
273
281
  // Effort levels vary per spec (`thinkingEffortLevels` keys —
@@ -284,7 +292,7 @@ const mohdel = async ({ logger, verbosity: verbosityOpt, onSuccess, onFailure, c
284
292
  // Fallback specs are excluded from that test on purpose:
285
293
  // providers that synthesize them resolve any string, which
286
294
  // would disable suffix parsing for them entirely.
287
- const exactId = libraryMode ? modelId : expandModelAliasSync(modelId)
295
+ const exactId = expandModelAliasSync(modelId)
288
296
  const isCuratedId = !!catalog[exactId]
289
297
 
290
298
  // `@speed` is parsed off first so the two suffixes are read
@@ -299,7 +307,7 @@ const mohdel = async ({ logger, verbosity: verbosityOpt, onSuccess, onFailure, c
299
307
  // resolves the same as `x@fast`.
300
308
  const colon = base.lastIndexOf(':')
301
309
  const probe = colon > 0 ? base.slice(0, colon) : base
302
- const probeResolved = libraryMode ? probe : expandModelAliasSync(probe)
310
+ const probeResolved = expandModelAliasSync(probe)
303
311
  if (catalog[probeResolved] || createFallbackModelSpec(probeResolved)) {
304
312
  aliasSpeed = candidate
305
313
  modelId = base
@@ -311,7 +319,7 @@ const mohdel = async ({ logger, verbosity: verbosityOpt, onSuccess, onFailure, c
311
319
  if (colonIdx > 0) {
312
320
  const candidate = modelId.slice(colonIdx + 1)
313
321
  const base = modelId.slice(0, colonIdx)
314
- const baseResolved = libraryMode ? base : expandModelAliasSync(base)
322
+ const baseResolved = expandModelAliasSync(base)
315
323
  const baseSpec = catalog[baseResolved] || createFallbackModelSpec(baseResolved)
316
324
  if (baseSpec) {
317
325
  aliasOutputEffort = candidate
@@ -319,17 +327,14 @@ const mohdel = async ({ logger, verbosity: verbosityOpt, onSuccess, onFailure, c
319
327
  }
320
328
  }
321
329
 
322
- let resolvedModelId = libraryMode ? modelId : expandModelAliasSync(modelId)
330
+ let resolvedModelId = expandModelAliasSync(modelId)
323
331
  let modelSpec = catalog[resolvedModelId]
324
332
 
325
333
  if (!modelSpec) {
326
334
  modelSpec = createFallbackModelSpec(resolvedModelId)
327
335
  if (!modelSpec) {
328
- if (libraryMode) {
329
- throw new Error(`Model '${modelId}' not found in provided models.`)
330
- }
331
336
  const suggestions = suggestModels(modelId)
332
- let msg = `Model '${modelId}' not found in curated models.`
337
+ let msg = `Model '${modelId}' not found in catalog.`
333
338
  if (suggestions.length) {
334
339
  msg += ' Did you mean?\n' + suggestions.map(s => ` ${s.id} ${s.label}`).join('\n')
335
340
  }
@@ -777,7 +782,7 @@ const createModelProxy = (resolvedModelId, modelSpec, handlers, aliasOutputEffor
777
782
 
778
783
  if (prop === 'info') {
779
784
  return () => { // Sync
780
- const catalog = externalConfigurations ? null : getCuratedCacheSnapshot()
785
+ const catalog = getCuratedCacheSnapshot()
781
786
  return catalog?.[resolvedModelId] ? { ...catalog[resolvedModelId] } : { ...modelSpec }
782
787
  }
783
788
  }