@owlmeans/llm 0.1.18-rc.47 → 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.47 @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 |
@@ -186,7 +186,7 @@ This package ships embedded agent skills under `agent-meta/`. After installing y
186
186
  your project's skill store (`.agents/skills/`):
187
187
 
188
188
  ```sh
189
- npx @owlmeans/agent-skills@^0.1.18-rc.52
189
+ npx @owlmeans/agent-skills@^0.1.18-rc.53
190
190
  ```
191
191
 
192
192
  The embedded files are version-matched to this package release. Do not edit them
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "schemaVersion": 2,
3
3
  "package": "@owlmeans/llm",
4
- "version": "0.1.18-rc.47",
5
- "generatedAt": "2026-10-08T21:27:54.297Z",
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.47"` 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
@@ -270,15 +274,15 @@ bug: another provider's rung asked in the primary's `tool_choice` spelling is a
270
274
  OpenAI `reasoning.effort`, Anthropic `output_config.effort` — and not `ExecutionEffort`, which is
271
275
  this package's token-budget tier. Plugin tables are the authority (`OPENAI_EFFORT_SUPPORT` verified
272
276
  2026-09-23 against developers.openai.com/api/docs/models/*; `ANTHROPIC_MODEL_SUPPORT` verified
273
- 2026-09-29 against platform.claude.com/docs/en/models/{sonnet-5-5,opus-5-5,fable-5-1}/whats-new-* and
274
- build-with-claude/effort):
277
+ 2026-10-08 against platform.claude.com/docs/en/models/{sonnet-5-5,opus-5-5,fable-5-1,haiku-5-5}/* and
278
+ build-with-claude/{effort,prompt-caching}):
275
279
 
276
280
  | Model | Levels | Default |
277
281
  |---|---|---|
278
282
  | `gpt-6-sol`, `gpt-6-luna` | none, low, medium, high, xhigh, max | medium |
279
283
  | `gpt-6-astra` | low … max (`none` is a 400) | medium |
280
284
  | `gpt-5*` and older OpenAI | not sent — accepted sets vary per snapshot | — |
281
- | Claude Opus 5.5 | low … max | medium |
285
+ | Claude Opus 5.5, Haiku 5.5 | low … max | medium |
282
286
  | Claude Sonnet 5.5, Opus 5, Fable 5.1/5, Mythos 5.1/5, Opus 4.8/4.7, Sonnet 5 | low … max | high |
283
287
  | Claude Mythos Preview, Opus 4.6, Sonnet 4.6 | low, medium, high, max (no xhigh) | high |
284
288
  | Claude Opus 4.5 | low, medium, high | high |
@@ -297,10 +301,14 @@ lives — never an inline check on one id:
297
301
  | Opus 5.5 | none: always thinks, nothing is sent, effort is the control | all | 400 | 512 |
298
302
  | Fable 5.1, Mythos 5.1 | none | all | 400 | 512 |
299
303
  | Fable 5, Mythos 5 | none | all | accepted | 512 |
300
- | Opus 5 | `disabled` | low … high (`thinkingOffCeiling`) | accepted | 512 |
304
+ | Opus 5, Haiku 5.5 | `disabled` | low … high (`thinkingOffCeiling`) | accepted | 512 |
301
305
  | Sonnet 5, Opus 4.8/4.7 | `disabled` | all | accepted | 1024 |
302
306
  | Older models (Haiku 4.5, Sonnet 4.6, …) | none — they reason only when asked | per the effort table | accepted | 1024 |
303
307
 
308
+ Haiku 5.5 (`claude-haiku-5-5`, no dated snapshot) is a 5-family model: no `temperature`/`top_p`/`top_k`
309
+ (`NO_SAMPLING_PREFIXES` has `claude-haiku-5`), no assistant prefill, adaptive thinking on by default,
310
+ 1M window and 128K output; Haiku 4.5 is unchanged and unlisted.
311
+
304
312
  `anthropicSupportHelper.thinkingOffFor(config)` is the value `build` sends; `refine` carries it through `lc_kwargs` and
305
313
  reads it back to keep the ceiling, so a climbed retry stops at `high` instead of answering 400. The
306
314
  cache minimum is the default under a preset's own `cacheMinTokens`.
@@ -457,6 +465,12 @@ the retry on a text-only reply) through `ChatAnthropic.invocationParams`; `struc
457
465
  pins the refusal of a hidden property name before any request. `plugins.spec.ts` covers the
458
466
  `Compatible` provider offline.
459
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
+
460
474
  ## Depends On
461
475
 
462
476
  - `@owlmeans/llm-common` · `@owlmeans/context` · `@owlmeans/error` · `@owlmeans/basic-ids` · `ajv`
@@ -1,8 +1,8 @@
1
1
  import type { EffortSupport, AnthropicModelSupport } from './types.js';
2
2
  /**
3
3
  * Model families that REJECT the sampling parameters — Claude 4.7 and later, and the whole
4
- * 5 family. `temperature`, `top_p` and `top_k` were removed there, and sending any of them
5
- * is a 400, not a silently ignored field. Matched with `startsWith`, so a dated snapshot
4
+ * 5 family, Haiku 5.5 included. `temperature`, `top_p` and `top_k` were removed there, and sending
5
+ * any of them is a 400, not a silently ignored field. Matched with `startsWith`, so a dated snapshot
6
6
  * (`claude-sonnet-5-20260114`) is covered by its base id.
7
7
  *
8
8
  * This is the Anthropic counterpart of the OpenAI plugin's `RESPONSES_API_PREFIXES`: the
@@ -18,19 +18,20 @@ export declare enum ThinkingOff {
18
18
  }
19
19
  /**
20
20
  * Per-family facts of the Anthropic models that take `output_config.effort`, first prefix
21
- * match wins. From Anthropic's model pages (2026-09-29): `max` on every model below except Opus
22
- * 4.5, `xhigh` only on the first eleven, `medium` the default on Opus 5.5 and `high` everywhere
23
- * else. A model not listed (Haiku 4.5, Sonnet 4.5 and older) rejects the field, so it is never
24
- * sent there — and takes forced tool use and every older thinking shape.
21
+ * match wins. From Anthropic's model pages (2026-10-08): `max` on every model below except Opus
22
+ * 4.5, `xhigh` only on the first twelve, `medium` the default on Opus 5.5 and Haiku 5.5 and `high`
23
+ * everywhere else. A model not listed (Haiku 4.5, Sonnet 4.5 and older) rejects the field, so it is
24
+ * never sent there — and takes forced tool use and every older thinking shape.
25
25
  *
26
- * - **Thinking off.** Opus 5 accepts `disabled` only at `high` or below. Sonnet 5.5 refuses
26
+ * - **Thinking off.** Opus 5 and Haiku 5.5 accept `disabled` only at `high` or below. Sonnet 5.5 refuses
27
27
  * `disabled` and takes `between_tools` instead, again only at `high` or below, with no other
28
28
  * field beside it. Opus 5.5 and the Fable and Mythos 5 families always think: any off switch
29
29
  * is a 400, so the request carries none.
30
30
  * - **Forced tool use.** Opus 5.5, Sonnet 5.5, Fable 5.1 and Mythos 5.1 answer `tool_choice`
31
31
  * `any`/`tool` with a 400; structured output asks them with `auto`, a prompt instruction naming
32
32
  * the tool, and `strict` when the schema allows it.
33
- * - **Cache minimum.** 512 tokens on the 5 family, 1024 on Sonnet 5 and Opus 4.8.
33
+ * - **Cache minimum.** 512 tokens on the 5 family (Haiku 5.5 included), 1024 on Sonnet 5 and Opus 4.8.
34
+ * - **Haiku 5.5** takes forced `tool_choice` (the reply is the call, with no thinking block before it).
34
35
  */
35
36
  export declare const ANTHROPIC_MODEL_SUPPORT: ReadonlyArray<AnthropicModelSupport>;
36
37
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"consts.d.ts","sourceRoot":"","sources":["../../src/plugins/consts.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,aAAa,EAAE,qBAAqB,EAAE,MAAM,YAAY,CAAA;AAGtE;;;;;;;;;GASG;AACH,eAAO,MAAM,oBAAoB,UAQhC,CAAA;AAED,oEAAoE;AACpE,oBAAY,WAAW;IACrB,QAAQ,aAAa;IACrB,wFAAwF;IACxF,YAAY,kBAAkB;CAC/B;AAED;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,uBAAuB,EAAE,aAAa,CAAC,qBAAqB,CAqCxE,CAAA;AAED;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,uBAAuB,QAAS,CAAA;AAE7C,eAAO,MAAM,gBAAgB,cAAc,CAAA;AAE3C;;;;;;;;;;GAUG;AACH,eAAO,MAAM,sBAAsB,UAA+B,CAAA;AAElE;;;;;GAKG;AACH,eAAO,MAAM,qBAAqB,EAAE,aAAa,CAAC,aAAa,GAAG;IAAE,MAAM,EAAE,MAAM,CAAA;CAAE,CAcnF,CAAA;AAED;;;;;GAKG;AACH,eAAO,MAAM,wBAAwB,QAAS,CAAA;AAE9C,eAAO,MAAM,aAAa,WAAW,CAAA;AAErC;;;;;;;;;GASG;AACH,eAAO,MAAM,4BAA4B,EAAE,WAAW,CAAC,MAAM,CAM3D,CAAA"}
1
+ {"version":3,"file":"consts.d.ts","sourceRoot":"","sources":["../../src/plugins/consts.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,aAAa,EAAE,qBAAqB,EAAE,MAAM,YAAY,CAAA;AAGtE;;;;;;;;;GASG;AACH,eAAO,MAAM,oBAAoB,UAShC,CAAA;AAED,oEAAoE;AACpE,oBAAY,WAAW;IACrB,QAAQ,aAAa;IACrB,wFAAwF;IACxF,YAAY,kBAAkB;CAC/B;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,eAAO,MAAM,uBAAuB,EAAE,aAAa,CAAC,qBAAqB,CAyCxE,CAAA;AAED;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,uBAAuB,QAAS,CAAA;AAE7C,eAAO,MAAM,gBAAgB,cAAc,CAAA;AAE3C;;;;;;;;;;GAUG;AACH,eAAO,MAAM,sBAAsB,UAA+B,CAAA;AAElE;;;;;GAKG;AACH,eAAO,MAAM,qBAAqB,EAAE,aAAa,CAAC,aAAa,GAAG;IAAE,MAAM,EAAE,MAAM,CAAA;CAAE,CAcnF,CAAA;AAED;;;;;GAKG;AACH,eAAO,MAAM,wBAAwB,QAAS,CAAA;AAE9C,eAAO,MAAM,aAAa,WAAW,CAAA;AAErC;;;;;;;;;GASG;AACH,eAAO,MAAM,4BAA4B,EAAE,WAAW,CAAC,MAAM,CAM3D,CAAA"}
@@ -2,8 +2,8 @@ import { ModelEffort } from '@owlmeans/llm-common';
2
2
  import { ALL_EFFORTS, NO_XHIGH, UP_TO_HIGH } from './consts.local.js';
3
3
  /**
4
4
  * Model families that REJECT the sampling parameters — Claude 4.7 and later, and the whole
5
- * 5 family. `temperature`, `top_p` and `top_k` were removed there, and sending any of them
6
- * is a 400, not a silently ignored field. Matched with `startsWith`, so a dated snapshot
5
+ * 5 family, Haiku 5.5 included. `temperature`, `top_p` and `top_k` were removed there, and sending
6
+ * any of them is a 400, not a silently ignored field. Matched with `startsWith`, so a dated snapshot
7
7
  * (`claude-sonnet-5-20260114`) is covered by its base id.
8
8
  *
9
9
  * This is the Anthropic counterpart of the OpenAI plugin's `RESPONSES_API_PREFIXES`: the
@@ -18,6 +18,7 @@ export const NO_SAMPLING_PREFIXES = [
18
18
  'claude-opus-4-8',
19
19
  'claude-opus-4-7',
20
20
  'claude-sonnet-5',
21
+ 'claude-haiku-5',
21
22
  ];
22
23
  /** The `thinking.type` a model takes to do no up-front thinking. */
23
24
  export var ThinkingOff;
@@ -28,19 +29,20 @@ export var ThinkingOff;
28
29
  })(ThinkingOff || (ThinkingOff = {}));
29
30
  /**
30
31
  * Per-family facts of the Anthropic models that take `output_config.effort`, first prefix
31
- * match wins. From Anthropic's model pages (2026-09-29): `max` on every model below except Opus
32
- * 4.5, `xhigh` only on the first eleven, `medium` the default on Opus 5.5 and `high` everywhere
33
- * else. A model not listed (Haiku 4.5, Sonnet 4.5 and older) rejects the field, so it is never
34
- * sent there — and takes forced tool use and every older thinking shape.
32
+ * match wins. From Anthropic's model pages (2026-10-08): `max` on every model below except Opus
33
+ * 4.5, `xhigh` only on the first twelve, `medium` the default on Opus 5.5 and Haiku 5.5 and `high`
34
+ * everywhere else. A model not listed (Haiku 4.5, Sonnet 4.5 and older) rejects the field, so it is
35
+ * never sent there — and takes forced tool use and every older thinking shape.
35
36
  *
36
- * - **Thinking off.** Opus 5 accepts `disabled` only at `high` or below. Sonnet 5.5 refuses
37
+ * - **Thinking off.** Opus 5 and Haiku 5.5 accept `disabled` only at `high` or below. Sonnet 5.5 refuses
37
38
  * `disabled` and takes `between_tools` instead, again only at `high` or below, with no other
38
39
  * field beside it. Opus 5.5 and the Fable and Mythos 5 families always think: any off switch
39
40
  * is a 400, so the request carries none.
40
41
  * - **Forced tool use.** Opus 5.5, Sonnet 5.5, Fable 5.1 and Mythos 5.1 answer `tool_choice`
41
42
  * `any`/`tool` with a 400; structured output asks them with `auto`, a prompt instruction naming
42
43
  * the tool, and `strict` when the schema allows it.
43
- * - **Cache minimum.** 512 tokens on the 5 family, 1024 on Sonnet 5 and Opus 4.8.
44
+ * - **Cache minimum.** 512 tokens on the 5 family (Haiku 5.5 included), 1024 on Sonnet 5 and Opus 4.8.
45
+ * - **Haiku 5.5** takes forced `tool_choice` (the reply is the call, with no thinking block before it).
44
46
  */
45
47
  export const ANTHROPIC_MODEL_SUPPORT = [
46
48
  {
@@ -56,6 +58,10 @@ export const ANTHROPIC_MODEL_SUPPORT = [
56
58
  thinkingOff: ThinkingOff.BetweenTools, thinkingOffCeiling: ModelEffort.High, thinksByDefault: true,
57
59
  rejectsForcedTool: true, cacheMinTokens: 512,
58
60
  },
61
+ {
62
+ prefix: 'claude-haiku-5-5', levels: ALL_EFFORTS, default: ModelEffort.Medium,
63
+ thinkingOffCeiling: ModelEffort.High, thinksByDefault: true, cacheMinTokens: 512,
64
+ },
59
65
  {
60
66
  prefix: 'claude-fable-5-1', levels: ALL_EFFORTS, default: ModelEffort.High,
61
67
  thinkingOff: null, thinksByDefault: true, rejectsForcedTool: true, cacheMinTokens: 512,
@@ -1 +1 @@
1
- {"version":3,"file":"consts.js","sourceRoot":"","sources":["../../src/plugins/consts.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAA;AAElD,OAAO,EAAE,WAAW,EAAE,QAAQ,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAA;AAErE;;;;;;;;;GASG;AACH,MAAM,CAAC,MAAM,oBAAoB,GAAG;IAClC,gBAAgB;IAChB,iBAAiB;IACjB,uBAAuB;IACvB,eAAe;IACf,iBAAiB;IACjB,iBAAiB;IACjB,iBAAiB;CAClB,CAAA;AAED,oEAAoE;AACpE,MAAM,CAAN,IAAY,WAIX;AAJD,WAAY,WAAW;IACrB,oCAAqB,CAAA;IACrB,wFAAwF;IACxF,6CAA8B,CAAA;AAChC,CAAC,EAJW,WAAW,KAAX,WAAW,QAItB;AAED;;;;;;;;;;;;;;;GAeG;AACH,MAAM,CAAC,MAAM,uBAAuB,GAAyC;IAC3E;QACE,MAAM,EAAE,iBAAiB,EAAE,MAAM,EAAE,WAAW,EAAE,OAAO,EAAE,WAAW,CAAC,MAAM;QAC3E,WAAW,EAAE,IAAI,EAAE,eAAe,EAAE,IAAI,EAAE,iBAAiB,EAAE,IAAI,EAAE,cAAc,EAAE,GAAG;KACvF;IACD;QACE,MAAM,EAAE,eAAe,EAAE,MAAM,EAAE,WAAW,EAAE,OAAO,EAAE,WAAW,CAAC,IAAI;QACvE,kBAAkB,EAAE,WAAW,CAAC,IAAI,EAAE,eAAe,EAAE,IAAI,EAAE,cAAc,EAAE,GAAG;KACjF;IACD;QACE,MAAM,EAAE,mBAAmB,EAAE,MAAM,EAAE,WAAW,EAAE,OAAO,EAAE,WAAW,CAAC,IAAI;QAC3E,WAAW,EAAE,WAAW,CAAC,YAAY,EAAE,kBAAkB,EAAE,WAAW,CAAC,IAAI,EAAE,eAAe,EAAE,IAAI;QAClG,iBAAiB,EAAE,IAAI,EAAE,cAAc,EAAE,GAAG;KAC7C;IACD;QACE,MAAM,EAAE,kBAAkB,EAAE,MAAM,EAAE,WAAW,EAAE,OAAO,EAAE,WAAW,CAAC,IAAI;QAC1E,WAAW,EAAE,IAAI,EAAE,eAAe,EAAE,IAAI,EAAE,iBAAiB,EAAE,IAAI,EAAE,cAAc,EAAE,GAAG;KACvF;IACD;QACE,MAAM,EAAE,mBAAmB,EAAE,MAAM,EAAE,WAAW,EAAE,OAAO,EAAE,WAAW,CAAC,IAAI;QAC3E,WAAW,EAAE,IAAI,EAAE,eAAe,EAAE,IAAI,EAAE,iBAAiB,EAAE,IAAI,EAAE,cAAc,EAAE,GAAG;KACvF;IACD;QACE,MAAM,EAAE,gBAAgB,EAAE,MAAM,EAAE,WAAW,EAAE,OAAO,EAAE,WAAW,CAAC,IAAI;QACxE,WAAW,EAAE,IAAI,EAAE,eAAe,EAAE,IAAI,EAAE,cAAc,EAAE,GAAG;KAC9D;IACD;QACE,MAAM,EAAE,iBAAiB,EAAE,MAAM,EAAE,WAAW,EAAE,OAAO,EAAE,WAAW,CAAC,IAAI;QACzE,WAAW,EAAE,IAAI,EAAE,eAAe,EAAE,IAAI,EAAE,cAAc,EAAE,GAAG;KAC9D;IACD,EAAE,MAAM,EAAE,iBAAiB,EAAE,MAAM,EAAE,WAAW,EAAE,OAAO,EAAE,WAAW,CAAC,IAAI,EAAE;IAC7E,EAAE,MAAM,EAAE,iBAAiB,EAAE,MAAM,EAAE,WAAW,EAAE,OAAO,EAAE,WAAW,CAAC,IAAI,EAAE;IAC7E,EAAE,MAAM,EAAE,iBAAiB,EAAE,MAAM,EAAE,WAAW,EAAE,OAAO,EAAE,WAAW,CAAC,IAAI,EAAE,eAAe,EAAE,IAAI,EAAE;IACpG,EAAE,MAAM,EAAE,uBAAuB,EAAE,MAAM,EAAE,QAAQ,EAAE,OAAO,EAAE,WAAW,CAAC,IAAI,EAAE;IAChF,EAAE,MAAM,EAAE,iBAAiB,EAAE,MAAM,EAAE,QAAQ,EAAE,OAAO,EAAE,WAAW,CAAC,IAAI,EAAE;IAC1E,EAAE,MAAM,EAAE,mBAAmB,EAAE,MAAM,EAAE,QAAQ,EAAE,OAAO,EAAE,WAAW,CAAC,IAAI,EAAE;IAC5E,EAAE,MAAM,EAAE,iBAAiB,EAAE,MAAM,EAAE,UAAU,EAAE,OAAO,EAAE,WAAW,CAAC,IAAI,EAAE;CAC7E,CAAA;AAED;;;;;;;;;;;;;GAaG;AACH,MAAM,CAAC,MAAM,uBAAuB,GAAG,MAAM,CAAA;AAE7C,MAAM,CAAC,MAAM,gBAAgB,GAAG,WAAW,CAAA;AAE3C;;;;;;;;;;GAUG;AACH,MAAM,CAAC,MAAM,sBAAsB,GAAG,CAAC,OAAO,EAAE,OAAO,EAAE,QAAQ,CAAC,CAAA;AAElE;;;;;GAKG;AACH,MAAM,CAAC,MAAM,qBAAqB,GAAsD;IACtF;QACE,MAAM,EAAE,aAAa;QACrB,MAAM,EAAE,CAAC,WAAW,CAAC,GAAG,EAAE,WAAW,CAAC,MAAM,EAAE,WAAW,CAAC,IAAI,EAAE,WAAW,CAAC,KAAK,EAAE,WAAW,CAAC,GAAG,CAAC;QACnG,OAAO,EAAE,WAAW,CAAC,MAAM;KAC5B;IACD;QACE,MAAM,EAAE,OAAO;QACf,MAAM,EAAE;YACN,WAAW,CAAC,IAAI,EAAE,WAAW,CAAC,GAAG,EAAE,WAAW,CAAC,MAAM,EAAE,WAAW,CAAC,IAAI,EAAE,WAAW,CAAC,KAAK;YAC1F,WAAW,CAAC,GAAG;SAChB;QACD,OAAO,EAAE,WAAW,CAAC,MAAM;KAC5B;CACF,CAAA;AAED;;;;;GAKG;AACH,MAAM,CAAC,MAAM,wBAAwB,GAAG,MAAM,CAAA;AAE9C,MAAM,CAAC,MAAM,aAAa,GAAG,QAAQ,CAAA;AAErC;;;;;;;;;GASG;AACH,MAAM,CAAC,MAAM,4BAA4B,GAAwB,IAAI,GAAG,CAAC;IACvE,UAAU,EAAE,SAAS,EAAE,QAAQ,EAAE,SAAS,EAAE,sBAAsB,EAAE,UAAU,EAAE,YAAY;IAC5F,UAAU,EAAE,WAAW,EAAE,SAAS,EAAE,SAAS,EAAE,kBAAkB,EAAE,kBAAkB;IACrF,YAAY,EAAE,WAAW,EAAE,WAAW,EAAE,UAAU,EAAE,UAAU,EAAE,aAAa,EAAE,eAAe;IAC9F,eAAe,EAAE,OAAO,EAAE,KAAK,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,UAAU,EAAE,eAAe;IAClF,mBAAmB,EAAE,mBAAmB,EAAE,uBAAuB;CAClE,CAAC,CAAA"}
1
+ {"version":3,"file":"consts.js","sourceRoot":"","sources":["../../src/plugins/consts.ts"],"names":[],"mappings":"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"}
@@ -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.47",
3
+ "version": "0.1.18-rc.49",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -4,8 +4,8 @@ import { ALL_EFFORTS, NO_XHIGH, UP_TO_HIGH } from './consts.local.js'
4
4
 
5
5
  /**
6
6
  * Model families that REJECT the sampling parameters — Claude 4.7 and later, and the whole
7
- * 5 family. `temperature`, `top_p` and `top_k` were removed there, and sending any of them
8
- * is a 400, not a silently ignored field. Matched with `startsWith`, so a dated snapshot
7
+ * 5 family, Haiku 5.5 included. `temperature`, `top_p` and `top_k` were removed there, and sending
8
+ * any of them is a 400, not a silently ignored field. Matched with `startsWith`, so a dated snapshot
9
9
  * (`claude-sonnet-5-20260114`) is covered by its base id.
10
10
  *
11
11
  * This is the Anthropic counterpart of the OpenAI plugin's `RESPONSES_API_PREFIXES`: the
@@ -20,6 +20,7 @@ export const NO_SAMPLING_PREFIXES = [
20
20
  'claude-opus-4-8',
21
21
  'claude-opus-4-7',
22
22
  'claude-sonnet-5',
23
+ 'claude-haiku-5',
23
24
  ]
24
25
 
25
26
  /** The `thinking.type` a model takes to do no up-front thinking. */
@@ -31,19 +32,20 @@ export enum ThinkingOff {
31
32
 
32
33
  /**
33
34
  * Per-family facts of the Anthropic models that take `output_config.effort`, first prefix
34
- * match wins. From Anthropic's model pages (2026-09-29): `max` on every model below except Opus
35
- * 4.5, `xhigh` only on the first eleven, `medium` the default on Opus 5.5 and `high` everywhere
36
- * else. A model not listed (Haiku 4.5, Sonnet 4.5 and older) rejects the field, so it is never
37
- * sent there — and takes forced tool use and every older thinking shape.
35
+ * match wins. From Anthropic's model pages (2026-10-08): `max` on every model below except Opus
36
+ * 4.5, `xhigh` only on the first twelve, `medium` the default on Opus 5.5 and Haiku 5.5 and `high`
37
+ * everywhere else. A model not listed (Haiku 4.5, Sonnet 4.5 and older) rejects the field, so it is
38
+ * never sent there — and takes forced tool use and every older thinking shape.
38
39
  *
39
- * - **Thinking off.** Opus 5 accepts `disabled` only at `high` or below. Sonnet 5.5 refuses
40
+ * - **Thinking off.** Opus 5 and Haiku 5.5 accept `disabled` only at `high` or below. Sonnet 5.5 refuses
40
41
  * `disabled` and takes `between_tools` instead, again only at `high` or below, with no other
41
42
  * field beside it. Opus 5.5 and the Fable and Mythos 5 families always think: any off switch
42
43
  * is a 400, so the request carries none.
43
44
  * - **Forced tool use.** Opus 5.5, Sonnet 5.5, Fable 5.1 and Mythos 5.1 answer `tool_choice`
44
45
  * `any`/`tool` with a 400; structured output asks them with `auto`, a prompt instruction naming
45
46
  * the tool, and `strict` when the schema allows it.
46
- * - **Cache minimum.** 512 tokens on the 5 family, 1024 on Sonnet 5 and Opus 4.8.
47
+ * - **Cache minimum.** 512 tokens on the 5 family (Haiku 5.5 included), 1024 on Sonnet 5 and Opus 4.8.
48
+ * - **Haiku 5.5** takes forced `tool_choice` (the reply is the call, with no thinking block before it).
47
49
  */
48
50
  export const ANTHROPIC_MODEL_SUPPORT: ReadonlyArray<AnthropicModelSupport> = [
49
51
  {
@@ -59,6 +61,10 @@ export const ANTHROPIC_MODEL_SUPPORT: ReadonlyArray<AnthropicModelSupport> = [
59
61
  thinkingOff: ThinkingOff.BetweenTools, thinkingOffCeiling: ModelEffort.High, thinksByDefault: true,
60
62
  rejectsForcedTool: true, cacheMinTokens: 512,
61
63
  },
64
+ {
65
+ prefix: 'claude-haiku-5-5', levels: ALL_EFFORTS, default: ModelEffort.Medium,
66
+ thinkingOffCeiling: ModelEffort.High, thinksByDefault: true, cacheMinTokens: 512,
67
+ },
62
68
  {
63
69
  prefix: 'claude-fable-5-1', levels: ALL_EFFORTS, default: ModelEffort.High,
64
70
  thinkingOff: null, thinksByDefault: true, rejectsForcedTool: true, cacheMinTokens: 512,
@@ -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
 
@@ -49,6 +49,7 @@ describe('@owlmeans/llm — anthropic thinking off switch per family', () => {
49
49
  ['claude-fable-5-1', undefined],
50
50
  ['claude-mythos-5-1', undefined],
51
51
  ['claude-haiku-4-5', undefined],
52
+ ['claude-haiku-5-5', ThinkingOff.Disabled],
52
53
  ]
53
54
 
54
55
  test('disableThinking sends the model\'s own off switch, or none where every switch is a 400', () => {
@@ -119,6 +120,17 @@ describe('@owlmeans/llm — anthropic effort beside the off switch', () => {
119
120
  }
120
121
  })
121
122
 
123
+ // Haiku 5.5: effort on every level, `disabled` only at high or below, no sampling, 512-token cache floor.
124
+ test('Haiku 5.5 sends effort and the disabled switch, never a sampling field', () => {
125
+ const model = 'claude-haiku-5-5'
126
+ const wire = wireOf(build({ model, disableThinking: true, effort: ModelEffort.High }), { model })
127
+ expect(wire.thinking).toEqual({ type: 'disabled' })
128
+ expect(wire.output_config).toEqual({ effort: 'high' })
129
+ expect(anthropicSupportHelper.rejectsSampling(model)).toBe(true)
130
+ expect(anthropicSupportHelper.anthropicSupportOf(model)?.default).toBe(ModelEffort.Medium)
131
+ expect(anthropicSupportHelper.anthropicSupportOf(model)?.cacheMinTokens).toBe(512)
132
+ })
133
+
122
134
  test('Sonnet 5 is unchanged: disabled, every level', () => {
123
135
  const wire = wireOf(build({ model: 'claude-sonnet-5', disableThinking: true, effort: ModelEffort.XHigh }), {})
124
136
  expect(wire.thinking).toEqual({ type: 'disabled' })
@@ -128,7 +140,7 @@ describe('@owlmeans/llm — anthropic effort beside the off switch', () => {
128
140
 
129
141
  describe('@owlmeans/llm — anthropic structured output per family', () => {
130
142
  const refusing = ['claude-sonnet-5-5', 'claude-opus-5-5', 'claude-fable-5-1', 'claude-mythos-5-1']
131
- const pinning = ['claude-sonnet-5', 'claude-opus-5', 'claude-fable-5', 'claude-haiku-4-5', undefined]
143
+ const pinning = ['claude-sonnet-5', 'claude-opus-5', 'claude-fable-5', 'claude-haiku-4-5', 'claude-haiku-5-5', undefined]
132
144
 
133
145
  test('a model that refuses a pinned tool is asked with auto and a strict tool', () => {
134
146
  for (const model of refusing) {
@@ -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
+ })