mohdel 2.0.0 → 3.0.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 +13 -12
- package/js/client/call.js +67 -30
- package/js/client/transport.js +5 -5
- package/js/core/events.js +1 -1
- package/js/core/status.js +4 -4
- package/js/session/adapters/{_cancelled.js → _aborted.js} +7 -7
- package/js/session/adapters/_chat_completions.js +6 -6
- package/js/session/adapters/_dispatcher.js +6 -5
- package/js/session/adapters/_videos.js +1 -1
- package/js/session/adapters/anthropic.js +5 -5
- package/js/session/adapters/echo.js +4 -4
- package/js/session/adapters/fake.js +8 -8
- package/js/session/adapters/gemini.js +6 -6
- package/js/session/adapters/openai.js +4 -4
- package/js/session/driver.js +14 -14
- package/js/session/run.js +8 -8
- package/js/session/run_embedding.js +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -276,7 +276,7 @@ const result = await mo.use('anthropic/claude-sonnet-4-6').answer('Hello')
|
|
|
276
276
|
console.log(result.output, result.cost)
|
|
277
277
|
```
|
|
278
278
|
|
|
279
|
-
No subprocess, no setup beyond your API key. Right for CLI tools (`mo ask`), scripts, tests, and single-process services — which is most projects. Pass an `AbortSignal` as `answer(prompt, { signal })` to
|
|
279
|
+
No subprocess, no setup beyond your API key. Right for CLI tools (`mo ask`), scripts, tests, and single-process services — which is most projects. Pass an `AbortSignal` as `answer(prompt, { signal })` to abort in flight.
|
|
280
280
|
|
|
281
281
|
The factory reads the catalog from `~/.config/mohdel/curated.json` and keys from the environment, the same defaults a gate session starts from. `mohdel({ models })` replaces the catalog for the process, factory and session runtime alike, the way `set_catalog` replaces it in a gate session. `mohdel({ configurations: { openai: { apiKey } } })` overrides the key per provider.
|
|
282
282
|
|
|
@@ -333,7 +333,7 @@ Mohdel splits into three planes that can be deployed independently:
|
|
|
333
333
|
```
|
|
334
334
|
|
|
335
335
|
- **`mohdel/client`** (JS) — thin stub that callers import. Opens a unix socket to thin-gate, sends a `CallEnvelope`, receives an async-iterable of `Event`s. Zero transitive provider-SDK imports — caller-side code stays light.
|
|
336
|
-
- **`mohdel-thin-gate`** (Rust binary, prebuilt and shipped via the `mohdel-thin-gate-<platform>` npm sub-packages) — scheduler / state owner / supervisor. Binds the data-plane socket, validates the envelope, dispatches to a pooled session subprocess, relays events back,
|
|
336
|
+
- **`mohdel-thin-gate`** (Rust binary, prebuilt and shipped via the `mohdel-thin-gate-<platform>` npm sub-packages) — scheduler / state owner / supervisor. Binds the data-plane socket, validates the envelope, dispatches to a pooled session subprocess, relays events back, aborts gracefully on client disconnect. Binds the admin plane for `GET /v1/health`. Pushes OTLP metrics (sessions alive/respawned, calls by provider/status, call-duration histogram, cooldown / quota / policy rejections) when `OTEL_EXPORTER_OTLP_ENDPOINT` is set. Internal trait hooks (`RoutePolicy`, `QuotaPolicy`, `ConfigSource`, `CachePolicy`) make the crate testable and fork-friendly for deployments that need bespoke policy — not a published-library surface.
|
|
337
337
|
- **`mohdel/session`** (JS subprocess) — provider executor. Spawned by thin-gate, reads envelopes from stdin, dispatches to the matching adapter, writes events to stdout. A napi-rs addon was scoped for hot-loop optimization but current benchmarks show per-call JS CPU is not the bottleneck; the stub stays under `rust/napi-addon/` for future reactivation.
|
|
338
338
|
|
|
339
339
|
### Running thin-gate
|
|
@@ -355,7 +355,7 @@ With no session-bin configured, thin-gate runs in demo mode: `POST /v1/call` ret
|
|
|
355
355
|
|
|
356
356
|
### Calling from JS
|
|
357
357
|
|
|
358
|
-
The client snippet under [Library Usage](#library-usage) above is the full surface: `call(envelope, { socketPath, signal?, headers? })` returns an async iterable of events. `headers` go with the request, for a router in front of the gate that authenticates its callers. Pass an `AbortSignal` to
|
|
358
|
+
The client snippet under [Library Usage](#library-usage) above is the full surface: `call(envelope, { socketPath, signal?, headers? })` returns an async iterable of events. `headers` go with the request and with its abort, for a router in front of the gate that authenticates its callers. Pass an `AbortSignal` to abort in flight; the client posts `/v1/abort`, thin-gate forwards an abort control message to the session and reuses it on the pool, and the stream ends with the session's aborted `done`, the one the in-process path returns (status `incomplete`, warning `aborted`, the partial output and the usage reported before the cut). The envelope is the flat `answer(prompt, options)` surface plus transport metadata (`callId`, `authId`, `auth.key`, optional `traceparent`); see [`js/core/envelope.js`](js/core/envelope.js) for the full field list.
|
|
359
359
|
|
|
360
360
|
### Other languages
|
|
361
361
|
|
|
@@ -376,7 +376,7 @@ end
|
|
|
376
376
|
|
|
377
377
|
### Canonical types (frozen wire contract)
|
|
378
378
|
|
|
379
|
-
Wire format is JSON over NDJSON frames, camelCase. Types are defined in `js/core/` (JSDoc) and mirrored in `rust/protocol/src/protocol.rs` (serde, the `mohdel-protocol` crate). Cross-language conformance tests enforce round-trip fidelity. The session-side protocol (envelopes in, events out,
|
|
379
|
+
Wire format is JSON over NDJSON frames, camelCase. Types are defined in `js/core/` (JSDoc) and mirrored in `rust/protocol/src/protocol.rs` (serde, the `mohdel-protocol` crate). Cross-language conformance tests enforce round-trip fidelity. The session-side protocol (envelopes in, events out, abort control messages) is specified in [PROTOCOL.md](PROTOCOL.md) — read that to implement a session in another language.
|
|
380
380
|
|
|
381
381
|
- **`CallEnvelope`** — flat `answer()` options plus transport metadata: `callId`, `authId`, `auth.key`, `traceparent?`, `baggage?`, `provider`, `model`, `prompt`, `outputBudget?`, `outputType?`, `outputStyle?`, `outputEffort?`, `images?`, `videos?`, `cache?`, `tools?`, `toolChoice?`, `parallelToolCalls?`, `identifier?`.
|
|
382
382
|
- **`Event`** — three-variant union discriminated on `type`:
|
|
@@ -385,10 +385,10 @@ Wire format is JSON over NDJSON frames, camelCase. Types are defined in `js/core
|
|
|
385
385
|
- `{ type: 'error', error: TypedError }`
|
|
386
386
|
- **`AnswerResult`** — `status`, `output`, `inputTokens`, `outputTokens`, `thinkingTokens`, `cost` (single number), `timestamps`, `warning?`, `toolCalls?`.
|
|
387
387
|
- **`Status`** — `'completed' | 'tool_use' | 'incomplete'`.
|
|
388
|
-
- **`Warning`** — additive string union: `'insufficientOutputBudget'`, `'
|
|
388
|
+
- **`Warning`** — additive string union: `'insufficientOutputBudget'`, `'aborted'`, ...
|
|
389
389
|
- **`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'`.
|
|
390
390
|
|
|
391
|
-
|
|
391
|
+
An `abort` control message `{ op: "abort", callId }` on session stdin aborts the matching in-flight call.
|
|
392
392
|
|
|
393
393
|
Extending the frozen wire types is breaking — additive changes only on trait method sets and non-frozen internals. See [ARCHITECTURE.md §What isn't frozen](ARCHITECTURE.md#what-isnt-frozen) for the refinable-vs-frozen split.
|
|
394
394
|
|
|
@@ -398,8 +398,8 @@ See [CONTRIBUTING.md](CONTRIBUTING.md#adding-a-session-adapter). Short version:
|
|
|
398
398
|
|
|
399
399
|
1. Create `js/session/adapters/<provider>.js` exporting `async function* <provider>(envelope, { client?, signal? })`.
|
|
400
400
|
2. Map provider-native events to the canonical Event union.
|
|
401
|
-
3. Pass `{ signal }` to the SDK's streaming method so
|
|
402
|
-
4. On SDK throw: if `signal?.aborted`, return silently (run()
|
|
401
|
+
3. Pass `{ signal }` to the SDK's streaming method so an abort tears down in-flight HTTP.
|
|
402
|
+
4. On SDK throw: if `signal?.aborted`, return silently (run() yields the aborted `done`); else yield `{ type: 'error', error: classifyProviderError(e) }` from `./_errors.js`.
|
|
403
403
|
5. Register in `js/session/adapters/index.js`.
|
|
404
404
|
6. Write unit tests with a dependency-injected mock client.
|
|
405
405
|
7. Optionally add a gated live test in `test/live/<provider>.live.test.js`.
|
|
@@ -492,11 +492,12 @@ Test files under `rust/thin-gate/tests/`:
|
|
|
492
492
|
| `conformance.rs` | JS↔Rust protocol round-trip |
|
|
493
493
|
| `protocol.rs` | serde (de)serialization of envelope/events/results |
|
|
494
494
|
| `server.rs` | HTTP layer, synthetic dispatch, 404/400 paths |
|
|
495
|
-
| `session_dispatch.rs` | real `node js/session/bin.js` spawn + dispatch + graceful
|
|
495
|
+
| `session_dispatch.rs` | real `node js/session/bin.js` spawn + dispatch + graceful abort + `/v1/abort` |
|
|
496
|
+
| `oneshot_abandon.rs` | one-shot handler dropped mid-exchange: the session finishes and returns to the pool |
|
|
496
497
|
| `policy.rs` | `RoutePolicy` + `QuotaPolicy` + `Enforcer` end-to-end |
|
|
497
498
|
| `config.rs` | TOML `ConfigSource` parsing, defaults, malformed, env override |
|
|
498
499
|
| `supervision.rs` | readiness ping/pong + readiness timeout + garbage-response handling |
|
|
499
|
-
| `stress.rs` | 100 concurrent calls,
|
|
500
|
+
| `stress.rs` | 100 concurrent calls, abort storm, session-death-under-load |
|
|
500
501
|
|
|
501
502
|
Spawning tests require `node` in PATH.
|
|
502
503
|
|
|
@@ -528,11 +529,11 @@ For deterministic stress, benchmark, and bug-repro work, register `provider: "fa
|
|
|
528
529
|
{ mode: 'volume', tokens: 1000 } // throughput stress
|
|
529
530
|
{ mode: 'slow', tokens: 50, delayMs: 100 } // streaming cadence
|
|
530
531
|
{ mode: 'error', type: 'AUTH_INVALID' } // error classification
|
|
531
|
-
{ mode: 'hang' } //
|
|
532
|
+
{ mode: 'hang' } // abort / timeout plumbing
|
|
532
533
|
{ mode: 'tool', name: 'f', args: { x: 1 } } // tool round-trip
|
|
533
534
|
{ mode: 'incomplete' } // status contract
|
|
534
535
|
{ mode: 'crash' } // process isolation (exits the adapter process)
|
|
535
|
-
{ mode: '
|
|
536
|
+
{ mode: 'abort_after', tokens: 5 } // abort mid-stream
|
|
536
537
|
```
|
|
537
538
|
|
|
538
539
|
All modes honor `AbortSignal`. The benchmarks in `bench/` use this to pin adapter work to a fixed shape and isolate what's being measured — see `bench/bench.js` (throughput) and `bench/isolation.js` (crash containment).
|
package/js/client/call.js
CHANGED
|
@@ -1,57 +1,90 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Send a CallEnvelope to thin-gate; returns an async iterable of Events.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
4
|
+
* Abort: pass an AbortSignal. Once the gate has answered, an abort
|
|
5
|
+
* posts `/v1/abort` for this call and the stream keeps flowing until the
|
|
6
|
+
* session's own aborted `done` — partial output and the usage reported
|
|
7
|
+
* before the cut, as on the in-process path. That wait has no limit of
|
|
8
|
+
* its own: to stop waiting, stop iterating, which abandons the call. An
|
|
9
|
+
* abort before the gate answered drops the request instead; nothing has
|
|
10
|
+
* been reported yet, so the client ends with an aborted `done` of its
|
|
11
|
+
* own, zero usage.
|
|
10
12
|
* @module client/call
|
|
11
13
|
*/
|
|
12
14
|
|
|
13
15
|
import { requestUnix } from './transport.js'
|
|
14
16
|
import { readAll, parseErrorBody } from './response.js'
|
|
15
17
|
import { parseNDJSON } from './ndjson.js'
|
|
16
|
-
import { isEvent, MohdelError, STATUS_INCOMPLETE,
|
|
18
|
+
import { isEvent, MohdelError, STATUS_INCOMPLETE, WARNING_ABORTED } from '#core'
|
|
19
|
+
|
|
20
|
+
const ABORT_PATH = '/v1/abort'
|
|
21
|
+
const GATE_HEADER = 'mohdel-gate'
|
|
17
22
|
|
|
18
23
|
/**
|
|
19
24
|
* @param {string} start
|
|
20
|
-
* @param {string | null} first
|
|
21
|
-
* @param {string} output
|
|
22
25
|
* @returns {import('#core/events.js').DoneEvent}
|
|
23
26
|
*/
|
|
24
|
-
function
|
|
27
|
+
function abortedDone (start) {
|
|
25
28
|
const end = String(process.hrtime.bigint())
|
|
26
29
|
return {
|
|
27
30
|
type: 'done',
|
|
28
31
|
result: {
|
|
29
32
|
status: STATUS_INCOMPLETE,
|
|
30
|
-
output:
|
|
33
|
+
output: null,
|
|
31
34
|
inputTokens: 0,
|
|
32
35
|
outputTokens: 0,
|
|
33
36
|
thinkingTokens: 0,
|
|
34
37
|
cost: 0,
|
|
35
|
-
timestamps: { start, first:
|
|
36
|
-
warning:
|
|
38
|
+
timestamps: { start, first: end, end },
|
|
39
|
+
warning: WARNING_ABORTED
|
|
37
40
|
}
|
|
38
41
|
}
|
|
39
42
|
}
|
|
40
43
|
|
|
44
|
+
/**
|
|
45
|
+
* `CALL_NOT_FOUND` means the call ended before the abort reached it; its
|
|
46
|
+
* terminal is already on the stream.
|
|
47
|
+
*
|
|
48
|
+
* @param {import('#core/envelope.js').CallEnvelope} envelope
|
|
49
|
+
* @param {string | string[] | undefined} gate the call response's `mohdel-gate`
|
|
50
|
+
* @param {string} socketPath
|
|
51
|
+
* @param {Record<string, string> | undefined} headers
|
|
52
|
+
*/
|
|
53
|
+
async function requestAbort (envelope, gate, socketPath, headers) {
|
|
54
|
+
if (typeof gate !== 'string') {
|
|
55
|
+
throw new MohdelError(
|
|
56
|
+
`the /v1/call response carries no ${GATE_HEADER} header`,
|
|
57
|
+
{ type: 'PROTOCOL_GATE_UNIDENTIFIED', retryable: false }
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
const res = await requestUnix({
|
|
61
|
+
socketPath,
|
|
62
|
+
path: ABORT_PATH,
|
|
63
|
+
method: 'POST',
|
|
64
|
+
body: { callId: envelope.callId, authId: envelope.authId, gate },
|
|
65
|
+
headers
|
|
66
|
+
})
|
|
67
|
+
const body = await readAll(res)
|
|
68
|
+
if (res.statusCode === 202) return
|
|
69
|
+
const error = parseErrorBody(body, res.statusCode ?? 0)
|
|
70
|
+
if (error.type === 'CALL_NOT_FOUND') return
|
|
71
|
+
throw MohdelError.fromJSON(error)
|
|
72
|
+
}
|
|
73
|
+
|
|
41
74
|
/**
|
|
42
75
|
* @param {import('#core/envelope.js').CallEnvelope} envelope
|
|
43
76
|
* @param {object} options
|
|
44
77
|
* @param {string} options.socketPath
|
|
45
78
|
* @param {AbortSignal} [options.signal]
|
|
46
79
|
* @param {string} [options.path] HTTP path; defaults to '/v1/call'
|
|
47
|
-
* @param {Record<string, string>} [options.headers] sent with the request, for a router in
|
|
48
|
-
* `content-type`, `content-length`, `transfer-encoding`, `connection` and `host` are the transport's
|
|
80
|
+
* @param {Record<string, string>} [options.headers] sent with the request and with its abort, for a router in
|
|
81
|
+
* front of the gate; `content-type`, `content-length`, `transfer-encoding`, `connection` and `host` are the transport's
|
|
49
82
|
* @returns {AsyncGenerator<import('#core/events.js').Event>}
|
|
50
83
|
*/
|
|
51
84
|
export async function * call (envelope, { socketPath, signal, path = '/v1/call', headers }) {
|
|
52
85
|
const start = String(process.hrtime.bigint())
|
|
53
86
|
if (signal?.aborted) {
|
|
54
|
-
yield
|
|
87
|
+
yield abortedDone(start)
|
|
55
88
|
return
|
|
56
89
|
}
|
|
57
90
|
|
|
@@ -67,7 +100,7 @@ export async function * call (envelope, { socketPath, signal, path = '/v1/call',
|
|
|
67
100
|
})
|
|
68
101
|
} catch (e) {
|
|
69
102
|
if (signal?.aborted) {
|
|
70
|
-
yield
|
|
103
|
+
yield abortedDone(start)
|
|
71
104
|
return
|
|
72
105
|
}
|
|
73
106
|
throw e
|
|
@@ -78,9 +111,19 @@ export async function * call (envelope, { socketPath, signal, path = '/v1/call',
|
|
|
78
111
|
throw MohdelError.fromJSON(parseErrorBody(body, res.statusCode ?? 0))
|
|
79
112
|
}
|
|
80
113
|
|
|
81
|
-
|
|
82
|
-
let
|
|
114
|
+
/** @type {unknown} */
|
|
115
|
+
let abortError = null
|
|
83
116
|
let sawTerminal = false
|
|
117
|
+
const onAbort = () => {
|
|
118
|
+
if (sawTerminal) return
|
|
119
|
+
requestAbort(envelope, res.headers[GATE_HEADER], socketPath, headers).catch((e) => {
|
|
120
|
+
abortError = e
|
|
121
|
+
res.destroy()
|
|
122
|
+
})
|
|
123
|
+
}
|
|
124
|
+
if (signal?.aborted) onAbort()
|
|
125
|
+
else signal?.addEventListener('abort', onAbort, { once: true })
|
|
126
|
+
|
|
84
127
|
try {
|
|
85
128
|
for await (const obj of parseNDJSON(res)) {
|
|
86
129
|
if (!isEvent(obj)) {
|
|
@@ -89,19 +132,13 @@ export async function * call (envelope, { socketPath, signal, path = '/v1/call',
|
|
|
89
132
|
{ type: 'PROTOCOL_INVALID_EVENT', retryable: false }
|
|
90
133
|
)
|
|
91
134
|
}
|
|
92
|
-
if (obj.type === '
|
|
93
|
-
if (first === null) first = String(process.hrtime.bigint())
|
|
94
|
-
if (obj.delta?.type === 'message') outputParts.push(obj.delta.delta)
|
|
95
|
-
} else if (obj.type === 'done' || obj.type === 'error') {
|
|
96
|
-
sawTerminal = true
|
|
97
|
-
}
|
|
135
|
+
if (obj.type === 'done' || obj.type === 'error') sawTerminal = true
|
|
98
136
|
yield /** @type {import('#core/events.js').Event} */(obj)
|
|
99
137
|
}
|
|
100
138
|
} catch (e) {
|
|
101
|
-
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
if (signal?.aborted && !sawTerminal) {
|
|
105
|
-
yield cancelledDone(start, first, outputParts.join(''))
|
|
139
|
+
throw abortError ?? e
|
|
140
|
+
} finally {
|
|
141
|
+
signal?.removeEventListener('abort', onAbort)
|
|
106
142
|
}
|
|
143
|
+
if (abortError) throw abortError
|
|
107
144
|
}
|
package/js/client/transport.js
CHANGED
|
@@ -30,7 +30,11 @@ export function requestUnix ({ socketPath, path, method, body, signal, headers }
|
|
|
30
30
|
const h = { ...(headers || {}) }
|
|
31
31
|
if (body !== undefined) h['content-type'] = 'application/json'
|
|
32
32
|
|
|
33
|
-
const
|
|
33
|
+
const onAbort = () => req.destroy(new Error('aborted'))
|
|
34
|
+
const req = http.request({ socketPath, path, method, headers: h }, (res) => {
|
|
35
|
+
signal?.removeEventListener('abort', onAbort)
|
|
36
|
+
resolve(res)
|
|
37
|
+
})
|
|
34
38
|
req.on('error', reject)
|
|
35
39
|
|
|
36
40
|
if (signal) {
|
|
@@ -39,11 +43,7 @@ export function requestUnix ({ socketPath, path, method, body, signal, headers }
|
|
|
39
43
|
reject(new Error('aborted'))
|
|
40
44
|
return
|
|
41
45
|
}
|
|
42
|
-
const onAbort = () => req.destroy(new Error('aborted'))
|
|
43
46
|
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
47
|
req.on('close', () => signal.removeEventListener('abort', onAbort))
|
|
48
48
|
}
|
|
49
49
|
|
package/js/core/events.js
CHANGED
|
@@ -98,7 +98,7 @@
|
|
|
98
98
|
* USD, computed from curated pricing. Single number (not a breakdown).
|
|
99
99
|
* @property {Timestamps} timestamps
|
|
100
100
|
* @property {string} [warning]
|
|
101
|
-
* `'insufficientOutputBudget' | '
|
|
101
|
+
* `'insufficientOutputBudget' | 'aborted' | ...` additive union.
|
|
102
102
|
* @property {ToolCall[]} [toolCalls]
|
|
103
103
|
* Present when `status === 'tool_use'`.
|
|
104
104
|
* @property {number} [maxInterFrameMs]
|
package/js/core/status.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* - `completed` — the call finished normally.
|
|
6
6
|
* - `tool_use` — the model emitted tool calls; the caller is
|
|
7
7
|
* expected to round-trip results back.
|
|
8
|
-
* - `incomplete` — the call was cut short (budget,
|
|
8
|
+
* - `incomplete` — the call was cut short (budget, abort, policy).
|
|
9
9
|
*
|
|
10
10
|
* Rust mirror: `rust/thin-gate/src/protocol.rs::Status`.
|
|
11
11
|
*
|
|
@@ -18,13 +18,13 @@
|
|
|
18
18
|
* Warning values emitted on `AnswerResult.warning` when status is
|
|
19
19
|
* `incomplete`:
|
|
20
20
|
* - `insufficientOutputBudget` — the model hit `max_tokens`.
|
|
21
|
-
* - `
|
|
21
|
+
* - `aborted` — the call was aborted via an abort control
|
|
22
22
|
* message or `AbortSignal`.
|
|
23
23
|
*
|
|
24
24
|
* Additive: future releases may add new warning strings; consumers
|
|
25
25
|
* should treat unknown warnings as pass-through metadata.
|
|
26
26
|
*
|
|
27
|
-
* @typedef {('insufficientOutputBudget'|'
|
|
27
|
+
* @typedef {('insufficientOutputBudget'|'aborted'|string)} Warning
|
|
28
28
|
*/
|
|
29
29
|
|
|
30
30
|
export const STATUS_COMPLETED = 'completed'
|
|
@@ -37,4 +37,4 @@ export const STATUSES = Object.freeze([
|
|
|
37
37
|
])
|
|
38
38
|
|
|
39
39
|
export const WARNING_INSUFFICIENT_OUTPUT_BUDGET = 'insufficientOutputBudget'
|
|
40
|
-
export const
|
|
40
|
+
export const WARNING_ABORTED = 'aborted'
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Shared `
|
|
2
|
+
* Shared `abortedDone` helper for adapters that need to synthesize
|
|
3
3
|
* a terminal `done` event on `signal.aborted` mid-stream.
|
|
4
4
|
*
|
|
5
|
-
* @module session/adapters/
|
|
5
|
+
* @module session/adapters/_aborted
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { STATUS_INCOMPLETE,
|
|
8
|
+
import { STATUS_INCOMPLETE, WARNING_ABORTED } from '#core/status.js'
|
|
9
9
|
import { costFor } from './_pricing.js'
|
|
10
10
|
|
|
11
11
|
/**
|
|
@@ -16,12 +16,12 @@ import { costFor } from './_pricing.js'
|
|
|
16
16
|
* @param {number} inputTokens
|
|
17
17
|
* @param {number} outputTokens
|
|
18
18
|
* @param {{cacheWriteInputTokens?: number, cacheReadInputTokens?: number}} [extra]
|
|
19
|
-
* Optional cache token counts captured before
|
|
20
|
-
* so the
|
|
19
|
+
* Optional cache token counts captured before the abort. Threaded through
|
|
20
|
+
* so the abort-cost calculation prices any cache writes/reads that
|
|
21
21
|
* already happened before the abort.
|
|
22
22
|
* @returns {import('#core/events.js').DoneEvent}
|
|
23
23
|
*/
|
|
24
|
-
export function
|
|
24
|
+
export function abortedDone (start, first, envelope, output, inputTokens, outputTokens, extra = {}) {
|
|
25
25
|
const end = String(process.hrtime.bigint())
|
|
26
26
|
const cacheWriteInputTokens = extra.cacheWriteInputTokens || 0
|
|
27
27
|
const cacheReadInputTokens = extra.cacheReadInputTokens || 0
|
|
@@ -40,7 +40,7 @@ export function cancelledDone (start, first, envelope, output, inputTokens, outp
|
|
|
40
40
|
{ inputTokens, outputTokens, thinkingTokens: 0, cacheWriteInputTokens, cacheReadInputTokens }
|
|
41
41
|
),
|
|
42
42
|
timestamps: { start, first: first ?? end, end },
|
|
43
|
-
warning:
|
|
43
|
+
warning: WARNING_ABORTED
|
|
44
44
|
}
|
|
45
45
|
}
|
|
46
46
|
}
|
|
@@ -22,7 +22,7 @@ import { classifyProviderError } from './_errors.js'
|
|
|
22
22
|
import { hasImagePart, loadImage, loadImageParts } from './_images.js'
|
|
23
23
|
import { isTrustedMedia, mediaError, mediaScheme } from './_media.js'
|
|
24
24
|
import { costFor } from './_pricing.js'
|
|
25
|
-
import {
|
|
25
|
+
import { abortedDone } from './_aborted.js'
|
|
26
26
|
import { catalogKey, bareOf } from '#core/model-id.js'
|
|
27
27
|
import {
|
|
28
28
|
STATUS_COMPLETED,
|
|
@@ -116,7 +116,7 @@ export async function * runChatCompletions (envelope, client, config, deps = {})
|
|
|
116
116
|
response = await client.chat.completions.create(args, { signal: deps.signal })
|
|
117
117
|
} catch (e) {
|
|
118
118
|
if (deps.signal?.aborted) {
|
|
119
|
-
yield
|
|
119
|
+
yield abortedDone(start, null, envelope, '', 0, 0)
|
|
120
120
|
return
|
|
121
121
|
}
|
|
122
122
|
deps.log?.warn({ err: e }, `[mohdel:${config.provider}] request failed`)
|
|
@@ -182,7 +182,7 @@ async function * runStreaming (envelope, client, args, config, start, deps) {
|
|
|
182
182
|
stream = await client.chat.completions.create(args, { signal: deps.signal })
|
|
183
183
|
} catch (e) {
|
|
184
184
|
if (deps.signal?.aborted) {
|
|
185
|
-
yield
|
|
185
|
+
yield abortedDone(start, null, envelope, '', 0, 0)
|
|
186
186
|
return
|
|
187
187
|
}
|
|
188
188
|
deps.log?.warn({ err: e }, `[mohdel:${config.provider}] request failed`)
|
|
@@ -193,7 +193,7 @@ async function * runStreaming (envelope, client, args, config, start, deps) {
|
|
|
193
193
|
try {
|
|
194
194
|
for await (const chunk of stream) {
|
|
195
195
|
if (deps.signal?.aborted) {
|
|
196
|
-
yield
|
|
196
|
+
yield abortedDone(start, first, envelope, contentParts.join(''), 0, 0)
|
|
197
197
|
return
|
|
198
198
|
}
|
|
199
199
|
const choice = chunk.choices?.[0]
|
|
@@ -249,7 +249,7 @@ async function * runStreaming (envelope, client, args, config, start, deps) {
|
|
|
249
249
|
}
|
|
250
250
|
} catch (e) {
|
|
251
251
|
if (deps.signal?.aborted) {
|
|
252
|
-
yield
|
|
252
|
+
yield abortedDone(start, first, envelope, contentParts.join(''), 0, 0)
|
|
253
253
|
return
|
|
254
254
|
}
|
|
255
255
|
deps.log?.warn({ err: e }, `[mohdel:${config.provider}] stream failed`)
|
|
@@ -258,7 +258,7 @@ async function * runStreaming (envelope, client, args, config, start, deps) {
|
|
|
258
258
|
}
|
|
259
259
|
|
|
260
260
|
if (deps.signal?.aborted) {
|
|
261
|
-
yield
|
|
261
|
+
yield abortedDone(start, first, envelope, contentParts.join(''), 0, 0)
|
|
262
262
|
return
|
|
263
263
|
}
|
|
264
264
|
|
|
@@ -7,11 +7,12 @@
|
|
|
7
7
|
* phase, so any non-trivial task on a thinking-capable provider can
|
|
8
8
|
* blow that limit and surface as a `NET_ERROR / "terminated"` mid-run.
|
|
9
9
|
*
|
|
10
|
-
* We disable the inter-chunk idle timeout
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
10
|
+
* We disable the inter-chunk idle timeout, so nothing in this process
|
|
11
|
+
* bounds a stream once its headers have arrived: the SDKs' request
|
|
12
|
+
* timeout is armed around `fetch` only and cleared when the headers
|
|
13
|
+
* land. What can still end a stream:
|
|
14
|
+
* 1. caller's `AbortSignal` (per-run timeout, user abort)
|
|
15
|
+
* 2. provider-side stream limits
|
|
15
16
|
*
|
|
16
17
|
* Headers timeout stays bounded — connect + first response must be
|
|
17
18
|
* fast even when the body afterwards may be slow.
|
|
@@ -288,7 +288,7 @@ async function uploadFile (filePath, mimeType, ctx) {
|
|
|
288
288
|
/**
|
|
289
289
|
* Raise an `AbortError` when `signal` is aborted. The gemini
|
|
290
290
|
* adapter's video-load catch block already converts this shape to
|
|
291
|
-
* the standard
|
|
291
|
+
* the standard aborted terminal via the outer `signal?.aborted`
|
|
292
292
|
* check in `run.js`.
|
|
293
293
|
*
|
|
294
294
|
* @param {AbortSignal | undefined} signal
|
|
@@ -24,7 +24,7 @@ import {
|
|
|
24
24
|
WARNING_INSUFFICIENT_OUTPUT_BUDGET
|
|
25
25
|
} from '#core/status.js'
|
|
26
26
|
|
|
27
|
-
import {
|
|
27
|
+
import { abortedDone } from './_aborted.js'
|
|
28
28
|
import { getSpec } from './_catalog.js'
|
|
29
29
|
import { classifyProviderError } from './_errors.js'
|
|
30
30
|
import { hasImagePart, loadImageParts, loadImages } from './_images.js'
|
|
@@ -58,7 +58,7 @@ import { streamingDispatcher } from './_dispatcher.js'
|
|
|
58
58
|
*
|
|
59
59
|
* **Cost impact:** provably zero when `thinkingPrice == outputPrice`
|
|
60
60
|
* (true for every Anthropic entry in the curated catalog today) —
|
|
61
|
-
* the heuristic error
|
|
61
|
+
* the heuristic error drops out of `cost = i*ip + o*op + t*tp` because
|
|
62
62
|
* `o*op + t*op = (o+t)*op = totalOutput*op`. If a catalog maintainer
|
|
63
63
|
* ever sets asymmetric Anthropic pricing, cost drifts by
|
|
64
64
|
* `estimate_error × (thinkingPrice − outputPrice)` — that's a
|
|
@@ -134,7 +134,7 @@ export async function * anthropic (envelope, deps = {}) {
|
|
|
134
134
|
|
|
135
135
|
for await (const event of stream) {
|
|
136
136
|
if (signal?.aborted) {
|
|
137
|
-
yield
|
|
137
|
+
yield abortedDone(start, first, envelope, currentOutput(), inputTokens, outputTokens, { cacheWriteInputTokens: cacheWriteTokens, cacheReadInputTokens: cacheReadTokens })
|
|
138
138
|
return
|
|
139
139
|
}
|
|
140
140
|
switch (event.type) {
|
|
@@ -208,7 +208,7 @@ export async function * anthropic (envelope, deps = {}) {
|
|
|
208
208
|
}
|
|
209
209
|
} catch (e) {
|
|
210
210
|
if (signal?.aborted) {
|
|
211
|
-
yield
|
|
211
|
+
yield abortedDone(start, first, envelope, currentOutput(), inputTokens, outputTokens, { cacheWriteInputTokens: cacheWriteTokens, cacheReadInputTokens: cacheReadTokens })
|
|
212
212
|
return
|
|
213
213
|
}
|
|
214
214
|
log?.warn({ err: e }, '[mohdel:anthropic] stream failed')
|
|
@@ -217,7 +217,7 @@ export async function * anthropic (envelope, deps = {}) {
|
|
|
217
217
|
}
|
|
218
218
|
|
|
219
219
|
if (signal?.aborted) {
|
|
220
|
-
yield
|
|
220
|
+
yield abortedDone(start, first, envelope, currentOutput(), inputTokens, outputTokens, { cacheWriteInputTokens: cacheWriteTokens, cacheReadInputTokens: cacheReadTokens })
|
|
221
221
|
return
|
|
222
222
|
}
|
|
223
223
|
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Echo adapter — deterministic stub. Emits two message deltas and a
|
|
3
3
|
* `done` event with a synthetic `AnswerResult`. Honors `signal` for
|
|
4
|
-
* test-controlled
|
|
4
|
+
* test-controlled aborts.
|
|
5
5
|
*
|
|
6
6
|
* @module session/adapters/echo
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { STATUS_COMPLETED } from '#core/status.js'
|
|
10
10
|
|
|
11
|
-
import {
|
|
11
|
+
import { abortedDone } from './_aborted.js'
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* @param {import('#core/envelope.js').CallEnvelope} envelope
|
|
@@ -22,7 +22,7 @@ export async function * echo (envelope, { signal } = {}) {
|
|
|
22
22
|
|
|
23
23
|
for (const delta of ['Hello', ', world.']) {
|
|
24
24
|
if (signal?.aborted) {
|
|
25
|
-
yield
|
|
25
|
+
yield abortedDone(start, first, envelope, output, 0, 0)
|
|
26
26
|
return
|
|
27
27
|
}
|
|
28
28
|
if (first === null) first = String(process.hrtime.bigint())
|
|
@@ -31,7 +31,7 @@ export async function * echo (envelope, { signal } = {}) {
|
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
if (signal?.aborted) {
|
|
34
|
-
yield
|
|
34
|
+
yield abortedDone(start, first, envelope, output, 0, 0)
|
|
35
35
|
return
|
|
36
36
|
}
|
|
37
37
|
|
|
@@ -18,11 +18,11 @@
|
|
|
18
18
|
* | `incomplete` | `warning` | `done` with status=incomplete + warning |
|
|
19
19
|
* | `error` | `type`, `message`, `retryable` | yield typed error event |
|
|
20
20
|
* | `hang` | – | never emits a terminal (caller aborts via signal) |
|
|
21
|
-
* | `
|
|
21
|
+
* | `abort_after`| `tokens` | emit N deltas then wait for `signal.aborted` |
|
|
22
22
|
* | `crash` | `code` | `process.exit(code\|1)` — kills whichever process is running the adapter. Used by the isolation benchmark to demonstrate that via-gate the crash stays in the session subprocess, in-process it takes down the caller. |
|
|
23
23
|
*
|
|
24
24
|
* Every mode honors `deps.signal`: when aborted mid-stream, emits a
|
|
25
|
-
* `
|
|
25
|
+
* `aborted` done event. That keeps consumer behavior uniform with
|
|
26
26
|
* the real provider adapters.
|
|
27
27
|
*
|
|
28
28
|
* @module session/adapters/fake
|
|
@@ -35,7 +35,7 @@ import {
|
|
|
35
35
|
WARNING_INSUFFICIENT_OUTPUT_BUDGET
|
|
36
36
|
} from '#core/status.js'
|
|
37
37
|
|
|
38
|
-
import {
|
|
38
|
+
import { abortedDone } from './_aborted.js'
|
|
39
39
|
|
|
40
40
|
/**
|
|
41
41
|
* @param {import('#core/envelope.js').CallEnvelope} envelope
|
|
@@ -63,7 +63,7 @@ export async function * fake (envelope, deps = {}) {
|
|
|
63
63
|
|
|
64
64
|
case 'hang':
|
|
65
65
|
await waitForAbort(signal)
|
|
66
|
-
yield
|
|
66
|
+
yield abortedDone(start, first, envelope, '', 0, 0)
|
|
67
67
|
return
|
|
68
68
|
|
|
69
69
|
case 'crash': {
|
|
@@ -120,7 +120,7 @@ export async function * fake (envelope, deps = {}) {
|
|
|
120
120
|
let output = ''
|
|
121
121
|
for (let i = 0; i < total; i++) {
|
|
122
122
|
if (signal?.aborted) {
|
|
123
|
-
yield
|
|
123
|
+
yield abortedDone(start, first, envelope, output, approxTokens(output), approxTokens(output))
|
|
124
124
|
return
|
|
125
125
|
}
|
|
126
126
|
if (first === null) first = String(process.hrtime.bigint())
|
|
@@ -130,7 +130,7 @@ export async function * fake (envelope, deps = {}) {
|
|
|
130
130
|
if (delayMs > 0) {
|
|
131
131
|
await sleep(delayMs, signal)
|
|
132
132
|
if (signal?.aborted) {
|
|
133
|
-
yield
|
|
133
|
+
yield abortedDone(start, first, envelope, output, approxTokens(output), approxTokens(output))
|
|
134
134
|
return
|
|
135
135
|
}
|
|
136
136
|
}
|
|
@@ -145,7 +145,7 @@ export async function * fake (envelope, deps = {}) {
|
|
|
145
145
|
return
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
-
case '
|
|
148
|
+
case 'abort_after': {
|
|
149
149
|
const total = clampPositive(spec.tokens, 3)
|
|
150
150
|
let output = ''
|
|
151
151
|
for (let i = 0; i < total; i++) {
|
|
@@ -155,7 +155,7 @@ export async function * fake (envelope, deps = {}) {
|
|
|
155
155
|
yield { type: 'delta', delta: { type: 'message', delta: chunk } }
|
|
156
156
|
}
|
|
157
157
|
await waitForAbort(signal)
|
|
158
|
-
yield
|
|
158
|
+
yield abortedDone(start, first, envelope, output, approxTokens(output), approxTokens(output))
|
|
159
159
|
return
|
|
160
160
|
}
|
|
161
161
|
|
|
@@ -25,7 +25,7 @@ import {
|
|
|
25
25
|
WARNING_INSUFFICIENT_OUTPUT_BUDGET
|
|
26
26
|
} from '#core/status.js'
|
|
27
27
|
|
|
28
|
-
import {
|
|
28
|
+
import { abortedDone } from './_aborted.js'
|
|
29
29
|
import { getSpec } from './_catalog.js'
|
|
30
30
|
import { classifyProviderError } from './_errors.js'
|
|
31
31
|
import { loadImageParts, loadImages } from './_images.js'
|
|
@@ -80,7 +80,7 @@ export async function * gemini (envelope, deps = {}) {
|
|
|
80
80
|
if (parts.length) injectParts(contents, parts)
|
|
81
81
|
} catch (e) {
|
|
82
82
|
if (signal?.aborted) {
|
|
83
|
-
yield
|
|
83
|
+
yield abortedDone(start, first, envelope, '', 0, 0)
|
|
84
84
|
return
|
|
85
85
|
}
|
|
86
86
|
log?.warn({ err: e }, '[mohdel:gemini] video load failed')
|
|
@@ -98,7 +98,7 @@ export async function * gemini (envelope, deps = {}) {
|
|
|
98
98
|
// from `params.config.abortSignal` — a second-arg `{signal}` is
|
|
99
99
|
// dropped (verified against the compiled SDK at
|
|
100
100
|
// node_modules/@google/genai/dist/index.cjs). Merge into config
|
|
101
|
-
// so
|
|
101
|
+
// so an abort actually tears down the HTTPS request, not just
|
|
102
102
|
// the local loop.
|
|
103
103
|
if (signal) {
|
|
104
104
|
request.config = { ...(request.config ?? {}), abortSignal: signal }
|
|
@@ -122,7 +122,7 @@ export async function * gemini (envelope, deps = {}) {
|
|
|
122
122
|
|
|
123
123
|
for await (const chunk of stream) {
|
|
124
124
|
if (signal?.aborted) {
|
|
125
|
-
yield
|
|
125
|
+
yield abortedDone(start, first, envelope, currentOutput(), inputTokens, outputTokens)
|
|
126
126
|
return
|
|
127
127
|
}
|
|
128
128
|
|
|
@@ -178,7 +178,7 @@ export async function * gemini (envelope, deps = {}) {
|
|
|
178
178
|
}
|
|
179
179
|
} catch (e) {
|
|
180
180
|
if (signal?.aborted) {
|
|
181
|
-
yield
|
|
181
|
+
yield abortedDone(start, first, envelope, currentOutput(), inputTokens, outputTokens)
|
|
182
182
|
return
|
|
183
183
|
}
|
|
184
184
|
log?.warn({ err: e }, '[mohdel:gemini] stream failed')
|
|
@@ -187,7 +187,7 @@ export async function * gemini (envelope, deps = {}) {
|
|
|
187
187
|
}
|
|
188
188
|
|
|
189
189
|
if (signal?.aborted) {
|
|
190
|
-
yield
|
|
190
|
+
yield abortedDone(start, first, envelope, currentOutput(), inputTokens, outputTokens)
|
|
191
191
|
return
|
|
192
192
|
}
|
|
193
193
|
|
|
@@ -25,7 +25,7 @@ import {
|
|
|
25
25
|
WARNING_INSUFFICIENT_OUTPUT_BUDGET
|
|
26
26
|
} from '#core/status.js'
|
|
27
27
|
|
|
28
|
-
import {
|
|
28
|
+
import { abortedDone } from './_aborted.js'
|
|
29
29
|
import { getSpec } from './_catalog.js'
|
|
30
30
|
import { classifyProviderError } from './_errors.js'
|
|
31
31
|
import { hasImagePart, loadImageParts, loadImages } from './_images.js'
|
|
@@ -99,7 +99,7 @@ export async function * openai (envelope, deps = {}) {
|
|
|
99
99
|
|
|
100
100
|
for await (const event of stream) {
|
|
101
101
|
if (signal?.aborted) {
|
|
102
|
-
yield
|
|
102
|
+
yield abortedDone(start, first, envelope, currentOutput(), inputTokens, outputTokens)
|
|
103
103
|
return
|
|
104
104
|
}
|
|
105
105
|
switch (event.type) {
|
|
@@ -170,7 +170,7 @@ export async function * openai (envelope, deps = {}) {
|
|
|
170
170
|
}
|
|
171
171
|
} catch (e) {
|
|
172
172
|
if (signal?.aborted) {
|
|
173
|
-
yield
|
|
173
|
+
yield abortedDone(start, first, envelope, currentOutput(), inputTokens, outputTokens)
|
|
174
174
|
return
|
|
175
175
|
}
|
|
176
176
|
log?.warn({ err: e }, '[mohdel:openai] stream failed')
|
|
@@ -179,7 +179,7 @@ export async function * openai (envelope, deps = {}) {
|
|
|
179
179
|
}
|
|
180
180
|
|
|
181
181
|
if (signal?.aborted) {
|
|
182
|
-
yield
|
|
182
|
+
yield abortedDone(start, first, envelope, currentOutput(), inputTokens, outputTokens)
|
|
183
183
|
return
|
|
184
184
|
}
|
|
185
185
|
|
package/js/session/driver.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Reads lines from stdin concurrently with running calls:
|
|
5
5
|
* - Envelope line (no `op` field): queued for sequential dispatch
|
|
6
6
|
* via `run()`. Events written to stdout as NDJSON.
|
|
7
|
-
* - Control message `{op:"
|
|
7
|
+
* - Control message `{op:"abort", callId}`: if it matches the
|
|
8
8
|
* in-flight call, aborts via AbortController.
|
|
9
9
|
*
|
|
10
10
|
* Single-call-at-a-time per process.
|
|
@@ -22,9 +22,9 @@ import { runEmbedding } from './run_embedding.js'
|
|
|
22
22
|
import { runInfo } from './run_info.js'
|
|
23
23
|
import { setCatalog } from './adapters/_catalog.js'
|
|
24
24
|
|
|
25
|
-
// Bounded memory for pre-dequeue
|
|
25
|
+
// Bounded memory for pre-dequeue aborts. Hostile/buggy supervisors
|
|
26
26
|
// spamming random callIds can't grow the set without bound.
|
|
27
|
-
const
|
|
27
|
+
const EARLY_ABORT_CAP = 128
|
|
28
28
|
|
|
29
29
|
/**
|
|
30
30
|
* @param {NodeJS.ReadableStream} stdin
|
|
@@ -40,17 +40,17 @@ export async function drive (stdin, stdout) {
|
|
|
40
40
|
let queueNotify = null
|
|
41
41
|
let stdinClosed = false
|
|
42
42
|
let framingError = null
|
|
43
|
-
/**
|
|
43
|
+
/** Abort messages received before their envelope was dequeued.
|
|
44
44
|
* JS Sets are insertion-ordered, so `values().next()` is the
|
|
45
45
|
* oldest entry — cheap FIFO eviction at cap. */
|
|
46
|
-
const
|
|
46
|
+
const earlyAborted = new Set()
|
|
47
47
|
|
|
48
|
-
function
|
|
49
|
-
if (
|
|
50
|
-
if (
|
|
51
|
-
|
|
48
|
+
function recordEarlyAbort (callId) {
|
|
49
|
+
if (earlyAborted.has(callId)) return
|
|
50
|
+
if (earlyAborted.size >= EARLY_ABORT_CAP) {
|
|
51
|
+
earlyAborted.delete(earlyAborted.values().next().value)
|
|
52
52
|
}
|
|
53
|
-
|
|
53
|
+
earlyAborted.add(callId)
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
/**
|
|
@@ -94,13 +94,13 @@ export async function drive (stdin, stdout) {
|
|
|
94
94
|
return
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
-
if (obj && typeof obj === 'object' && obj.op === '
|
|
97
|
+
if (obj && typeof obj === 'object' && obj.op === 'abort') {
|
|
98
98
|
if (currentCall && currentCall.callId === obj.callId) {
|
|
99
99
|
currentCall.controller.abort()
|
|
100
100
|
} else {
|
|
101
|
-
// Pre-dequeue
|
|
101
|
+
// Pre-dequeue abort: remember the callId so the envelope
|
|
102
102
|
// aborts immediately on dispatch. Honored once then cleared.
|
|
103
|
-
|
|
103
|
+
recordEarlyAbort(obj.callId)
|
|
104
104
|
}
|
|
105
105
|
return
|
|
106
106
|
}
|
|
@@ -197,7 +197,7 @@ export async function drive (stdin, stdout) {
|
|
|
197
197
|
|
|
198
198
|
const envelope = envelopeQueue.shift()
|
|
199
199
|
const controller = new AbortController()
|
|
200
|
-
if (
|
|
200
|
+
if (earlyAborted.delete(envelope.callId)) controller.abort()
|
|
201
201
|
currentCall = { callId: envelope.callId, controller }
|
|
202
202
|
|
|
203
203
|
try {
|
package/js/session/run.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* skipped for non-retryable provider errors). Span ends with
|
|
15
15
|
* `gen_ai.*` + `mohdel.*` attributes.
|
|
16
16
|
*
|
|
17
|
-
* Terminal events are `done` (success / incomplete /
|
|
17
|
+
* Terminal events are `done` (success / incomplete / aborted /
|
|
18
18
|
* tool_use) and `error`. The adapter is expected to emit exactly
|
|
19
19
|
* one terminal per call. If it returns without one, `run()`
|
|
20
20
|
* synthesizes an `error`.
|
|
@@ -37,7 +37,7 @@ import {
|
|
|
37
37
|
endSpanError,
|
|
38
38
|
remoteParentFromTraceparent
|
|
39
39
|
} from './_tracing.js'
|
|
40
|
-
import { STATUS_INCOMPLETE,
|
|
40
|
+
import { STATUS_INCOMPLETE, WARNING_ABORTED } from '#core/status.js'
|
|
41
41
|
|
|
42
42
|
/**
|
|
43
43
|
* @param {import('#core/envelope.js').CallEnvelope} envelope
|
|
@@ -227,12 +227,12 @@ export async function * run (envelope, {
|
|
|
227
227
|
sawDelta = true
|
|
228
228
|
} else if (ev.type === 'done') {
|
|
229
229
|
sawTerminal = true
|
|
230
|
-
//
|
|
230
|
+
// An aborted terminal is the caller's action, not evidence
|
|
231
231
|
// of provider recovery — don't wipe an accumulated failure
|
|
232
232
|
// streak. Every other `done` state (completed /
|
|
233
233
|
// incomplete-budget / tool_use) IS a genuine provider-side
|
|
234
234
|
// success and resets the streak.
|
|
235
|
-
if (ev.result?.warning !==
|
|
235
|
+
if (ev.result?.warning !== WARNING_ABORTED) {
|
|
236
236
|
cooldown.reset(provider)
|
|
237
237
|
}
|
|
238
238
|
if (tpmLimit != null && ev.result) {
|
|
@@ -267,7 +267,7 @@ export async function * run (envelope, {
|
|
|
267
267
|
}
|
|
268
268
|
} catch (e) {
|
|
269
269
|
if (signal?.aborted && !sawTerminal) {
|
|
270
|
-
const fallback =
|
|
270
|
+
const fallback = abortedFallback()
|
|
271
271
|
if (fallback.result) fallback.result.maxInterFrameMs = maxInterFrameMs
|
|
272
272
|
finalizeSpanOk(span, fallback.result, sawDelta, maxInterFrameMs)
|
|
273
273
|
yield fallback
|
|
@@ -281,7 +281,7 @@ export async function * run (envelope, {
|
|
|
281
281
|
|
|
282
282
|
if (!sawTerminal) {
|
|
283
283
|
if (signal?.aborted) {
|
|
284
|
-
const fallback =
|
|
284
|
+
const fallback = abortedFallback()
|
|
285
285
|
if (fallback.result) fallback.result.maxInterFrameMs = maxInterFrameMs
|
|
286
286
|
finalizeSpanOk(span, fallback.result, sawDelta, maxInterFrameMs)
|
|
287
287
|
yield fallback
|
|
@@ -561,7 +561,7 @@ function recordFailureFromError (cooldown, provider, err) {
|
|
|
561
561
|
}
|
|
562
562
|
|
|
563
563
|
/** @returns {import('#core/events.js').DoneEvent} */
|
|
564
|
-
function
|
|
564
|
+
function abortedFallback () {
|
|
565
565
|
const now = String(process.hrtime.bigint())
|
|
566
566
|
return {
|
|
567
567
|
type: 'done',
|
|
@@ -573,7 +573,7 @@ function cancelledFallback () {
|
|
|
573
573
|
thinkingTokens: 0,
|
|
574
574
|
cost: 0,
|
|
575
575
|
timestamps: { start: now, first: now, end: now },
|
|
576
|
-
warning:
|
|
576
|
+
warning: WARNING_ABORTED
|
|
577
577
|
}
|
|
578
578
|
}
|
|
579
579
|
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* returns either a result or a typed error, never throwing.
|
|
4
4
|
*
|
|
5
5
|
* Mirrors `run_transcription.js`: one synchronous request, no streaming, no
|
|
6
|
-
*
|
|
6
|
+
* abort path beyond the caller's own signal. Rate limits are enforced
|
|
7
7
|
* as in `run.js`, minus the speed lanes embeddings do not have.
|
|
8
8
|
*
|
|
9
9
|
* @module session/run_embedding
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mohdel",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Christophe Le Bars",
|
|
@@ -136,7 +136,7 @@
|
|
|
136
136
|
"@opentelemetry/exporter-trace-otlp-grpc": "^0.222.0",
|
|
137
137
|
"@opentelemetry/sdk-node": "^0.222.0",
|
|
138
138
|
"chalk": "^6.0.0",
|
|
139
|
-
"mohdel-thin-gate-linux-x64-gnu": "
|
|
139
|
+
"mohdel-thin-gate-linux-x64-gnu": "3.0.0"
|
|
140
140
|
},
|
|
141
141
|
"dependencies": {
|
|
142
142
|
"@anthropic-ai/sdk": "^0.128.0",
|