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 +2 -2
- package/js/client/call.js +4 -3
- package/js/client/call_image.js +5 -4
- package/js/client/call_transcription.js +5 -4
- package/js/core/envelope.js +45 -1
- package/js/core/errors.js +30 -14
- package/js/core/image.js +1 -1
- package/js/core/model-id.js +7 -30
- package/js/core/status.js +0 -8
- package/js/core/transcription.js +1 -1
- package/js/factory/bridge.js +47 -53
- package/js/session/adapters/_cancelled.js +1 -1
- package/js/session/adapters/_chat_completions.js +1 -1
- package/js/session/adapters/_errors.js +8 -8
- package/js/session/adapters/_images.js +26 -17
- package/js/session/adapters/_lazy_json_cache.js +1 -2
- package/js/session/adapters/_media.js +207 -0
- package/js/session/adapters/_providers.js +0 -8
- package/js/session/adapters/_videos.js +37 -26
- package/js/session/adapters/anthropic.js +5 -3
- package/js/session/adapters/gemini.js +6 -3
- package/js/session/adapters/image/novita.js +2 -2
- package/js/session/adapters/openai.js +5 -3
- package/js/session/adapters/transcription/index.js +0 -9
- package/js/session/adapters/transcription/openai_compatible.js +13 -23
- package/package.json +3 -3
- package/src/lib/common.js +0 -10
- package/src/lib/curated-cache.js +0 -16
- package/src/lib/cooldown.js +0 -63
- package/src/lib/errors.js +0 -43
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
|
|
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,
|
|
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
|
|
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
|
|
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
|
}
|
package/js/client/call_image.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { requestUnix } from './transport.js'
|
|
11
|
-
import {
|
|
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
|
|
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
|
|
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
|
|
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 {
|
|
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
|
|
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
|
|
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
|
|
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
|
}
|
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
|
|
@@ -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
|
-
* `
|
|
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
|
}
|
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
|
package/js/factory/bridge.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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('
|
|
109
|
-
|
|
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
|
|
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
|
|
140
|
-
authId
|
|
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
|
|
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
|
|
173
|
-
authId
|
|
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
|
|
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
|
|
202
|
-
authId
|
|
213
|
+
callId,
|
|
214
|
+
authId,
|
|
203
215
|
auth: configToAuth(configuration),
|
|
204
|
-
model:
|
|
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
|
-
*
|
|
241
|
-
*
|
|
242
|
-
*
|
|
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
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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('
|
|
295
|
-
|
|
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('
|
|
376
|
-
|
|
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
|
|
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
|
-
//
|
|
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',
|