mohdel 0.117.3 → 0.119.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -2
- package/js/client/call.js +4 -32
- package/js/client/call_image.js +5 -33
- package/js/client/call_transcription.js +5 -33
- package/js/client/ndjson.js +8 -4
- package/js/client/response.js +40 -0
- package/js/client/transport.js +6 -1
- package/js/core/envelope.js +46 -1
- package/js/core/errors.js +30 -14
- package/js/core/framing.js +29 -0
- package/js/core/image.js +1 -1
- package/js/core/model-id.js +7 -30
- package/js/core/status.js +0 -8
- package/js/core/transcription.js +1 -1
- package/js/factory/bridge.js +48 -54
- package/js/session/_idle_heartbeat.js +53 -24
- package/js/session/adapters/_cancelled.js +1 -1
- package/js/session/adapters/_chat_completions.js +1 -1
- package/js/session/adapters/_errors.js +43 -8
- package/js/session/adapters/_images.js +26 -17
- package/js/session/adapters/_lazy_json_cache.js +32 -12
- package/js/session/adapters/_media.js +205 -0
- package/js/session/adapters/_providers.js +0 -8
- package/js/session/adapters/_videos.js +67 -28
- package/js/session/adapters/anthropic.js +5 -3
- package/js/session/adapters/gemini.js +6 -3
- package/js/session/adapters/image/novita.js +1 -17
- package/js/session/adapters/openai.js +5 -3
- package/js/session/adapters/transcription/index.js +0 -9
- package/js/session/adapters/transcription/openai_compatible.js +13 -38
- package/js/session/driver.js +69 -28
- package/js/session/run.js +7 -1
- package/package.json +31 -10
- package/src/cli/_chalk.js +41 -0
- package/src/cli/colored-logger.js +1 -1
- package/src/cli/colors.js +1 -1
- package/src/cli/onboard.js +8 -3
- package/src/lib/cache.js +0 -73
- package/src/lib/catalog/gemini.js +3 -2
- package/src/lib/common.js +20 -11
- package/src/lib/curated-cache.js +0 -16
- package/src/lib/providers.js +1 -12
- package/src/lib/cooldown.js +0 -63
- package/src/lib/errors.js +0 -43
package/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
|
|
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,
|
|
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
|
|
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
|
|
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
|
-
}
|
package/js/client/call_image.js
CHANGED
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { requestUnix } from './transport.js'
|
|
11
|
-
import {
|
|
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
|
|
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
|
|
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
|
|
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 {
|
|
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
|
|
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
|
|
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
|
|
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
|
-
}
|
package/js/client/ndjson.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/js/client/transport.js
CHANGED
|
@@ -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
|
-
|
|
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))
|
package/js/core/envelope.js
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
* @property {string} [traceparent] W3C tracecontext header.
|
|
24
24
|
* @property {string} [baggage] W3C baggage header.
|
|
25
25
|
*
|
|
26
|
-
* @property {
|
|
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
|
-
* `
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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
|
-
*
|
|
24
|
+
* Short human-readable label (e.g. `'provider error 400'`). Never
|
|
25
|
+
* echo provider response bodies.
|
|
19
26
|
* @property {string} [detail]
|
|
20
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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 = '
|
|
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
|
-
* @
|
|
79
|
+
* @param {object} [context] In-process only; not part of `data`.
|
|
80
|
+
* @returns {MohdelError}
|
|
66
81
|
*/
|
|
67
|
-
static fromJSON (data) {
|
|
68
|
-
return new
|
|
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 {
|
|
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.
|
package/js/core/model-id.js
CHANGED
|
@@ -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
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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
|
-
}
|
package/js/core/transcription.js
CHANGED
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
* @property {string} [traceparent]
|
|
28
28
|
* @property {string} [baggage]
|
|
29
29
|
*
|
|
30
|
-
* @property {
|
|
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
|