mohdel 0.117.3 → 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 +4 -2
- package/js/client/call.js +4 -32
- package/js/client/call_image.js +5 -33
- package/js/client/call_transcription.js +5 -33
- 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 +46 -1
- package/js/core/errors.js +30 -14
- package/js/core/framing.js +29 -0
- package/js/core/image.js +1 -1
- package/js/core/model-id.js +7 -30
- package/js/core/status.js +0 -8
- package/js/core/transcription.js +1 -1
- package/js/factory/bridge.js +48 -54
- package/js/session/_idle_heartbeat.js +53 -24
- package/js/session/adapters/_cancelled.js +1 -1
- package/js/session/adapters/_chat_completions.js +1 -1
- package/js/session/adapters/_errors.js +43 -8
- package/js/session/adapters/_images.js +26 -17
- package/js/session/adapters/_lazy_json_cache.js +32 -12
- package/js/session/adapters/_media.js +205 -0
- package/js/session/adapters/_providers.js +0 -8
- package/js/session/adapters/_videos.js +67 -28
- package/js/session/adapters/anthropic.js +5 -3
- package/js/session/adapters/gemini.js +6 -3
- package/js/session/adapters/image/novita.js +1 -17
- package/js/session/adapters/openai.js +5 -3
- package/js/session/adapters/transcription/index.js +0 -9
- package/js/session/adapters/transcription/openai_compatible.js +13 -38
- package/js/session/driver.js +69 -28
- package/js/session/run.js +7 -1
- package/package.json +31 -10
- 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 -11
- package/src/lib/curated-cache.js +0 -16
- package/src/lib/providers.js +1 -12
- package/src/lib/cooldown.js +0 -63
- package/src/lib/errors.js +0 -43
package/js/factory/bridge.js
CHANGED
|
@@ -23,7 +23,8 @@
|
|
|
23
23
|
import { run } from '../session/run.js'
|
|
24
24
|
import { runImage } from '../session/run_image.js'
|
|
25
25
|
import { runTranscription } from '../session/run_transcription.js'
|
|
26
|
-
import {
|
|
26
|
+
import { markTrustedMedia } from '../session/adapters/_media.js'
|
|
27
|
+
import { MohdelError, validateIds } from '#core'
|
|
27
28
|
import { createRealtimeDeltaBuffer } from '../../src/lib/utils.js'
|
|
28
29
|
|
|
29
30
|
/**
|
|
@@ -60,20 +61,20 @@ import { createRealtimeDeltaBuffer } from '../../src/lib/utils.js'
|
|
|
60
61
|
* | `configuration.apiKey` | envelope.auth.key |
|
|
61
62
|
* | **`parentSpan`** | **dropped** — use `traceparent` instead |
|
|
62
63
|
* | **`maybeThrowHandler`** | **dropped** — no factory-side validation hook |
|
|
63
|
-
* | **`configuration.baseURL` / `defaultHeaders` / …**| **rejected** — adapters own baseURL
|
|
64
|
+
* | **`configuration.baseURL` / `defaultHeaders` / …**| **rejected** — adapters own baseURL |
|
|
64
65
|
*
|
|
65
66
|
* @param {object} args
|
|
66
67
|
* @param {string} args.provider Resolved provider name (e.g. 'openai').
|
|
67
68
|
* @param {string} args.model Provider-native model id (no provider prefix).
|
|
68
69
|
* @param {string} args.modelKey Catalog key `<provider>/<model>` — for spec/pricing.
|
|
69
|
-
* @param {any} args.configuration Provider config. Only `apiKey` is threaded; other fields are rejected
|
|
70
|
+
* @param {any} args.configuration Provider config. Only `apiKey` is threaded; other fields are rejected.
|
|
70
71
|
* @param {string | any[] | {system?: any, messages: any[]}} args.prompt
|
|
71
72
|
* @param {any} [args.options] Factory `answer()` options.
|
|
72
73
|
* @param {BridgeDeps} [deps]
|
|
73
74
|
* @returns {Promise<any>} AnswerResult (matches the factory's return shape).
|
|
74
75
|
*/
|
|
75
76
|
export async function runAnswer ({ provider, model, modelKey, configuration, prompt, options = {} }, deps = {}) {
|
|
76
|
-
const envelope = toEnvelope({ modelKey, configuration, prompt, options })
|
|
77
|
+
const envelope = markTrustedMedia(toEnvelope({ modelKey, configuration, prompt, options }))
|
|
77
78
|
|
|
78
79
|
// If the caller passed a `realtimeHandler`, feed every `delta`
|
|
79
80
|
// event into a buffer that invokes the handler on batches matching
|
|
@@ -81,7 +82,7 @@ export async function runAnswer ({ provider, model, modelKey, configuration, pro
|
|
|
81
82
|
// never fire — `mo ask --stream` and any integration that relies
|
|
82
83
|
// on streaming callbacks stops working.
|
|
83
84
|
//
|
|
84
|
-
//
|
|
85
|
+
// Skip the buffer allocation entirely when no handler was
|
|
85
86
|
// supplied — the common case.
|
|
86
87
|
const deltaBuffer = options.realtimeHandler
|
|
87
88
|
? createRealtimeDeltaBuffer(options.realtimeHandler, options.bufferOpts)
|
|
@@ -105,15 +106,14 @@ export async function runAnswer ({ provider, model, modelKey, configuration, pro
|
|
|
105
106
|
}
|
|
106
107
|
|
|
107
108
|
if (!terminal) {
|
|
108
|
-
throw new MohdelError('
|
|
109
|
-
|
|
110
|
-
detail: 'session run produced no terminal event',
|
|
109
|
+
throw new MohdelError('session run produced no terminal event', {
|
|
110
|
+
type: 'SESSION_NO_TERMINAL',
|
|
111
111
|
retryable: false
|
|
112
112
|
})
|
|
113
113
|
}
|
|
114
114
|
|
|
115
115
|
if (terminal.type === 'error') {
|
|
116
|
-
throw
|
|
116
|
+
throw MohdelError.fromJSON(terminal.error, { provider, model, modelKey })
|
|
117
117
|
}
|
|
118
118
|
|
|
119
119
|
return terminal.result
|
|
@@ -135,9 +135,13 @@ export async function runAnswer ({ provider, model, modelKey, configuration, pro
|
|
|
135
135
|
* @returns {Promise<any>}
|
|
136
136
|
*/
|
|
137
137
|
export async function runAnswerImage ({ provider, model, configuration, prompt, options = {}, spec }) {
|
|
138
|
+
const callId = options.callId || newCallId()
|
|
139
|
+
const authId = options.authId || 'local'
|
|
140
|
+
assertValidIds(callId, authId, `${provider}/${model}`)
|
|
141
|
+
|
|
138
142
|
const envelope = {
|
|
139
|
-
callId
|
|
140
|
-
authId
|
|
143
|
+
callId,
|
|
144
|
+
authId,
|
|
141
145
|
auth: configToAuth(configuration),
|
|
142
146
|
model: `${provider}/${model}`,
|
|
143
147
|
prompt
|
|
@@ -145,8 +149,8 @@ export async function runAnswerImage ({ provider, model, configuration, prompt,
|
|
|
145
149
|
if (options.size) envelope.size = options.size
|
|
146
150
|
if (options.seed != null) envelope.seed = options.seed
|
|
147
151
|
|
|
148
|
-
const out = await runImage(envelope, spec ? { spec } : {})
|
|
149
|
-
if (!out.ok) throw
|
|
152
|
+
const out = await runImage(markTrustedMedia(envelope), spec ? { spec } : {})
|
|
153
|
+
if (!out.ok) throw MohdelError.fromJSON(out.error, { provider, model })
|
|
150
154
|
return out.result
|
|
151
155
|
}
|
|
152
156
|
|
|
@@ -168,9 +172,13 @@ export async function runAnswerImage ({ provider, model, configuration, prompt,
|
|
|
168
172
|
* @returns {Promise<any>}
|
|
169
173
|
*/
|
|
170
174
|
export async function runAnswerTranscription ({ provider, model, configuration, audio, options = {}, spec }) {
|
|
175
|
+
const callId = options.callId || newCallId()
|
|
176
|
+
const authId = options.authId || 'local'
|
|
177
|
+
assertValidIds(callId, authId, `${provider}/${model}`)
|
|
178
|
+
|
|
171
179
|
const envelope = {
|
|
172
|
-
callId
|
|
173
|
-
authId
|
|
180
|
+
callId,
|
|
181
|
+
authId,
|
|
174
182
|
auth: configToAuth(configuration),
|
|
175
183
|
model: `${provider}/${model}`,
|
|
176
184
|
audio
|
|
@@ -178,8 +186,8 @@ export async function runAnswerTranscription ({ provider, model, configuration,
|
|
|
178
186
|
if (options.language) envelope.language = options.language
|
|
179
187
|
if (options.prompt) envelope.prompt = options.prompt
|
|
180
188
|
|
|
181
|
-
const out = await runTranscription(envelope, spec ? { spec } : {})
|
|
182
|
-
if (!out.ok) throw
|
|
189
|
+
const out = await runTranscription(markTrustedMedia(envelope), spec ? { spec } : {})
|
|
190
|
+
if (!out.ok) throw MohdelError.fromJSON(out.error, { provider, model })
|
|
183
191
|
return out.result
|
|
184
192
|
}
|
|
185
193
|
|
|
@@ -196,12 +204,16 @@ export async function runAnswerTranscription ({ provider, model, configuration,
|
|
|
196
204
|
* @returns {import('#core/envelope.js').CallEnvelope}
|
|
197
205
|
*/
|
|
198
206
|
function toEnvelope ({ modelKey, configuration, prompt, options }) {
|
|
207
|
+
const callId = options.callId || newCallId()
|
|
208
|
+
const authId = options.authId || 'local'
|
|
209
|
+
assertValidIds(callId, authId, modelKey)
|
|
210
|
+
|
|
199
211
|
/** @type {import('#core/envelope.js').CallEnvelope} */
|
|
200
212
|
const envelope = {
|
|
201
|
-
callId
|
|
202
|
-
authId
|
|
213
|
+
callId,
|
|
214
|
+
authId,
|
|
203
215
|
auth: configToAuth(configuration),
|
|
204
|
-
model:
|
|
216
|
+
model: modelKey,
|
|
205
217
|
prompt: toEnvelopePrompt(prompt)
|
|
206
218
|
}
|
|
207
219
|
|
|
@@ -237,36 +249,18 @@ function toEnvelope ({ modelKey, configuration, prompt, options }) {
|
|
|
237
249
|
}
|
|
238
250
|
|
|
239
251
|
/**
|
|
240
|
-
*
|
|
241
|
-
*
|
|
242
|
-
*
|
|
243
|
-
*
|
|
244
|
-
* @param {import('#core/errors.js').TypedError} err
|
|
245
|
-
* @param {{provider: string, model: string, modelKey?: string}} ctx
|
|
246
|
-
* @returns {MohdelError}
|
|
252
|
+
* @param {string} callId
|
|
253
|
+
* @param {string} authId
|
|
254
|
+
* @param {string} model
|
|
247
255
|
*/
|
|
248
|
-
function
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
context: { provider: ctx.provider, model: ctx.model }
|
|
257
|
-
})
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
/** @param {string | undefined} s */
|
|
261
|
-
function toSeveritySymbol (s) {
|
|
262
|
-
switch (s) {
|
|
263
|
-
case 'trace': return Severity.TRACE
|
|
264
|
-
case 'debug': return Severity.DEBUG
|
|
265
|
-
case 'info': return Severity.INFO
|
|
266
|
-
case 'warn': return Severity.WARN
|
|
267
|
-
case 'error': return Severity.ERROR
|
|
268
|
-
case 'fatal': return Severity.FATAL
|
|
269
|
-
default: return Severity.ERROR
|
|
256
|
+
function assertValidIds (callId, authId, model) {
|
|
257
|
+
const reason = validateIds(callId, authId, model)
|
|
258
|
+
if (reason) {
|
|
259
|
+
throw new MohdelError('invalid envelope', {
|
|
260
|
+
type: 'PROTOCOL_INVALID_ENVELOPE',
|
|
261
|
+
detail: reason,
|
|
262
|
+
retryable: false
|
|
263
|
+
})
|
|
270
264
|
}
|
|
271
265
|
}
|
|
272
266
|
|
|
@@ -287,12 +281,12 @@ function newCallId () {
|
|
|
287
281
|
*/
|
|
288
282
|
const ALLOWED_CONFIG_KEYS = new Set(['apiKey', 'baseURL'])
|
|
289
283
|
|
|
290
|
-
function configToAuth (configuration) {
|
|
284
|
+
export function configToAuth (configuration) {
|
|
291
285
|
if (!configuration) return { key: '' }
|
|
292
286
|
const unsupported = Object.keys(configuration).filter(k => !ALLOWED_CONFIG_KEYS.has(k))
|
|
293
287
|
if (unsupported.length > 0) {
|
|
294
|
-
throw new MohdelError('
|
|
295
|
-
|
|
288
|
+
throw new MohdelError('unsupported per-call configuration', {
|
|
289
|
+
type: 'CONFIGURATION_UNSUPPORTED',
|
|
296
290
|
detail:
|
|
297
291
|
'per-call SDK configuration is limited to `apiKey` and `baseURL`. ' +
|
|
298
292
|
`Unsupported keys: ${unsupported.join(', ')}. ` +
|
|
@@ -372,8 +366,8 @@ function toEnvelopePrompt (prompt) {
|
|
|
372
366
|
// fall through would land a raw non-iterable in the envelope and
|
|
373
367
|
// produce a confusing `prompt.map is not a function` deep inside
|
|
374
368
|
// the adapter.
|
|
375
|
-
throw new MohdelError('
|
|
376
|
-
|
|
369
|
+
throw new MohdelError('invalid prompt shape', {
|
|
370
|
+
type: 'SESSION_INVALID_PROMPT',
|
|
377
371
|
retryable: false,
|
|
378
372
|
detail:
|
|
379
373
|
'prompt must be a string, a Message[] array, ' +
|
|
@@ -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.
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* a terminal `done` event on `signal.aborted` mid-stream.
|
|
4
4
|
*
|
|
5
5
|
* Three adapters (openai, anthropic, gemini) had byte-identical
|
|
6
|
-
* copies of this
|
|
6
|
+
* copies of this; consolidated here. `_chat_completions.js`
|
|
7
7
|
* and `run.js` have their own cancel paths — don't migrate them
|
|
8
8
|
* here unless you're certain the shape matches (thinkingTokens,
|
|
9
9
|
* cost, tool_calls semantics can all differ).
|
|
@@ -142,7 +142,7 @@ async function * runStreaming (envelope, client, args, config, start, deps) {
|
|
|
142
142
|
args.stream = true
|
|
143
143
|
args.stream_options = { include_usage: true }
|
|
144
144
|
|
|
145
|
-
//
|
|
145
|
+
// Accumulate via array + join to avoid per-delta V8 cons-string
|
|
146
146
|
// churn on long streams.
|
|
147
147
|
const contentParts = []
|
|
148
148
|
const reasoningParts = []
|
|
@@ -32,13 +32,6 @@
|
|
|
32
32
|
|
|
33
33
|
const DETAIL_CAP = 500
|
|
34
34
|
|
|
35
|
-
/**
|
|
36
|
-
* Extract a short human-readable detail from an SDK error. Trimmed
|
|
37
|
-
* to `DETAIL_CAP` chars so a verbose provider body doesn't blow up
|
|
38
|
-
* log pipelines.
|
|
39
|
-
* @param {any} err
|
|
40
|
-
* @returns {string | undefined}
|
|
41
|
-
*/
|
|
42
35
|
/**
|
|
43
36
|
* Replace verbatim occurrences of `key` in `detail` with a masked
|
|
44
37
|
* form. Long keys (≥ 16 chars) become `<first4>…<last4>` so a caller
|
|
@@ -60,6 +53,13 @@ function scrubKey (detail, key) {
|
|
|
60
53
|
return detail.split(key).join(mask)
|
|
61
54
|
}
|
|
62
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Extract a short human-readable detail from an SDK error. Trimmed
|
|
58
|
+
* to `DETAIL_CAP` chars so a verbose provider body doesn't blow up
|
|
59
|
+
* log pipelines.
|
|
60
|
+
* @param {any} err
|
|
61
|
+
* @returns {string | undefined}
|
|
62
|
+
*/
|
|
63
63
|
function extractDetail (err) {
|
|
64
64
|
if (!err) return undefined
|
|
65
65
|
const nested = err.error?.message || err.response?.data?.error?.message
|
|
@@ -410,10 +410,45 @@ export function classifyProviderError (e, key, opts = {}) {
|
|
|
410
410
|
}
|
|
411
411
|
}
|
|
412
412
|
return {
|
|
413
|
-
message: message ? String(message).slice(0, 200) : 'network error',
|
|
413
|
+
message: message ? scrubKey(String(message), key).slice(0, 200) : 'network error',
|
|
414
414
|
severity: 'warn',
|
|
415
415
|
retryable: true,
|
|
416
416
|
type: 'NET_ERROR',
|
|
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
|
+
}
|
|
@@ -11,10 +11,16 @@
|
|
|
11
11
|
* provider. Errors are surfaced — file IO failures bubble up so the
|
|
12
12
|
* adapter can emit a typed error rather than silently skipping.
|
|
13
13
|
*
|
|
14
|
+
* Local paths are resolved through `_media.js`, which owns symlink
|
|
15
|
+
* resolution, the `MOHDEL_MEDIA_ROOTS` confinement check and the read
|
|
16
|
+
* cap.
|
|
17
|
+
*
|
|
14
18
|
* @module session/adapters/_images
|
|
15
19
|
*/
|
|
16
20
|
|
|
17
|
-
import {
|
|
21
|
+
import { dataUriPayload, mediaError, mediaScheme, readLocalMedia } from './_media.js'
|
|
22
|
+
|
|
23
|
+
const IMAGE_ERROR = 'SESSION_INVALID_IMAGE'
|
|
18
24
|
|
|
19
25
|
/**
|
|
20
26
|
* @typedef {object} LoadedImage
|
|
@@ -25,36 +31,39 @@ import { readFile } from 'node:fs/promises'
|
|
|
25
31
|
|
|
26
32
|
/**
|
|
27
33
|
* @param {Array<{fileUri: string, mimeType: string}>} images
|
|
34
|
+
* @param {{trusted?: boolean}} [opts]
|
|
28
35
|
* @returns {Promise<LoadedImage[]>}
|
|
29
36
|
*/
|
|
30
|
-
export async function loadImages (images) {
|
|
37
|
+
export async function loadImages (images, opts = {}) {
|
|
31
38
|
if (!images || !Array.isArray(images)) return []
|
|
32
39
|
const out = []
|
|
33
40
|
for (const img of images) {
|
|
34
41
|
if (!img?.fileUri || !img?.mimeType) continue
|
|
35
|
-
out.push(await loadImage(img))
|
|
42
|
+
out.push(await loadImage(img, opts))
|
|
36
43
|
}
|
|
37
44
|
return out
|
|
38
45
|
}
|
|
39
46
|
|
|
40
47
|
/**
|
|
41
48
|
* @param {{fileUri: string, mimeType: string}} image
|
|
49
|
+
* @param {{trusted?: boolean}} [opts]
|
|
42
50
|
* @returns {Promise<LoadedImage>}
|
|
43
51
|
*/
|
|
44
|
-
export async function loadImage (image) {
|
|
52
|
+
export async function loadImage (image, opts = {}) {
|
|
45
53
|
const { fileUri, mimeType } = image
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
54
|
+
switch (mediaScheme(fileUri)) {
|
|
55
|
+
case 'file': {
|
|
56
|
+
const { bytes } = await readLocalMedia(fileUri, { type: IMAGE_ERROR, trusted: opts.trusted })
|
|
57
|
+
return { mimeType, base64: bytes.toString('base64') }
|
|
58
|
+
}
|
|
59
|
+
case 'data':
|
|
60
|
+
return { mimeType, base64: dataUriPayload(fileUri, IMAGE_ERROR) }
|
|
61
|
+
case 'remote':
|
|
62
|
+
return { mimeType, url: fileUri }
|
|
63
|
+
default:
|
|
64
|
+
throw mediaError(
|
|
65
|
+
`unsupported image URI scheme: ${fileUri.slice(0, 32)}…`,
|
|
66
|
+
IMAGE_ERROR
|
|
67
|
+
)
|
|
58
68
|
}
|
|
59
|
-
throw new Error(`unsupported image URI scheme: ${fileUri.slice(0, 32)}…`)
|
|
60
69
|
}
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shared lazy-load-once JSON cache for config files under
|
|
3
3
|
* `~/.config/mohdel/`. `_catalog.js` and `_providers.js` both had
|
|
4
|
-
* byte-similar implementations
|
|
5
|
-
* pattern.
|
|
4
|
+
* byte-similar implementations; this helper owns the pattern.
|
|
6
5
|
*
|
|
7
6
|
* Contract:
|
|
8
7
|
* - `loadSync(path?)` — synchronous read; used as the lazy
|
|
@@ -15,9 +14,12 @@
|
|
|
15
14
|
* hook for deployments that source config from elsewhere).
|
|
16
15
|
* - `get(key)` — read-through; loads synchronously on first miss.
|
|
17
16
|
*
|
|
18
|
-
* A
|
|
19
|
-
* `
|
|
20
|
-
* 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.
|
|
21
23
|
*
|
|
22
24
|
* @module session/adapters/_lazy_json_cache
|
|
23
25
|
*/
|
|
@@ -41,24 +43,42 @@ export function createLazyJsonFileCache (pathFn, { defaultValue = /** @type {any
|
|
|
41
43
|
return /** @type {V} */(parsed)
|
|
42
44
|
}
|
|
43
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
|
+
|
|
44
55
|
/** @param {string} [p] */
|
|
45
56
|
function loadSync (p) {
|
|
46
57
|
const file = p ?? pathFn()
|
|
58
|
+
let text
|
|
47
59
|
try {
|
|
48
|
-
|
|
49
|
-
} catch {
|
|
50
|
-
return defaultValue
|
|
60
|
+
text = fs.readFileSync(file, 'utf8')
|
|
61
|
+
} catch (e) {
|
|
62
|
+
if (e.code === 'ENOENT') return defaultValue
|
|
63
|
+
throw e
|
|
51
64
|
}
|
|
65
|
+
return parseOrThrow(text, file)
|
|
52
66
|
}
|
|
53
67
|
|
|
54
68
|
async function initAsync () {
|
|
55
69
|
if (active !== null) return
|
|
70
|
+
const file = pathFn()
|
|
71
|
+
let text
|
|
56
72
|
try {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
|
61
80
|
}
|
|
81
|
+
active = parseOrThrow(text, file)
|
|
62
82
|
}
|
|
63
83
|
|
|
64
84
|
/** @param {V} table */
|