mohdel 0.118.0 → 0.120.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 +7 -0
- package/config/curated.schema.json +38 -0
- package/js/client/call.js +1 -30
- package/js/client/call_image.js +1 -30
- package/js/client/call_transcription.js +1 -30
- 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 +9 -0
- package/js/core/framing.js +29 -0
- package/js/core/model-id.js +47 -13
- package/js/factory/bridge.js +2 -1
- package/js/session/_idle_heartbeat.js +53 -24
- package/js/session/adapters/_cancelled.js +1 -2
- package/js/session/adapters/_catalog.js +16 -0
- package/js/session/adapters/_chat_completions.js +4 -1
- package/js/session/adapters/_errors.js +59 -1
- package/js/session/adapters/_lazy_json_cache.js +31 -10
- package/js/session/adapters/_media.js +4 -6
- package/js/session/adapters/_pricing.js +7 -4
- package/js/session/adapters/_speed.js +119 -0
- package/js/session/adapters/_videos.js +30 -2
- package/js/session/adapters/anthropic.js +3 -1
- package/js/session/adapters/gemini.js +3 -1
- package/js/session/adapters/image/novita.js +1 -17
- package/js/session/adapters/openai.js +4 -1
- package/js/session/adapters/transcription/openai_compatible.js +1 -16
- package/js/session/driver.js +69 -28
- package/js/session/run.js +118 -39
- package/package.json +30 -9
- package/src/cli/_chalk.js +41 -0
- package/src/cli/check.js +17 -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 -1
- package/src/lib/index.js +41 -3
- package/src/lib/providers.js +1 -12
- package/src/lib/schema.js +21 -0
package/README.md
CHANGED
|
@@ -101,6 +101,9 @@ mo ask anthropic/claude-sonnet-4-6 --stream "write a haiku about recursion"
|
|
|
101
101
|
# With thinking effort
|
|
102
102
|
mo ask anthropic/claude-opus-4-6 --effort high "prove P != NP"
|
|
103
103
|
|
|
104
|
+
# On a faster service lane, when the model sells one
|
|
105
|
+
mo ask anthropic/claude-opus-4-6@fast "triage this alert"
|
|
106
|
+
|
|
104
107
|
# Speech → text from an audio file
|
|
105
108
|
mo transcribe groq/whisper-large-v3-turbo meeting.mp3
|
|
106
109
|
mo transcribe mistral/voxtral-mini-transcribe interview.wav --language fr
|
|
@@ -221,6 +224,8 @@ cargo run --bin mohdel-thin-gate /tmp/mohdel-data.sock /tmp/mohdel-admin.sock /p
|
|
|
221
224
|
Positional args are optional (data socket, admin socket, session bin). Env overrides:
|
|
222
225
|
- `MOHDEL_SESSION_BIN` — path to session entrypoint (defaults to none; if unset, data plane returns synthetic events)
|
|
223
226
|
- `MOHDEL_SESSION_POOL_SIZE` — pre-warmed sessions (default 2)
|
|
227
|
+
- `MOHDEL_POOL_ACQUIRE_TIMEOUT_MS` — wait for a free session before `503 SESSION_POOL_BUSY` (default 30000)
|
|
228
|
+
- `MOHDEL_MAX_CONNECTIONS` — concurrently served data-plane connections (default 64)
|
|
224
229
|
|
|
225
230
|
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
231
|
|
|
@@ -313,6 +318,8 @@ What each provider supports through mohdel's unified interface:
|
|
|
313
318
|
|
|
314
319
|
Adapter capability ≠ model capability — whether a given model accepts images, tools, or thinking effort depends on the model spec in `curated.json`. The adapter passes through what the envelope supplies; the provider rejects unsupported combos.
|
|
315
320
|
|
|
321
|
+
**Service speeds** are the exception to that pass-through rule. Where a provider sells the same weights at several speeds (Anthropic's fast mode, and equivalents elsewhere), the lanes a model sells are declared in its `curated.json` entry under `speeds`, with their own prices and rate limits. Select one with `speed` or the `@lane` id suffix. There is no default lane and no fallback: an undeclared lane fails the call before it is sent, because a model that silently ignores an unsupported lane would otherwise be billed at the lane's rates for standard service. See [docs/CATALOG.md](docs/CATALOG.md#service-speeds).
|
|
322
|
+
|
|
316
323
|
## Local Development
|
|
317
324
|
|
|
318
325
|
```bash
|
|
@@ -88,6 +88,7 @@
|
|
|
88
88
|
"description": "USD per 1M thinking/reasoning tokens (when the provider bills these separately)."
|
|
89
89
|
},
|
|
90
90
|
"cacheWritePrice": { "type": "number", "minimum": 0, "description": "USD per 1M tokens written to provider-side prompt cache." },
|
|
91
|
+
"cacheWrite1hPrice": { "type": "number", "minimum": 0, "description": "USD per 1M tokens written with a 1h TTL, when the provider prices that above the default write rate. Falls back to cacheWritePrice." },
|
|
91
92
|
"cacheReadPrice": { "type": "number", "minimum": 0, "description": "USD per 1M tokens served from provider-side prompt cache." },
|
|
92
93
|
|
|
93
94
|
"contextTokenLimit": { "type": "integer", "minimum": 1, "description": "Maximum total tokens (input + output)." },
|
|
@@ -107,6 +108,43 @@
|
|
|
107
108
|
},
|
|
108
109
|
"defaultThinkingEffort": { "type": "string", "description": "Effort level used when the envelope omits 'outputEffort'." },
|
|
109
110
|
|
|
111
|
+
"speeds": {
|
|
112
|
+
"type": "object",
|
|
113
|
+
"description": "Service speed lanes this model sells, keyed by lane name. A lane is a provider request parameter buying a different speed/price point for the same weights. There is no default lane: omitting 'speed' on the envelope sends no parameter. Only lanes declared here may be requested.",
|
|
114
|
+
"additionalProperties": {
|
|
115
|
+
"type": "object",
|
|
116
|
+
"required": ["wire"],
|
|
117
|
+
"additionalProperties": false,
|
|
118
|
+
"description": "Lane overlay. Fields named here override the base entry; unnamed fields fall through. Only prices and rate limits are overridable — anything that would change what the model is belongs in its own entry.",
|
|
119
|
+
"properties": {
|
|
120
|
+
"wire": { "type": "string", "description": "Provider-native value for the lane parameter (e.g. 'fast')." },
|
|
121
|
+
"inputPrice": {
|
|
122
|
+
"oneOf": [
|
|
123
|
+
{ "type": "number", "minimum": 0 },
|
|
124
|
+
{ "type": "object", "required": ["default"], "additionalProperties": { "type": "number" }, "properties": { "default": { "type": "number" } } }
|
|
125
|
+
]
|
|
126
|
+
},
|
|
127
|
+
"outputPrice": {
|
|
128
|
+
"oneOf": [
|
|
129
|
+
{ "type": "number", "minimum": 0 },
|
|
130
|
+
{ "type": "object", "required": ["default"], "additionalProperties": { "type": "number" }, "properties": { "default": { "type": "number" } } }
|
|
131
|
+
]
|
|
132
|
+
},
|
|
133
|
+
"thinkingPrice": {
|
|
134
|
+
"oneOf": [
|
|
135
|
+
{ "type": "number", "minimum": 0 },
|
|
136
|
+
{ "type": "object", "required": ["default"], "additionalProperties": { "type": "number" }, "properties": { "default": { "type": "number" } } }
|
|
137
|
+
]
|
|
138
|
+
},
|
|
139
|
+
"cacheWritePrice": { "type": "number", "minimum": 0 },
|
|
140
|
+
"cacheWrite1hPrice": { "type": "number", "minimum": 0 },
|
|
141
|
+
"cacheReadPrice": { "type": "number", "minimum": 0 },
|
|
142
|
+
"rpmLimit": { "type": "integer", "minimum": 1 },
|
|
143
|
+
"tpmLimit": { "type": "integer", "minimum": 1 }
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
|
|
110
148
|
"tags": {
|
|
111
149
|
"type": "array",
|
|
112
150
|
"items": { "type": "string", "pattern": "^[a-zA-Z][a-zA-Z0-9._-]{0,31}$" },
|
package/js/client/call.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
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
14
|
import { isEvent, MohdelError } from '#core'
|
|
14
15
|
|
|
@@ -44,33 +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
|
-
severity: 'error',
|
|
74
|
-
retryable: status >= 500
|
|
75
|
-
}
|
|
76
|
-
}
|
package/js/client/call_image.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { requestUnix } from './transport.js'
|
|
11
|
+
import { readAll, parseErrorBody } from './response.js'
|
|
11
12
|
import { MohdelError } from '#core'
|
|
12
13
|
|
|
13
14
|
/**
|
|
@@ -51,33 +52,3 @@ export async function callImage (envelope, { socketPath, signal, path = '/v1/ima
|
|
|
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
|
-
severity: 'error',
|
|
81
|
-
retryable: status >= 500
|
|
82
|
-
}
|
|
83
|
-
}
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { requestUnix } from './transport.js'
|
|
14
|
+
import { readAll, parseErrorBody } from './response.js'
|
|
14
15
|
import { MohdelError } from '#core'
|
|
15
16
|
|
|
16
17
|
/**
|
|
@@ -54,33 +55,3 @@ export async function callTranscription (envelope, { socketPath, signal, path =
|
|
|
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
|
-
severity: 'error',
|
|
84
|
-
retryable: status >= 500
|
|
85
|
-
}
|
|
86
|
-
}
|
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
|
@@ -43,6 +43,13 @@
|
|
|
43
43
|
* Thinking effort level. Valid keys are per-model — validated at
|
|
44
44
|
* runtime against the curated entry's `thinkingEffortLevels`.
|
|
45
45
|
* Default is the model's `defaultThinkingEffort`.
|
|
46
|
+
* @property {string} [speed]
|
|
47
|
+
* Service speed lane. Valid keys are per-model — validated at
|
|
48
|
+
* runtime against the curated entry's `speeds`. There is no
|
|
49
|
+
* default and no fallback: omitting the field sends no speed
|
|
50
|
+
* parameter, while naming a lane the entry does not declare
|
|
51
|
+
* (`SESSION_INVALID_SPEED`) or the provider's adapter cannot emit
|
|
52
|
+
* (`SESSION_SPEED_NOT_IMPLEMENTED`) fails the call before dispatch.
|
|
46
53
|
*
|
|
47
54
|
* @property {MediaRef[]} [images]
|
|
48
55
|
* @property {MediaRef[]} [videos]
|
|
@@ -64,6 +71,7 @@
|
|
|
64
71
|
* the gap persists. The consumer decides whether to act (log,
|
|
65
72
|
* bump a watchdog, abort via its own AbortSignal). Mohdel never
|
|
66
73
|
* aborts on its own. Omitting the field disables the heartbeat.
|
|
74
|
+
* Positive values are raised to `MIN_IDLE_HEARTBEAT_MS` (250).
|
|
67
75
|
*
|
|
68
76
|
* @property {Object<string, object>} [providerOptions]
|
|
69
77
|
* Namespaced bag of provider-specific knobs that don't fit the
|
|
@@ -145,6 +153,7 @@ export const ENVELOPE_FIELDS = Object.freeze([
|
|
|
145
153
|
'outputType',
|
|
146
154
|
'outputStyle',
|
|
147
155
|
'outputEffort',
|
|
156
|
+
'speed',
|
|
148
157
|
'images',
|
|
149
158
|
'videos',
|
|
150
159
|
'cache',
|
|
@@ -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/model-id.js
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
* Model-id helpers.
|
|
3
3
|
*
|
|
4
4
|
* A mohdel model id is a single string of shape
|
|
5
|
-
* `"<provider>/<bare>[:<effort>]"` — same on the wire and
|
|
6
|
-
* See PROTOCOL §3. Nothing in mohdel ever holds the id in
|
|
7
|
-
* object form; when
|
|
8
|
-
*
|
|
5
|
+
* `"<provider>/<bare>[:<effort>][@<speed>]"` — same on the wire and
|
|
6
|
+
* in-process. See PROTOCOL §3. Nothing in mohdel ever holds the id in
|
|
7
|
+
* a split object form; when a part is needed, these helpers return it
|
|
8
|
+
* as a substring.
|
|
9
9
|
*
|
|
10
10
|
* Ids are validated at ingress by the gate
|
|
11
11
|
* (`rust/thin-gate/src/protocol.rs::validate_ids`); these accessors
|
|
@@ -47,12 +47,8 @@ export function bareOf (model) {
|
|
|
47
47
|
* @returns {string}
|
|
48
48
|
*/
|
|
49
49
|
export function catalogKey (model) {
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
// Only treat `:` as an effort separator when it appears after the
|
|
53
|
-
// provider slash (otherwise a model id without `/` that happens to
|
|
54
|
-
// contain `:` would get the wrong thing stripped).
|
|
55
|
-
return colon > slash ? model.slice(0, colon) : model
|
|
50
|
+
const base = beforeSuffix(model, SPEED_SIGIL)
|
|
51
|
+
return beforeSuffix(base, EFFORT_SIGIL)
|
|
56
52
|
}
|
|
57
53
|
|
|
58
54
|
/**
|
|
@@ -62,8 +58,46 @@ export function catalogKey (model) {
|
|
|
62
58
|
* @returns {string | undefined}
|
|
63
59
|
*/
|
|
64
60
|
export function effortOf (model) {
|
|
65
|
-
|
|
61
|
+
return afterSuffix(beforeSuffix(model, SPEED_SIGIL), EFFORT_SIGIL)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Speed-lane suffix, without the `@`, or `undefined` if absent.
|
|
66
|
+
*
|
|
67
|
+
* @param {string} model
|
|
68
|
+
* @returns {string | undefined}
|
|
69
|
+
*/
|
|
70
|
+
export function speedOf (model) {
|
|
71
|
+
return afterSuffix(model, SPEED_SIGIL)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const EFFORT_SIGIL = ':'
|
|
75
|
+
const SPEED_SIGIL = '@'
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Index of `sigil` when it separates a suffix, or `-1`. A sigil only
|
|
79
|
+
* separates when it falls after the provider slash, so a bare id that
|
|
80
|
+
* itself contains one is left whole.
|
|
81
|
+
*
|
|
82
|
+
* @param {string} model
|
|
83
|
+
* @param {string} sigil
|
|
84
|
+
* @returns {number}
|
|
85
|
+
*/
|
|
86
|
+
function suffixIndex (model, sigil) {
|
|
66
87
|
const slash = model.indexOf('/')
|
|
67
|
-
if (
|
|
68
|
-
|
|
88
|
+
if (slash < 0) return -1
|
|
89
|
+
const at = model.lastIndexOf(sigil)
|
|
90
|
+
return at > slash ? at : -1
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** @returns {string} */
|
|
94
|
+
function beforeSuffix (model, sigil) {
|
|
95
|
+
const at = suffixIndex(model, sigil)
|
|
96
|
+
return at < 0 ? model : model.slice(0, at)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** @returns {string | undefined} */
|
|
100
|
+
function afterSuffix (model, sigil) {
|
|
101
|
+
const at = suffixIndex(model, sigil)
|
|
102
|
+
return at < 0 ? undefined : model.slice(at + 1)
|
|
69
103
|
}
|
package/js/factory/bridge.js
CHANGED
|
@@ -223,6 +223,7 @@ function toEnvelope ({ modelKey, configuration, prompt, options }) {
|
|
|
223
223
|
if (options.outputType) envelope.outputType = options.outputType
|
|
224
224
|
if (options.outputStyle) envelope.outputStyle = options.outputStyle
|
|
225
225
|
if (options.outputEffort) envelope.outputEffort = options.outputEffort
|
|
226
|
+
if (options.speed) envelope.speed = options.speed
|
|
226
227
|
if (options.images?.length) envelope.images = options.images
|
|
227
228
|
if (options.videos?.length) envelope.videos = options.videos
|
|
228
229
|
if (options.cache !== undefined) envelope.cache = options.cache
|
|
@@ -281,7 +282,7 @@ function newCallId () {
|
|
|
281
282
|
*/
|
|
282
283
|
const ALLOWED_CONFIG_KEYS = new Set(['apiKey', 'baseURL'])
|
|
283
284
|
|
|
284
|
-
function configToAuth (configuration) {
|
|
285
|
+
export function configToAuth (configuration) {
|
|
285
286
|
if (!configuration) return { key: '' }
|
|
286
287
|
const unsupported = Object.keys(configuration).filter(k => !ALLOWED_CONFIG_KEYS.has(k))
|
|
287
288
|
if (unsupported.length > 0) {
|
|
@@ -11,20 +11,30 @@
|
|
|
11
11
|
* own. Consumers decide whether to log, bump a watchdog, or trigger
|
|
12
12
|
* an external AbortSignal.
|
|
13
13
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
14
|
+
* `idleMs` is caller-supplied and raised to `MIN_IDLE_HEARTBEAT_MS`.
|
|
15
|
+
* Without a floor one request can ask for a millisecond cadence and
|
|
16
|
+
* turn adapter silence into thousands of serialized events per second
|
|
17
|
+
* through the gate.
|
|
18
|
+
*
|
|
19
|
+
* The in-flight `iterator.next()` is reused across timer firings so no
|
|
20
|
+
* real event is dropped. Its continuation is attached exactly once, at
|
|
21
|
+
* creation, and parks its result in `settled`; each idle tick races a
|
|
22
|
+
* fresh timer against that flag rather than re-subscribing. Attaching
|
|
23
|
+
* per tick instead would retain two reaction records per firing on a
|
|
24
|
+
* promise that by definition has not settled.
|
|
18
25
|
*
|
|
19
26
|
* @module session/_idle_heartbeat
|
|
20
27
|
*/
|
|
21
28
|
|
|
29
|
+
export const MIN_IDLE_HEARTBEAT_MS = 250
|
|
30
|
+
|
|
22
31
|
/**
|
|
23
32
|
* @template T
|
|
24
33
|
* @param {AsyncIterable<T>} source
|
|
25
34
|
* @param {number | undefined | null} idleMs
|
|
26
35
|
* When falsy or non-positive, the source is yielded through
|
|
27
|
-
* unchanged (no timer is set up).
|
|
36
|
+
* unchanged (no timer is set up). Positive values below
|
|
37
|
+
* `MIN_IDLE_HEARTBEAT_MS` are raised to it.
|
|
28
38
|
* @returns {AsyncGenerator<T | import('#core/events.js').IdleEvent>}
|
|
29
39
|
*/
|
|
30
40
|
export async function * withIdleHeartbeat (source, idleMs) {
|
|
@@ -33,28 +43,45 @@ export async function * withIdleHeartbeat (source, idleMs) {
|
|
|
33
43
|
return
|
|
34
44
|
}
|
|
35
45
|
|
|
46
|
+
const tickMs = Math.max(idleMs, MIN_IDLE_HEARTBEAT_MS)
|
|
36
47
|
const iter = source[Symbol.asyncIterator]()
|
|
37
48
|
let lastAt = Date.now()
|
|
38
|
-
|
|
39
|
-
|
|
49
|
+
|
|
50
|
+
/** @type {{real: IteratorResult<T>} | {err: unknown} | null} */
|
|
51
|
+
let settled = null
|
|
52
|
+
/** @type {(() => void) | null} */
|
|
53
|
+
let wake = null
|
|
54
|
+
let inFlight = false
|
|
55
|
+
|
|
56
|
+
const park = (result) => {
|
|
57
|
+
settled = result
|
|
58
|
+
const w = wake
|
|
59
|
+
wake = null
|
|
60
|
+
w?.()
|
|
61
|
+
}
|
|
40
62
|
|
|
41
63
|
try {
|
|
42
64
|
while (true) {
|
|
43
|
-
if (!
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const winner = await new Promise(resolve => {
|
|
49
|
-
timer = setTimeout(() => resolve({ idle: true }), idleMs)
|
|
50
|
-
pending.then(
|
|
51
|
-
r => resolve({ real: r }),
|
|
52
|
-
e => resolve({ err: e })
|
|
65
|
+
if (!inFlight) {
|
|
66
|
+
inFlight = true
|
|
67
|
+
iter.next().then(
|
|
68
|
+
r => park({ real: r }),
|
|
69
|
+
e => park({ err: e })
|
|
53
70
|
)
|
|
54
|
-
}
|
|
55
|
-
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (!settled) {
|
|
74
|
+
/** @type {NodeJS.Timeout | undefined} */
|
|
75
|
+
let timer
|
|
76
|
+
await new Promise(resolve => {
|
|
77
|
+
wake = resolve
|
|
78
|
+
timer = setTimeout(resolve, tickMs)
|
|
79
|
+
})
|
|
80
|
+
clearTimeout(timer)
|
|
81
|
+
wake = null
|
|
82
|
+
}
|
|
56
83
|
|
|
57
|
-
if (
|
|
84
|
+
if (!settled) {
|
|
58
85
|
yield /** @type {import('#core/events.js').IdleEvent} */ ({
|
|
59
86
|
type: 'idle',
|
|
60
87
|
sinceMs: Date.now() - lastAt
|
|
@@ -62,11 +89,13 @@ export async function * withIdleHeartbeat (source, idleMs) {
|
|
|
62
89
|
continue
|
|
63
90
|
}
|
|
64
91
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
92
|
+
const done = settled
|
|
93
|
+
settled = null
|
|
94
|
+
inFlight = false
|
|
95
|
+
if ('err' in done) throw done.err
|
|
96
|
+
if (done.real.done) return
|
|
68
97
|
lastAt = Date.now()
|
|
69
|
-
yield
|
|
98
|
+
yield done.real.value
|
|
70
99
|
}
|
|
71
100
|
} finally {
|
|
72
101
|
// Best-effort cleanup if the consumer abandons us mid-stream.
|
|
@@ -13,7 +13,6 @@
|
|
|
13
13
|
|
|
14
14
|
import { STATUS_INCOMPLETE, WARNING_CANCELLED } from '#core/status.js'
|
|
15
15
|
import { costFor } from './_pricing.js'
|
|
16
|
-
import { catalogKey } from '#core/model-id.js'
|
|
17
16
|
|
|
18
17
|
/**
|
|
19
18
|
* @param {string} start hrtime-bigint-as-string at call entry
|
|
@@ -43,7 +42,7 @@ export function cancelledDone (start, first, envelope, output, inputTokens, outp
|
|
|
43
42
|
...(cacheWriteInputTokens > 0 && { cacheWriteInputTokens }),
|
|
44
43
|
...(cacheReadInputTokens > 0 && { cacheReadInputTokens }),
|
|
45
44
|
cost: costFor(
|
|
46
|
-
|
|
45
|
+
envelope,
|
|
47
46
|
{ inputTokens, outputTokens, thinkingTokens: 0, cacheWriteInputTokens, cacheReadInputTokens }
|
|
48
47
|
),
|
|
49
48
|
timestamps: { start, first: first ?? end, end },
|
|
@@ -12,7 +12,10 @@
|
|
|
12
12
|
|
|
13
13
|
import envPaths from 'env-paths'
|
|
14
14
|
|
|
15
|
+
import { catalogKey } from '#core/model-id.js'
|
|
16
|
+
|
|
15
17
|
import { createLazyJsonFileCache } from './_lazy_json_cache.js'
|
|
18
|
+
import { mergeSpeed } from './_speed.js'
|
|
16
19
|
|
|
17
20
|
// `{ suffix: null }` mirrors `src/lib/common.js::CONFIG_DIR` so the
|
|
18
21
|
// session subprocess reads the same `~/.config/mohdel/curated.json`
|
|
@@ -56,3 +59,16 @@ export function setCatalog (table) {
|
|
|
56
59
|
export function getSpec (model) {
|
|
57
60
|
return cache.get(model)
|
|
58
61
|
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Effective spec for a call: the catalog entry with any speed-lane
|
|
65
|
+
* overlay applied. Adapters resolve spec through this rather than
|
|
66
|
+
* `getSpec` so lane prices and quotas reach pricing and throttling
|
|
67
|
+
* without each adapter merging for itself.
|
|
68
|
+
*
|
|
69
|
+
* @param {import('#core/envelope.js').CallEnvelope} envelope
|
|
70
|
+
* @returns {any | undefined}
|
|
71
|
+
*/
|
|
72
|
+
export function specFor (envelope) {
|
|
73
|
+
return mergeSpeed(getSpec(catalogKey(envelope.model)), envelope.speed)
|
|
74
|
+
}
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
import { getSpec } from './_catalog.js'
|
|
20
20
|
import { classifyProviderError } from './_errors.js'
|
|
21
21
|
import { costFor } from './_pricing.js'
|
|
22
|
+
import { applySpeed } from './_speed.js'
|
|
22
23
|
import { catalogKey, bareOf } from '#core/model-id.js'
|
|
23
24
|
import {
|
|
24
25
|
STATUS_COMPLETED,
|
|
@@ -287,7 +288,7 @@ function finalize ({ envelope, content, toolCalls, usage, finishReason, start, f
|
|
|
287
288
|
thinkingTokens,
|
|
288
289
|
...(cachedInputTokens > 0 && { cacheReadInputTokens: cachedInputTokens }),
|
|
289
290
|
cost: costFor(
|
|
290
|
-
|
|
291
|
+
envelope,
|
|
291
292
|
{
|
|
292
293
|
inputTokens,
|
|
293
294
|
outputTokens: visibleOutputTokens,
|
|
@@ -370,6 +371,8 @@ function buildRequest (envelope, spec, config) {
|
|
|
370
371
|
args[config.identifierField || 'user'] = envelope.identifier
|
|
371
372
|
}
|
|
372
373
|
|
|
374
|
+
applySpeed(args, envelope, spec)
|
|
375
|
+
|
|
373
376
|
return args
|
|
374
377
|
}
|
|
375
378
|
|