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.
Files changed (41) hide show
  1. package/README.md +7 -0
  2. package/config/curated.schema.json +38 -0
  3. package/js/client/call.js +1 -30
  4. package/js/client/call_image.js +1 -30
  5. package/js/client/call_transcription.js +1 -30
  6. package/js/client/ndjson.js +8 -4
  7. package/js/client/response.js +40 -0
  8. package/js/client/transport.js +6 -1
  9. package/js/core/envelope.js +9 -0
  10. package/js/core/framing.js +29 -0
  11. package/js/core/model-id.js +47 -13
  12. package/js/factory/bridge.js +2 -1
  13. package/js/session/_idle_heartbeat.js +53 -24
  14. package/js/session/adapters/_cancelled.js +1 -2
  15. package/js/session/adapters/_catalog.js +16 -0
  16. package/js/session/adapters/_chat_completions.js +4 -1
  17. package/js/session/adapters/_errors.js +59 -1
  18. package/js/session/adapters/_lazy_json_cache.js +31 -10
  19. package/js/session/adapters/_media.js +4 -6
  20. package/js/session/adapters/_pricing.js +7 -4
  21. package/js/session/adapters/_speed.js +119 -0
  22. package/js/session/adapters/_videos.js +30 -2
  23. package/js/session/adapters/anthropic.js +3 -1
  24. package/js/session/adapters/gemini.js +3 -1
  25. package/js/session/adapters/image/novita.js +1 -17
  26. package/js/session/adapters/openai.js +4 -1
  27. package/js/session/adapters/transcription/openai_compatible.js +1 -16
  28. package/js/session/driver.js +69 -28
  29. package/js/session/run.js +118 -39
  30. package/package.json +30 -9
  31. package/src/cli/_chalk.js +41 -0
  32. package/src/cli/check.js +17 -0
  33. package/src/cli/colored-logger.js +1 -1
  34. package/src/cli/colors.js +1 -1
  35. package/src/cli/onboard.js +8 -3
  36. package/src/lib/cache.js +0 -73
  37. package/src/lib/catalog/gemini.js +3 -2
  38. package/src/lib/common.js +20 -1
  39. package/src/lib/index.js +41 -3
  40. package/src/lib/providers.js +1 -12
  41. 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
- }
@@ -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
- }
@@ -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))
@@ -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
+ }
@@ -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 in-process.
6
- * See PROTOCOL §3. Nothing in mohdel ever holds the id in a split
7
- * object form; when the provider or bare part is needed, these
8
- * helpers return it as a substring.
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 colon = model.lastIndexOf(':')
51
- const slash = model.indexOf('/')
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
- const colon = model.lastIndexOf(':')
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 (colon <= slash) return undefined
68
- return model.slice(colon + 1)
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
  }
@@ -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
- * The in-flight `iterator.next()` is reused across timer firings so
15
- * no real event is dropped: when the timer wins the race, the
16
- * underlying promise stays pending and the next loop iteration
17
- * attaches a fresh race to the same promise.
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
- /** @type {Promise<IteratorResult<T>> | null} */
39
- let pending = null
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 (!pending) pending = iter.next()
44
-
45
- /** @type {NodeJS.Timeout | undefined} */
46
- let timer
47
- /** @type {{idle: true} | {real: IteratorResult<T>} | {err: unknown}} */
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
- clearTimeout(timer)
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 ('idle' in winner) {
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
- pending = null
66
- if ('err' in winner) throw winner.err
67
- if (winner.real.done) return
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 winner.real.value
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
- catalogKey(envelope.model),
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
- catalogKey(envelope.model),
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