mohdel 0.124.0 → 0.125.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 CHANGED
@@ -233,7 +233,7 @@ With no session-bin configured, thin-gate runs in demo mode: `POST /v1/call` ret
233
233
 
234
234
  ### Calling from JS
235
235
 
236
- The client snippet under [Library Usage](#library-usage) above is the full surface: `call(envelope, { socketPath, signal? })` returns an async iterable of events. Pass an `AbortSignal` to cancel in flight; thin-gate forwards a cancel control message to the session and reuses it on the pool. 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.
236
+ The client snippet under [Library Usage](#library-usage) above is the full surface: `call(envelope, { socketPath, signal? })` returns an async iterable of events. Pass an `AbortSignal` to cancel in flight; thin-gate forwards a cancel control message to the session and reuses it on the pool, and the client ends the stream with the same cancelled `done` the in-process path returns (status `incomplete`, warning `cancelled`, the partial output it relayed, zero tokens). 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.
237
237
 
238
238
  ### Other languages
239
239
 
package/js/client/call.js CHANGED
@@ -1,17 +1,42 @@
1
1
  /**
2
2
  * Send a CallEnvelope to thin-gate; returns an async iterable of Events.
3
3
  *
4
- * Cancellation: pass an AbortSignal. Aborting destroys the HTTP request;
5
- * thin-gate infers cancel from connection close and emits
6
- * `call.cancelled` upstream.
7
- *
4
+ * Cancellation: pass an AbortSignal. Aborting closes the HTTP request;
5
+ * thin-gate infers cancel from connection close and sends the session a
6
+ * cancel control message. The session's own cancelled terminal is drained
7
+ * gate-side, so the client synthesizes the same cancelled `done` here —
8
+ * partial output from the deltas it relayed, zero tokens, zero cost —
9
+ * giving a caller the same terminal on this path as on the in-process one.
8
10
  * @module client/call
9
11
  */
10
12
 
11
13
  import { requestUnix } from './transport.js'
12
14
  import { readAll, parseErrorBody } from './response.js'
13
15
  import { parseNDJSON } from './ndjson.js'
14
- import { isEvent, MohdelError } from '#core'
16
+ import { isEvent, MohdelError, STATUS_INCOMPLETE, WARNING_CANCELLED } from '#core'
17
+
18
+ /**
19
+ * @param {string} start
20
+ * @param {string | null} first
21
+ * @param {string} output
22
+ * @returns {import('#core/events.js').DoneEvent}
23
+ */
24
+ function cancelledDone (start, first, output) {
25
+ const end = String(process.hrtime.bigint())
26
+ return {
27
+ type: 'done',
28
+ result: {
29
+ status: STATUS_INCOMPLETE,
30
+ output: output || null,
31
+ inputTokens: 0,
32
+ outputTokens: 0,
33
+ thinkingTokens: 0,
34
+ cost: 0,
35
+ timestamps: { start, first: first ?? end, end },
36
+ warning: WARNING_CANCELLED
37
+ }
38
+ }
39
+ }
15
40
 
16
41
  /**
17
42
  * @param {import('#core/envelope.js').CallEnvelope} envelope
@@ -22,26 +47,58 @@ import { isEvent, MohdelError } from '#core'
22
47
  * @returns {AsyncGenerator<import('#core/events.js').Event>}
23
48
  */
24
49
  export async function * call (envelope, { socketPath, signal, path = '/v1/call' }) {
25
- const res = await requestUnix({
26
- socketPath,
27
- path,
28
- method: 'POST',
29
- body: envelope,
30
- signal
31
- })
50
+ const start = String(process.hrtime.bigint())
51
+ if (signal?.aborted) {
52
+ yield cancelledDone(start, null, '')
53
+ return
54
+ }
55
+
56
+ let res
57
+ try {
58
+ res = await requestUnix({
59
+ socketPath,
60
+ path,
61
+ method: 'POST',
62
+ body: envelope,
63
+ signal
64
+ })
65
+ } catch (e) {
66
+ if (signal?.aborted) {
67
+ yield cancelledDone(start, null, '')
68
+ return
69
+ }
70
+ throw e
71
+ }
32
72
 
33
73
  if (res.statusCode !== 200) {
34
74
  const body = await readAll(res)
35
75
  throw MohdelError.fromJSON(parseErrorBody(body, res.statusCode ?? 0))
36
76
  }
37
77
 
38
- for await (const obj of parseNDJSON(res)) {
39
- if (!isEvent(obj)) {
40
- throw new MohdelError(
41
- 'received non-Event object from thin-gate',
42
- { type: 'PROTOCOL_INVALID_EVENT', retryable: false }
43
- )
78
+ const outputParts = []
79
+ let first = null
80
+ let sawTerminal = false
81
+ try {
82
+ for await (const obj of parseNDJSON(res)) {
83
+ if (!isEvent(obj)) {
84
+ throw new MohdelError(
85
+ 'received non-Event object from thin-gate',
86
+ { type: 'PROTOCOL_INVALID_EVENT', retryable: false }
87
+ )
88
+ }
89
+ if (obj.type === 'delta') {
90
+ if (first === null) first = String(process.hrtime.bigint())
91
+ if (obj.delta?.type === 'message') outputParts.push(obj.delta.delta)
92
+ } else if (obj.type === 'done' || obj.type === 'error') {
93
+ sawTerminal = true
94
+ }
95
+ yield /** @type {import('#core/events.js').Event} */(obj)
44
96
  }
45
- yield /** @type {import('#core/events.js').Event} */(obj)
97
+ } catch (e) {
98
+ if (!signal?.aborted) throw e
99
+ }
100
+
101
+ if (signal?.aborted && !sawTerminal) {
102
+ yield cancelledDone(start, first, outputParts.join(''))
46
103
  }
47
104
  }
@@ -2,12 +2,6 @@
2
2
  * Shared `cancelledDone` helper for adapters that need to synthesize
3
3
  * a terminal `done` event on `signal.aborted` mid-stream.
4
4
  *
5
- * Three adapters (openai, anthropic, gemini) had byte-identical
6
- * copies of this; consolidated here. `_chat_completions.js`
7
- * and `run.js` have their own cancel paths — don't migrate them
8
- * here unless you're certain the shape matches (thinkingTokens,
9
- * cost, tool_calls semantics can all differ).
10
- *
11
5
  * @module session/adapters/_cancelled
12
6
  */
13
7
 
@@ -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 { cancelledDone } from './_cancelled.js'
22
23
  import { catalogKey, bareOf } from '#core/model-id.js'
23
24
  import {
24
25
  STATUS_COMPLETED,
@@ -98,6 +99,10 @@ export async function * runChatCompletions (envelope, client, config, deps = {})
98
99
  try {
99
100
  response = await client.chat.completions.create(args, { signal: deps.signal })
100
101
  } catch (e) {
102
+ if (deps.signal?.aborted) {
103
+ yield cancelledDone(start, null, envelope, '', 0, 0)
104
+ return
105
+ }
101
106
  deps.log?.warn({ err: e }, `[mohdel:${config.provider}] request failed`)
102
107
  yield { type: 'error', error: classifyProviderError(e, envelope.auth?.key, { provider: config.provider }) }
103
108
  return
@@ -161,6 +166,10 @@ async function * runStreaming (envelope, client, args, config, start, deps) {
161
166
  try {
162
167
  stream = await client.chat.completions.create(args, { signal: deps.signal })
163
168
  } catch (e) {
169
+ if (deps.signal?.aborted) {
170
+ yield cancelledDone(start, null, envelope, '', 0, 0)
171
+ return
172
+ }
164
173
  deps.log?.warn({ err: e }, `[mohdel:${config.provider}] request failed`)
165
174
  yield { type: 'error', error: classifyProviderError(e, envelope.auth?.key, { provider: config.provider }) }
166
175
  return
@@ -168,6 +177,10 @@ async function * runStreaming (envelope, client, args, config, start, deps) {
168
177
 
169
178
  try {
170
179
  for await (const chunk of stream) {
180
+ if (deps.signal?.aborted) {
181
+ yield cancelledDone(start, first, envelope, contentParts.join(''), 0, 0)
182
+ return
183
+ }
171
184
  const choice = chunk.choices?.[0]
172
185
  // DeepSeek V4 / deepseek-reasoner / Cerebras reasoning models emit
173
186
  // `delta.reasoning_content` chunks before visible content. Capture
@@ -225,11 +238,20 @@ async function * runStreaming (envelope, client, args, config, start, deps) {
225
238
  if (chunk.usage) usage = chunk.usage
226
239
  }
227
240
  } catch (e) {
241
+ if (deps.signal?.aborted) {
242
+ yield cancelledDone(start, first, envelope, contentParts.join(''), 0, 0)
243
+ return
244
+ }
228
245
  deps.log?.warn({ err: e }, `[mohdel:${config.provider}] stream failed`)
229
246
  yield { type: 'error', error: classifyProviderError(e, envelope.auth?.key, { provider: config.provider }) }
230
247
  return
231
248
  }
232
249
 
250
+ if (deps.signal?.aborted) {
251
+ yield cancelledDone(start, first, envelope, contentParts.join(''), 0, 0)
252
+ return
253
+ }
254
+
233
255
  const collectedToolCalls = Object.values(toolCallAccum).map(tc => ({
234
256
  id: tc.id,
235
257
  function: { name: tc.name, arguments: tc.arguments }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mohdel",
3
- "version": "0.124.0",
3
+ "version": "0.125.0",
4
4
  "license": "MIT",
5
5
  "author": {
6
6
  "name": "Christophe Le Bars",
@@ -108,12 +108,12 @@
108
108
  "@opentelemetry/exporter-trace-otlp-grpc": "^0.222.0",
109
109
  "@opentelemetry/sdk-node": "^0.222.0",
110
110
  "chalk": "^6.0.0",
111
- "mohdel-thin-gate-linux-x64-gnu": "0.124.0"
111
+ "mohdel-thin-gate-linux-x64-gnu": "0.125.0"
112
112
  },
113
113
  "dependencies": {
114
114
  "@anthropic-ai/sdk": "^0.123.0",
115
115
  "@cerebras/cerebras_cloud_sdk": "^1.91.0",
116
- "@google/genai": "^2.20.0",
116
+ "@google/genai": "^2.21.0",
117
117
  "@opentelemetry/api": "^1.9.1",
118
118
  "env-paths": "^4.0.0",
119
119
  "groq-sdk": "^1.6.0",