mohdel 0.118.0 → 0.119.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
@@ -221,6 +221,8 @@ cargo run --bin mohdel-thin-gate /tmp/mohdel-data.sock /tmp/mohdel-admin.sock /p
221
221
  Positional args are optional (data socket, admin socket, session bin). Env overrides:
222
222
  - `MOHDEL_SESSION_BIN` — path to session entrypoint (defaults to none; if unset, data plane returns synthetic events)
223
223
  - `MOHDEL_SESSION_POOL_SIZE` — pre-warmed sessions (default 2)
224
+ - `MOHDEL_POOL_ACQUIRE_TIMEOUT_MS` — wait for a free session before `503 SESSION_POOL_BUSY` (default 30000)
225
+ - `MOHDEL_MAX_CONNECTIONS` — concurrently served data-plane connections (default 64)
224
226
 
225
227
  With no session-bin configured, thin-gate runs in demo mode: `POST /v1/call` returns a synthetic echo event sequence. Useful for health-checking the HTTP layer without a runtime dependency on Node.
226
228
 
package/js/client/call.js CHANGED
@@ -9,6 +9,7 @@
9
9
  */
10
10
 
11
11
  import { requestUnix } from './transport.js'
12
+ import { readAll, parseErrorBody } from './response.js'
12
13
  import { parseNDJSON } from './ndjson.js'
13
14
  import { isEvent, MohdelError } from '#core'
14
15
 
@@ -44,33 +45,3 @@ export async function * call (envelope, { socketPath, signal, path = '/v1/call'
44
45
  yield /** @type {import('#core/events.js').Event} */(obj)
45
46
  }
46
47
  }
47
-
48
- /**
49
- * @param {AsyncIterable<Buffer|string>} stream
50
- * @returns {Promise<string>}
51
- */
52
- async function readAll (stream) {
53
- let s = ''
54
- for await (const c of stream) s += typeof c === 'string' ? c : c.toString('utf8')
55
- return s
56
- }
57
-
58
- /**
59
- * @param {string} body
60
- * @param {number} status
61
- * @returns {import('#core/errors.js').TypedError}
62
- */
63
- function parseErrorBody (body, status) {
64
- try {
65
- const parsed = JSON.parse(body)
66
- if (parsed && typeof parsed === 'object' && typeof parsed.type === 'string') {
67
- return parsed
68
- }
69
- } catch {}
70
- return {
71
- type: 'PROTOCOL_HTTP_ERROR',
72
- message: `thin-gate returned HTTP ${status}`,
73
- severity: 'error',
74
- retryable: status >= 500
75
- }
76
- }
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import { requestUnix } from './transport.js'
11
+ import { readAll, parseErrorBody } from './response.js'
11
12
  import { MohdelError } from '#core'
12
13
 
13
14
  /**
@@ -51,33 +52,3 @@ export async function callImage (envelope, { socketPath, signal, path = '/v1/ima
51
52
  }
52
53
  return parsed
53
54
  }
54
-
55
- /**
56
- * @param {AsyncIterable<Buffer|string>} stream
57
- * @returns {Promise<string>}
58
- */
59
- async function readAll (stream) {
60
- let s = ''
61
- for await (const c of stream) s += typeof c === 'string' ? c : c.toString('utf8')
62
- return s
63
- }
64
-
65
- /**
66
- * @param {string} body
67
- * @param {number} status
68
- * @returns {import('#core/errors.js').TypedError}
69
- */
70
- function parseErrorBody (body, status) {
71
- try {
72
- const parsed = JSON.parse(body)
73
- if (parsed && typeof parsed === 'object' && typeof parsed.type === 'string') {
74
- return parsed
75
- }
76
- } catch {}
77
- return {
78
- type: 'PROTOCOL_HTTP_ERROR',
79
- message: `thin-gate returned HTTP ${status}`,
80
- severity: 'error',
81
- retryable: status >= 500
82
- }
83
- }
@@ -11,6 +11,7 @@
11
11
  */
12
12
 
13
13
  import { requestUnix } from './transport.js'
14
+ import { readAll, parseErrorBody } from './response.js'
14
15
  import { MohdelError } from '#core'
15
16
 
16
17
  /**
@@ -54,33 +55,3 @@ export async function callTranscription (envelope, { socketPath, signal, path =
54
55
  }
55
56
  return parsed
56
57
  }
57
-
58
- /**
59
- * @param {AsyncIterable<Buffer|string>} stream
60
- * @returns {Promise<string>}
61
- */
62
- async function readAll (stream) {
63
- let s = ''
64
- for await (const c of stream) s += typeof c === 'string' ? c : c.toString('utf8')
65
- return s
66
- }
67
-
68
- /**
69
- * @param {string} body
70
- * @param {number} status
71
- * @returns {import('#core/errors.js').TypedError}
72
- */
73
- function parseErrorBody (body, status) {
74
- try {
75
- const parsed = JSON.parse(body)
76
- if (parsed && typeof parsed === 'object' && typeof parsed.type === 'string') {
77
- return parsed
78
- }
79
- } catch {}
80
- return {
81
- type: 'PROTOCOL_HTTP_ERROR',
82
- message: `thin-gate returned HTTP ${status}`,
83
- severity: 'error',
84
- retryable: status >= 500
85
- }
86
- }
@@ -1,10 +1,14 @@
1
1
  /**
2
2
  * NDJSON line parser. Yields parsed objects from a byte/string stream.
3
3
  *
4
+ * The cap applies to a single unterminated line, not to the accumulated
5
+ * buffer: a buffer holding several complete frames is a legitimate
6
+ * burst, not a runaway line.
7
+ *
4
8
  * @module client/ndjson
5
9
  */
6
10
 
7
- const MAX_LINE_BYTES = 16 * 1024 * 1024
11
+ import { MAX_LINE_BYTES, exceedsLineBytes } from '#core/framing.js'
8
12
 
9
13
  /**
10
14
  * @param {AsyncIterable<Buffer|string>} stream
@@ -14,15 +18,15 @@ export async function * parseNDJSON (stream) {
14
18
  let buf = ''
15
19
  for await (const chunk of stream) {
16
20
  buf += typeof chunk === 'string' ? chunk : chunk.toString('utf8')
17
- if (buf.length > MAX_LINE_BYTES) {
18
- throw new Error(`NDJSON line exceeds ${MAX_LINE_BYTES} bytes without newline`)
19
- }
20
21
  let nl
21
22
  while ((nl = buf.indexOf('\n')) !== -1) {
22
23
  const line = buf.slice(0, nl).trim()
23
24
  buf = buf.slice(nl + 1)
24
25
  if (line) yield JSON.parse(line)
25
26
  }
27
+ if (exceedsLineBytes(buf)) {
28
+ throw new Error(`NDJSON line exceeds ${MAX_LINE_BYTES} bytes without newline`)
29
+ }
26
30
  }
27
31
  const tail = buf.trim()
28
32
  if (tail) yield JSON.parse(tail)
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Shared response handling for the three gate call paths.
3
+ *
4
+ * `call.js`, `call_image.js` and `call_transcription.js` each read a
5
+ * non-200 body the same way and turn it into the same `TypedError`;
6
+ * keeping one copy means a fix to the error shape cannot land on some
7
+ * paths and miss others.
8
+ *
9
+ * @module client/response
10
+ */
11
+
12
+ /**
13
+ * @param {AsyncIterable<Buffer|string>} stream
14
+ * @returns {Promise<string>}
15
+ */
16
+ export async function readAll (stream) {
17
+ let s = ''
18
+ for await (const c of stream) s += typeof c === 'string' ? c : c.toString('utf8')
19
+ return s
20
+ }
21
+
22
+ /**
23
+ * @param {string} body
24
+ * @param {number} status
25
+ * @returns {import('#core/errors.js').TypedError}
26
+ */
27
+ export function parseErrorBody (body, status) {
28
+ try {
29
+ const parsed = JSON.parse(body)
30
+ if (parsed && typeof parsed === 'object' && typeof parsed.type === 'string') {
31
+ return parsed
32
+ }
33
+ } catch {}
34
+ return {
35
+ type: 'PROTOCOL_HTTP_ERROR',
36
+ message: `thin-gate returned HTTP ${status}`,
37
+ severity: 'error',
38
+ retryable: status >= 500
39
+ }
40
+ }
@@ -39,7 +39,12 @@ export function requestUnix ({ socketPath, path, method, body, signal, headers }
39
39
  reject(new Error('aborted'))
40
40
  return
41
41
  }
42
- signal.addEventListener('abort', () => req.destroy(new Error('aborted')), { once: true })
42
+ const onAbort = () => req.destroy(new Error('aborted'))
43
+ signal.addEventListener('abort', onAbort, { once: true })
44
+ // Released on `close` rather than on resolve: the promise
45
+ // resolves at response headers, and cancellation has to stay
46
+ // live for the streaming body that follows.
47
+ req.on('close', () => signal.removeEventListener('abort', onAbort))
43
48
  }
44
49
 
45
50
  if (body !== undefined) req.end(JSON.stringify(body))
@@ -64,6 +64,7 @@
64
64
  * the gap persists. The consumer decides whether to act (log,
65
65
  * bump a watchdog, abort via its own AbortSignal). Mohdel never
66
66
  * aborts on its own. Omitting the field disables the heartbeat.
67
+ * Positive values are raised to `MIN_IDLE_HEARTBEAT_MS` (250).
67
68
  *
68
69
  * @property {Object<string, object>} [providerOptions]
69
70
  * Namespaced bag of provider-specific knobs that don't fit the
@@ -0,0 +1,29 @@
1
+ /**
2
+ * NDJSON framing limits, shared by every JS reader of the protocol.
3
+ *
4
+ * The cap bounds a single unterminated line, not accumulated buffered
5
+ * frames, and is measured in UTF-8 bytes so it matches the gate's
6
+ * `read_capped_line` (`rust/thin-gate/src/server.rs`), which counts
7
+ * bytes off the socket. A reader that capped UTF-16 units instead
8
+ * would accept up to three times what the gate does.
9
+ *
10
+ * @module core/framing
11
+ */
12
+
13
+ export const MAX_LINE_BYTES = 16 * 1024 * 1024
14
+
15
+ /**
16
+ * Exact byte length is O(n) in the string, so it is only computed in
17
+ * the band where the answer is in doubt: UTF-8 encodes a UTF-16 unit
18
+ * as 1-3 bytes, which brackets the count between `length` and
19
+ * `3 * length`.
20
+ *
21
+ * @param {string} s
22
+ * @param {number} [cap]
23
+ * @returns {boolean}
24
+ */
25
+ export function exceedsLineBytes (s, cap = MAX_LINE_BYTES) {
26
+ if (s.length > cap) return true
27
+ if (s.length * 3 <= cap) return false
28
+ return Buffer.byteLength(s, 'utf8') > cap
29
+ }
@@ -281,7 +281,7 @@ function newCallId () {
281
281
  */
282
282
  const ALLOWED_CONFIG_KEYS = new Set(['apiKey', 'baseURL'])
283
283
 
284
- function configToAuth (configuration) {
284
+ export function configToAuth (configuration) {
285
285
  if (!configuration) return { key: '' }
286
286
  const unsupported = Object.keys(configuration).filter(k => !ALLOWED_CONFIG_KEYS.has(k))
287
287
  if (unsupported.length > 0) {
@@ -11,20 +11,30 @@
11
11
  * own. Consumers decide whether to log, bump a watchdog, or trigger
12
12
  * an external AbortSignal.
13
13
  *
14
- * The in-flight `iterator.next()` is reused across timer firings so
15
- * no real event is dropped: when the timer wins the race, the
16
- * underlying promise stays pending and the next loop iteration
17
- * attaches a fresh race to the same promise.
14
+ * `idleMs` is caller-supplied and raised to `MIN_IDLE_HEARTBEAT_MS`.
15
+ * Without a floor one request can ask for a millisecond cadence and
16
+ * turn adapter silence into thousands of serialized events per second
17
+ * through the gate.
18
+ *
19
+ * The in-flight `iterator.next()` is reused across timer firings so no
20
+ * real event is dropped. Its continuation is attached exactly once, at
21
+ * creation, and parks its result in `settled`; each idle tick races a
22
+ * fresh timer against that flag rather than re-subscribing. Attaching
23
+ * per tick instead would retain two reaction records per firing on a
24
+ * promise that by definition has not settled.
18
25
  *
19
26
  * @module session/_idle_heartbeat
20
27
  */
21
28
 
29
+ export const MIN_IDLE_HEARTBEAT_MS = 250
30
+
22
31
  /**
23
32
  * @template T
24
33
  * @param {AsyncIterable<T>} source
25
34
  * @param {number | undefined | null} idleMs
26
35
  * When falsy or non-positive, the source is yielded through
27
- * unchanged (no timer is set up).
36
+ * unchanged (no timer is set up). Positive values below
37
+ * `MIN_IDLE_HEARTBEAT_MS` are raised to it.
28
38
  * @returns {AsyncGenerator<T | import('#core/events.js').IdleEvent>}
29
39
  */
30
40
  export async function * withIdleHeartbeat (source, idleMs) {
@@ -33,28 +43,45 @@ export async function * withIdleHeartbeat (source, idleMs) {
33
43
  return
34
44
  }
35
45
 
46
+ const tickMs = Math.max(idleMs, MIN_IDLE_HEARTBEAT_MS)
36
47
  const iter = source[Symbol.asyncIterator]()
37
48
  let lastAt = Date.now()
38
- /** @type {Promise<IteratorResult<T>> | null} */
39
- let pending = null
49
+
50
+ /** @type {{real: IteratorResult<T>} | {err: unknown} | null} */
51
+ let settled = null
52
+ /** @type {(() => void) | null} */
53
+ let wake = null
54
+ let inFlight = false
55
+
56
+ const park = (result) => {
57
+ settled = result
58
+ const w = wake
59
+ wake = null
60
+ w?.()
61
+ }
40
62
 
41
63
  try {
42
64
  while (true) {
43
- if (!pending) pending = iter.next()
44
-
45
- /** @type {NodeJS.Timeout | undefined} */
46
- let timer
47
- /** @type {{idle: true} | {real: IteratorResult<T>} | {err: unknown}} */
48
- const winner = await new Promise(resolve => {
49
- timer = setTimeout(() => resolve({ idle: true }), idleMs)
50
- pending.then(
51
- r => resolve({ real: r }),
52
- e => resolve({ err: e })
65
+ if (!inFlight) {
66
+ inFlight = true
67
+ iter.next().then(
68
+ r => park({ real: r }),
69
+ e => park({ err: e })
53
70
  )
54
- })
55
- clearTimeout(timer)
71
+ }
72
+
73
+ if (!settled) {
74
+ /** @type {NodeJS.Timeout | undefined} */
75
+ let timer
76
+ await new Promise(resolve => {
77
+ wake = resolve
78
+ timer = setTimeout(resolve, tickMs)
79
+ })
80
+ clearTimeout(timer)
81
+ wake = null
82
+ }
56
83
 
57
- if ('idle' in winner) {
84
+ if (!settled) {
58
85
  yield /** @type {import('#core/events.js').IdleEvent} */ ({
59
86
  type: 'idle',
60
87
  sinceMs: Date.now() - lastAt
@@ -62,11 +89,13 @@ export async function * withIdleHeartbeat (source, idleMs) {
62
89
  continue
63
90
  }
64
91
 
65
- pending = null
66
- if ('err' in winner) throw winner.err
67
- if (winner.real.done) return
92
+ const done = settled
93
+ settled = null
94
+ inFlight = false
95
+ if ('err' in done) throw done.err
96
+ if (done.real.done) return
68
97
  lastAt = Date.now()
69
- yield winner.real.value
98
+ yield done.real.value
70
99
  }
71
100
  } finally {
72
101
  // Best-effort cleanup if the consumer abandons us mid-stream.
@@ -417,3 +417,38 @@ export function classifyProviderError (e, key, opts = {}) {
417
417
  detail
418
418
  }
419
419
  }
420
+
421
+ /**
422
+ * Wrap a `TypedError` payload in a throwable `Error`. Adapters that
423
+ * fetch directly (rather than through a provider SDK) throw these; the
424
+ * run loop lifts `.typed` onto the terminal error event.
425
+ *
426
+ * @param {string} message
427
+ * @param {string} type
428
+ * @param {boolean} retryable
429
+ * @param {string} [detail]
430
+ * @returns {Error & {typed: import('#core/errors.js').TypedError}}
431
+ */
432
+ export function typedError (message, type, retryable, detail) {
433
+ const err = new Error(message)
434
+ /** @type {import('#core/errors.js').TypedError} */
435
+ const typed = { message, severity: retryable ? 'warn' : 'error', retryable, type }
436
+ if (detail) typed.detail = detail
437
+ return Object.assign(err, { typed })
438
+ }
439
+
440
+ /**
441
+ * Classify an HTTP status, keeping the classifier's stable message and
442
+ * routing the caller's context plus any response-body snippet into
443
+ * `detail` — provider response bodies must never reach
444
+ * `TypedError.message`.
445
+ *
446
+ * @param {number} status
447
+ * @param {string} message
448
+ * @param {string} [detail]
449
+ * @returns {Error & {typed: import('#core/errors.js').TypedError}}
450
+ */
451
+ export function fromHttpStatus (status, message, detail) {
452
+ const typed = classifyProviderError({ status })
453
+ return typedError(typed.message, typed.type, typed.retryable, detail ? `${message}: ${detail}` : message)
454
+ }
@@ -14,9 +14,12 @@
14
14
  * hook for deployments that source config from elsewhere).
15
15
  * - `get(key)` — read-through; loads synchronously on first miss.
16
16
  *
17
- * A malformed / missing / non-object file resolves to the supplied
18
- * `defaultValue` (default `{}`) so callers never have to handle
19
- * file-absence explicitly.
17
+ * A missing file resolves to the supplied `defaultValue` (default
18
+ * `{}`) so callers never have to handle file-absence explicitly. A
19
+ * file that exists but does not parse throws: absent config is a
20
+ * runtime state, corrupt config is a bug, and collapsing the two
21
+ * turns a typo in `curated.json` into `Unknown model` on every call
22
+ * with nothing naming the real cause.
20
23
  *
21
24
  * @module session/adapters/_lazy_json_cache
22
25
  */
@@ -40,24 +43,42 @@ export function createLazyJsonFileCache (pathFn, { defaultValue = /** @type {any
40
43
  return /** @type {V} */(parsed)
41
44
  }
42
45
 
46
+ /** @param {string} file */
47
+ function parseOrThrow (text, file) {
48
+ try {
49
+ return normalize(JSON.parse(text))
50
+ } catch (e) {
51
+ throw new Error(`[mohdel] ${file} is not valid JSON: ${e.message}`, { cause: e })
52
+ }
53
+ }
54
+
43
55
  /** @param {string} [p] */
44
56
  function loadSync (p) {
45
57
  const file = p ?? pathFn()
58
+ let text
46
59
  try {
47
- return normalize(JSON.parse(fs.readFileSync(file, 'utf8')))
48
- } catch {
49
- return defaultValue
60
+ text = fs.readFileSync(file, 'utf8')
61
+ } catch (e) {
62
+ if (e.code === 'ENOENT') return defaultValue
63
+ throw e
50
64
  }
65
+ return parseOrThrow(text, file)
51
66
  }
52
67
 
53
68
  async function initAsync () {
54
69
  if (active !== null) return
70
+ const file = pathFn()
71
+ let text
55
72
  try {
56
- const text = await fs.promises.readFile(pathFn(), 'utf8')
57
- active = normalize(JSON.parse(text))
58
- } catch {
59
- active = defaultValue
73
+ text = await fs.promises.readFile(file, 'utf8')
74
+ } catch (e) {
75
+ if (e.code === 'ENOENT') {
76
+ active = defaultValue
77
+ return
78
+ }
79
+ throw e
60
80
  }
81
+ active = parseOrThrow(text, file)
61
82
  }
62
83
 
63
84
  /** @param {V} table */
@@ -10,8 +10,10 @@
10
10
  */
11
11
 
12
12
  import { readFile as fsReadFile, realpath as fsRealpath, stat as fsStat } from 'node:fs/promises'
13
- import { fileURLToPath } from 'node:url'
14
13
  import { delimiter, isAbsolute, relative } from 'node:path'
14
+ import { fileURLToPath } from 'node:url'
15
+
16
+ import { typedError } from './_errors.js'
15
17
 
16
18
  export const MEDIA_MAX_BYTES = 64 * 1024 * 1024
17
19
 
@@ -58,11 +60,7 @@ export function mediaScheme (uri) {
58
60
  * @returns {Error & {typed: import('#core/errors.js').TypedError}}
59
61
  */
60
62
  export function mediaError (message, type, detail) {
61
- const err = new Error(message)
62
- /** @type {import('#core/errors.js').TypedError} */
63
- const typed = { message, severity: 'error', retryable: false, type }
64
- if (detail) typed.detail = detail
65
- return Object.assign(err, { typed })
63
+ return typedError(message, type, false, detail)
66
64
  }
67
65
 
68
66
  /**
@@ -77,11 +77,39 @@ async function hashFile (filePath) {
77
77
  return h.digest('hex')
78
78
  }
79
79
 
80
+ /**
81
+ * Provider file handles expire server-side — Gemini's Files API keeps
82
+ * an upload about 48h — while a cache entry would live forever. An
83
+ * entry outliving its handle is worse than a cache miss: the upload is
84
+ * skipped and the provider rejects a URI it no longer knows.
85
+ */
86
+ const CACHE_TTL_MS = 24 * 60 * 60 * 1000
87
+
88
+ /** Bound on retained entries; the file is rewritten whole on each save. */
89
+ const CACHE_MAX_ENTRIES = 500
90
+
91
+ /**
92
+ * @param {Record<string, UploadedFileRecord>} cache
93
+ * @returns {Record<string, UploadedFileRecord>}
94
+ */
95
+ function prune (cache) {
96
+ const cutoff = Date.now() - CACHE_TTL_MS
97
+ const live = Object.entries(cache).filter(([, e]) => {
98
+ const at = Date.parse(e?.cachedAt ?? '')
99
+ return Number.isFinite(at) && at >= cutoff
100
+ })
101
+ if (live.length <= CACHE_MAX_ENTRIES) return Object.fromEntries(live)
102
+ live.sort((a, b) => Date.parse(b[1].cachedAt) - Date.parse(a[1].cachedAt))
103
+ return Object.fromEntries(live.slice(0, CACHE_MAX_ENTRIES))
104
+ }
105
+
80
106
  async function loadCache () {
81
107
  try {
82
108
  if (!existsSync(CACHE_PATH)) return {}
83
109
  const text = await fs.readFile(CACHE_PATH, 'utf8')
84
- return JSON.parse(text)
110
+ const parsed = JSON.parse(text)
111
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}
112
+ return prune(parsed)
85
113
  } catch {
86
114
  return {}
87
115
  }
@@ -90,7 +118,7 @@ async function loadCache () {
90
118
  async function saveCache (cache) {
91
119
  try {
92
120
  await ensureCacheDir()
93
- await fs.writeFile(CACHE_PATH, JSON.stringify(cache, null, 2))
121
+ await fs.writeFile(CACHE_PATH, JSON.stringify(prune(cache), null, 2))
94
122
  } catch {
95
123
  // cache write failures shouldn't bring down a call
96
124
  }
@@ -11,7 +11,7 @@
11
11
  */
12
12
 
13
13
  import { getSpec } from '../_catalog.js'
14
- import { classifyProviderError } from '../_errors.js'
14
+ import { classifyProviderError, fromHttpStatus, typedError } from '../_errors.js'
15
15
  import { catalogKey } from '#core/model-id.js'
16
16
 
17
17
  const BASE_URL = 'https://api.novita.ai'
@@ -118,19 +118,3 @@ async function pollTaskResult (fetchFn, sleep, now, taskId, apiKey) {
118
118
 
119
119
  throw typedError('novita image generation timed out', 'PROVIDER_UNAVAILABLE', true)
120
120
  }
121
-
122
- function fromHttpStatus (status, message, detail) {
123
- const typed = classifyProviderError({ status })
124
- // Keep the classifier's message (stable/machine-readable); put the
125
- // caller's context + any response-body snippet into `detail`. Never
126
- // echo provider response bodies into `TypedError.message`.
127
- return typedError(typed.message, typed.type, typed.retryable, detail ? `${message}: ${detail}` : message)
128
- }
129
-
130
- function typedError (message, type, retryable, detail) {
131
- const err = new Error(message)
132
- const typed = { message, severity: retryable ? 'warn' : 'error', retryable, type }
133
- if (detail) typed.detail = detail
134
- err.typed = typed
135
- return err
136
- }
@@ -21,7 +21,7 @@
21
21
  import { basename } from 'node:path'
22
22
 
23
23
  import { getSpec } from '../_catalog.js'
24
- import { classifyProviderError } from '../_errors.js'
24
+ import { classifyProviderError, fromHttpStatus, typedError } from '../_errors.js'
25
25
  import { dataUriPayload, isTrustedMedia, mediaScheme, readLocalMedia } from '../_media.js'
26
26
  import { computeTranscriptionCost } from '../_pricing.js'
27
27
  import { catalogKey, bareOf } from '#core/model-id.js'
@@ -150,18 +150,3 @@ export async function loadAudio (audio, opts = {}) {
150
150
  false
151
151
  )
152
152
  }
153
-
154
- function fromHttpStatus (status, message, detail) {
155
- const typed = classifyProviderError({ status })
156
- // Keep the classifier's message (stable/machine-readable); response
157
- // body snippets go to `detail` only.
158
- return typedError(typed.message, typed.type, typed.retryable, detail ? `${message}: ${detail}` : message)
159
- }
160
-
161
- function typedError (message, type, retryable, detail) {
162
- const err = new Error(message)
163
- const typed = { message, severity: retryable ? 'warn' : 'error', retryable, type }
164
- if (detail) typed.detail = detail
165
- err.typed = typed
166
- return err
167
- }
@@ -12,6 +12,9 @@
12
12
  * @module session/driver
13
13
  */
14
14
 
15
+ import { once } from 'node:events'
16
+
17
+ import { MAX_LINE_BYTES, exceedsLineBytes } from '#core/framing.js'
15
18
  import { run } from './run.js'
16
19
  import { runImage } from './run_image.js'
17
20
  import { runTranscription } from './run_transcription.js'
@@ -48,6 +51,34 @@ export async function drive (stdin, stdout) {
48
51
  precancelled.add(callId)
49
52
  }
50
53
 
54
+ /**
55
+ * Write one NDJSON frame, pausing on a full pipe. A fast adapter
56
+ * feeding a slow gate reader would otherwise grow Node's internal
57
+ * write buffer without bound, since `for await` keeps pulling events
58
+ * regardless of whether the previous one reached the socket.
59
+ *
60
+ * @param {unknown} payload
61
+ */
62
+ async function writeLine (payload) {
63
+ if (!stdout.write(JSON.stringify(payload) + '\n')) {
64
+ await once(stdout, 'drain')
65
+ }
66
+ }
67
+
68
+ function stdinMalformed (detail) {
69
+ process.stderr.write(`session: ${detail}; exiting\n`)
70
+ const error = {
71
+ message: 'unusable stdin line',
72
+ detail,
73
+ severity: 'error',
74
+ retryable: false,
75
+ type: 'SESSION_STDIN_MALFORMED'
76
+ }
77
+ stdout.write(JSON.stringify({ type: 'error', error }) + '\n')
78
+ framingError = Object.assign(new Error('SESSION_STDIN_MALFORMED'), { detail })
79
+ if (queueNotify) { queueNotify(); queueNotify = null }
80
+ }
81
+
51
82
  const onLine = (line) => {
52
83
  if (framingError) return
53
84
  const trimmed = line.trim()
@@ -57,17 +88,7 @@ export async function drive (stdin, stdout) {
57
88
  try {
58
89
  obj = JSON.parse(trimmed)
59
90
  } catch (e) {
60
- process.stderr.write(`session: malformed stdin line, exiting: ${e.message}\n`)
61
- const error = {
62
- message: 'SESSION_STDIN_MALFORMED',
63
- detail: `stdin line is not valid JSON: ${e.message}`,
64
- severity: 'error',
65
- retryable: false,
66
- type: 'SESSION_STDIN_MALFORMED'
67
- }
68
- stdout.write(JSON.stringify({ type: 'error', error }) + '\n')
69
- framingError = Object.assign(new Error(error.message), { detail: error.detail })
70
- if (queueNotify) { queueNotify(); queueNotify = null }
91
+ stdinMalformed(`stdin line is not valid JSON: ${e.message}`)
71
92
  return
72
93
  }
73
94
 
@@ -82,18 +103,6 @@ export async function drive (stdin, stdout) {
82
103
  return
83
104
  }
84
105
 
85
- // Readiness heartbeat from supervisor: the pool sends `ping`
86
- // before marking a fresh session "pool-ready". Reply immediately
87
- // with `pong` on stdout — emitted as a standalone control frame,
88
- // outside any in-flight call's event stream.
89
- //
90
- // Current protocol pings only between calls. If a supervisor
91
- // violates that invariant and pings a busy session, emitting
92
- // `{op:"pong"}` mid-stream would land in the gate's
93
- // `pool_stream_next` read buffer, fail Event parse, and get
94
- // classified as `SESSION_INVALID_EVENT` — killing the session.
95
- // Drop mid-call pings; the supervisor can re-ping after the
96
- // call terminates.
97
106
  // Catalog injection from the supervisor. Supersedes whatever was
98
107
  // loaded from disk at startup. Lets a supervisor (e.g. a
99
108
  // thin-gate session pool) run sessions in contexts without
@@ -108,6 +117,18 @@ export async function drive (stdin, stdout) {
108
117
  return
109
118
  }
110
119
 
120
+ // Readiness heartbeat from supervisor: the pool sends `ping`
121
+ // before marking a fresh session "pool-ready". Reply immediately
122
+ // with `pong` on stdout — emitted as a standalone control frame,
123
+ // outside any in-flight call's event stream.
124
+ //
125
+ // Current protocol pings only between calls. If a supervisor
126
+ // violates that invariant and pings a busy session, emitting
127
+ // `{op:"pong"}` mid-stream would land in the gate's
128
+ // `pool_stream_next` read buffer, fail Event parse, and get
129
+ // classified as `SESSION_INVALID_EVENT` — killing the session.
130
+ // Drop mid-call pings; the supervisor can re-ping after the
131
+ // call terminates.
111
132
  if (obj && typeof obj === 'object' && obj.op === 'ping') {
112
133
  if (currentCall) {
113
134
  process.stderr.write(
@@ -119,6 +140,19 @@ export async function drive (stdin, stdout) {
119
140
  return
120
141
  }
121
142
 
143
+ // Parseable JSON is not yet a usable envelope: `null`, an array or
144
+ // a bare scalar would reach the dispatch loop and throw on
145
+ // `envelope.callId`, killing the process with no terminal event.
146
+ // PROTOCOL.md §3 requires a terminal `error` for unusable stdin.
147
+ if (!obj || typeof obj !== 'object' || Array.isArray(obj)) {
148
+ stdinMalformed(`stdin line is not an envelope object (got ${obj === null ? 'null' : Array.isArray(obj) ? 'array' : typeof obj})`)
149
+ return
150
+ }
151
+ if (typeof obj.callId !== 'string' || !obj.callId) {
152
+ stdinMalformed('envelope is missing a string `callId`')
153
+ return
154
+ }
155
+
122
156
  envelopeQueue.push(obj)
123
157
  if (queueNotify) { queueNotify(); queueNotify = null }
124
158
  }
@@ -133,6 +167,13 @@ export async function drive (stdin, stdout) {
133
167
  stdinBuf = stdinBuf.slice(nl + 1)
134
168
  onLine(line)
135
169
  }
170
+ // The gate caps what it reads *from* a session; nothing capped what
171
+ // a session accepts, so a supervisor streaming a newline-less line
172
+ // grew this buffer without bound.
173
+ if (!framingError && exceedsLineBytes(stdinBuf)) {
174
+ stdinBuf = ''
175
+ stdinMalformed(`stdin line exceeds ${MAX_LINE_BYTES} bytes without newline`)
176
+ }
136
177
  })
137
178
  stdin.on('end', () => {
138
179
  if (stdinBuf) onLine(stdinBuf)
@@ -166,9 +207,9 @@ export async function drive (stdin, stdout) {
166
207
  const { op: _op, ...imgEnv } = envelope
167
208
  const out = await runImage(imgEnv)
168
209
  if (out.ok) {
169
- stdout.write(JSON.stringify({ type: 'image_done', result: out.result }) + '\n')
210
+ await writeLine({ type: 'image_done', result: out.result })
170
211
  } else {
171
- stdout.write(JSON.stringify({ type: 'error', error: out.error }) + '\n')
212
+ await writeLine({ type: 'error', error: out.error })
172
213
  }
173
214
  } else if (envelope.op === 'transcription') {
174
215
  // Same one-shot contract as the image path; shape matches
@@ -176,13 +217,13 @@ export async function drive (stdin, stdout) {
176
217
  const { op: _op, ...trEnv } = envelope
177
218
  const out = await runTranscription(trEnv)
178
219
  if (out.ok) {
179
- stdout.write(JSON.stringify({ type: 'transcription_done', result: out.result }) + '\n')
220
+ await writeLine({ type: 'transcription_done', result: out.result })
180
221
  } else {
181
- stdout.write(JSON.stringify({ type: 'error', error: out.error }) + '\n')
222
+ await writeLine({ type: 'error', error: out.error })
182
223
  }
183
224
  } else {
184
225
  for await (const ev of run(envelope, { signal: controller.signal })) {
185
- stdout.write(JSON.stringify(ev) + '\n')
226
+ await writeLine(ev)
186
227
  }
187
228
  }
188
229
  } finally {
package/js/session/run.js CHANGED
@@ -29,7 +29,7 @@ import { getProviderLimits } from './adapters/_providers.js'
29
29
  import { providerOf, catalogKey, effortOf } from '#core/model-id.js'
30
30
  import * as defaultCooldown from './_cooldown.js'
31
31
  import * as defaultLimiter from './_rate_limiter.js'
32
- import { withIdleHeartbeat } from './_idle_heartbeat.js'
32
+ import { withIdleHeartbeat, MIN_IDLE_HEARTBEAT_MS } from './_idle_heartbeat.js'
33
33
  import { logger as defaultLogger } from './_logger.js'
34
34
  import {
35
35
  startSpan,
@@ -176,6 +176,12 @@ export async function * run (envelope, {
176
176
  let lastFrameAt = startedAt
177
177
  let maxInterFrameMs = 0
178
178
  try {
179
+ if (envelope.idleHeartbeatMs > 0 && envelope.idleHeartbeatMs < MIN_IDLE_HEARTBEAT_MS) {
180
+ log.warn(
181
+ { requested: envelope.idleHeartbeatMs, applied: MIN_IDLE_HEARTBEAT_MS },
182
+ '[mohdel:answer] idleHeartbeatMs raised to the floor'
183
+ )
184
+ }
179
185
  const adapterStream = adapter(envelope, { signal, log, span })
180
186
  const heartbeated = withIdleHeartbeat(adapterStream, envelope.idleHeartbeatMs)
181
187
  for await (const ev of heartbeated) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mohdel",
3
- "version": "0.118.0",
3
+ "version": "0.119.0",
4
4
  "license": "MIT",
5
5
  "author": {
6
6
  "name": "Christophe Le Bars",
@@ -8,6 +8,27 @@
8
8
  },
9
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.",
10
10
  "type": "module",
11
+ "keywords": [
12
+ "llm",
13
+ "llm-gateway",
14
+ "ai-gateway",
15
+ "ai-sdk",
16
+ "openai",
17
+ "anthropic",
18
+ "claude",
19
+ "gemini",
20
+ "mistral",
21
+ "groq",
22
+ "deepseek",
23
+ "openrouter",
24
+ "streaming",
25
+ "tool-calling",
26
+ "token-cost",
27
+ "rate-limiting",
28
+ "opentelemetry",
29
+ "self-hosted",
30
+ "cli"
31
+ ],
11
32
  "repository": {
12
33
  "type": "git",
13
34
  "url": "git+https://github.com/clbrge/mohdel.git"
@@ -86,26 +107,26 @@
86
107
  "@clack/prompts": "^1.7.0",
87
108
  "@opentelemetry/exporter-trace-otlp-grpc": "^0.221.0",
88
109
  "@opentelemetry/sdk-node": "^0.221.0",
89
- "chalk": "^5.4.0",
90
- "mohdel-thin-gate-linux-x64-gnu": "0.118.0"
110
+ "chalk": "^6.0.0",
111
+ "mohdel-thin-gate-linux-x64-gnu": "0.119.0"
91
112
  },
92
113
  "dependencies": {
93
114
  "@anthropic-ai/sdk": "^0.115.0",
94
115
  "@cerebras/cerebras_cloud_sdk": "^1.91.0",
95
- "@google/genai": "^2.13.0",
116
+ "@google/genai": "^2.16.0",
96
117
  "@opentelemetry/api": "^1.9.1",
97
118
  "env-paths": "^4.0.0",
98
- "groq-sdk": "^1.4.0",
99
- "openai": "^6.49.0",
100
- "undici": "^7.24.5"
119
+ "groq-sdk": "^1.5.0",
120
+ "openai": "^7.4.0",
121
+ "undici": "^7.29.0"
101
122
  },
102
123
  "lint-staged": {
103
124
  "*.{js,cjs}": "standard"
104
125
  },
105
126
  "devDependencies": {
106
127
  "gpt-tokenizer": "^3.4.0",
107
- "lint-staged": "^17.2.0",
108
- "release-it": "^20.2.1",
128
+ "lint-staged": "^17.3.0",
129
+ "release-it": "^21.0.1",
109
130
  "standard": "^17.1.2",
110
131
  "vitest": "^4.1.10"
111
132
  }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Chalk, or an uncoloured stand-in when it is absent.
3
+ *
4
+ * `chalk` is an `optionalDependency`, so npm skips it on an engine
5
+ * mismatch or `--no-optional` without failing the install. A static
6
+ * import would then take the whole CLI down at load time instead of
7
+ * dropping colour, which is the only thing actually lost. Dynamic
8
+ * import is the exception the style rule allows: an optional package
9
+ * cannot be resolved statically.
10
+ *
11
+ * The stand-in has to be callable *and* chainable, because call sites
12
+ * use both `dim('x')` and `bold.red('x')`.
13
+ *
14
+ * @module cli/_chalk
15
+ */
16
+
17
+ const identity = (s) => String(s)
18
+
19
+ /** @type {any} */
20
+ const plain = new Proxy(identity, {
21
+ get (target, prop, receiver) {
22
+ // Symbols and function internals must resolve on the function
23
+ // itself; returning the chain proxy for `Symbol.toPrimitive` or
24
+ // `util.inspect.custom` breaks string coercion and console output.
25
+ if (typeof prop === 'symbol' || prop in Function.prototype) {
26
+ return Reflect.get(target, prop, receiver)
27
+ }
28
+ return plain
29
+ }
30
+ })
31
+
32
+ /** @type {any} */
33
+ let chalk
34
+ try {
35
+ chalk = (await import('chalk')).default
36
+ } catch {
37
+ chalk = plain
38
+ }
39
+
40
+ export const isColorAvailable = chalk !== plain
41
+ export default chalk
@@ -10,7 +10,7 @@
10
10
  * @module cli/colored-logger
11
11
  */
12
12
 
13
- import chalk from 'chalk'
13
+ import chalk from './_chalk.js'
14
14
 
15
15
  const noop = () => {}
16
16
 
package/src/cli/colors.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // Semantic color roles for CLI output.
2
- import chalk from 'chalk'
2
+ import chalk from './_chalk.js'
3
3
 
4
4
  export const id = chalk.cyan // model IDs, provider names
5
5
  export const label = chalk.bold // display names, titles
@@ -107,6 +107,8 @@ function getConfiguredProviders () {
107
107
  return { configured, unconfigured }
108
108
  }
109
109
 
110
+ const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
111
+
110
112
  async function appendToEnvFile (key, value) {
111
113
  const dir = dirname(ENV_PATH)
112
114
  if (!existsSync(dir)) await mkdir(dir, { recursive: true })
@@ -114,10 +116,13 @@ async function appendToEnvFile (key, value) {
114
116
  let content = ''
115
117
  if (existsSync(ENV_PATH)) {
116
118
  content = await readFile(ENV_PATH, 'utf8')
117
- // Replace existing line if present
118
- const re = new RegExp(`^${key}=.*$`, 'm')
119
+ // Replace existing line if present. The replacement is a function
120
+ // because `$&`, `` $` ``, `$'` and `$1` are expanded inside a
121
+ // replacement *string* — a pasted key containing any of them would
122
+ // be silently rewritten before it hit disk.
123
+ const re = new RegExp(`^${escapeRegExp(key)}=.*$`, 'm')
119
124
  if (re.test(content)) {
120
- content = content.replace(re, `${key}=${value}`)
125
+ content = content.replace(re, () => `${key}=${value}`)
121
126
  await writeFile(ENV_PATH, content, { mode: 0o600 })
122
127
  chmodSync(ENV_PATH, 0o600)
123
128
  return
package/src/lib/cache.js CHANGED
@@ -1,76 +1,3 @@
1
- import { createHash } from 'crypto'
2
- import { join } from 'path'
3
- import { readFile, writeFile, mkdir, stat } from 'fs/promises'
4
- import { existsSync } from 'fs'
5
1
  import envPaths from 'env-paths'
6
- import { silent } from './logger.js'
7
2
 
8
3
  export const CACHE_DIR = envPaths('mohdel', { suffix: null }).cache
9
- export const FILE_CACHE_PATH = join(CACHE_DIR, 'uploaded-files.json')
10
-
11
- const ensureCacheDir = async () => {
12
- if (!existsSync(CACHE_DIR)) {
13
- await mkdir(CACHE_DIR, { recursive: true })
14
- }
15
- }
16
-
17
- const createFileHash = async (filePath) => {
18
- const data = await readFile(filePath)
19
- const stats = await stat(filePath)
20
- const hash = createHash('sha256')
21
- hash.update(data)
22
- hash.update(filePath)
23
- hash.update(stats.mtime.toISOString())
24
- return hash.digest('hex')
25
- }
26
-
27
- export const loadFileCache = async (logger = silent) => {
28
- try {
29
- await ensureCacheDir()
30
- if (!existsSync(FILE_CACHE_PATH)) {
31
- return {}
32
- }
33
- const content = await readFile(FILE_CACHE_PATH, 'utf8')
34
- return JSON.parse(content)
35
- } catch (err) {
36
- logger.warn({ err }, '[mohdel:cache] failed to load file cache')
37
- return {}
38
- }
39
- }
40
-
41
- export const saveFileCache = async (cache, logger = silent) => {
42
- try {
43
- await ensureCacheDir()
44
- await writeFile(FILE_CACHE_PATH, JSON.stringify(cache, null, 2))
45
- } catch (err) {
46
- logger.error({ err }, '[mohdel:cache] failed to save file cache')
47
- }
48
- }
49
-
50
- export const getCachedFileData = async (filePath, provider = 'gemini', logger = silent) => {
51
- try {
52
- const hash = await createFileHash(filePath)
53
- const cache = await loadFileCache(logger)
54
- return cache[`${provider}:${hash}`]
55
- } catch (err) {
56
- logger.warn({ err }, '[mohdel:cache] failed to get cached file ID')
57
- return null
58
- }
59
- }
60
-
61
- export const setCachedFileData = async (filePath, data, provider = 'gemini', logger = silent) => {
62
- try {
63
- const hash = await createFileHash(filePath)
64
- const cache = await loadFileCache(logger)
65
- cache[`${provider}:${hash}`] = {
66
- hash,
67
- data,
68
- filePath,
69
- provider,
70
- cachedAt: new Date().toISOString()
71
- }
72
- await saveFileCache(cache, logger)
73
- } catch (err) {
74
- logger.error({ err }, '[mohdel:cache] failed to set cached file data')
75
- }
76
- }
@@ -5,10 +5,11 @@ const fetchModels = async ({ apiKey }) => {
5
5
  let pageToken = null
6
6
  while (true) {
7
7
  const url = new URL(`${BASE_URL}/models`)
8
- url.searchParams.set('key', apiKey)
9
8
  url.searchParams.set('pageSize', '1000')
10
9
  if (pageToken) url.searchParams.set('pageToken', pageToken)
11
- const res = await fetch(url)
10
+ // Header, not `?key=`: query strings are recorded verbatim by
11
+ // proxies, CDNs and server access logs.
12
+ const res = await fetch(url, { headers: { 'x-goog-api-key': apiKey } })
12
13
  if (!res.ok) throw new Error(`${res.status} ${res.statusText} fetching ${url.pathname}`)
13
14
  const body = await res.json()
14
15
  const page = Array.isArray(body?.models) ? body.models : []
package/src/lib/common.js CHANGED
@@ -101,6 +101,13 @@ const rotateBackup = async (filePath) => {
101
101
 
102
102
  export const BACKUP_SLOTS = ['prev', 'daily', 'weekly']
103
103
 
104
+ export class ConfigParseError extends Error {
105
+ constructor (message, options) {
106
+ super(message, options)
107
+ this.name = 'ConfigParseError'
108
+ }
109
+ }
110
+
104
111
  const createFileOperation = (filePath, defaultValue = {}, operationType) => {
105
112
  const loadHandler = async () => {
106
113
  let loadedData
@@ -118,7 +125,14 @@ const createFileOperation = (filePath, defaultValue = {}, operationType) => {
118
125
  }
119
126
  } else {
120
127
  const fileContent = await readFile(filePath, 'utf8')
121
- loadedData = JSON.parse(fileContent)
128
+ try {
129
+ loadedData = JSON.parse(fileContent)
130
+ } catch (e) {
131
+ throw new ConfigParseError(
132
+ `[mohdel:common] ${filePath} is not valid JSON: ${e.message}`,
133
+ { cause: e }
134
+ )
135
+ }
122
136
  }
123
137
 
124
138
  if (typeof loadedData === 'object' && loadedData !== null && !Array.isArray(loadedData)) {
@@ -158,6 +172,11 @@ const createFileOperation = (filePath, defaultValue = {}, operationType) => {
158
172
 
159
173
  return loadedData
160
174
  } catch (err) {
175
+ // A file that exists but does not parse is corrupt state, not an
176
+ // absent-config runtime branch. Falling back would make it read
177
+ // as empty and surface later as `Unknown model`, and this warn
178
+ // goes to `silent` unless the embedder wired a logger.
179
+ if (err instanceof ConfigParseError) throw err
161
180
  moduleLogger.warn(`[mohdel:common] failed to load ${operationType}: ${err.message}`)
162
181
  return JSON.parse(JSON.stringify(defaultValue || {}))
163
182
  }
@@ -74,18 +74,7 @@ const providers = {
74
74
  openrouter: {
75
75
  sdk: 'openrouter',
76
76
  apiKeyEnv: 'OPENROUTER_API_SK',
77
- createConfiguration: apiKey => {
78
- // Optional OpenRouter attribution headers — only sent when the
79
- // embedder opts in via env. No defaults.
80
- const defaultHeaders = {}
81
- if (process.env.OPENROUTER_REFERER) defaultHeaders['HTTP-Referer'] = process.env.OPENROUTER_REFERER
82
- if (process.env.OPENROUTER_TITLE) defaultHeaders['X-Title'] = process.env.OPENROUTER_TITLE
83
- return {
84
- baseURL: 'https://openrouter.ai/api/v1',
85
- apiKey,
86
- defaultHeaders
87
- }
88
- },
77
+ createConfiguration: apiKey => ({ baseURL: 'https://openrouter.ai/api/v1', apiKey }),
89
78
  creators: []
90
79
  },
91
80
  qwen: {