mohdel 0.117.3 → 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 CHANGED
@@ -179,7 +179,7 @@ Every call emits:
179
179
 
180
180
  - **OpenTelemetry span** (`mohdel.session.answer`) under the caller's `traceparent`, with GenAI semantic-convention attributes (`gen_ai.request.model`, `gen_ai.system`, `gen_ai.usage.input_tokens`, `gen_ai.usage.output_tokens`) plus mohdel's own (`mohdel.status`, `mohdel.cost`, `mohdel.thinking_tokens`, `mohdel.time_to_first_token_ms`, `mohdel.cooldown` on fast-fail).
181
181
  - **Trace-linked logs** — every stderr log line carries `{traceId, spanId, callId, authId, provider, model}`. Dump logs + traces into the same collector (SigNoz, Honeycomb, Jaeger + Loki) and they're correlated for free. No per-call instrumentation code.
182
- - **Gate-side OTLP metrics** (when running `thin-gate`): `mohdel.sessions.{alive,respawned,spawn_failures}`, `mohdel.calls{provider,status}`, `mohdel.call.duration_ms`, `mohdel.cooldown.rejections`, `mohdel.quota.rejections`, `mohdel.policy.errors`.
182
+ - **Gate-side OTLP metrics** (when running `thin-gate`): `mohdel.sessions.{alive,respawned,spawn_failures}`, `mohdel.calls{provider,status}`, `mohdel.call.duration_ms`, `mohdel.cooldown.rejections`, `mohdel.quota.rejections`, `mohdel.policy.errors`, `mohdel.enforcer.keyspace_full{map}`. The `provider` attribute is folded to `other` for anything outside mohdel's provider set, so a caller can't mint metric series by varying the model prefix.
183
183
 
184
184
  One endpoint for everything: set `OTEL_EXPORTER_OTLP_ENDPOINT` and spans + metrics flow to it over gRPC. No-op when unset — zero overhead for callers who aren't wired. See [INTEGRATION.md §OpenTelemetry](INTEGRATION.md#opentelemetry) and [LOGGING.md](LOGGING.md) for details.
185
185
 
@@ -240,7 +240,7 @@ Wire format is JSON over NDJSON frames, camelCase. Types are defined in `js/core
240
240
  - **`AnswerResult`** — `status`, `output`, `inputTokens`, `outputTokens`, `thinkingTokens`, `cost` (single number), `timestamps`, `warning?`, `toolCalls?`.
241
241
  - **`Status`** — `'completed' | 'tool_use' | 'incomplete'`.
242
242
  - **`Warning`** — additive string union: `'insufficientOutputBudget'`, `'cancelled'`, ...
243
- - **`TypedError`** — `{ message, detail?, severity, retryable, type }`. `message` is a stable machine key; `detail` is user-facing context; `severity` is `'trace' | 'debug' | 'info' | 'warn' | 'error' | 'fatal'`; `type` is an optional canonical tag (e.g. `'AUTH_INVALID'`, `'PROVIDER_COOLDOWN'`).
243
+ - **`TypedError`** — `{ message, detail?, severity, retryable, type }`. `type` is the canonical tag callers branch on (e.g. `'AUTH_INVALID'`, `'PROVIDER_COOLDOWN'`), optional on the wire; `message` is a short human-readable label; `detail` is the provider's own rejection text; `severity` is `'trace' | 'debug' | 'info' | 'warn' | 'error' | 'fatal'`.
244
244
 
245
245
  A `cancel` control message `{ op: "cancel", callId }` on session stdin aborts the matching in-flight call.
246
246
 
package/js/client/call.js CHANGED
@@ -10,7 +10,7 @@
10
10
 
11
11
  import { requestUnix } from './transport.js'
12
12
  import { parseNDJSON } from './ndjson.js'
13
- import { isEvent, MohdelTypedError } from '#core'
13
+ import { isEvent, MohdelError } from '#core'
14
14
 
15
15
  /**
16
16
  * @param {import('#core/envelope.js').CallEnvelope} envelope
@@ -31,12 +31,12 @@ export async function * call (envelope, { socketPath, signal, path = '/v1/call'
31
31
 
32
32
  if (res.statusCode !== 200) {
33
33
  const body = await readAll(res)
34
- throw MohdelTypedError.fromJSON(parseErrorBody(body, res.statusCode ?? 0))
34
+ throw MohdelError.fromJSON(parseErrorBody(body, res.statusCode ?? 0))
35
35
  }
36
36
 
37
37
  for await (const obj of parseNDJSON(res)) {
38
38
  if (!isEvent(obj)) {
39
- throw new MohdelTypedError(
39
+ throw new MohdelError(
40
40
  'received non-Event object from thin-gate',
41
41
  { type: 'PROTOCOL_INVALID_EVENT', retryable: false }
42
42
  )
@@ -70,6 +70,7 @@ function parseErrorBody (body, status) {
70
70
  return {
71
71
  type: 'PROTOCOL_HTTP_ERROR',
72
72
  message: `thin-gate returned HTTP ${status}`,
73
+ severity: 'error',
73
74
  retryable: status >= 500
74
75
  }
75
76
  }
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import { requestUnix } from './transport.js'
11
- import { MohdelTypedError } from '#core'
11
+ import { MohdelError } from '#core'
12
12
 
13
13
  /**
14
14
  * @param {import('#core/image.js').ImageEnvelope} envelope
@@ -30,21 +30,21 @@ export async function callImage (envelope, { socketPath, signal, path = '/v1/ima
30
30
  const body = await readAll(res)
31
31
 
32
32
  if (res.statusCode !== 200) {
33
- throw MohdelTypedError.fromJSON(parseErrorBody(body, res.statusCode ?? 0))
33
+ throw MohdelError.fromJSON(parseErrorBody(body, res.statusCode ?? 0))
34
34
  }
35
35
 
36
36
  let parsed
37
37
  try {
38
38
  parsed = JSON.parse(body)
39
39
  } catch (e) {
40
- throw new MohdelTypedError(
40
+ throw new MohdelError(
41
41
  'thin-gate returned non-JSON image response',
42
42
  { type: 'PROTOCOL_INVALID_EVENT', retryable: false }
43
43
  )
44
44
  }
45
45
 
46
46
  if (!parsed || typeof parsed !== 'object' || parsed.status !== 'completed' || !Array.isArray(parsed.images)) {
47
- throw new MohdelTypedError(
47
+ throw new MohdelError(
48
48
  'thin-gate returned malformed ImageResult',
49
49
  { type: 'PROTOCOL_INVALID_EVENT', retryable: false }
50
50
  )
@@ -77,6 +77,7 @@ function parseErrorBody (body, status) {
77
77
  return {
78
78
  type: 'PROTOCOL_HTTP_ERROR',
79
79
  message: `thin-gate returned HTTP ${status}`,
80
+ severity: 'error',
80
81
  retryable: status >= 500
81
82
  }
82
83
  }
@@ -11,7 +11,7 @@
11
11
  */
12
12
 
13
13
  import { requestUnix } from './transport.js'
14
- import { MohdelTypedError } from '#core'
14
+ import { MohdelError } from '#core'
15
15
 
16
16
  /**
17
17
  * @param {import('#core/transcription.js').TranscriptionEnvelope} envelope
@@ -33,21 +33,21 @@ export async function callTranscription (envelope, { socketPath, signal, path =
33
33
  const body = await readAll(res)
34
34
 
35
35
  if (res.statusCode !== 200) {
36
- throw MohdelTypedError.fromJSON(parseErrorBody(body, res.statusCode ?? 0))
36
+ throw MohdelError.fromJSON(parseErrorBody(body, res.statusCode ?? 0))
37
37
  }
38
38
 
39
39
  let parsed
40
40
  try {
41
41
  parsed = JSON.parse(body)
42
42
  } catch (e) {
43
- throw new MohdelTypedError(
43
+ throw new MohdelError(
44
44
  'thin-gate returned non-JSON transcription response',
45
45
  { type: 'PROTOCOL_INVALID_EVENT', retryable: false }
46
46
  )
47
47
  }
48
48
 
49
49
  if (!parsed || typeof parsed !== 'object' || parsed.status !== 'completed' || typeof parsed.text !== 'string') {
50
- throw new MohdelTypedError(
50
+ throw new MohdelError(
51
51
  'thin-gate returned malformed TranscriptionResult',
52
52
  { type: 'PROTOCOL_INVALID_EVENT', retryable: false }
53
53
  )
@@ -80,6 +80,7 @@ function parseErrorBody (body, status) {
80
80
  return {
81
81
  type: 'PROTOCOL_HTTP_ERROR',
82
82
  message: `thin-gate returned HTTP ${status}`,
83
+ severity: 'error',
83
84
  retryable: status >= 500
84
85
  }
85
86
  }
@@ -23,7 +23,7 @@
23
23
  * @property {string} [traceparent] W3C tracecontext header.
24
24
  * @property {string} [baggage] W3C baggage header.
25
25
  *
26
- * @property {import('./model-id.js').ModelId} model
26
+ * @property {string} model
27
27
  * Full mohdel id — `"<provider>/<bare>[:<effort>]"`. Same shape
28
28
  * on the wire and in-process. See PROTOCOL §3. No separate
29
29
  * `provider` field exists at any layer; callers that need the
@@ -155,3 +155,47 @@ export const ENVELOPE_FIELDS = Object.freeze([
155
155
  'idleHeartbeatMs',
156
156
  'providerOptions'
157
157
  ])
158
+
159
+ export const MAX_ID_BYTES = 128
160
+ export const MAX_MODEL_BYTES = 256
161
+ export const MAX_PROVIDER_BYTES = 32
162
+
163
+ const encoder = new TextEncoder()
164
+
165
+ /**
166
+ * @param {string} s
167
+ * @returns {number}
168
+ */
169
+ function byteLength (s) {
170
+ return encoder.encode(s).length
171
+ }
172
+
173
+ /**
174
+ * Bound the envelope fields that become long-lived map keys and
175
+ * telemetry attributes, and reject a model id with an empty half.
176
+ *
177
+ * Mirror of `rust/thin-gate/src/protocol.rs::validate_ids`, applied on
178
+ * the in-process factory path which does not cross the gate. Reason
179
+ * strings match the gate's byte for byte so both transports report a
180
+ * rejection identically.
181
+ *
182
+ * @param {string} callId
183
+ * @param {string} authId
184
+ * @param {string} model
185
+ * @returns {string | undefined} Reason, or undefined when valid.
186
+ */
187
+ export function validateIds (callId, authId, model) {
188
+ if (byteLength(callId) > MAX_ID_BYTES) return `callId exceeds ${MAX_ID_BYTES} bytes`
189
+ if (byteLength(authId) > MAX_ID_BYTES) return `authId exceeds ${MAX_ID_BYTES} bytes`
190
+ if (byteLength(model) > MAX_MODEL_BYTES) return `model exceeds ${MAX_MODEL_BYTES} bytes`
191
+
192
+ const slash = model.indexOf('/')
193
+ const provider = slash < 0 ? '' : model.slice(0, slash)
194
+ const bare = slash < 0 ? '' : model.slice(slash + 1)
195
+ if (slash < 0 || !provider || !bare) {
196
+ return `model must be '<provider>/<id>' (got: ${model})`
197
+ }
198
+ if (byteLength(provider) > MAX_PROVIDER_BYTES) {
199
+ return `model provider exceeds ${MAX_PROVIDER_BYTES} bytes`
200
+ }
201
+ }
package/js/core/errors.js CHANGED
@@ -1,7 +1,13 @@
1
1
  /**
2
- * `TypedError` — wire-format error. Carries `message` (machine key),
3
- * optional `detail` (user-facing context), `severity` (lowercase
4
- * string), `retryable`, and optional `type` (canonical tag).
2
+ * `MohdelError` — the one error kind. `TypedError` is its serialized
3
+ * form: `type` is the canonical machine tag callers branch on,
4
+ * `message` is a short human-readable label, `detail` carries the
5
+ * provider's own rejection text, plus `severity` (lowercase string)
6
+ * and `retryable`.
7
+ *
8
+ * Thrown in-process by the factory and serialized over the gate are
9
+ * two transports for the same error. `toJSON()` whitelists the wire
10
+ * fields, so in-process-only state (`context`) cannot reach the wire.
5
11
  *
6
12
  * Rust mirror: `rust/thin-gate/src/protocol.rs::TypedError`.
7
13
  *
@@ -15,36 +21,44 @@
15
21
  /**
16
22
  * @typedef {object} TypedError
17
23
  * @property {string} message
18
- * Top-level message. Never echo provider response bodies.
24
+ * Short human-readable label (e.g. `'provider error 400'`). Never
25
+ * echo provider response bodies.
19
26
  * @property {string} [detail]
20
- * User-facing error detail (mirrors `MohdelError.detail`).
27
+ * Provider rejection text, capped and API-key-scrubbed by
28
+ * `classifyProviderError`. Whether to surface, log, or redact it
29
+ * further is the caller's policy.
21
30
  * @property {SeverityTag} severity
22
31
  * @property {boolean} retryable
23
32
  * @property {string} [type]
24
- * Optional canonical tag (e.g. `'PROVIDER_COOLDOWN'`, `'AUTH_INVALID'`).
33
+ * Canonical tag callers branch on (e.g. `'PROVIDER_COOLDOWN'`,
34
+ * `'AUTH_INVALID'`). Optional on the wire; every
35
+ * `classifyProviderError` result sets it.
25
36
  */
26
37
 
27
38
  export const SEVERITY_TAGS = Object.freeze([
28
39
  'trace', 'debug', 'info', 'warn', 'error', 'fatal'
29
40
  ])
30
41
 
31
- export class MohdelTypedError extends Error {
42
+ export class MohdelError extends Error {
32
43
  /**
33
44
  * @param {string} message
34
45
  * @param {{
35
46
  * severity?: SeverityTag,
36
47
  * retryable?: boolean,
37
48
  * detail?: string,
38
- * type?: string
49
+ * type?: string,
50
+ * context?: object
39
51
  * }} [options]
52
+ * `context` is in-process only and never serialized.
40
53
  */
41
- constructor (message, { severity = 'error', retryable = false, detail, type } = {}) {
54
+ constructor (message, { severity = 'error', retryable = false, detail, type, context } = {}) {
42
55
  super(message)
43
- this.name = 'MohdelTypedError'
56
+ this.name = 'MohdelError'
44
57
  this.severity = severity
45
58
  this.retryable = retryable
46
59
  if (detail) this.detail = detail
47
60
  if (type) this.type = type
61
+ if (context) this.context = context
48
62
  }
49
63
 
50
64
  /** @returns {TypedError} */
@@ -62,14 +76,16 @@ export class MohdelTypedError extends Error {
62
76
 
63
77
  /**
64
78
  * @param {TypedError} data
65
- * @returns {MohdelTypedError}
79
+ * @param {object} [context] In-process only; not part of `data`.
80
+ * @returns {MohdelError}
66
81
  */
67
- static fromJSON (data) {
68
- return new MohdelTypedError(data.message, {
82
+ static fromJSON (data, context) {
83
+ return new MohdelError(data.message, {
69
84
  severity: data.severity,
70
85
  retryable: data.retryable,
71
86
  detail: data.detail,
72
- type: data.type
87
+ type: data.type,
88
+ context
73
89
  })
74
90
  }
75
91
  }
package/js/core/image.js CHANGED
@@ -17,7 +17,7 @@
17
17
  * @property {string} [traceparent]
18
18
  * @property {string} [baggage]
19
19
  *
20
- * @property {import('./model-id.js').ModelId} model
20
+ * @property {string} model
21
21
  * Full mohdel id — `"<provider>/<bare>"`. Same shape as
22
22
  * `CallEnvelope.model` (see `envelope.js`). No separate `provider`
23
23
  * field.
@@ -7,39 +7,16 @@
7
7
  * object form; when the provider or bare part is needed, these
8
8
  * helpers return it as a substring.
9
9
  *
10
- * `parseModelId` validates + brands at the boundary (factory input,
11
- * wire deserialize, admin endpoints). After that every `ModelId` in
12
- * memory is known-valid; adapters and core code call the accessors
13
- * freely without re-validating.
10
+ * Ids are validated at ingress by the gate
11
+ * (`rust/thin-gate/src/protocol.rs::validate_ids`); these accessors
12
+ * assume a well-formed id and do not re-validate.
14
13
  *
15
14
  * @module core/model-id
16
15
  */
17
16
 
18
- /**
19
- * Branded string type. Only `parseModelId` produces one.
20
- * @typedef {string & { __brand: 'ModelId' }} ModelId
21
- */
22
-
23
- const MODEL_ID_RE = /^[a-z0-9][a-z0-9-]*\/[a-z0-9][a-z0-9._-]*(?::[a-z]+)?$/i
24
-
25
- /**
26
- * Validate and brand a raw string. Throws on malformed input so the
27
- * boundary layer fails loudly instead of letting a bad id flow
28
- * through.
29
- *
30
- * @param {string} raw
31
- * @returns {ModelId}
32
- */
33
- export function parseModelId (raw) {
34
- if (typeof raw !== 'string' || !MODEL_ID_RE.test(raw)) {
35
- throw new TypeError(`invalid model id: ${JSON.stringify(raw)} (expected "<provider>/<bare>[:<effort>]")`)
36
- }
37
- return /** @type {ModelId} */ (raw)
38
- }
39
-
40
17
  /**
41
18
  * Provider segment of a model id.
42
- * @param {ModelId | string} model
19
+ * @param {string} model
43
20
  * @returns {string}
44
21
  */
45
22
  export function providerOf (model) {
@@ -52,7 +29,7 @@ export function providerOf (model) {
52
29
  * `:effort` suffix. Callers that want effort stripped use
53
30
  * `catalogKey()` instead.
54
31
  *
55
- * @param {ModelId | string} model
32
+ * @param {string} model
56
33
  * @returns {string}
57
34
  */
58
35
  export function bareOf (model) {
@@ -66,7 +43,7 @@ export function bareOf (model) {
66
43
  * output limits etc. are stored — per-effort variants do not get
67
44
  * their own entry.
68
45
  *
69
- * @param {ModelId | string} model
46
+ * @param {string} model
70
47
  * @returns {string}
71
48
  */
72
49
  export function catalogKey (model) {
@@ -81,7 +58,7 @@ export function catalogKey (model) {
81
58
  /**
82
59
  * Effort suffix, without the `:`, or `undefined` if absent.
83
60
  *
84
- * @param {ModelId | string} model
61
+ * @param {string} model
85
62
  * @returns {string | undefined}
86
63
  */
87
64
  export function effortOf (model) {
package/js/core/status.js CHANGED
@@ -38,11 +38,3 @@ export const STATUSES = Object.freeze([
38
38
 
39
39
  export const WARNING_INSUFFICIENT_OUTPUT_BUDGET = 'insufficientOutputBudget'
40
40
  export const WARNING_CANCELLED = 'cancelled'
41
-
42
- /**
43
- * @param {unknown} x
44
- * @returns {x is Status}
45
- */
46
- export function isStatus (x) {
47
- return typeof x === 'string' && STATUSES.includes(/** @type {Status} */(x))
48
- }
@@ -27,7 +27,7 @@
27
27
  * @property {string} [traceparent]
28
28
  * @property {string} [baggage]
29
29
  *
30
- * @property {import('./model-id.js').ModelId} model
30
+ * @property {string} model
31
31
  * Full mohdel id — `"<provider>/<bare>"`. Same shape as
32
32
  * `CallEnvelope.model` (see `envelope.js`).
33
33
  * @property {AudioRef} audio
@@ -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 { MohdelError, Severity } from '../../src/lib/errors.js'
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 (F24) |
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 (F24).
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
- // F54: skip the buffer allocation entirely when no handler was
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('SESSION_NO_TERMINAL', {
109
- severity: Severity.ERROR,
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 fromTypedError(terminal.error, { provider, model, modelKey })
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: options.callId || newCallId(),
140
- authId: options.authId || 'local',
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 fromTypedError(out.error, { provider, model })
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: options.callId || newCallId(),
173
- authId: options.authId || 'local',
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 fromTypedError(out.error, { provider, model })
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: options.callId || newCallId(),
202
- authId: options.authId || 'local',
213
+ callId,
214
+ authId,
203
215
  auth: configToAuth(configuration),
204
- model: /** @type {import('#core/model-id.js').ModelId} */ (modelKey),
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
- * Re-throw a TypedError as a MohdelError so factory-API caller catch
241
- * blocks — which duck-type on `.detail` / `.retryable` — keep
242
- * working without knowing about the session event-stream shape.
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 fromTypedError (err, ctx) {
249
- // TypedError `message` is the machine-key label (e.g. "provider error
250
- // 400"); `detail` carries the provider's own rejection text when it
251
- // was safe to surface. Prefer the detail so callers see what to fix.
252
- return new MohdelError(err.type || 'PROVIDER_ERROR', {
253
- severity: toSeveritySymbol(err.severity),
254
- detail: err.detail || err.message,
255
- retryable: !!err.retryable,
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
 
@@ -291,8 +285,8 @@ 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('CONFIGURATION_UNSUPPORTED', {
295
- severity: Severity.ERROR,
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('SESSION_INVALID_PROMPT', {
376
- severity: Severity.ERROR,
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, ' +
@@ -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 before F58; consolidated here. `_chat_completions.js`
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
- // F53: accumulate via array + join to avoid per-delta V8 cons-string
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,7 +410,7 @@ 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',