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 +2 -0
- package/js/client/call.js +1 -30
- package/js/client/call_image.js +1 -30
- package/js/client/call_transcription.js +1 -30
- package/js/client/ndjson.js +8 -4
- package/js/client/response.js +40 -0
- package/js/client/transport.js +6 -1
- package/js/core/envelope.js +1 -0
- package/js/core/framing.js +29 -0
- package/js/factory/bridge.js +1 -1
- package/js/session/_idle_heartbeat.js +53 -24
- package/js/session/adapters/_errors.js +35 -0
- package/js/session/adapters/_lazy_json_cache.js +31 -10
- package/js/session/adapters/_media.js +4 -6
- package/js/session/adapters/_videos.js +30 -2
- package/js/session/adapters/image/novita.js +1 -17
- package/js/session/adapters/transcription/openai_compatible.js +1 -16
- package/js/session/driver.js +69 -28
- package/js/session/run.js +7 -1
- package/package.json +30 -9
- package/src/cli/_chalk.js +41 -0
- package/src/cli/colored-logger.js +1 -1
- package/src/cli/colors.js +1 -1
- package/src/cli/onboard.js +8 -3
- package/src/lib/cache.js +0 -73
- package/src/lib/catalog/gemini.js +3 -2
- package/src/lib/common.js +20 -1
- package/src/lib/providers.js +1 -12
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
|
-
}
|
package/js/client/call_image.js
CHANGED
|
@@ -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
|
-
}
|
package/js/client/ndjson.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/js/client/transport.js
CHANGED
|
@@ -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
|
-
|
|
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))
|
package/js/core/envelope.js
CHANGED
|
@@ -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
|
+
}
|
package/js/factory/bridge.js
CHANGED
|
@@ -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
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
-
|
|
39
|
-
|
|
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 (!
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
|
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
|
|
18
|
-
* `
|
|
19
|
-
* file
|
|
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
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
}
|
package/js/session/driver.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
210
|
+
await writeLine({ type: 'image_done', result: out.result })
|
|
170
211
|
} else {
|
|
171
|
-
|
|
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
|
-
|
|
220
|
+
await writeLine({ type: 'transcription_done', result: out.result })
|
|
180
221
|
} else {
|
|
181
|
-
|
|
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
|
-
|
|
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.
|
|
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": "^
|
|
90
|
-
"mohdel-thin-gate-linux-x64-gnu": "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.
|
|
116
|
+
"@google/genai": "^2.16.0",
|
|
96
117
|
"@opentelemetry/api": "^1.9.1",
|
|
97
118
|
"env-paths": "^4.0.0",
|
|
98
|
-
"groq-sdk": "^1.
|
|
99
|
-
"openai": "^
|
|
100
|
-
"undici": "^7.
|
|
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.
|
|
108
|
-
"release-it": "^
|
|
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
|
package/src/cli/colors.js
CHANGED
package/src/cli/onboard.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/lib/providers.js
CHANGED
|
@@ -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: {
|