@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 +2 -2
- package/agent-meta/manifest.json +2 -2
- package/agent-meta/skills/llm/SKILL.md +12 -2
- package/build/utils/stream/types.d.ts +4 -3
- package/build/utils/stream/types.d.ts.map +1 -1
- package/build/utils/stream.d.ts.map +1 -1
- package/build/utils/stream.js +41 -6
- package/build/utils/stream.js.map +1 -1
- package/package.json +1 -1
- package/src/utils/stream/types.ts +4 -3
- package/src/utils/stream.ts +32 -6
- package/tests/internals.spec.ts +131 -3
- package/tests/stream-sdk.spec.ts +218 -0
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.
|
|
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 |
|
|
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 |
|
package/agent-meta/manifest.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 2,
|
|
3
3
|
"package": "@owlmeans/llm",
|
|
4
|
-
"version": "0.1.18-rc.
|
|
5
|
-
"generatedAt": "2026-10-
|
|
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.
|
|
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.
|
|
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}
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
|
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,
|
|
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"}
|
package/build/utils/stream.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
13
|
-
timer = setTimeout(() =>
|
|
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
|
|
18
|
-
|
|
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
|
-
|
|
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,
|
|
1
|
+
{"version":3,"file":"stream.js","sourceRoot":"","sources":["../../src/utils/stream.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -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}
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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()`
|
package/src/utils/stream.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
22
|
-
timer = setTimeout(() =>
|
|
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
|
|
27
|
-
|
|
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
|
-
|
|
63
|
+
disarm()
|
|
64
|
+
if (!closed) controller.abort()
|
|
65
|
+
close()
|
|
40
66
|
}
|
|
41
67
|
}
|
|
42
68
|
|
package/tests/internals.spec.ts
CHANGED
|
@@ -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
|
+
})
|