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.
@@ -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 { readFile } from 'node:fs/promises'
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
- if (fileUri.startsWith('file://')) {
47
- const path = fileUri.replace(/^file:\/\//, '')
48
- const buf = await readFile(path)
49
- return { mimeType, base64: buf.toString('base64') }
50
- }
51
- if (fileUri.startsWith('data:')) {
52
- const parts = fileUri.split(',')
53
- if (parts.length > 1) return { mimeType, base64: parts[1] }
54
- throw new Error(`malformed data URI: ${fileUri.slice(0, 32)}…`)
55
- }
56
- if (fileUri.startsWith('https://') || fileUri.startsWith('http://')) {
57
- return { mimeType, url: fileUri }
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 before F62; this helper owns the
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://` / local path, ≤20MB, no cache flag → read + base64
8
- * inline as `inlineData`.
9
- * 2. `file://` / local path, >20MB or `cache: true` → upload via
10
- * the provider SDK (Gemini `ai.files.upload`), poll until the
11
- * file is ACTIVE, return a `fileData` part. Content-hash +
12
- * mtime-keyed cache at `~/.cache/mohdel/uploaded-files.json`
13
- * short-circuits repeat uploads.
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
- // https:// → Gemini fetches it directly
178
- if (/^https?:\/\//i.test(fileUri)) {
186
+ const scheme = mediaScheme(fileUri)
187
+ if (scheme === 'remote') {
179
188
  return { fileData: { fileUri, mimeType } }
180
189
  }
181
-
182
- // data: URI → inline the base64 payload
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
- // file:// or local path
190
- const filePath = fileUri.replace(/^file:\/\//, '')
191
- let stats
192
- try {
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
- if (stats.size > INLINE_MAX_BYTES || ctx.useCache) {
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
- yield { type: 'error', error: classifyProviderError(e, envelope.auth?.key, { provider: 'anthropic' }) }
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
- // F53: accumulate via array + join to avoid per-delta V8 cons-string
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
- yield { type: 'error', error: classifyProviderError(e, envelope.auth?.key, { provider: 'gemini' }) }
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
- // F53: accumulate via array + join to avoid per-delta V8 cons-string
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`. F45:
126
- // never echo provider response bodies into `TypedError.message`.
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
- yield { type: 'error', error: classifyProviderError(e, envelope.auth?.key, { provider: 'openai' }) }
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
- // F53: accumulate via array + join to avoid per-delta V8 cons-string
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.startsWith('file://')) {
135
- const path = fileUri.replace(/^file:\/\//, '')
136
- let bytes
137
- try {
138
- bytes = await readFile(path)
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.startsWith('data:')) {
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
- return { bytes: Buffer.from(parts[1], 'base64'), mimeType, filename: `audio.${ext}` }
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 (F45).
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.117.2",
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": "./src/lib/errors.js",
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.117.2"
90
+ "mohdel-thin-gate-linux-x64-gnu": "0.118.0"
91
91
  },
92
92
  "dependencies": {
93
- "@anthropic-ai/sdk": "^0.112.5",
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.3.0",
99
- "openai": "^6.48.0",
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.1.1",
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"