@owlmeans/llm 0.1.18-rc.48 → 0.1.18-rc.49

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
@@ -20,7 +20,7 @@ abstraction that resolves models from an inheritable policy.
20
20
  ## Installation
21
21
 
22
22
  ```bash
23
- bun add @owlmeans/llm@^0.1.18-rc.48 @owlmeans/llm-common@^0.1.18-rc.39
23
+ bun add @owlmeans/llm@^0.1.18-rc.49 @owlmeans/llm-common@^0.1.18-rc.39
24
24
  bun add @langchain/core @langchain/openai @langchain/anthropic # peer dependencies
25
25
  ```
26
26
 
@@ -134,7 +134,7 @@ spectator entry), `filter` (reject a result and force a retry), `useCache` / `ca
134
134
 
135
135
  | Problem | What the package does |
136
136
  |---------|-----------------------|
137
- | Provider accepts the request and never streams | Idle (per-token) deadline aborts and retries — `ModelConfig.streamTimeout` |
137
+ | Provider accepts the request and never streams | Independent idle deadline rejects stream creation or reads and aborts the SDK; late values and cleanup cannot delay the retry — `ModelConfig.streamTimeout` |
138
138
  | Duplicate final SSE chunk corrupts tool-call arguments | Stream breaks at the first non-empty `finish_reason` |
139
139
  | Reasoning eats the whole output budget | Retry doubles `maxTokens` toward `maxTokensCap` **and** shrinks an absolute reasoning cap |
140
140
  | A weak cheap model keeps failing | Walks the `ModelConfig.fallback` chain, `FALLBACK_AFTER_ATTEMPTS` attempts per rung; a rung may be another provider, called in its own dialect |
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "schemaVersion": 2,
3
3
  "package": "@owlmeans/llm",
4
- "version": "0.1.18-rc.48",
5
- "generatedAt": "2026-10-09T11:30:42.745Z",
4
+ "version": "0.1.18-rc.49",
5
+ "generatedAt": "2026-10-09T18:54:30.297Z",
6
6
  "canonicalRepo": "https://github.com/owlmeans/common",
7
7
  "entries": [
8
8
  {
@@ -8,7 +8,7 @@ user-invocable: false
8
8
  # @owlmeans/llm
9
9
 
10
10
  **Layer:** Core
11
- **Install:** `"@owlmeans/llm": "^0.1.18-rc.48"` in `dependencies` (plus the `@langchain/*` peers)
11
+ **Install:** `"@owlmeans/llm": "^0.1.18-rc.49"` in `dependencies` (plus the `@langchain/*` peers)
12
12
 
13
13
  The inference runtime. Everything provider-specific is a **plugin**; the model itself only owns the
14
14
  provider-independent parts (streaming discipline, retries, validation, observability). Serializable
@@ -195,7 +195,11 @@ becomes a foreign type and `instanceof` starts lying.
195
195
  ## Hangs are bounded by an IDLE deadline, not a total one
196
196
 
197
197
  A stalled provider is aborted after `MODEL_STREAM_TIMEOUT_MS` (3 min) of SILENCE and surfaces as a
198
- retryable `LlmModelError`, so the escalator moves on. The timer re-arms on every token, so a
198
+ retryable `LlmModelError`, so the escalator moves on. Stream creation and each iterator read race
199
+ an independently rejecting deadline; forwarding an abort signal alone does not bound an SDK
200
+ promise that ignores cancellation. Late values never reach the caller, late failures are observed,
201
+ and best-effort iterator cleanup never delays timeout, terminal chunks or consumer cancellation.
202
+ The timer re-arms on every token, so a
199
203
  long-but-productive generation is never cut off — which is why the value can be low. Set it for a
200
204
  deployment with `LlmServiceOptions.streamTimeout` where the application composes its context; a
201
205
  preset naming its own `ModelConfig.streamTimeout` keeps it. It does NOT bound a call that keeps
@@ -461,6 +465,12 @@ the retry on a text-only reply) through `ChatAnthropic.invocationParams`; `struc
461
465
  pins the refusal of a hidden property name before any request. `plugins.spec.ts` covers the
462
466
  `Compatible` provider offline.
463
467
 
468
+ `internals.spec.ts` covers abort-insensitive stream creation, reads and cleanup, observed late
469
+ failures, ignored late values, consumer cancellation and active streams exceeding multiple idle
470
+ windows. `bun test ./tests/stream-sdk.spec.ts` exercises actual ChatOpenAI Responses against local
471
+ HTTP fixtures: missing headers, open headers without SSE, and a productive SSE control. It checks
472
+ socket cancellation and absence of late callbacks without spending provider tokens.
473
+
464
474
  ## Depends On
465
475
 
466
476
  - `@owlmeans/llm-common` · `@owlmeans/context` · `@owlmeans/error` · `@owlmeans/basic-ids` · `ajv`
@@ -10,9 +10,10 @@ export interface StreamUtils {
10
10
  * `AbortSignal` to forward to `model.stream(..., { signal })`; the timer is re-armed on
11
11
  * every received chunk, so it only fires after `timeoutMs` of SILENCE — a provider that
12
12
  * accepted the request but stalls and never streams another token. On fire the call is
13
- * aborted and surfaced as a retryable {@link LlmModelError} so the retry escalator moves
14
- * on instead of hanging forever. Because the timer resets per token, long but actively
15
- * streaming generations are never aborted.
13
+ * aborted and surfaced as a retryable {@link LlmModelError} independently of SDK
14
+ * cancellation settling. Both stream creation and iterator reads race the deadline;
15
+ * late results and cleanup cannot delay or resume the failed call. Because the timer
16
+ * resets per token, long but actively streaming generations are never aborted.
16
17
  *
17
18
  * The loop also breaks after the first chunk carrying a non-empty `finish_reason`. Some
18
19
  * providers send the final SSE data event twice, which makes `AIMessageChunk.concat()`
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../src/utils/stream/types.ts"],"names":[],"mappings":"AAAA,8BAA8B;AAC9B,MAAM,WAAW,WAAW;IAC1B;;;OAGG;IACH,oBAAoB,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,MAAM,GAAG,SAAS,CAAA;IAC5D;;;;;;;;;;;;;;OAcG;IACH,kBAAkB,EAAE,CAAC,CAAC,EACpB,KAAK,EAAE,CAAC,MAAM,EAAE,WAAW,KAAK,OAAO,CAAC,aAAa,CAAC,CAAC,CAAC,CAAC,EACzD,SAAS,CAAC,EAAE,MAAM,KACf,cAAc,CAAC,CAAC,CAAC,CAAA;CACvB"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../src/utils/stream/types.ts"],"names":[],"mappings":"AAAA,8BAA8B;AAC9B,MAAM,WAAW,WAAW;IAC1B;;;OAGG;IACH,oBAAoB,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,MAAM,GAAG,SAAS,CAAA;IAC5D;;;;;;;;;;;;;;;OAeG;IACH,kBAAkB,EAAE,CAAC,CAAC,EACpB,KAAK,EAAE,CAAC,MAAM,EAAE,WAAW,KAAK,OAAO,CAAC,aAAa,CAAC,CAAC,CAAC,CAAC,EACzD,SAAS,CAAC,EAAE,MAAM,KACf,cAAc,CAAC,CAAC,CAAC,CAAA;CACvB"}
@@ -1 +1 @@
1
- {"version":3,"file":"stream.d.ts","sourceRoot":"","sources":["../../src/utils/stream.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAA;AAEpD,eAAO,MAAM,iBAAiB,QAAO,WAuCpC,CAAA;AAED,eAAO,MAAM,WAAW,aAAsB,CAAA"}
1
+ {"version":3,"file":"stream.d.ts","sourceRoot":"","sources":["../../src/utils/stream.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAA;AAEpD,eAAO,MAAM,iBAAiB,QAAO,WAiEpC,CAAA;AAED,eAAO,MAAM,WAAW,aAAsB,CAAA"}
@@ -7,15 +7,47 @@ export const createStreamUtils = () => {
7
7
  };
8
8
  async function* streamWithDeadline(start, timeoutMs = MODEL_STREAM_TIMEOUT_MS) {
9
9
  const controller = new AbortController();
10
- let timer;
10
+ let rejectIdle = () => { };
11
+ const idle = new Promise((_, reject) => { rejectIdle = reject; });
12
+ // A consumer may pause at `yield` when the deadline fires.
13
+ void idle.catch(() => { });
14
+ let disarm = () => { };
11
15
  const arm = () => {
12
- clearTimeout(timer);
13
- timer = setTimeout(() => controller.abort(), timeoutMs);
16
+ disarm();
17
+ const timer = setTimeout(() => {
18
+ controller.abort();
19
+ rejectIdle(new LlmModelError(`stream-stalled:no token for ${timeoutMs}ms (idle deadline)`));
20
+ }, timeoutMs);
21
+ disarm = () => clearTimeout(timer);
22
+ };
23
+ let iterator;
24
+ let closed = false;
25
+ const close = () => {
26
+ if (iterator == null || closed)
27
+ return;
28
+ closed = true;
29
+ // SDK cancellation and iterator cleanup may themselves never settle.
30
+ try {
31
+ void Promise.resolve(iterator.return?.()).catch(() => { });
32
+ }
33
+ catch { }
14
34
  };
15
35
  arm();
16
36
  try {
17
- const stream = await start(controller.signal);
18
- for await (const chunk of stream) {
37
+ const starting = Promise.resolve().then(() => start(controller.signal)).then(stream => {
38
+ iterator = stream[Symbol.asyncIterator]();
39
+ if (controller.signal.aborted)
40
+ close();
41
+ return iterator;
42
+ });
43
+ const current = await Promise.race([starting, idle]);
44
+ for (;;) {
45
+ const next = await Promise.race([Promise.resolve().then(() => current.next()), idle]);
46
+ if (next.done) {
47
+ closed = true;
48
+ break;
49
+ }
50
+ const chunk = next.value;
19
51
  arm(); // reset the idle timer on each received token
20
52
  yield chunk;
21
53
  const reason = getChunkFinishReason(chunk);
@@ -30,7 +62,10 @@ export const createStreamUtils = () => {
30
62
  throw e;
31
63
  }
32
64
  finally {
33
- clearTimeout(timer);
65
+ disarm();
66
+ if (!closed)
67
+ controller.abort();
68
+ close();
34
69
  }
35
70
  }
36
71
  return { getChunkFinishReason, streamWithDeadline };
@@ -1 +1 @@
1
- {"version":3,"file":"stream.js","sourceRoot":"","sources":["../../src/utils/stream.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,cAAc,CAAA;AAC5C,OAAO,EAAE,uBAAuB,EAAE,MAAM,cAAc,CAAA;AAGtD,MAAM,CAAC,MAAM,iBAAiB,GAAG,GAAgB,EAAE;IACjD,MAAM,oBAAoB,GAAG,CAAC,KAAc,EAAsB,EAAE;QAClE,MAAM,CAAC,GAAG,KAGT,CAAA;QACD,OAAO,CAAC,CAAC,iBAAiB,EAAE,aAAa,IAAI,CAAC,CAAC,GAAG,EAAE,iBAAiB,EAAE,aAAa,CAAA;IACtF,CAAC,CAAA;IAED,KAAK,SAAS,CAAC,CAAC,kBAAkB,CAChC,KAAyD,EACzD,SAAS,GAAW,uBAAuB;QAE3C,MAAM,UAAU,GAAG,IAAI,eAAe,EAAE,CAAA;QACxC,IAAI,KAAoC,CAAA;QACxC,MAAM,GAAG,GAAG,GAAG,EAAE;YACf,YAAY,CAAC,KAAK,CAAC,CAAA;YACnB,KAAK,GAAG,UAAU,CAAC,GAAG,EAAE,CAAC,UAAU,CAAC,KAAK,EAAE,EAAE,SAAS,CAAC,CAAA;QACzD,CAAC,CAAA;QACD,GAAG,EAAE,CAAA;QACL,IAAI,CAAC;YACH,MAAM,MAAM,GAAG,MAAM,KAAK,CAAC,UAAU,CAAC,MAAM,CAAC,CAAA;YAC7C,IAAI,KAAK,EAAE,MAAM,KAAK,IAAI,MAAM,EAAE,CAAC;gBACjC,GAAG,EAAE,CAAA,CAAC,8CAA8C;gBACpD,MAAM,KAAK,CAAA;gBACX,MAAM,MAAM,GAAG,oBAAoB,CAAC,KAAK,CAAC,CAAA;gBAC1C,IAAI,MAAM,IAAI,IAAI,IAAI,MAAM,KAAK,EAAE;oBAAE,MAAK;YAC5C,CAAC;QACH,CAAC;QAAC,OAAO,CAAC,EAAE,CAAC;YACX,IAAI,UAAU,CAAC,MAAM,CAAC,OAAO,EAAE,CAAC;gBAC9B,MAAM,IAAI,aAAa,CAAC,+BAA+B,SAAS,oBAAoB,CAAC,CAAA;YACvF,CAAC;YACD,MAAM,CAAC,CAAA;QACT,CAAC;gBAAS,CAAC;YACT,YAAY,CAAC,KAAM,CAAC,CAAA;QACtB,CAAC;IACH,CAAC;IAED,OAAO,EAAE,oBAAoB,EAAE,kBAAkB,EAAE,CAAA;AACrD,CAAC,CAAA;AAED,MAAM,CAAC,MAAM,WAAW,GAAG,iBAAiB,EAAE,CAAA"}
1
+ {"version":3,"file":"stream.js","sourceRoot":"","sources":["../../src/utils/stream.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@owlmeans/llm",
3
- "version": "0.1.18-rc.48",
3
+ "version": "0.1.18-rc.49",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -10,9 +10,10 @@ export interface StreamUtils {
10
10
  * `AbortSignal` to forward to `model.stream(..., { signal })`; the timer is re-armed on
11
11
  * every received chunk, so it only fires after `timeoutMs` of SILENCE — a provider that
12
12
  * accepted the request but stalls and never streams another token. On fire the call is
13
- * aborted and surfaced as a retryable {@link LlmModelError} so the retry escalator moves
14
- * on instead of hanging forever. Because the timer resets per token, long but actively
15
- * streaming generations are never aborted.
13
+ * aborted and surfaced as a retryable {@link LlmModelError} independently of SDK
14
+ * cancellation settling. Both stream creation and iterator reads race the deadline;
15
+ * late results and cleanup cannot delay or resume the failed call. Because the timer
16
+ * resets per token, long but actively streaming generations are never aborted.
16
17
  *
17
18
  * The loop also breaks after the first chunk carrying a non-empty `finish_reason`. Some
18
19
  * providers send the final SSE data event twice, which makes `AIMessageChunk.concat()`
@@ -16,15 +16,39 @@ export const createStreamUtils = (): StreamUtils => {
16
16
  timeoutMs: number = MODEL_STREAM_TIMEOUT_MS,
17
17
  ): AsyncGenerator<T> {
18
18
  const controller = new AbortController()
19
- let timer: ReturnType<typeof setTimeout>
19
+ let rejectIdle: (error: LlmModelError) => void = () => {}
20
+ const idle = new Promise<never>((_, reject) => { rejectIdle = reject })
21
+ // A consumer may pause at `yield` when the deadline fires.
22
+ void idle.catch(() => {})
23
+ let disarm = () => {}
20
24
  const arm = () => {
21
- clearTimeout(timer)
22
- timer = setTimeout(() => controller.abort(), timeoutMs)
25
+ disarm()
26
+ const timer = setTimeout(() => {
27
+ controller.abort()
28
+ rejectIdle(new LlmModelError(`stream-stalled:no token for ${timeoutMs}ms (idle deadline)`))
29
+ }, timeoutMs)
30
+ disarm = () => clearTimeout(timer)
31
+ }
32
+ let iterator: AsyncIterator<T> | undefined
33
+ let closed = false
34
+ const close = () => {
35
+ if (iterator == null || closed) return
36
+ closed = true
37
+ // SDK cancellation and iterator cleanup may themselves never settle.
38
+ try { void Promise.resolve(iterator.return?.()).catch(() => {}) } catch {}
23
39
  }
24
40
  arm()
25
41
  try {
26
- const stream = await start(controller.signal)
27
- for await (const chunk of stream) {
42
+ const starting = Promise.resolve().then(() => start(controller.signal)).then(stream => {
43
+ iterator = stream[Symbol.asyncIterator]()
44
+ if (controller.signal.aborted) close()
45
+ return iterator
46
+ })
47
+ const current = await Promise.race([starting, idle])
48
+ for (;;) {
49
+ const next = await Promise.race([Promise.resolve().then(() => current.next()), idle])
50
+ if (next.done) { closed = true; break }
51
+ const chunk = next.value
28
52
  arm() // reset the idle timer on each received token
29
53
  yield chunk
30
54
  const reason = getChunkFinishReason(chunk)
@@ -36,7 +60,9 @@ export const createStreamUtils = (): StreamUtils => {
36
60
  }
37
61
  throw e
38
62
  } finally {
39
- clearTimeout(timer!)
63
+ disarm()
64
+ if (!closed) controller.abort()
65
+ close()
40
66
  }
41
67
  }
42
68
 
@@ -4,6 +4,7 @@ import { EMPTY_CONTENT_STUB, JSON_INSTRUCTION, NO_THINK_DIRECTIVE } from '../src
4
4
  import { streamUtils } from '../src/utils/stream.js'
5
5
  import { promptUtils } from '../src/utils/prompt.js'
6
6
  import { schemaUtils } from '../src/utils/schema.js'
7
+ import { LlmModelError } from '../src/errors.js'
7
8
 
8
9
  /**
9
10
  * Internal utilities — deliberately not part of the package surface (`utils/` is
@@ -102,7 +103,7 @@ describe('utils/prompt — blank content sanitization', () => {
102
103
  ] as MessageFieldWithRole[]
103
104
  promptUtils.dropBlankContent(msgs)
104
105
  expect(msgs).toHaveLength(2)
105
- expect((msgs[1] as ToolMessage).content).toBe(EMPTY_CONTENT_STUB)
106
+ expect((msgs[1] as unknown as ToolMessage).content).toBe(EMPTY_CONTENT_STUB)
106
107
  })
107
108
 
108
109
  test('keeps a blank AI message that carries tool calls, with empty string content', () => {
@@ -112,7 +113,7 @@ describe('utils/prompt — blank content sanitization', () => {
112
113
  ] as MessageFieldWithRole[]
113
114
  promptUtils.dropBlankContent(msgs)
114
115
  expect(msgs).toHaveLength(2)
115
- expect((msgs[1] as AIMessage).content).toBe('')
116
+ expect((msgs[1] as unknown as AIMessage).content).toBe('')
116
117
  })
117
118
 
118
119
  test('replaces an all-blank input with a single stub user message', () => {
@@ -135,7 +136,7 @@ describe('utils/schema — tool naming and unwrapping', () => {
135
136
  })
136
137
 
137
138
  test('unwraps a named envelope, and passes anything else through', () => {
138
- expect(schemaUtils.unwrapNamed({ spec: { a: 1 } }, 'spec')).toEqual({ a: 1 })
139
+ expect(schemaUtils.unwrapNamed<object>({ spec: { a: 1 } }, 'spec')).toEqual({ a: 1 })
139
140
  expect(schemaUtils.unwrapNamed({ a: 1 }, 'spec')).toEqual({ a: 1 })
140
141
  expect(schemaUtils.unwrapNamed({ a: 1 }, undefined)).toEqual({ a: 1 })
141
142
  expect(schemaUtils.unwrapNamed('plain' as unknown as object, 'spec')).toBe('plain' as unknown as object)
@@ -202,4 +203,131 @@ describe('utils/stream — idle deadline and duplicate-final-chunk dedup', () =>
202
203
  }
203
204
  await expect(collect(streamUtils.streamWithDeadline(failing, 1000))).rejects.toThrow('401 unauthorized')
204
205
  })
206
+
207
+ test('bounds stream creation even when the provider ignores abort', async () => {
208
+ let signal: AbortSignal | undefined
209
+ const started = Date.now()
210
+ await expect(collect(streamUtils.streamWithDeadline(async received => {
211
+ signal = received
212
+ return await new Promise<AsyncIterable<unknown>>(() => {})
213
+ }, 60))).rejects.toBeInstanceOf(LlmModelError)
214
+ expect(signal?.aborted).toBe(true)
215
+ expect(Date.now() - started).toBeLessThan(1500)
216
+ }, 2000)
217
+
218
+ test('bounds iterator reads without waiting for uncooperative cleanup', async () => {
219
+ let signal: AbortSignal | undefined
220
+ let returned = 0
221
+ const iterator: AsyncIterator<unknown> = {
222
+ next: async () => await new Promise<IteratorResult<unknown>>(() => {}),
223
+ return: async () => { returned += 1; return await new Promise<IteratorResult<unknown>>(() => {}) },
224
+ }
225
+ const started = Date.now()
226
+ await expect(collect(streamUtils.streamWithDeadline(async received => {
227
+ signal = received
228
+ return { [Symbol.asyncIterator]: () => iterator }
229
+ }, 60))).rejects.toThrow('stream-stalled')
230
+ expect(signal?.aborted).toBe(true)
231
+ expect(returned).toBe(1)
232
+ expect(Date.now() - started).toBeLessThan(1500)
233
+ }, 2000)
234
+
235
+ test('closes a stream created after the deadline without reading late values', async () => {
236
+ let resolveStart!: (stream: AsyncIterable<unknown>) => void
237
+ let reads = 0
238
+ let returned = 0
239
+ const pending = collect(streamUtils.streamWithDeadline(async () => await new Promise<AsyncIterable<unknown>>(resolve => {
240
+ resolveStart = resolve
241
+ }), 60))
242
+ await expect(pending).rejects.toThrow('stream-stalled')
243
+ resolveStart({ [Symbol.asyncIterator]: () => ({
244
+ next: async () => { reads += 1; return { done: false, value: 'late' } },
245
+ return: async () => { returned += 1; throw new Error('late cleanup failure') },
246
+ }) })
247
+ await Bun.sleep(20)
248
+ expect(reads).toBe(0)
249
+ expect(returned).toBe(1)
250
+ }, 2000)
251
+
252
+ test('observes a late stream-creation failure after the caller has already failed', async () => {
253
+ let rejectStart!: (error: Error) => void
254
+ const pending = collect(streamUtils.streamWithDeadline(async () => await new Promise<AsyncIterable<unknown>>((_, reject) => {
255
+ rejectStart = reject
256
+ }), 60))
257
+ const failure = await pending.catch(error => error)
258
+ expect(failure).toBeInstanceOf(LlmModelError)
259
+ rejectStart(new Error('late provider failure'))
260
+ await Bun.sleep(20)
261
+ // Bun would fail this spec on an unhandled late rejection.
262
+ expect(failure.message).toContain('stream-stalled')
263
+ }, 2000)
264
+
265
+ test('ignores an iterator value arriving after the deadline', async () => {
266
+ let resolveNext!: (item: IteratorResult<unknown>) => void
267
+ let reads = 0
268
+ let returned = 0
269
+ const received: unknown[] = []
270
+ const iterator: AsyncIterator<unknown> = {
271
+ next: async () => { reads += 1; return await new Promise<IteratorResult<unknown>>(resolve => { resolveNext = resolve }) },
272
+ return: async () => { returned += 1; return { done: true, value: undefined } },
273
+ }
274
+ const pending = (async () => {
275
+ for await (const value of streamUtils.streamWithDeadline(async () => ({ [Symbol.asyncIterator]: () => iterator }), 60)) received.push(value)
276
+ })()
277
+ await expect(pending).rejects.toThrow('stream-stalled')
278
+ resolveNext({ done: false, value: 'late' })
279
+ await Bun.sleep(20)
280
+ expect(received).toEqual([])
281
+ expect(reads).toBe(1)
282
+ expect(returned).toBe(1)
283
+ }, 2000)
284
+
285
+ test('allows active streams to exceed multiple idle windows', async () => {
286
+ let signal: AbortSignal | undefined
287
+ const started = Date.now()
288
+ const output = await collect(streamUtils.streamWithDeadline(async received => {
289
+ signal = received
290
+ return { async *[Symbol.asyncIterator]() {
291
+ for (let i = 0; i < 12; i += 1) { await Bun.sleep(20); yield i }
292
+ } }
293
+ }, 100))
294
+ expect(output).toEqual(Array.from({ length: 12 }, (_, i) => i))
295
+ expect(Date.now() - started).toBeGreaterThan(200)
296
+ expect(signal?.aborted).toBe(false)
297
+ }, 3000)
298
+
299
+ test('a terminal chunk is delivered even when iterator cleanup never settles', async () => {
300
+ let signal: AbortSignal | undefined
301
+ let returned = 0
302
+ const final = { response_metadata: { finish_reason: 'stop' } }
303
+ const output = await collect(streamUtils.streamWithDeadline(async received => {
304
+ signal = received
305
+ return { [Symbol.asyncIterator]: () => ({
306
+ next: async () => ({ done: false, value: final }),
307
+ return: async () => { returned += 1; return await new Promise<IteratorResult<unknown>>(() => {}) },
308
+ }) }
309
+ }, 60))
310
+ expect(output).toEqual([final])
311
+ expect(signal?.aborted).toBe(true)
312
+ expect(returned).toBe(1)
313
+ }, 2000)
314
+
315
+ test('consumer cancellation aborts the provider and observes cleanup failures', async () => {
316
+ let signal: AbortSignal | undefined
317
+ let reads = 0
318
+ let returned = 0
319
+ const stream = streamUtils.streamWithDeadline(async received => {
320
+ signal = received
321
+ return { [Symbol.asyncIterator]: () => ({
322
+ next: async () => { reads += 1; return { done: false, value: 'first' } },
323
+ return: async () => { returned += 1; throw new Error('cleanup failure') },
324
+ }) }
325
+ }, 60)
326
+ expect(await stream.next()).toEqual({ done: false, value: 'first' })
327
+ expect((await stream.return(undefined)).done).toBe(true)
328
+ expect(signal?.aborted).toBe(true)
329
+ await Bun.sleep(80)
330
+ expect(reads).toBe(1)
331
+ expect(returned).toBe(1)
332
+ }, 2000)
205
333
  })
@@ -0,0 +1,218 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { createServer, type ServerResponse } from 'node:http'
3
+ import type { AddressInfo, Socket } from 'node:net'
4
+ import { ChatOpenAI } from '@langchain/openai'
5
+ import type { AIMessage } from '@langchain/core/messages'
6
+ import { ModelProvider, type SpectatorArgument } from '@owlmeans/llm-common'
7
+ import { LlmModelError, LlmRetryExceededError } from '../src/errors.js'
8
+ import { makeLlmModel } from '../src/model.js'
9
+ import type { LlmModel, RefferedResult } from '../src/types.js'
10
+
11
+ interface ResponsesRequest {
12
+ method: string | undefined
13
+ path: string | undefined
14
+ body: { model: string, stream: boolean, input: unknown[] }
15
+ }
16
+
17
+ interface LocalResponses {
18
+ baseURL: string
19
+ request: Promise<ResponsesRequest>
20
+ disconnected: Promise<void>
21
+ requests: () => number
22
+ delta: (text: string) => void
23
+ complete: () => void
24
+ stop: () => Promise<void>
25
+ }
26
+
27
+ interface ObservedModel {
28
+ model: LlmModel
29
+ tokens: string[]
30
+ traces: SpectatorArgument[]
31
+ ref: RefferedResult<AIMessage>
32
+ accepted: string[]
33
+ }
34
+
35
+ /** Real HTTP and SDK parsing; only the remote provider is replaced by a local fixture. */
36
+ const localResponses = async (sendHeaders: boolean): Promise<LocalResponses> => {
37
+ let resolveRequest: (request: ResponsesRequest) => void = () => {}
38
+ const request = new Promise<ResponsesRequest>(resolve => { resolveRequest = resolve })
39
+ let resolveDisconnected: () => void = () => {}
40
+ const disconnected = new Promise<void>(resolve => { resolveDisconnected = resolve })
41
+ let response: ServerResponse | undefined
42
+ let requests = 0
43
+ let sequence = 0
44
+ const sockets = new Set<Socket>()
45
+ const server = createServer((req, res) => {
46
+ requests += 1
47
+ response = res
48
+ req.socket.once('close', resolveDisconnected)
49
+ // A deliberate late write after the client's cancellation is part of these tests.
50
+ res.on('error', () => {})
51
+ const body: Buffer[] = []
52
+ req.on('data', chunk => body.push(Buffer.from(chunk)))
53
+ req.on('end', () => {
54
+ if (sendHeaders) {
55
+ res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' })
56
+ res.flushHeaders()
57
+ }
58
+ resolveRequest({ method: req.method, path: req.url, body: JSON.parse(Buffer.concat(body).toString()) })
59
+ })
60
+ })
61
+ server.on('connection', socket => {
62
+ sockets.add(socket)
63
+ socket.once('close', () => sockets.delete(socket))
64
+ })
65
+ await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
66
+
67
+ const event = (value: Record<string, unknown>): void => {
68
+ if (response == null) throw new Error('Responses fixture has received no request')
69
+ if (!response.headersSent) {
70
+ response.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' })
71
+ }
72
+ response.write(`event: ${value.type}\ndata: ${JSON.stringify({ ...value, sequence_number: sequence++ })}\n\n`)
73
+ }
74
+
75
+ return {
76
+ baseURL: `http://127.0.0.1:${(server.address() as AddressInfo).port}/v1`,
77
+ request,
78
+ disconnected,
79
+ requests: () => requests,
80
+ delta: text => event({
81
+ type: 'response.output_text.delta', item_id: 'msg_local', output_index: 0,
82
+ content_index: 0, delta: text, logprobs: [],
83
+ }),
84
+ complete: () => {
85
+ event({
86
+ type: 'response.completed',
87
+ response: {
88
+ id: 'resp_local', object: 'response', model: 'gpt-6.1-sol', status: 'completed',
89
+ created_at: 1, output: [], usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 },
90
+ },
91
+ })
92
+ response!.end()
93
+ },
94
+ stop: async () => {
95
+ await new Promise<void>(resolve => {
96
+ server.close(() => resolve())
97
+ for (const socket of sockets) socket.destroy()
98
+ })
99
+ },
100
+ }
101
+ }
102
+
103
+ const observedModel = (baseURL: string, idleMs: number): ObservedModel => {
104
+ const tokens: string[] = []
105
+ const traces: SpectatorArgument[] = []
106
+ const accepted: string[] = []
107
+ const ref: RefferedResult<AIMessage> = {}
108
+ const model = makeLlmModel({
109
+ model: new ChatOpenAI({
110
+ apiKey: 'local-sdk-regression', model: 'gpt-6.1-sol', useResponsesApi: true,
111
+ configuration: { baseURL }, maxRetries: 5, timeout: 10_000, maxTokens: 64,
112
+ metadata: { config: { provider: ModelProvider.OpenAI, model: 'gpt-6.1-sol', streamTimeout: idleMs } },
113
+ callbacks: [{ handleLLMNewToken: token => { tokens.push(token) } }],
114
+ }),
115
+ purpose: { type: 'local-sdk-regression' }, retries: 1,
116
+ }, {
117
+ log: async entry => {
118
+ traces.push(entry)
119
+ return {
120
+ ...entry, id: 'local-trace', kind: 'general', model: 'gpt-6.1-sol',
121
+ purpose: { type: 'local-sdk-regression' }, timestamp: Date.now(),
122
+ }
123
+ },
124
+ })
125
+
126
+ return { model, tokens, traces, accepted, ref }
127
+ }
128
+
129
+ /** The test's guard is deliberately longer than the model's independent idle deadline. */
130
+ const bounded = async <T>(promise: Promise<T>): Promise<T> => {
131
+ let timer: NodeJS.Timeout | undefined
132
+ try {
133
+ return await Promise.race([
134
+ promise,
135
+ new Promise<never>((_, reject) => {
136
+ timer = setTimeout(() => reject(new Error('Local SDK operation did not settle within 2 seconds')), 2_000)
137
+ }),
138
+ ])
139
+ } finally {
140
+ clearTimeout(timer)
141
+ }
142
+ }
143
+
144
+ describe('@owlmeans/llm — Responses SDK stream cancellation over real HTTP', () => {
145
+ for (const sendHeaders of [false, true]) {
146
+ test(`rejects and closes a ${sendHeaders ? 'headers-open/no-SSE' : 'never-headers'} response without accepting late output`, async () => {
147
+ const transport = await localResponses(sendHeaders)
148
+ const observed = observedModel(transport.baseURL, 180)
149
+ try {
150
+ const startedAt = performance.now()
151
+ const result = observed.model.ask('Local transport fixture only', {
152
+ action: 'local-stalled-response', ref: observed.ref,
153
+ filter: async text => { observed.accepted.push(text); return text },
154
+ }).then(value => ({ value, error: undefined }), (error: unknown) => ({ value: undefined, error }))
155
+ const request = await bounded(transport.request)
156
+ expect(request.method).toBe('POST')
157
+ expect(request.path).toBe('/v1/responses')
158
+ expect(request.body.model).toBe('gpt-6.1-sol')
159
+ expect(request.body.stream).toBe(true)
160
+ expect(request.body.input).toHaveLength(1)
161
+
162
+ const outcome = await bounded(result)
163
+ expect(outcome.error).toBeInstanceOf(LlmRetryExceededError)
164
+ expect((outcome.error as LlmRetryExceededError).cause).toBeInstanceOf(LlmModelError)
165
+ expect(((outcome.error as LlmRetryExceededError).cause as Error).message).toContain('stream-stalled')
166
+ expect(outcome.value).toBeUndefined()
167
+ expect(performance.now() - startedAt).toBeLessThan(1_500)
168
+
169
+ // Prove the client closed the network before the fixture's cleanup can do it.
170
+ await bounded(transport.disconnected)
171
+ expect(transport.requests()).toBe(1)
172
+ transport.delta('late output must not be accepted')
173
+ transport.complete()
174
+ await new Promise<void>(resolve => setTimeout(resolve, 40))
175
+ expect(observed.tokens).toEqual([])
176
+ expect(observed.traces).toEqual([])
177
+ expect(observed.accepted).toEqual([])
178
+ expect(observed.ref.value).toBeUndefined()
179
+ expect(observed.ref.spectatorEntry).toBeUndefined()
180
+ } finally {
181
+ await transport.stop()
182
+ }
183
+ })
184
+ }
185
+
186
+ test('accepts real Responses deltas that keep arriving beyond one idle window', async () => {
187
+ const transport = await localResponses(true)
188
+ const observed = observedModel(transport.baseURL, 250)
189
+ try {
190
+ const result = observed.model.ask('Local transport fixture only', {
191
+ action: 'local-active-response', ref: observed.ref,
192
+ filter: async text => { observed.accepted.push(text); return text },
193
+ })
194
+ // Attach the failure observer before driving the response, including when this regresses.
195
+ const completed = result.then(value => ({ value, error: undefined }), (error: unknown) => ({ value: undefined, error }))
196
+ await bounded(transport.request)
197
+ const startedAt = performance.now()
198
+ for (const text of ['Long ', 'active ', 'stream ', 'is ', 'valid.']) {
199
+ transport.delta(text)
200
+ await new Promise<void>(resolve => setTimeout(resolve, 85))
201
+ }
202
+ transport.complete()
203
+ const outcome = await bounded(completed)
204
+
205
+ expect(outcome.error).toBeUndefined()
206
+ expect(outcome.value).toBe('Long active stream is valid.')
207
+ expect(performance.now() - startedAt).toBeGreaterThan(250)
208
+ expect(observed.tokens.join('')).toBe('Long active stream is valid.')
209
+ expect(observed.accepted).toEqual(['Long active stream is valid.'])
210
+ expect(observed.traces).toHaveLength(1)
211
+ expect(observed.ref.value).toBeDefined()
212
+ expect(observed.ref.spectatorEntry?.id).toBe('local-trace')
213
+ expect(transport.requests()).toBe(1)
214
+ } finally {
215
+ await transport.stop()
216
+ }
217
+ })
218
+ })