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.
Files changed (44) hide show
  1. package/README.md +4 -2
  2. package/js/client/call.js +4 -32
  3. package/js/client/call_image.js +5 -33
  4. package/js/client/call_transcription.js +5 -33
  5. package/js/client/ndjson.js +8 -4
  6. package/js/client/response.js +40 -0
  7. package/js/client/transport.js +6 -1
  8. package/js/core/envelope.js +46 -1
  9. package/js/core/errors.js +30 -14
  10. package/js/core/framing.js +29 -0
  11. package/js/core/image.js +1 -1
  12. package/js/core/model-id.js +7 -30
  13. package/js/core/status.js +0 -8
  14. package/js/core/transcription.js +1 -1
  15. package/js/factory/bridge.js +48 -54
  16. package/js/session/_idle_heartbeat.js +53 -24
  17. package/js/session/adapters/_cancelled.js +1 -1
  18. package/js/session/adapters/_chat_completions.js +1 -1
  19. package/js/session/adapters/_errors.js +43 -8
  20. package/js/session/adapters/_images.js +26 -17
  21. package/js/session/adapters/_lazy_json_cache.js +32 -12
  22. package/js/session/adapters/_media.js +205 -0
  23. package/js/session/adapters/_providers.js +0 -8
  24. package/js/session/adapters/_videos.js +67 -28
  25. package/js/session/adapters/anthropic.js +5 -3
  26. package/js/session/adapters/gemini.js +6 -3
  27. package/js/session/adapters/image/novita.js +1 -17
  28. package/js/session/adapters/openai.js +5 -3
  29. package/js/session/adapters/transcription/index.js +0 -9
  30. package/js/session/adapters/transcription/openai_compatible.js +13 -38
  31. package/js/session/driver.js +69 -28
  32. package/js/session/run.js +7 -1
  33. package/package.json +31 -10
  34. package/src/cli/_chalk.js +41 -0
  35. package/src/cli/colored-logger.js +1 -1
  36. package/src/cli/colors.js +1 -1
  37. package/src/cli/onboard.js +8 -3
  38. package/src/lib/cache.js +0 -73
  39. package/src/lib/catalog/gemini.js +3 -2
  40. package/src/lib/common.js +20 -11
  41. package/src/lib/curated-cache.js +0 -16
  42. package/src/lib/providers.js +1 -12
  43. package/src/lib/cooldown.js +0 -63
  44. package/src/lib/errors.js +0 -43
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
 
@@ -221,6 +221,8 @@ cargo run --bin mohdel-thin-gate /tmp/mohdel-data.sock /tmp/mohdel-admin.sock /p
221
221
  Positional args are optional (data socket, admin socket, session bin). Env overrides:
222
222
  - `MOHDEL_SESSION_BIN` — path to session entrypoint (defaults to none; if unset, data plane returns synthetic events)
223
223
  - `MOHDEL_SESSION_POOL_SIZE` — pre-warmed sessions (default 2)
224
+ - `MOHDEL_POOL_ACQUIRE_TIMEOUT_MS` — wait for a free session before `503 SESSION_POOL_BUSY` (default 30000)
225
+ - `MOHDEL_MAX_CONNECTIONS` — concurrently served data-plane connections (default 64)
224
226
 
225
227
  With no session-bin configured, thin-gate runs in demo mode: `POST /v1/call` returns a synthetic echo event sequence. Useful for health-checking the HTTP layer without a runtime dependency on Node.
226
228
 
@@ -240,7 +242,7 @@ Wire format is JSON over NDJSON frames, camelCase. Types are defined in `js/core
240
242
  - **`AnswerResult`** — `status`, `output`, `inputTokens`, `outputTokens`, `thinkingTokens`, `cost` (single number), `timestamps`, `warning?`, `toolCalls?`.
241
243
  - **`Status`** — `'completed' | 'tool_use' | 'incomplete'`.
242
244
  - **`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'`).
245
+ - **`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
246
 
245
247
  A `cancel` control message `{ op: "cancel", callId }` on session stdin aborts the matching in-flight call.
246
248
 
package/js/client/call.js CHANGED
@@ -9,8 +9,9 @@
9
9
  */
10
10
 
11
11
  import { requestUnix } from './transport.js'
12
+ import { readAll, parseErrorBody } from './response.js'
12
13
  import { parseNDJSON } from './ndjson.js'
13
- import { isEvent, MohdelTypedError } from '#core'
14
+ import { isEvent, MohdelError } from '#core'
14
15
 
15
16
  /**
16
17
  * @param {import('#core/envelope.js').CallEnvelope} envelope
@@ -31,12 +32,12 @@ export async function * call (envelope, { socketPath, signal, path = '/v1/call'
31
32
 
32
33
  if (res.statusCode !== 200) {
33
34
  const body = await readAll(res)
34
- throw MohdelTypedError.fromJSON(parseErrorBody(body, res.statusCode ?? 0))
35
+ throw MohdelError.fromJSON(parseErrorBody(body, res.statusCode ?? 0))
35
36
  }
36
37
 
37
38
  for await (const obj of parseNDJSON(res)) {
38
39
  if (!isEvent(obj)) {
39
- throw new MohdelTypedError(
40
+ throw new MohdelError(
40
41
  'received non-Event object from thin-gate',
41
42
  { type: 'PROTOCOL_INVALID_EVENT', retryable: false }
42
43
  )
@@ -44,32 +45,3 @@ export async function * call (envelope, { socketPath, signal, path = '/v1/call'
44
45
  yield /** @type {import('#core/events.js').Event} */(obj)
45
46
  }
46
47
  }
47
-
48
- /**
49
- * @param {AsyncIterable<Buffer|string>} stream
50
- * @returns {Promise<string>}
51
- */
52
- async function readAll (stream) {
53
- let s = ''
54
- for await (const c of stream) s += typeof c === 'string' ? c : c.toString('utf8')
55
- return s
56
- }
57
-
58
- /**
59
- * @param {string} body
60
- * @param {number} status
61
- * @returns {import('#core/errors.js').TypedError}
62
- */
63
- function parseErrorBody (body, status) {
64
- try {
65
- const parsed = JSON.parse(body)
66
- if (parsed && typeof parsed === 'object' && typeof parsed.type === 'string') {
67
- return parsed
68
- }
69
- } catch {}
70
- return {
71
- type: 'PROTOCOL_HTTP_ERROR',
72
- message: `thin-gate returned HTTP ${status}`,
73
- retryable: status >= 500
74
- }
75
- }
@@ -8,7 +8,8 @@
8
8
  */
9
9
 
10
10
  import { requestUnix } from './transport.js'
11
- import { MohdelTypedError } from '#core'
11
+ import { readAll, parseErrorBody } from './response.js'
12
+ import { MohdelError } from '#core'
12
13
 
13
14
  /**
14
15
  * @param {import('#core/image.js').ImageEnvelope} envelope
@@ -30,53 +31,24 @@ export async function callImage (envelope, { socketPath, signal, path = '/v1/ima
30
31
  const body = await readAll(res)
31
32
 
32
33
  if (res.statusCode !== 200) {
33
- throw MohdelTypedError.fromJSON(parseErrorBody(body, res.statusCode ?? 0))
34
+ throw MohdelError.fromJSON(parseErrorBody(body, res.statusCode ?? 0))
34
35
  }
35
36
 
36
37
  let parsed
37
38
  try {
38
39
  parsed = JSON.parse(body)
39
40
  } catch (e) {
40
- throw new MohdelTypedError(
41
+ throw new MohdelError(
41
42
  'thin-gate returned non-JSON image response',
42
43
  { type: 'PROTOCOL_INVALID_EVENT', retryable: false }
43
44
  )
44
45
  }
45
46
 
46
47
  if (!parsed || typeof parsed !== 'object' || parsed.status !== 'completed' || !Array.isArray(parsed.images)) {
47
- throw new MohdelTypedError(
48
+ throw new MohdelError(
48
49
  'thin-gate returned malformed ImageResult',
49
50
  { type: 'PROTOCOL_INVALID_EVENT', retryable: false }
50
51
  )
51
52
  }
52
53
  return parsed
53
54
  }
54
-
55
- /**
56
- * @param {AsyncIterable<Buffer|string>} stream
57
- * @returns {Promise<string>}
58
- */
59
- async function readAll (stream) {
60
- let s = ''
61
- for await (const c of stream) s += typeof c === 'string' ? c : c.toString('utf8')
62
- return s
63
- }
64
-
65
- /**
66
- * @param {string} body
67
- * @param {number} status
68
- * @returns {import('#core/errors.js').TypedError}
69
- */
70
- function parseErrorBody (body, status) {
71
- try {
72
- const parsed = JSON.parse(body)
73
- if (parsed && typeof parsed === 'object' && typeof parsed.type === 'string') {
74
- return parsed
75
- }
76
- } catch {}
77
- return {
78
- type: 'PROTOCOL_HTTP_ERROR',
79
- message: `thin-gate returned HTTP ${status}`,
80
- retryable: status >= 500
81
- }
82
- }
@@ -11,7 +11,8 @@
11
11
  */
12
12
 
13
13
  import { requestUnix } from './transport.js'
14
- import { MohdelTypedError } from '#core'
14
+ import { readAll, parseErrorBody } from './response.js'
15
+ import { MohdelError } from '#core'
15
16
 
16
17
  /**
17
18
  * @param {import('#core/transcription.js').TranscriptionEnvelope} envelope
@@ -33,53 +34,24 @@ export async function callTranscription (envelope, { socketPath, signal, path =
33
34
  const body = await readAll(res)
34
35
 
35
36
  if (res.statusCode !== 200) {
36
- throw MohdelTypedError.fromJSON(parseErrorBody(body, res.statusCode ?? 0))
37
+ throw MohdelError.fromJSON(parseErrorBody(body, res.statusCode ?? 0))
37
38
  }
38
39
 
39
40
  let parsed
40
41
  try {
41
42
  parsed = JSON.parse(body)
42
43
  } catch (e) {
43
- throw new MohdelTypedError(
44
+ throw new MohdelError(
44
45
  'thin-gate returned non-JSON transcription response',
45
46
  { type: 'PROTOCOL_INVALID_EVENT', retryable: false }
46
47
  )
47
48
  }
48
49
 
49
50
  if (!parsed || typeof parsed !== 'object' || parsed.status !== 'completed' || typeof parsed.text !== 'string') {
50
- throw new MohdelTypedError(
51
+ throw new MohdelError(
51
52
  'thin-gate returned malformed TranscriptionResult',
52
53
  { type: 'PROTOCOL_INVALID_EVENT', retryable: false }
53
54
  )
54
55
  }
55
56
  return parsed
56
57
  }
57
-
58
- /**
59
- * @param {AsyncIterable<Buffer|string>} stream
60
- * @returns {Promise<string>}
61
- */
62
- async function readAll (stream) {
63
- let s = ''
64
- for await (const c of stream) s += typeof c === 'string' ? c : c.toString('utf8')
65
- return s
66
- }
67
-
68
- /**
69
- * @param {string} body
70
- * @param {number} status
71
- * @returns {import('#core/errors.js').TypedError}
72
- */
73
- function parseErrorBody (body, status) {
74
- try {
75
- const parsed = JSON.parse(body)
76
- if (parsed && typeof parsed === 'object' && typeof parsed.type === 'string') {
77
- return parsed
78
- }
79
- } catch {}
80
- return {
81
- type: 'PROTOCOL_HTTP_ERROR',
82
- message: `thin-gate returned HTTP ${status}`,
83
- retryable: status >= 500
84
- }
85
- }
@@ -1,10 +1,14 @@
1
1
  /**
2
2
  * NDJSON line parser. Yields parsed objects from a byte/string stream.
3
3
  *
4
+ * The cap applies to a single unterminated line, not to the accumulated
5
+ * buffer: a buffer holding several complete frames is a legitimate
6
+ * burst, not a runaway line.
7
+ *
4
8
  * @module client/ndjson
5
9
  */
6
10
 
7
- const MAX_LINE_BYTES = 16 * 1024 * 1024
11
+ import { MAX_LINE_BYTES, exceedsLineBytes } from '#core/framing.js'
8
12
 
9
13
  /**
10
14
  * @param {AsyncIterable<Buffer|string>} stream
@@ -14,15 +18,15 @@ export async function * parseNDJSON (stream) {
14
18
  let buf = ''
15
19
  for await (const chunk of stream) {
16
20
  buf += typeof chunk === 'string' ? chunk : chunk.toString('utf8')
17
- if (buf.length > MAX_LINE_BYTES) {
18
- throw new Error(`NDJSON line exceeds ${MAX_LINE_BYTES} bytes without newline`)
19
- }
20
21
  let nl
21
22
  while ((nl = buf.indexOf('\n')) !== -1) {
22
23
  const line = buf.slice(0, nl).trim()
23
24
  buf = buf.slice(nl + 1)
24
25
  if (line) yield JSON.parse(line)
25
26
  }
27
+ if (exceedsLineBytes(buf)) {
28
+ throw new Error(`NDJSON line exceeds ${MAX_LINE_BYTES} bytes without newline`)
29
+ }
26
30
  }
27
31
  const tail = buf.trim()
28
32
  if (tail) yield JSON.parse(tail)
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Shared response handling for the three gate call paths.
3
+ *
4
+ * `call.js`, `call_image.js` and `call_transcription.js` each read a
5
+ * non-200 body the same way and turn it into the same `TypedError`;
6
+ * keeping one copy means a fix to the error shape cannot land on some
7
+ * paths and miss others.
8
+ *
9
+ * @module client/response
10
+ */
11
+
12
+ /**
13
+ * @param {AsyncIterable<Buffer|string>} stream
14
+ * @returns {Promise<string>}
15
+ */
16
+ export async function readAll (stream) {
17
+ let s = ''
18
+ for await (const c of stream) s += typeof c === 'string' ? c : c.toString('utf8')
19
+ return s
20
+ }
21
+
22
+ /**
23
+ * @param {string} body
24
+ * @param {number} status
25
+ * @returns {import('#core/errors.js').TypedError}
26
+ */
27
+ export function parseErrorBody (body, status) {
28
+ try {
29
+ const parsed = JSON.parse(body)
30
+ if (parsed && typeof parsed === 'object' && typeof parsed.type === 'string') {
31
+ return parsed
32
+ }
33
+ } catch {}
34
+ return {
35
+ type: 'PROTOCOL_HTTP_ERROR',
36
+ message: `thin-gate returned HTTP ${status}`,
37
+ severity: 'error',
38
+ retryable: status >= 500
39
+ }
40
+ }
@@ -39,7 +39,12 @@ export function requestUnix ({ socketPath, path, method, body, signal, headers }
39
39
  reject(new Error('aborted'))
40
40
  return
41
41
  }
42
- signal.addEventListener('abort', () => req.destroy(new Error('aborted')), { once: true })
42
+ const onAbort = () => req.destroy(new Error('aborted'))
43
+ signal.addEventListener('abort', onAbort, { once: true })
44
+ // Released on `close` rather than on resolve: the promise
45
+ // resolves at response headers, and cancellation has to stay
46
+ // live for the streaming body that follows.
47
+ req.on('close', () => signal.removeEventListener('abort', onAbort))
43
48
  }
44
49
 
45
50
  if (body !== undefined) req.end(JSON.stringify(body))
@@ -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
@@ -64,6 +64,7 @@
64
64
  * the gap persists. The consumer decides whether to act (log,
65
65
  * bump a watchdog, abort via its own AbortSignal). Mohdel never
66
66
  * aborts on its own. Omitting the field disables the heartbeat.
67
+ * Positive values are raised to `MIN_IDLE_HEARTBEAT_MS` (250).
67
68
  *
68
69
  * @property {Object<string, object>} [providerOptions]
69
70
  * Namespaced bag of provider-specific knobs that don't fit the
@@ -155,3 +156,47 @@ export const ENVELOPE_FIELDS = Object.freeze([
155
156
  'idleHeartbeatMs',
156
157
  'providerOptions'
157
158
  ])
159
+
160
+ export const MAX_ID_BYTES = 128
161
+ export const MAX_MODEL_BYTES = 256
162
+ export const MAX_PROVIDER_BYTES = 32
163
+
164
+ const encoder = new TextEncoder()
165
+
166
+ /**
167
+ * @param {string} s
168
+ * @returns {number}
169
+ */
170
+ function byteLength (s) {
171
+ return encoder.encode(s).length
172
+ }
173
+
174
+ /**
175
+ * Bound the envelope fields that become long-lived map keys and
176
+ * telemetry attributes, and reject a model id with an empty half.
177
+ *
178
+ * Mirror of `rust/thin-gate/src/protocol.rs::validate_ids`, applied on
179
+ * the in-process factory path which does not cross the gate. Reason
180
+ * strings match the gate's byte for byte so both transports report a
181
+ * rejection identically.
182
+ *
183
+ * @param {string} callId
184
+ * @param {string} authId
185
+ * @param {string} model
186
+ * @returns {string | undefined} Reason, or undefined when valid.
187
+ */
188
+ export function validateIds (callId, authId, model) {
189
+ if (byteLength(callId) > MAX_ID_BYTES) return `callId exceeds ${MAX_ID_BYTES} bytes`
190
+ if (byteLength(authId) > MAX_ID_BYTES) return `authId exceeds ${MAX_ID_BYTES} bytes`
191
+ if (byteLength(model) > MAX_MODEL_BYTES) return `model exceeds ${MAX_MODEL_BYTES} bytes`
192
+
193
+ const slash = model.indexOf('/')
194
+ const provider = slash < 0 ? '' : model.slice(0, slash)
195
+ const bare = slash < 0 ? '' : model.slice(slash + 1)
196
+ if (slash < 0 || !provider || !bare) {
197
+ return `model must be '<provider>/<id>' (got: ${model})`
198
+ }
199
+ if (byteLength(provider) > MAX_PROVIDER_BYTES) {
200
+ return `model provider exceeds ${MAX_PROVIDER_BYTES} bytes`
201
+ }
202
+ }
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
  }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * NDJSON framing limits, shared by every JS reader of the protocol.
3
+ *
4
+ * The cap bounds a single unterminated line, not accumulated buffered
5
+ * frames, and is measured in UTF-8 bytes so it matches the gate's
6
+ * `read_capped_line` (`rust/thin-gate/src/server.rs`), which counts
7
+ * bytes off the socket. A reader that capped UTF-16 units instead
8
+ * would accept up to three times what the gate does.
9
+ *
10
+ * @module core/framing
11
+ */
12
+
13
+ export const MAX_LINE_BYTES = 16 * 1024 * 1024
14
+
15
+ /**
16
+ * Exact byte length is O(n) in the string, so it is only computed in
17
+ * the band where the answer is in doubt: UTF-8 encodes a UTF-16 unit
18
+ * as 1-3 bytes, which brackets the count between `length` and
19
+ * `3 * length`.
20
+ *
21
+ * @param {string} s
22
+ * @param {number} [cap]
23
+ * @returns {boolean}
24
+ */
25
+ export function exceedsLineBytes (s, cap = MAX_LINE_BYTES) {
26
+ if (s.length > cap) return true
27
+ if (s.length * 3 <= cap) return false
28
+ return Buffer.byteLength(s, 'utf8') > cap
29
+ }
package/js/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