mohdel 0.117.2 → 0.118.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 -2
- package/js/client/call.js +4 -3
- package/js/client/call_image.js +5 -4
- package/js/client/call_transcription.js +5 -4
- package/js/core/envelope.js +45 -1
- package/js/core/errors.js +30 -14
- 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 +47 -53
- package/js/session/adapters/_cancelled.js +1 -1
- package/js/session/adapters/_chat_completions.js +1 -1
- package/js/session/adapters/_errors.js +8 -8
- package/js/session/adapters/_images.js +26 -17
- package/js/session/adapters/_lazy_json_cache.js +1 -2
- package/js/session/adapters/_media.js +207 -0
- package/js/session/adapters/_providers.js +0 -8
- package/js/session/adapters/_videos.js +37 -26
- package/js/session/adapters/anthropic.js +5 -3
- package/js/session/adapters/gemini.js +6 -3
- package/js/session/adapters/image/novita.js +2 -2
- 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 -23
- package/package.json +7 -7
- package/src/lib/common.js +0 -10
- package/src/lib/curated-cache.js +0 -16
- package/src/lib/cooldown.js +0 -63
- package/src/lib/errors.js +0 -43
|
@@ -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
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local-media resolution for envelope `fileUri` references.
|
|
3
|
+
*
|
|
4
|
+
* Under thin-gate a `fileUri` comes from whoever can reach the data
|
|
5
|
+
* socket, while the session process holds the gate host's filesystem
|
|
6
|
+
* privileges. `MOHDEL_MEDIA_ROOTS` (PATH-style) confines local reads
|
|
7
|
+
* to the listed directories; unset means unconfined.
|
|
8
|
+
*
|
|
9
|
+
* @module session/adapters/_media
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { readFile as fsReadFile, realpath as fsRealpath, stat as fsStat } from 'node:fs/promises'
|
|
13
|
+
import { fileURLToPath } from 'node:url'
|
|
14
|
+
import { delimiter, isAbsolute, relative } from 'node:path'
|
|
15
|
+
|
|
16
|
+
export const MEDIA_MAX_BYTES = 64 * 1024 * 1024
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A symbol key cannot survive `JSON.parse`, so an envelope arriving
|
|
20
|
+
* over the wire can never carry this mark however it is crafted. Only
|
|
21
|
+
* an in-process caller holding the symbol can set it.
|
|
22
|
+
*/
|
|
23
|
+
const TRUSTED_MEDIA = Symbol('mohdel.trustedMedia')
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @template {object} T
|
|
27
|
+
* @param {T} envelope
|
|
28
|
+
* @returns {T}
|
|
29
|
+
*/
|
|
30
|
+
export function markTrustedMedia (envelope) {
|
|
31
|
+
return Object.assign(envelope, { [TRUSTED_MEDIA]: true })
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @param {any} envelope
|
|
36
|
+
* @returns {boolean}
|
|
37
|
+
*/
|
|
38
|
+
export function isTrustedMedia (envelope) {
|
|
39
|
+
return envelope?.[TRUSTED_MEDIA] === true
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @param {string} uri
|
|
44
|
+
* @returns {'remote' | 'data' | 'file' | 'unknown'}
|
|
45
|
+
*/
|
|
46
|
+
export function mediaScheme (uri) {
|
|
47
|
+
if (typeof uri !== 'string' || !uri) return 'unknown'
|
|
48
|
+
if (/^https?:\/\//i.test(uri)) return 'remote'
|
|
49
|
+
if (uri.startsWith('data:')) return 'data'
|
|
50
|
+
if (uri.startsWith('file://')) return 'file'
|
|
51
|
+
return 'unknown'
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @param {string} message
|
|
56
|
+
* @param {string} type Canonical TypedError tag supplied by the caller.
|
|
57
|
+
* @param {string} [detail]
|
|
58
|
+
* @returns {Error & {typed: import('#core/errors.js').TypedError}}
|
|
59
|
+
*/
|
|
60
|
+
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 })
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* @param {string} uri
|
|
70
|
+
* @param {string} type
|
|
71
|
+
* @returns {string} Raw base64 payload, no `data:` prefix.
|
|
72
|
+
*/
|
|
73
|
+
export function dataUriPayload (uri, type) {
|
|
74
|
+
const comma = uri.indexOf(',')
|
|
75
|
+
if (comma < 0) {
|
|
76
|
+
throw mediaError('malformed data URI', type, 'expected data:<mime>;base64,<payload>')
|
|
77
|
+
}
|
|
78
|
+
return uri.slice(comma + 1)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* A character device such as `/dev/zero` reports `size: 0` and reads
|
|
83
|
+
* without end, so the regular-file check — not the size check — is
|
|
84
|
+
* what bounds the read.
|
|
85
|
+
*
|
|
86
|
+
* `MOHDEL_MEDIA_ROOTS`, when set, confines reads for every caller.
|
|
87
|
+
* When unset, `trusted` decides: an in-process caller reads its own
|
|
88
|
+
* disk freely, an envelope off the wire reads nothing.
|
|
89
|
+
*
|
|
90
|
+
* @param {string} fileUri
|
|
91
|
+
* @param {{
|
|
92
|
+
* type: string,
|
|
93
|
+
* trusted?: boolean,
|
|
94
|
+
* maxBytes?: number,
|
|
95
|
+
* stat?: (path: string) => Promise<any>,
|
|
96
|
+
* realpath?: (path: string) => Promise<string>
|
|
97
|
+
* }} opts
|
|
98
|
+
* @returns {Promise<{path: string, size: number}>}
|
|
99
|
+
*/
|
|
100
|
+
export async function resolveLocalMedia (fileUri, opts) {
|
|
101
|
+
const { type, trusted = false, maxBytes = MEDIA_MAX_BYTES } = opts
|
|
102
|
+
const stat = opts.stat ?? fsStat
|
|
103
|
+
const realpath = opts.realpath ?? fsRealpath
|
|
104
|
+
|
|
105
|
+
let requested
|
|
106
|
+
try {
|
|
107
|
+
requested = fileURLToPath(new URL(fileUri))
|
|
108
|
+
} catch {
|
|
109
|
+
throw mediaError('media path is not a valid file URI', type, fileUri.slice(0, 64))
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
let path
|
|
113
|
+
try {
|
|
114
|
+
path = await realpath(requested)
|
|
115
|
+
} catch (e) {
|
|
116
|
+
throw mediaError('media file is unreadable', type, messageOf(e))
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const roots = await mediaRoots(realpath)
|
|
120
|
+
if (roots.length) {
|
|
121
|
+
if (!roots.some(root => contains(root, path))) {
|
|
122
|
+
throw mediaError(
|
|
123
|
+
'media path is outside the permitted directories',
|
|
124
|
+
type,
|
|
125
|
+
`${path} is not under any MOHDEL_MEDIA_ROOTS entry`
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
} else if (!trusted) {
|
|
129
|
+
throw mediaError(
|
|
130
|
+
'reading local files is not permitted for this caller',
|
|
131
|
+
type,
|
|
132
|
+
'set MOHDEL_MEDIA_ROOTS to the directories this deployment may read media from, or send the media as a data: URI'
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
let stats
|
|
137
|
+
try {
|
|
138
|
+
stats = await stat(path)
|
|
139
|
+
} catch (e) {
|
|
140
|
+
throw mediaError('media file is unreadable', type, messageOf(e))
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (!stats.isFile()) {
|
|
144
|
+
throw mediaError('media path is not a regular file', type, path)
|
|
145
|
+
}
|
|
146
|
+
if (stats.size > maxBytes) {
|
|
147
|
+
throw mediaError(
|
|
148
|
+
'media file exceeds the size limit',
|
|
149
|
+
type,
|
|
150
|
+
`${stats.size} bytes exceeds the ${maxBytes} byte limit`
|
|
151
|
+
)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return { path, size: stats.size }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* @param {string} fileUri
|
|
159
|
+
* @param {{
|
|
160
|
+
* type: string,
|
|
161
|
+
* trusted?: boolean,
|
|
162
|
+
* maxBytes?: number,
|
|
163
|
+
* stat?: (path: string) => Promise<any>,
|
|
164
|
+
* realpath?: (path: string) => Promise<string>,
|
|
165
|
+
* readFile?: (path: string) => Promise<Buffer>
|
|
166
|
+
* }} opts
|
|
167
|
+
* @returns {Promise<{path: string, bytes: Buffer}>}
|
|
168
|
+
*/
|
|
169
|
+
export async function readLocalMedia (fileUri, opts) {
|
|
170
|
+
const { path } = await resolveLocalMedia(fileUri, opts)
|
|
171
|
+
const readFile = opts.readFile ?? fsReadFile
|
|
172
|
+
try {
|
|
173
|
+
return { path, bytes: await readFile(path) }
|
|
174
|
+
} catch (e) {
|
|
175
|
+
throw mediaError('media file is unreadable', opts.type, messageOf(e))
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* @param {(path: string) => Promise<string>} realpath
|
|
181
|
+
* @returns {Promise<string[]>}
|
|
182
|
+
*/
|
|
183
|
+
async function mediaRoots (realpath) {
|
|
184
|
+
const raw = process.env.MOHDEL_MEDIA_ROOTS
|
|
185
|
+
if (!raw) return []
|
|
186
|
+
const entries = raw.split(delimiter).map(s => s.trim()).filter(Boolean)
|
|
187
|
+
return Promise.all(entries.map(entry => realpath(entry).catch(() => entry)))
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* `startsWith` would accept `/srv/media-evil` for root `/srv/media`;
|
|
192
|
+
* the relative path never does.
|
|
193
|
+
*
|
|
194
|
+
* @param {string} root
|
|
195
|
+
* @param {string} path
|
|
196
|
+
* @returns {boolean}
|
|
197
|
+
*/
|
|
198
|
+
function contains (root, path) {
|
|
199
|
+
if (path === root) return true
|
|
200
|
+
const rel = relative(root, path)
|
|
201
|
+
return !!rel && !rel.startsWith('..') && !isAbsolute(rel)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** @param {unknown} e */
|
|
205
|
+
function messageOf (e) {
|
|
206
|
+
return e instanceof Error ? e.message : String(e)
|
|
207
|
+
}
|
|
@@ -28,14 +28,6 @@ const cache = createLazyJsonFileCache(
|
|
|
28
28
|
() => `${envPaths('mohdel', { suffix: null }).config}/providers.json`
|
|
29
29
|
)
|
|
30
30
|
|
|
31
|
-
/**
|
|
32
|
-
* @param {string} path
|
|
33
|
-
* @returns {Record<string, ProviderLimits>}
|
|
34
|
-
*/
|
|
35
|
-
export function loadProviders (path) {
|
|
36
|
-
return cache.loadSync(path)
|
|
37
|
-
}
|
|
38
|
-
|
|
39
31
|
/**
|
|
40
32
|
* Eager async initialization from the default providers path. Called
|
|
41
33
|
* from `bin.js::main` before `drive()` so `getProviderLimits` doesn't
|
|
@@ -4,16 +4,20 @@
|
|
|
4
4
|
* shape).
|
|
5
5
|
*
|
|
6
6
|
* Three code paths per envelope video ref:
|
|
7
|
-
* 1. `file
|
|
8
|
-
*
|
|
9
|
-
* 2. `file
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
7
|
+
* 1. `file://`, ≤20MB, no cache flag → read + base64 inline as
|
|
8
|
+
* `inlineData`.
|
|
9
|
+
* 2. `file://`, >20MB or `cache: true` → upload via the provider
|
|
10
|
+
* SDK (Gemini `ai.files.upload`), poll until the file is ACTIVE,
|
|
11
|
+
* return a `fileData` part. Content-hash + mtime-keyed cache at
|
|
12
|
+
* `~/.cache/mohdel/uploaded-files.json` short-circuits repeat
|
|
13
|
+
* uploads.
|
|
14
14
|
* 3. `https://` → passthrough as `fileData.fileUri` (Gemini fetches
|
|
15
15
|
* it directly).
|
|
16
16
|
*
|
|
17
|
+
* Local paths resolve through `_media.js`. Bare filesystem paths are
|
|
18
|
+
* not accepted — the scheme is required, matching `_images.js` and the
|
|
19
|
+
* transcription loader.
|
|
20
|
+
*
|
|
17
21
|
* @module session/adapters/_videos
|
|
18
22
|
*/
|
|
19
23
|
|
|
@@ -24,9 +28,12 @@ import { join } from 'node:path'
|
|
|
24
28
|
|
|
25
29
|
import envPaths from 'env-paths'
|
|
26
30
|
|
|
31
|
+
import { dataUriPayload, mediaError, mediaScheme, resolveLocalMedia } from './_media.js'
|
|
32
|
+
|
|
27
33
|
const CACHE_DIR = envPaths('mohdel', { suffix: null }).cache
|
|
28
34
|
const CACHE_PATH = join(CACHE_DIR, 'uploaded-files.json')
|
|
29
35
|
|
|
36
|
+
const VIDEO_ERROR = 'SESSION_INVALID_VIDEO'
|
|
30
37
|
const INLINE_MAX_BYTES = 20 * 1024 * 1024
|
|
31
38
|
const VIDEO_UPLOAD_POLL_INTERVAL_MS = 5_000
|
|
32
39
|
/** Hard deadline on the PROCESSING → ACTIVE wait. Videos occasionally
|
|
@@ -144,7 +151,8 @@ export async function setCachedFile (filePath, data, provider = 'gemini') {
|
|
|
144
151
|
* readFile?: (path: string) => Promise<Buffer>,
|
|
145
152
|
* stat?: (path: string) => Promise<{size: number}>,
|
|
146
153
|
* signal?: AbortSignal,
|
|
147
|
-
* provider?: string
|
|
154
|
+
* provider?: string,
|
|
155
|
+
* trusted?: boolean
|
|
148
156
|
* }} deps
|
|
149
157
|
* @returns {Promise<VideoPart[]>}
|
|
150
158
|
*/
|
|
@@ -159,7 +167,8 @@ export async function loadVideos (videos, deps) {
|
|
|
159
167
|
readFileFn: deps.readFile ?? fs.readFile,
|
|
160
168
|
statFn: deps.stat ?? fs.stat,
|
|
161
169
|
signal: deps.signal,
|
|
162
|
-
provider: deps.provider ?? 'gemini'
|
|
170
|
+
provider: deps.provider ?? 'gemini',
|
|
171
|
+
trusted: !!deps.trusted
|
|
163
172
|
}
|
|
164
173
|
|
|
165
174
|
for (const v of videos) {
|
|
@@ -174,28 +183,30 @@ export async function loadVideos (videos, deps) {
|
|
|
174
183
|
async function toPart (ref, ctx) {
|
|
175
184
|
const { fileUri, mimeType } = ref
|
|
176
185
|
|
|
177
|
-
|
|
178
|
-
if (
|
|
186
|
+
const scheme = mediaScheme(fileUri)
|
|
187
|
+
if (scheme === 'remote') {
|
|
179
188
|
return { fileData: { fileUri, mimeType } }
|
|
180
189
|
}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
if (fileUri.startsWith('data:')) {
|
|
184
|
-
const comma = fileUri.indexOf(',')
|
|
185
|
-
if (comma < 0) return null
|
|
186
|
-
return { inlineData: { data: fileUri.slice(comma + 1), mimeType } }
|
|
190
|
+
if (scheme === 'data') {
|
|
191
|
+
return { inlineData: { data: dataUriPayload(fileUri, VIDEO_ERROR), mimeType } }
|
|
187
192
|
}
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
stats = await ctx.statFn(filePath)
|
|
194
|
-
} catch {
|
|
195
|
-
return null
|
|
193
|
+
if (scheme !== 'file') {
|
|
194
|
+
throw mediaError(
|
|
195
|
+
`unsupported video URI scheme: ${fileUri.slice(0, 32)}…`,
|
|
196
|
+
VIDEO_ERROR
|
|
197
|
+
)
|
|
196
198
|
}
|
|
197
199
|
|
|
198
|
-
|
|
200
|
+
// The upload path streams from the path rather than buffering, so
|
|
201
|
+
// only the inline branch is bounded here — by INLINE_MAX_BYTES.
|
|
202
|
+
const { path: filePath, size } = await resolveLocalMedia(fileUri, {
|
|
203
|
+
type: VIDEO_ERROR,
|
|
204
|
+
trusted: ctx.trusted,
|
|
205
|
+
maxBytes: Number.POSITIVE_INFINITY,
|
|
206
|
+
stat: ctx.statFn
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
if (size > INLINE_MAX_BYTES || ctx.useCache) {
|
|
199
210
|
const uri = await uploadFile(filePath, mimeType, ctx)
|
|
200
211
|
return { fileData: { fileUri: uri, mimeType } }
|
|
201
212
|
}
|
|
@@ -27,6 +27,7 @@ import { cancelledDone } from './_cancelled.js'
|
|
|
27
27
|
import { getSpec } from './_catalog.js'
|
|
28
28
|
import { classifyProviderError } from './_errors.js'
|
|
29
29
|
import { loadImages } from './_images.js'
|
|
30
|
+
import { isTrustedMedia } from './_media.js'
|
|
30
31
|
import { costFor } from './_pricing.js'
|
|
31
32
|
import { catalogKey, bareOf } from '#core/model-id.js'
|
|
32
33
|
import {
|
|
@@ -95,19 +96,20 @@ export async function * anthropic (envelope, deps = {}) {
|
|
|
95
96
|
// Attach images to the last user message before building the request.
|
|
96
97
|
if (envelope.images?.length) {
|
|
97
98
|
try {
|
|
98
|
-
const loaded = await loadImages(envelope.images)
|
|
99
|
+
const loaded = await loadImages(envelope.images, { trusted: isTrustedMedia(envelope) })
|
|
99
100
|
const blocks = loaded.map(toAnthropicImageBlock).filter(Boolean)
|
|
100
101
|
if (blocks.length) injectImageBlocks(conversation, blocks)
|
|
101
102
|
} catch (e) {
|
|
102
103
|
log?.warn({ err: e }, '[mohdel:anthropic] image load failed')
|
|
103
|
-
|
|
104
|
+
const typed = /** @type {any} */(e).typed
|
|
105
|
+
yield { type: 'error', error: typed || classifyProviderError(e, envelope.auth?.key, { provider: 'anthropic' }) }
|
|
104
106
|
return
|
|
105
107
|
}
|
|
106
108
|
}
|
|
107
109
|
|
|
108
110
|
const request = buildRequest(envelope, conversation, system, conversationCacheTtl)
|
|
109
111
|
|
|
110
|
-
//
|
|
112
|
+
// Accumulate via array + join to avoid per-delta V8 cons-string
|
|
111
113
|
// churn. Materialized at each exit point.
|
|
112
114
|
const outputParts = []
|
|
113
115
|
const currentOutput = () => outputParts.join('')
|
|
@@ -28,6 +28,7 @@ import { cancelledDone } from './_cancelled.js'
|
|
|
28
28
|
import { getSpec } from './_catalog.js'
|
|
29
29
|
import { classifyProviderError } from './_errors.js'
|
|
30
30
|
import { loadImages } from './_images.js'
|
|
31
|
+
import { isTrustedMedia } from './_media.js'
|
|
31
32
|
import { loadVideos } from './_videos.js'
|
|
32
33
|
import { costFor } from './_pricing.js'
|
|
33
34
|
import { catalogKey, bareOf } from '#core/model-id.js'
|
|
@@ -53,12 +54,13 @@ export async function * gemini (envelope, deps = {}) {
|
|
|
53
54
|
|
|
54
55
|
if (envelope.images?.length) {
|
|
55
56
|
try {
|
|
56
|
-
const loaded = await loadImages(envelope.images)
|
|
57
|
+
const loaded = await loadImages(envelope.images, { trusted: isTrustedMedia(envelope) })
|
|
57
58
|
const parts = loaded.map(toGeminiImagePart).filter(Boolean)
|
|
58
59
|
if (parts.length) injectParts(contents, parts)
|
|
59
60
|
} catch (e) {
|
|
60
61
|
log?.warn({ err: e }, '[mohdel:gemini] image load failed')
|
|
61
|
-
|
|
62
|
+
const typed = /** @type {any} */(e).typed
|
|
63
|
+
yield { type: 'error', error: typed || classifyProviderError(e, envelope.auth?.key, { provider: 'gemini' }) }
|
|
62
64
|
return
|
|
63
65
|
}
|
|
64
66
|
}
|
|
@@ -68,6 +70,7 @@ export async function * gemini (envelope, deps = {}) {
|
|
|
68
70
|
const parts = await loadVideos(envelope.videos, {
|
|
69
71
|
client,
|
|
70
72
|
useCache: !!envelope.cache,
|
|
73
|
+
trusted: isTrustedMedia(envelope),
|
|
71
74
|
signal
|
|
72
75
|
})
|
|
73
76
|
if (parts.length) injectParts(contents, parts)
|
|
@@ -97,7 +100,7 @@ export async function * gemini (envelope, deps = {}) {
|
|
|
97
100
|
request.config = { ...(request.config ?? {}), abortSignal: signal }
|
|
98
101
|
}
|
|
99
102
|
|
|
100
|
-
//
|
|
103
|
+
// Accumulate via array + join to avoid per-delta V8 cons-string
|
|
101
104
|
// churn. Materialized at each exit point.
|
|
102
105
|
const outputParts = []
|
|
103
106
|
const currentOutput = () => outputParts.join('')
|
|
@@ -122,8 +122,8 @@ async function pollTaskResult (fetchFn, sleep, now, taskId, apiKey) {
|
|
|
122
122
|
function fromHttpStatus (status, message, detail) {
|
|
123
123
|
const typed = classifyProviderError({ status })
|
|
124
124
|
// Keep the classifier's message (stable/machine-readable); put the
|
|
125
|
-
// caller's context + any response-body snippet into `detail`.
|
|
126
|
-
//
|
|
125
|
+
// caller's context + any response-body snippet into `detail`. Never
|
|
126
|
+
// echo provider response bodies into `TypedError.message`.
|
|
127
127
|
return typedError(typed.message, typed.type, typed.retryable, detail ? `${message}: ${detail}` : message)
|
|
128
128
|
}
|
|
129
129
|
|
|
@@ -28,6 +28,7 @@ import { cancelledDone } from './_cancelled.js'
|
|
|
28
28
|
import { getSpec } from './_catalog.js'
|
|
29
29
|
import { classifyProviderError } from './_errors.js'
|
|
30
30
|
import { loadImages } from './_images.js'
|
|
31
|
+
import { isTrustedMedia } from './_media.js'
|
|
31
32
|
import { costFor } from './_pricing.js'
|
|
32
33
|
import { catalogKey, providerOf, bareOf } from '#core/model-id.js'
|
|
33
34
|
import {
|
|
@@ -57,19 +58,20 @@ export async function * openai (envelope, deps = {}) {
|
|
|
57
58
|
|
|
58
59
|
if (envelope.images?.length) {
|
|
59
60
|
try {
|
|
60
|
-
const loaded = await loadImages(envelope.images)
|
|
61
|
+
const loaded = await loadImages(envelope.images, { trusted: isTrustedMedia(envelope) })
|
|
61
62
|
const parts = loaded.map(toOpenAIImagePart).filter(Boolean)
|
|
62
63
|
if (parts.length) injectImageParts(input, parts)
|
|
63
64
|
} catch (e) {
|
|
64
65
|
log?.warn({ err: e }, '[mohdel:openai] image load failed')
|
|
65
|
-
|
|
66
|
+
const typed = /** @type {any} */(e).typed
|
|
67
|
+
yield { type: 'error', error: typed || classifyProviderError(e, envelope.auth?.key, { provider: 'openai' }) }
|
|
66
68
|
return
|
|
67
69
|
}
|
|
68
70
|
}
|
|
69
71
|
|
|
70
72
|
const request = buildRequest(envelope, input, instructions)
|
|
71
73
|
|
|
72
|
-
//
|
|
74
|
+
// Accumulate via array + join to avoid per-delta V8 cons-string
|
|
73
75
|
// churn. Materialized at each exit point.
|
|
74
76
|
const outputParts = []
|
|
75
77
|
const currentOutput = () => outputParts.join('')
|
|
@@ -47,12 +47,3 @@ export function getTranscriptionAdapter (provider) {
|
|
|
47
47
|
if (!adapter) throw new Error(`no transcription adapter for provider: ${provider}`)
|
|
48
48
|
return adapter
|
|
49
49
|
}
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* Whether the provider has a transcription adapter registered.
|
|
53
|
-
*
|
|
54
|
-
* @param {string} provider
|
|
55
|
-
*/
|
|
56
|
-
export function isTranscriptionProvider (provider) {
|
|
57
|
-
return Object.prototype.hasOwnProperty.call(TRANSCRIPTION_ADAPTERS, provider)
|
|
58
|
-
}
|
|
@@ -18,11 +18,11 @@
|
|
|
18
18
|
* @module session/adapters/transcription/openai_compatible
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
import { readFile } from 'node:fs/promises'
|
|
22
21
|
import { basename } from 'node:path'
|
|
23
22
|
|
|
24
23
|
import { getSpec } from '../_catalog.js'
|
|
25
24
|
import { classifyProviderError } from '../_errors.js'
|
|
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'
|
|
28
28
|
|
|
@@ -39,7 +39,7 @@ export function createTranscriptionAdapter ({ baseURL, responseFormat }) {
|
|
|
39
39
|
const spec = deps.spec ?? getSpec(catalogKey(envelope.model)) ?? {}
|
|
40
40
|
const start = String(process.hrtime.bigint())
|
|
41
41
|
|
|
42
|
-
const audio = await loadAudio(envelope.audio)
|
|
42
|
+
const audio = await loadAudio(envelope.audio, { trusted: isTrustedMedia(envelope) })
|
|
43
43
|
|
|
44
44
|
const form = new FormData()
|
|
45
45
|
form.append('model', spec.model ?? bareOf(envelope.model))
|
|
@@ -124,30 +124,25 @@ const EXT_BY_MIME = {
|
|
|
124
124
|
* downloading arbitrary URLs; the caller owns that step.
|
|
125
125
|
*
|
|
126
126
|
* @param {import('#core/transcription.js').AudioRef} audio
|
|
127
|
+
* @param {{trusted?: boolean}} [opts]
|
|
127
128
|
* @returns {Promise<{bytes: Buffer, mimeType: string, filename: string}>}
|
|
128
129
|
*/
|
|
129
|
-
export async function loadAudio (audio) {
|
|
130
|
+
export async function loadAudio (audio, opts = {}) {
|
|
130
131
|
if (!audio?.fileUri || !audio?.mimeType) {
|
|
131
132
|
throw typedError('transcription requires audio {fileUri, mimeType}', 'SESSION_INVALID_AUDIO', false)
|
|
132
133
|
}
|
|
133
134
|
const { fileUri, mimeType } = audio
|
|
134
|
-
if (fileUri
|
|
135
|
-
const path = fileUri
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
} catch (e) {
|
|
140
|
-
throw typedError('audio file unreadable', 'SESSION_INVALID_AUDIO', false, messageOf(e))
|
|
141
|
-
}
|
|
135
|
+
if (mediaScheme(fileUri) === 'file') {
|
|
136
|
+
const { path, bytes } = await readLocalMedia(fileUri, {
|
|
137
|
+
type: 'SESSION_INVALID_AUDIO',
|
|
138
|
+
trusted: opts.trusted
|
|
139
|
+
})
|
|
142
140
|
return { bytes, mimeType, filename: basename(path) }
|
|
143
141
|
}
|
|
144
|
-
if (fileUri
|
|
145
|
-
const parts = fileUri.split(',')
|
|
146
|
-
if (parts.length < 2) {
|
|
147
|
-
throw typedError('malformed audio data URI', 'SESSION_INVALID_AUDIO', false)
|
|
148
|
-
}
|
|
142
|
+
if (mediaScheme(fileUri) === 'data') {
|
|
149
143
|
const ext = EXT_BY_MIME[mimeType] || mimeType.split('/').pop() || 'bin'
|
|
150
|
-
|
|
144
|
+
const payload = dataUriPayload(fileUri, 'SESSION_INVALID_AUDIO')
|
|
145
|
+
return { bytes: Buffer.from(payload, 'base64'), mimeType, filename: `audio.${ext}` }
|
|
151
146
|
}
|
|
152
147
|
throw typedError(
|
|
153
148
|
`unsupported audio URI scheme: ${fileUri.slice(0, 32)}…`,
|
|
@@ -159,7 +154,7 @@ export async function loadAudio (audio) {
|
|
|
159
154
|
function fromHttpStatus (status, message, detail) {
|
|
160
155
|
const typed = classifyProviderError({ status })
|
|
161
156
|
// Keep the classifier's message (stable/machine-readable); response
|
|
162
|
-
// body snippets go to `detail` only
|
|
157
|
+
// body snippets go to `detail` only.
|
|
163
158
|
return typedError(typed.message, typed.type, typed.retryable, detail ? `${message}: ${detail}` : message)
|
|
164
159
|
}
|
|
165
160
|
|
|
@@ -170,8 +165,3 @@ function typedError (message, type, retryable, detail) {
|
|
|
170
165
|
err.typed = typed
|
|
171
166
|
return err
|
|
172
167
|
}
|
|
173
|
-
|
|
174
|
-
/** @param {unknown} e */
|
|
175
|
-
function messageOf (e) {
|
|
176
|
-
return e instanceof Error ? e.message : String(e)
|
|
177
|
-
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mohdel",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.118.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Christophe Le Bars",
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"./providers": "./src/lib/providers.js",
|
|
26
26
|
"./creators": "./src/lib/creators.js",
|
|
27
27
|
"./utils": "./src/lib/utils.js",
|
|
28
|
-
"./errors": "./
|
|
28
|
+
"./errors": "./js/core/errors.js",
|
|
29
29
|
"./client": "./js/client/index.js",
|
|
30
30
|
"./session": "./js/session/index.js",
|
|
31
31
|
"./session/bin": "./js/session/bin.js"
|
|
@@ -87,16 +87,16 @@
|
|
|
87
87
|
"@opentelemetry/exporter-trace-otlp-grpc": "^0.221.0",
|
|
88
88
|
"@opentelemetry/sdk-node": "^0.221.0",
|
|
89
89
|
"chalk": "^5.4.0",
|
|
90
|
-
"mohdel-thin-gate-linux-x64-gnu": "0.
|
|
90
|
+
"mohdel-thin-gate-linux-x64-gnu": "0.118.0"
|
|
91
91
|
},
|
|
92
92
|
"dependencies": {
|
|
93
|
-
"@anthropic-ai/sdk": "^0.
|
|
93
|
+
"@anthropic-ai/sdk": "^0.115.0",
|
|
94
94
|
"@cerebras/cerebras_cloud_sdk": "^1.91.0",
|
|
95
95
|
"@google/genai": "^2.13.0",
|
|
96
96
|
"@opentelemetry/api": "^1.9.1",
|
|
97
97
|
"env-paths": "^4.0.0",
|
|
98
|
-
"groq-sdk": "^1.
|
|
99
|
-
"openai": "^6.
|
|
98
|
+
"groq-sdk": "^1.4.0",
|
|
99
|
+
"openai": "^6.49.0",
|
|
100
100
|
"undici": "^7.24.5"
|
|
101
101
|
},
|
|
102
102
|
"lint-staged": {
|
|
@@ -104,7 +104,7 @@
|
|
|
104
104
|
},
|
|
105
105
|
"devDependencies": {
|
|
106
106
|
"gpt-tokenizer": "^3.4.0",
|
|
107
|
-
"lint-staged": "^17.
|
|
107
|
+
"lint-staged": "^17.2.0",
|
|
108
108
|
"release-it": "^20.2.1",
|
|
109
109
|
"standard": "^17.1.2",
|
|
110
110
|
"vitest": "^4.1.10"
|