@combycode/llm-sdk 1.7.0 → 2.0.1

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.
Files changed (50) hide show
  1. package/CHANGELOG.md +462 -1
  2. package/MIGRATION.md +93 -0
  3. package/README.md +17 -2
  4. package/dist/agent/loop-config.d.ts +22 -0
  5. package/dist/agent/loop-step-state.d.ts +2 -0
  6. package/dist/agent/loop.d.ts +7 -0
  7. package/dist/agent/reflect-retry.d.ts +56 -0
  8. package/dist/agent/tool-key.d.ts +3 -0
  9. package/dist/agent/types.d.ts +8 -2
  10. package/dist/helpers/mcp.d.ts +24 -2
  11. package/dist/helpers/provenance-types.d.ts +63 -0
  12. package/dist/helpers/provenance.d.ts +12 -0
  13. package/dist/helpers/transcribe.d.ts +35 -6
  14. package/dist/index.browser.js +2997 -708
  15. package/dist/index.d.ts +19 -7
  16. package/dist/index.js +2997 -708
  17. package/dist/llm/providers/anthropic/constants.d.ts +2 -0
  18. package/dist/llm/providers/openai/completions.d.ts +9 -0
  19. package/dist/llm/providers/openai/provenance.d.ts +26 -0
  20. package/dist/llm/providers/openai/responses.d.ts +4 -2
  21. package/dist/llm/providers/openai/transcription.d.ts +39 -2
  22. package/dist/llm/types/audio.d.ts +31 -0
  23. package/dist/llm/types/messages.d.ts +89 -1
  24. package/dist/llm/types/options.d.ts +15 -0
  25. package/dist/llm/types/request.d.ts +13 -1
  26. package/dist/llm/types/response.d.ts +31 -1
  27. package/dist/llm/types/stream.d.ts +14 -1
  28. package/dist/llm/types/tiers.d.ts +6 -6
  29. package/dist/network/queue-state-config.d.ts +5 -0
  30. package/dist/network/queue-state.d.ts +7 -0
  31. package/dist/network/types.d.ts +23 -0
  32. package/dist/plugins/context-guard/strategies/anchored.d.ts +47 -0
  33. package/dist/plugins/context-measurer/counter/hybrid.d.ts +4 -1
  34. package/dist/plugins/context-measurer/counter/tiktoken.d.ts +8 -1
  35. package/dist/plugins/mcp/base-transport.d.ts +16 -0
  36. package/dist/plugins/mcp/client.d.ts +144 -7
  37. package/dist/plugins/mcp/input-required.d.ts +35 -0
  38. package/dist/plugins/mcp/jsonrpc.d.ts +7 -0
  39. package/dist/plugins/mcp/oauth.d.ts +21 -1
  40. package/dist/plugins/mcp/protocol-version.d.ts +61 -0
  41. package/dist/plugins/mcp/result-cache.d.ts +31 -0
  42. package/dist/plugins/mcp/subscriptions.d.ts +69 -0
  43. package/dist/plugins/mcp/transport-http.d.ts +31 -0
  44. package/dist/plugins/mcp/transport-stdio.d.ts +2 -0
  45. package/dist/plugins/mcp/transport-ws.d.ts +11 -1
  46. package/dist/plugins/mcp/transport.d.ts +11 -0
  47. package/dist/plugins/mcp/types.d.ts +54 -2
  48. package/dist/plugins/telemetry/telemetry.d.ts +12 -0
  49. package/dist/util/http.d.ts +8 -0
  50. package/package.json +9 -6
package/CHANGELOG.md CHANGED
@@ -4,7 +4,468 @@ All notable changes to `@combycode/llm-sdk` are documented here. The format foll
4
4
  [Keep a Changelog](https://keepachangelog.com/) and the project adheres to
5
5
  [Semantic Versioning](https://semver.org/).
6
6
 
7
- ## [Unreleased]
7
+ ## [2.0.1] - 2026-08-10
8
+
9
+ Three defects reported by a consumer within a day of 2.0.0 — all reachable by reading the shipped
10
+ `.d.ts`, none caught by our gate. See the note at the end.
11
+
12
+ ### Fixed
13
+
14
+ - **`agent.stream()` now carries `phase` on text events.** The raw stream event had it, and the
15
+ agent mapper *used* it internally to keep commentary out of the answer — then yielded both deltas
16
+ through one `{ type: 'text', text }` with the phase stripped. A UI streaming those straight
17
+ through put the model's thinking-aloud into the transcript **as if it were the reply**, with no
18
+ way to tell them apart. `finalAnswerText()` could not help: it takes a finished message's
19
+ `content`, not deltas.
20
+ - Additive: `phase` is **absent** (not `undefined`) when the provider reports none, so every
21
+ non-codex provider is byte-identical to before.
22
+ - **Docs: `agent.run()` does not exist.** The agent-loop guide recommended it for a non-throwing
23
+ report. The class exposes `stop` / `complete` / `structuredComplete` / `stream`; the report is
24
+ reached with `try/catch` + `agent.lastReport`. The guide now shows that.
25
+ - **Docs: the 2.0.0 changelog overstated live commentary.** It said commentary "is still yielded to
26
+ the consumer (a UI may well want to render it live)" — true only in the sense that the bytes
27
+ arrived; they were unlabelled, so a UI could not act on them. The 2.0.0 entry now says so and
28
+ points here.
29
+
30
+ ### Why this got out
31
+
32
+ The feature was verified end-to-end on the **buffered** path (`finalAnswerText`, `response.text`,
33
+ live-tested against real models) and never once from the **layer most consumers actually call**.
34
+ 1778 tests, four MCP transports and two live corpora, and no check that a shipped type was usable
35
+ from `agent.stream()`. The gate was deep where it was pointed and blind where it was not — so the
36
+ release checklist now includes a consumer-surface pass over the published `.d.ts`.
37
+
38
+ ## [2.0.0] - 2026-08-09
39
+
40
+ **Upgrading:** three things can require action, and none of them is a provider change — that is the
41
+ point of the facade. (1) Node **22+** is now required. (2) `tiktoken` is an optional **peer**: run
42
+ `npm i tiktoken` only if you use exact local OpenAI token counting. (3) If you switch exhaustively
43
+ over `FinishReason` or `ContentPart` without a `default` branch, add one — both are open by design
44
+ (CONSTITUTION R1) so future provider values arrive additively instead of breaking your build. The
45
+ only changed signature is `OpenAITranscriptionAdapter.transcribe()`, which now returns an object;
46
+ read `.text`. The `transcribe()` helper is unaffected. Full detail: [MIGRATION.md](./MIGRATION.md).
47
+
48
+ ### Changed — packaging (install/runtime level; no source change for consumers)
49
+
50
+ - **Node floor raised to `>=22`** (was `>=18`). Node 18 and 20 are both end-of-life; 22 is also the
51
+ floor `openai-node` 7 adopted.
52
+ - **`tiktoken` is now an OPTIONAL PEER dependency**, not an `optionalDependency`.
53
+ `optionalDependencies` means *"do not fail the install if this package fails to build"* — npm
54
+ installs it regardless, so every consumer received its ~5.6 MB wasm file. **If you use exact
55
+ OpenAI token counting, run `npm i tiktoken`**; the error thrown when it is missing names the
56
+ package and the alternatives (count-API and heuristic counters need no extra packages).
57
+ - **The wasm no longer lands in consumer bundles.** The dynamic import used a string literal, which
58
+ every bundler resolves during module-graph construction — so the blob was emitted even for code
59
+ paths that were never reached (one consumer reported it as **88% of their production output**).
60
+ `sideEffects: false` cannot prevent this: emitting a dynamic-import chunk is a graph-resolution
61
+ outcome, not dead-code elimination. The specifier now lives in a variable, opaque to bundlers and
62
+ resolved identically at runtime. Verified with a control — a literal import emits a 5.3 MB
63
+ `.wasm`; the shipped build emits none, even when the consumer imports `ContextMeasurer` directly
64
+ and has `tiktoken` installed.
65
+ - **`tiktoken` still works in the browser.** It ships a wasm/ESM build that bundlers resolve for
66
+ browser targets, so it is deliberately *not* stubbed out of `index.browser.js`.
67
+ - **`HybridTokenCounter` builds its tiktoken counter lazily**, on first route to that strategy,
68
+ rather than in the constructor.
69
+ - **The "zero dependencies" claim is now qualified** as *zero **required** runtime dependencies*.
70
+ `dependencies` genuinely is empty, but a consumer reading only that field concluded there were no
71
+ runtime packages at all.
72
+
73
+ ### Added — MCP speaks both protocol eras
74
+
75
+ `mcp` 2.0.0 shipped the **2026-07-28 revision**, which is not additive: it deletes the `initialize`
76
+ handshake, the session id, and the whole server→client back-channel. Real servers are still almost
77
+ entirely on 2025-11-25, so this is **dual-era or it is a regression** — both wires are supported and
78
+ neither is preferred.
79
+
80
+ **Verified against a real server, on every transport.** The modern wire was developed against our
81
+ own test doubles, which is not evidence: a shape that satisfies a fake can still be rejected by a
82
+ real implementation. Before release the whole surface was run against the official
83
+ `mcp` 2.0.0 Python server — negotiation, tools, resources, prompts, MRTR, `subscriptions/listen`
84
+ with live change events, result caching and era gating — over **stdio, Streamable HTTP and
85
+ WebSocket**, plus a real 2025-11-25 server (DeepWiki) to prove the fallback. That exercise found
86
+ six defects that no unit test could have caught, four of which failed *silently*: the per-request
87
+ `_meta` identity envelope was missing (on ordinary requests, and separately on the long-lived
88
+ `subscriptions/listen` POST, where the rejection surfaced as the stream simply ending — so `listen()`
89
+ returned a subscription that looked alive and delivered nothing); `connectMcp` never forwarded
90
+ `cacheResults`, making the opt-in cache a no-op; the discover probe omitted the
91
+ `MCP-Protocol-Version` header that modern servers route on; the routing headers keyed off an era
92
+ that is not yet set during the probe; and the long-lived POST accepted only `text/event-stream`,
93
+ which a modern server answers with `406`. All are fixed and pinned by regression tests.
94
+
95
+ - **`server/discover` negotiation with handshake fallback.** `ConnectMcpOptions.protocolMode`:
96
+ `'auto'` (default) probes the modern wire and falls back to `initialize`; `'legacy'` skips the
97
+ probe entirely (byte-identical to 1.x); a version string adopts that revision directly.
98
+ - **The fallback is a denylist, not an allowlist.** *Every* JSON-RPC error falls back to the
99
+ handshake, except a `-32022` whose `supported` list is modern-only and shares nothing with us —
100
+ a genuine incompatibility that must surface rather than be papered over. **Transport and network
101
+ errors are never treated as an era verdict**: silently downgrading the wire because a socket
102
+ blipped would be the worst available failure mode.
103
+ - **`McpClient.info` is unchanged on both wires.** A modern server has no `initialize` result, so
104
+ one is synthesised from the discover result and its `_meta` server-info stamp. Callers never
105
+ branch on the era (CONSTITUTION.md R2 — absorb the difference, never expose a union). The stamp is
106
+ display-only per spec, so absent *or* malformed degrades to a placeholder instead of failing the
107
+ connection.
108
+ - **New (additive):** `McpClient.protocolVersion`, `.era`, `.discoverResult`, the
109
+ `McpDiscoverResult` type, and the version registry (`MCP_KNOWN_PROTOCOL_VERSIONS`,
110
+ `mcpEraOf`, …). Versions are treated as an **enumerated set, not an ordered scalar** — comparing
111
+ `'zzz' > '2025-11-25'` is true and meaningless, so era questions go through the registry.
112
+ - **Methods the revision removed are gated by era.** `logging/setLevel` and `resources/subscribe`
113
+ throw on a modern session with a message naming the negotiated version and the replacement,
114
+ instead of letting the server answer a bare `-32601`. The `ping` keep-alive is not started on a
115
+ modern session. All of them are untouched on a handshake session.
116
+ - **A 4xx carrying a JSON-RPC error body no longer loses it.** The HTTP transport collapsed every
117
+ 4xx into `ConnectionClosed`, which discarded exactly the `-32022` that negotiation depends on — a
118
+ modern-only server looked like a dead connection.
119
+ - New error codes: `HeaderMismatch` (-32020), `MissingRequiredClientCapability` (-32021),
120
+ `UnsupportedProtocolVersion` (-32022).
121
+
122
+ **Multi-round-trip requests (MRTR, SEP-2322)** — the modern replacement for the back-channel. Where
123
+ a handshake-era server *pushes* a `sampling/createMessage` at us mid-call, a 2026-07-28 server
124
+ *returns* `resultType: 'input_required'` with the questions it needs answered, and the client
125
+ re-issues the same call carrying the answers plus the server's opaque `requestState`.
126
+
127
+ - **One handler serves both wires.** MRTR is dispatched through the same `onServerRequest` path as
128
+ a pushed request, so a caller who wired up sampling/elicitation/roots once gets it on either wire
129
+ without knowing which is in play.
130
+ - **`McpCallResult` did not become a union.** Upstream models this as a separate
131
+ `InputRequiredResult`, which would break every consumer reading `.content`. We attach
132
+ `resultType` / `inputRequests` / `requestState` as optional fields instead (CONSTITUTION.md R2).
133
+ An **absent `resultType` reads as `'complete'`**, so every pre-2026 result behaves exactly as
134
+ before and costs no extra round-trip.
135
+ - Applied to `tools/call`, `prompts/get` and `resources/read`. `requestState` is echoed back
136
+ byte-exact and never inspected.
137
+ - A leg carrying state but no questions backs off (50 ms doubling to a 250 ms cap, reset by any leg
138
+ with real questions) rather than spinning against the server.
139
+ - `inputRequiredMaxRounds` (default **10**, matching the other SDKs) bounds the loop, because a
140
+ handler that never satisfies the server would otherwise retry forever.
141
+
142
+ **`subscriptions/listen` (SEP-2575)** — the single change-notification stream that replaces
143
+ `resources/subscribe` and the standalone notification channel at 2026-07-28.
144
+
145
+ - `McpClient.listen(filter, onEvent)` returns an `McpSubscription`. Every kind is **opt-in**
146
+ (`toolsListChanged`, `promptsListChanged`, `resourcesListChanged`, `resourceSubscriptions[]`) and
147
+ the server acknowledges with the subset it actually honoured — which **can be narrower than what
148
+ was requested**, so `subscription.honored` / `isHonored(kind)` is worth checking rather than
149
+ assuming. Frames are attributed by the `io.modelcontextprotocol/subscriptionId` stamp, so frames
150
+ for another subscription are ignored.
151
+ - Refused with a clear error on a handshake session, which keeps `subscribeResource()` the right
152
+ answer there instead of silently returning a subscription that never fires.
153
+ - **Works on every transport** — stdio, WebSocket and Streamable HTTP. On HTTP a listen is a POST
154
+ whose *response body* is the long-lived stream, so it goes through the streaming fetch rather than
155
+ the buffered POST path (which would surface frames only once the stream closed — i.e. never, for
156
+ a healthy subscription). Frames route identically on all three, so the client sees no difference.
157
+ - **The end of a stream is observable.** `subscription.active` / `.ended` report whether the stream
158
+ is still live and, if not, the error that killed it — a rejected subscription, a dropped
159
+ connection, or a clean server-side teardown. A subscription that silently stopped delivering is
160
+ otherwise indistinguishable from one where nothing has changed yet; failures also surface on the
161
+ `onMcpError` hook. `close()` on the client tears down every open stream.
162
+
163
+ **Hardening**
164
+
165
+ - **The stdio read buffer is bounded** (`maxBufferSize`, default **10 MB**, matching mcp-ts 1.30).
166
+ JSON-RPC over stdio is newline-delimited, so a server that never emits `\n` — a crash dump, a
167
+ binary blob on the wrong stream, a runaway log line — grew the buffer until the process died.
168
+ The limit applies to a single *unterminated* line, so a large burst of complete messages is
169
+ unaffected; on overflow the pending requests fail with a message naming the likely cause.
170
+ - **`Content-Type` is compared by media-type essence, not substring.** `contentType.includes(...)`
171
+ routed anything merely *containing* `text/event-stream` — e.g. `application/json;
172
+ profile="text/event-stream"` — into the SSE parser.
173
+ - **RFC 9207 `iss` validation** on the OAuth authorization response, checked **before** the code is
174
+ redeemed. This is the mix-up-attack defence: without it a malicious authorization server can hand
175
+ back a code minted by a different server and have the client replay the user's credentials
176
+ against it. Comparison is exact string equality per §2.4 — deliberately *not* URL-normalised,
177
+ since that leniency is what an attacker looks for. A **missing** `iss` is rejected when the server
178
+ advertises `authorization_response_iss_parameter_supported`, otherwise stripping the parameter
179
+ would skip the check. Pass it via `finishMcpAuth(..., { iss })`; optional, so existing callers
180
+ keep working.
181
+ - **`application_type: 'native'` is sent at dynamic client registration** (SEP-837). MCP clients are
182
+ normally local processes with a loopback redirect, and some authorization servers apply stricter
183
+ redirect-URI rules when the type is left to be guessed as `web`. An explicit value from the caller
184
+ still wins.
185
+ - **The WebSocket transport is kept** and now documents itself as non-standard. Upstream removed
186
+ theirs as "never part of the MCP specification"; ours is public API we shipped, and an upstream
187
+ deletion is not our deletion (R7). It is also duplex, so it supports `subscriptions/listen` today.
188
+
189
+ **Result cache hints (`ttlMs` / `cacheScope`)** — opt-in via `cacheResults`, off by default.
190
+ Settable on `connectMcp(config, { cacheResults: true })` as well as on `McpClient` directly, along
191
+ with `inputRequiredMaxRounds`.
192
+
193
+ - Honours the server's freshness hint on `tools/list`, `prompts/list`, `resources/list`,
194
+ `resources/templates/list` and `resources/read`. **A server that sends no hints caches nothing**,
195
+ so this is a no-op against every pre-2026 server.
196
+ - **`ttlMs: 0` means "immediately stale"** — a real instruction, not a missing value to be replaced
197
+ with a default.
198
+ - A paginated list is only as fresh as its shortest-lived page, so the effective TTL is the
199
+ **minimum** across pages.
200
+ - Entries are dropped on the matching `*_changed` notification **before** the caller's handler
201
+ runs, so a handler that re-lists synchronously never reads a stale entry. A
202
+ `notifications/resources/updated` drops only the named resource.
203
+ - `cacheScope` is recorded but never used to widen sharing: this cache lives inside one client with
204
+ one credential, where `public` buys nothing.
205
+
206
+ ### Changed — `FinishReason` is now an OPEN union
207
+
208
+ - **`FinishReason` = `KnownFinishReason | (string & {})`.** Providers keep inventing terminal
209
+ states — four did so in a single upstream cycle — and against a closed union every one of those is
210
+ a breaking change for **every** consumer, including consumers of providers that changed nothing.
211
+ Opening it is what CONSTITUTION.md R1 exists for, and it means this is the **last** time this type
212
+ breaks anyone. Write a `default` branch; use `KnownFinishReason` for the documented set alone.
213
+ - **New known value `'malformed_tool_call'`** — the model tried to call a tool and produced
214
+ something unusable. Distinct from `error` (the request failed) and `tool_use` (a call we can
215
+ run), because this one is *recoverable*.
216
+ - **Google's `MALFORMED_FUNCTION_CALL` is now mapped.** It previously wasn't mapped at all, so it
217
+ fell through to `'stop'`: a turn where the model failed to produce a usable tool call looked like
218
+ a clean finish with no content.
219
+
220
+ ### Added — agent + network
221
+
222
+ - **`reflectAndRetry` on `AgentLoop`** (google-adk 2.6 `ReflectAndRetryModelPlugin`).
223
+ Self-healing recovery from a model failure the model itself can fix: it receives structured
224
+ guidance naming the attempt and forbidding an identical retry, then the step runs again within a
225
+ bounded budget. **Off unless configured** — a retry costs a real request.
226
+ - **Not a network retry.** The engine already retries transport failures; this is for a request
227
+ that *succeeded* and came back unusable, which resending unchanged would never fix.
228
+ - The failed turn is **not appended to history**, so the model never learns from its own broken
229
+ output; usage from it *is* counted, because a wasted turn still costs money.
230
+ - Counts **consecutive** failures, so an agent that recovers and fails again later gets a fresh
231
+ budget rather than inheriting a spent one.
232
+ - `throwIfExceeded` (default `true`) decides raise-vs-return when the budget is spent; the error
233
+ names the option so the alternative is discoverable.
234
+
235
+ - **`checkProvenance()`** — detect provider provenance signals in a file (C2PA manifest, SynthID
236
+ watermark) via OpenAI's new `POST /v1/content_provenance_checks`. Bytes in, structured verdict
237
+ out, same shape as `moderate()`, with an honest-zero cost entry so the ledger records the call.
238
+ It is the only "was this AI-generated" primitive any tracked SDK ships.
239
+ - The result separates **`detected`** from **`trusted`**, and the docs say why: signals are
240
+ strippable — a re-encode, crop or screenshot usually removes them — so `detected: false` is
241
+ absence of evidence, not evidence a human made the file. Only a detected manifest that
242
+ *validated* is a positive statement.
243
+ - `detected` is true if ANY signal fired: audio carries SynthID only, so requiring both schemes
244
+ would report every audio file as clean.
245
+ - **`AnchoredStrategy`** for ContextGuard — one growing scratchpad instead of a chain of summaries
246
+ (ported from google-adk 1.5 `AnchoredContextCompactor`). `LayeredStrategy` emits a new summary
247
+ per compaction, so old facts get summarised repeatedly and drift; anchored merges each compaction
248
+ into a single head entry, so every fact is summarised from raw text exactly once. The trade is
249
+ stated in the file: one anchor means one blast radius.
250
+ - **Never splits a tool call from its result.** The retain boundary walks backwards past a
251
+ `tool_result` whose `tool_call` would be cut away — several providers reject an orphaned
252
+ result outright and the rest silently misread it.
253
+ - **A summariser returning nothing declines rather than dropping entries**: trading a context
254
+ overflow for silent data loss is strictly worse.
255
+
256
+ - **`toolNameCollisionPolicy`** on `AgentLoop` (`'warn'` default, `'error'`). Tools are registered
257
+ in a map keyed by function name / builtin type, so two tools sharing a key meant one **silently
258
+ replaced** the other and the model never saw it — surfacing much later as "the model called the
259
+ wrong tool", with nothing in the logs pointing at the cause. `'warn'` keeps last-write-wins (an
260
+ app relying on a deliberate override still works — R4) but emits an `onWarning`
261
+ (`code: 'tool_name_collision'`) naming the key and which tool lost; `'error'` throws at
262
+ construction or `addTool()`, before the model is called. Re-registering the *same* tool object is
263
+ not a collision.
264
+ - **Per-request retry overrides** — `HttpRequest.retry` (`maxRetries`, `totalTimeoutMs`,
265
+ `attemptTimeoutMs`, `maxRetryAfterMs`, `backoff`). A queue's retry policy is shared by every call
266
+ on it, so a one-off that needs to be more or less patient — a long batch submit, a health check
267
+ that should fail fast — previously had to accept the shared policy or get its own queue. Mirrors
268
+ Google moving `retryOptions` from client-level to per-request `HttpOptions` (google-ts 2.15).
269
+ `perKind` deliberately stays queue-level: one request cannot redefine which error classes are
270
+ retryable for everyone sharing the queue. Precedence is per-request → per-kind → queue default.
271
+
272
+ ### Added — OpenAI Responses parity
273
+
274
+ - **Assistant `phase` (`commentary` | `final_answer`)** on `TextPart` and on streamed text events.
275
+ Codex-family models narrate before answering; `response.text` concatenates both, so **an agent's
276
+ final output used to include its own thinking-out-loud**. `AgentLoop` now derives its answer with
277
+ the new `finalAnswerText()` helper, which drops commentary. `response.text` and `contentText()`
278
+ are unchanged — callers who want everything still get everything.
279
+ - Open union (R1), and `finalAnswerText` excludes only what is explicitly `'commentary'` rather
280
+ than keeping only `'final_answer'`: the day a provider adds a third phase, an allow-list would
281
+ silently drop the answer.
282
+ - Streaming carries it too. `phase` is announced once on `response.output_item.added` and belongs
283
+ on every delta of that item, so the parser keeps per-stream item→phase state; concurrent streams
284
+ cannot leak phases into each other. Commentary is yielded to the consumer and preserved in the
285
+ assembled content as its own phase-tagged part. (In 2.0.0 the agent-layer event dropped the
286
+ phase, so a UI could not act on it — corrected in 2.0.1.)
287
+ - Nothing is inferred: a model that reports no phase produces parts with no phase, exactly as
288
+ before.
289
+ - **`name` + `namespace` on `function_call_output`.** The tool name is taken from the matching
290
+ call — tracked across messages while building the input, never invented, so a result with no
291
+ matching call simply omits it. `ToolResultPart.namespace` round-trips the namespace of a
292
+ namespaced tool. Probe-verified 2026-08-06: accepted, and a non-string `namespace` is rejected,
293
+ so the fields are validated rather than tolerated.
294
+
295
+ ### Added — programmatic tool calling (the model writes code that calls your tools)
296
+
297
+ A model can now write a short program that orchestrates your tools itself, instead of
298
+ emitting one call at a time and waiting for each result. Previously the `program` items
299
+ in the response were dropped on the floor.
300
+
301
+ - **`ToolCaller` on `ToolCallPart` and `ToolResultPart`** — `{ type: 'direct' | 'program',
302
+ callerId? }`. Absent means what it always meant (the model called the tool itself), so
303
+ nothing changes for existing code. Open union with an optional payload rather than
304
+ `{type:'direct'} | {type:'program', callerId}` (R1 + R2), so a future caller kind is
305
+ additive; an unknown type is preserved rather than flattened to `direct`.
306
+ - **`ProgramCallPart` (`program_call`) and `ProgramResultPart` (`program_result`)** —
307
+ the code the model wrote, and what it returned. The code is plain readable JavaScript
308
+ and worth surfacing: it is the plan the model is executing.
309
+ - **`allowedCallers` is now enforced locally**, not only by the provider. A tool without
310
+ it is `direct`-only, so model-written code cannot reach a tool that never opted in. A
311
+ violation denies that one call with an error result to the model — the way a guardrail
312
+ trip does — rather than ending the run.
313
+ - **Round-tripping is the whole feature**, and three wire rules make it work (all found
314
+ live, none of them in any SDK's types):
315
+ - The `program` item is **rejected without the `reasoning` item that produced it**, so
316
+ that item is captured and re-emitted with it.
317
+ - **Dropping the program item is worse than an error**: the model silently re-emits the
318
+ program and runs it again from the start.
319
+ - `program_output` **requires its `id`** when replayed as history — unlike
320
+ `function_call_output`, which needs none. Without it a follow-up question 400s on a
321
+ conversation that had just succeeded.
322
+ - **Availability, checked model by model:** of the 53 gpt-5 / o3 / o4 / codex models
323
+ visible on the test account, only the **`gpt-5.6` family** (`luna`, `sol`, `terra`)
324
+ accepts the `programmatic_tool_calling` tool. Every other one returns
325
+ *"Tool 'programmatic_tool_calling' is not supported with <model>"*.
326
+
327
+ ### Added — structured transcription
328
+
329
+ `transcribe()` returned `{ text }` and nothing else, so segments, speakers and word timings that
330
+ the provider had already computed were parsed and thrown away.
331
+
332
+ - **New request options:** `keywords` (spelling control for names and jargon), `languages`
333
+ (candidate languages when the language is unknown), `wordTimestamps`, and `diarization`.
334
+ - **New response fields, all optional:** `segments` (with `speaker` when diarizing), `words`,
335
+ detected `languages`, and `durationSeconds`. **`text` stays required** (R3), so existing code is
336
+ untouched — the additions appear only when the chosen model produces them.
337
+ - **Behaviourally verified, not just accepted** (E2). `keywords` changes the transcript: an invented
338
+ name that comes back as *"Zalbrequist"* without it comes back as *"Zylberquist"* with it, on
339
+ identical audio. `languages` changes what the model reports detecting, and an invalid code is
340
+ rejected. Both are `gpt-transcribe`-only; word timings are `whisper-1`-only; speaker labels are
341
+ `gpt-4o-transcribe-diarize`-only. **No model returns speakers and word timings together**, so
342
+ combining `wordTimestamps` with `diarization` throws before any request is sent.
343
+ - **Model-gated options are still sent.** A field the chosen model rejects produces a 400 naming
344
+ the parameter, rather than being dropped on our side — the caller learns their keywords did
345
+ nothing (R4: gating is internal, but silence is not a gate). On generateContent providers, which
346
+ have no structured endpoint at all, the same request emits an `onWarning`
347
+ (`transcription_option_unsupported`).
348
+ - **Transcription cost is now measured, not estimated.** These models return the audio duration
349
+ they billed for (`usage.seconds`, or a top-level `duration`), which is used when the caller
350
+ supplies none. Previously a non-WAV file with no `audioDurationSeconds` could only produce an
351
+ honest zero.
352
+ - **Google's equivalent is deliberately absent.** `audioTranscriptionConfig` is accepted *and
353
+ type-validated* by the Gemini Developer API and then completely ignored: a two-speaker round-trip
354
+ returned a response structurally identical to the control — no `speakerLabel`, no `words[]`
355
+ anywhere (2026-08-09). It is the second confirmed accepted-but-inert field after `top_k`. Shipping
356
+ it on the strength of the green probe would have meant a diarization feature that silently
357
+ returns nothing.
358
+
359
+ **Breaking (2.0):** `OpenAITranscriptionAdapter.transcribe()` returns
360
+ `OpenAITranscriptionResult` instead of `string`; read `.text`. The `transcribe()` helper is
361
+ unaffected — it already returned an object.
362
+
363
+ ### Fixed — correctness
364
+
365
+ - **Parallel tool calls were broken on every chat-completions backend.** The loop answers a round
366
+ of parallel calls with one tool message holding a `tool_result` part per call, and this API wants
367
+ a separate `{role:'tool'}` message per `tool_call_id` — but only the **first** was emitted. Every
368
+ call after the first went unanswered and the provider rejected the whole request with
369
+ *"No tool output found for function call <id>"*. Affected OpenRouter and any use of
370
+ `api: 'completions'` on OpenAI/xAI; the Responses path was always correct. Present in 1.7.0 and
371
+ earlier; found by running the examples corpus, not by a unit test.
372
+ - **`serviceTier: 'fast'` is actually sent to OpenAI.** The value shipped in openai-ts 7.x but was
373
+ missing from our known-tier set, so `openaiRequestTier('fast')` fell through to `'auto'` — a
374
+ caller asking for Fast mode silently got the project default, with no error and no warning.
375
+ Probe-verified on `gpt-5.5`: `fast` accepted, `hyperfast` rejected, so the value is validated
376
+ rather than merely tolerated. Applies to Responses and chat-completions.
377
+ - **A `Retry-After` longer than we will honour now fails fast instead of parking the request.** New
378
+ config `RetryConfig.maxRetryAfterMs` (default **120s**). Previously an un-capped value was obeyed
379
+ verbatim: `Retry-After: 86400` held the request for a day, which from the caller's side is
380
+ indistinguishable from a hang. Worse, on the rate-limit path it also paused the **entire**
381
+ limiter — every request on that queue, not just the one that was throttled. Both paths are now
382
+ clamped, and an over-cap value is treated as a refusal rather than a delay.
383
+ - **`Retry-After` parsing hardened.** The HTTP-date form (RFC 9110) is now parsed instead of being
384
+ silently ignored, and malformed values (`NaN`, negative, non-finite, a past date) are discarded
385
+ rather than propagated — `setTimeout(fn, NaN)` fires immediately, which turned one bad header
386
+ into an instant retry storm.
387
+
388
+ ### Unchanged, deliberately
389
+
390
+ - **Google Interactions keeps sending `temperature` and `top_p`.** google 2.15 deleted both from its
391
+ Interactions `GenerationConfig` type, which resembles the pattern behind two earlier live
392
+ breakages — but the wire disagrees: probed 2026-08-06 on `gemini-3.6-flash`, both are accepted
393
+ (200) and *validated* (`"warm"` / `-7` → 400). The removal is SDK-typing-only; stripping them
394
+ would have been the regression. Recorded at the call site so a later cycle does not "fix" it.
395
+
396
+ ---
397
+
398
+ Upstream reconciliation for the 2026-07-27 clone refresh (10 SDKs). This batch is dominated by
399
+ **terminal-state correctness**: three providers widened response enums that our adapters silently
400
+ flattened to `'stop'`, so a caller could not distinguish a refusal, a context overflow, a queued
401
+ interaction or an outright failure from a clean finish.
402
+
403
+ ### Fixed
404
+ - **Google Interactions no longer sends `cached_content`.** google 2.13 removed it from the
405
+ Interactions request model and the endpoint now hard-rejects it — live-probed:
406
+ `400 Unknown parameter 'cached_content'`. Any call passing `providerOptions.cachedContent` on
407
+ Interactions failed outright. The passthrough **moved to `generateContent`**, which still accepts
408
+ and validates it (top-level `cachedContent`), so the capability is preserved rather than dropped.
409
+ - **Anthropic `refusal` → `finishReason: 'content_filter'`** (was `'stop'`). A safety decline is a
410
+ block, not a clean finish; it now lines up with every other provider's block signal. The refusal
411
+ category enum also gained `general_harms` (anthropic 0.115).
412
+ - **Anthropic `model_context_window_exceeded` → `finishReason: 'length'`** (was `'stop'`), on both
413
+ the buffered and streamed reason maps.
414
+ - **OpenAI Responses `status: 'failed'` → `finishReason: 'error'`** (was `'stop'`), and `cancelled`
415
+ → `'error'`. A Responses call can fail *inside a 200*, so there was no exception to catch and the
416
+ caller silently received an empty success.
417
+ - **Google Interactions `queued` no longer ends a stream.** The status is non-terminal, but the
418
+ stream parser emitted a terminal `done` for it, truncating the run.
419
+
420
+ ### Security
421
+ - **`TelemetryAdapter` can redact provider error text.** New option
422
+ `includeSensitiveData` (default `true` — unchanged behaviour, and the same default as the OpenAI
423
+ Agents SDK's `trace_include_sensitive_data`). A provider's `error.message`/`error.raw` can echo
424
+ request content back (a refusal quotes the prompt, a validation error names the field and value),
425
+ and we stored it verbatim. With `includeSensitiveData: false` the message becomes `***REDACTED***`
426
+ and `raw` is dropped, while `name`/`code`/`status` are kept so traces stay triageable. URLs and
427
+ headers were, and remain, always redacted.
428
+
429
+ ### Fixed (hardening)
430
+ - **SSE streams are now cancelled, not just unlocked.** `parseSSEStream` released the reader lock in
431
+ its `finally` but never cancelled the body, so a consumer that broke out early (abort, error, or a
432
+ `break` after the first token) left the HTTP response open until GC. Verified live on Anthropic,
433
+ OpenAI and Google: full streams unchanged, early `break` tears down cleanly.
434
+ - **Non-replayable request bodies are never retried.** A streamed body is consumed by the first
435
+ attempt, so a retry would send an empty/partial body. Our own `rawBody` callers all pass FormData
436
+ or bytes (replayable), so this is a guard against a caller-supplied stream rather than a live bug.
437
+ - **Case-insensitive response-header lookup.** `google/files.ts` guessed three casings of
438
+ `x-goog-upload-url` and would have missed any fourth. Header reads now go through one shared
439
+ `header()` helper in `util/http` (de-duplicated with the private copy in `llm/files/retrieve.ts`).
440
+
441
+ ### Added
442
+ - **`topK` and `seed` sampling options.** Both were reachable on several providers and exposed by
443
+ none of our surface — parity gaps found by the new feature-matrix audit and closed the same day.
444
+ Each is emitted **only where the wire accepts it**, verified by live probe rather than inferred:
445
+ `topK` → Anthropic, Google (generateContent *and* Interactions), xAI chat, OpenRouter chat;
446
+ dropped for OpenAI, which defines no top-k. `seed` → OpenAI **chat-completions**, Google (both
447
+ surfaces), xAI (chat *and* responses), OpenRouter chat; dropped for Anthropic and OpenAI
448
+ **Responses**, which both reject it (400). Sending either to a surface that refuses it is a hard
449
+ error, so the gating is locked by unit tests and was live-verified end to end.
450
+ - **`docs/feature-matrix.json` — the parity matrix.** Every capability an official SDK exposes, how
451
+ each provider spells it, and where we stand, with citations into the version-pinned clones.
452
+ `scripts/validate-feature-matrix.mjs` runs as part of `bun run lint` and fails the build on a
453
+ broken citation, an unexplained `partial`/`beta`/`by-design`, or a duplicate id. It backs the
454
+ site's comparison page and is maintained by the upstream-update cycle.
455
+ - **`FinishReason` gains `'pending'`** — non-terminal: the provider accepted the request but has not
456
+ produced a completion (Google Interactions `queued`, OpenAI Responses `queued`/`in_progress` in
457
+ background mode). Treat as "poll/retry", never as a result. *Additive union member: exhaustive
458
+ `switch` statements over `FinishReason` should add a case.*
459
+ - **`CompletionResponse.error?: { code?, message? }`** — populated when `finishReason === 'error'`,
460
+ carrying the provider's own failure detail (e.g. OpenAI's new `data_residency_mismatch` code,
461
+ openai 6.49). Optional field; absent unless the provider reported a failure.
462
+ - Documented the OpenAI `reasoning.context` default (the `gpt-5.6` family defaults to `all_turns`,
463
+ earlier models to `current_turn`).
464
+ - **`itemId` on `text` / `thinking` stream events.** OpenAI Responses reports which output item a
465
+ delta belongs to (`item_id`); a turn can interleave deltas from several items, so consumers that
466
+ reassemble per item — rather than concatenating into one string — now can. Optional and additive:
467
+ ignoring it gives exactly the previous behaviour, and providers that report no item id (chat
468
+ completions) simply omit it. Live-confirmed present on every delta from the Responses API.
8
469
 
9
470
  ## [1.7.0] - 2026-07-16
10
471
 
package/MIGRATION.md ADDED
@@ -0,0 +1,93 @@
1
+ # Migrating to 2.0.0
2
+
3
+ **Most codebases need no source changes.** The point of this library is that provider churn is our
4
+ problem to absorb, not yours — and a whole cycle of it (a new MCP protocol revision, a new OpenAI
5
+ major, four SDK majors) landed here without becoming a breaking change for you.
6
+
7
+ Three things can require action, and none of them is a provider change.
8
+
9
+ ## 1. Node 22+ is required
10
+
11
+ ```json
12
+ "engines": { "node": ">=22", "bun": ">=1.1.0" }
13
+ ```
14
+
15
+ Node 18 and 20 are both end-of-life. 22 is also the floor `openai-node` 7 adopted.
16
+
17
+ **What to do:** upgrade the runtime. Nothing in your code changes.
18
+
19
+ ## 2. `tiktoken` is now an optional PEER dependency
20
+
21
+ It used to be an `optionalDependency`, which means *"do not fail the install if this package fails
22
+ to build"* — npm installed it **anyway**. Every consumer received its ~5.6 MB wasm file, and
23
+ bundlers emitted it into production builds even when local token counting was never used. One
24
+ consumer measured it at **88% of their shipped output**.
25
+
26
+ **What to do:** if you use exact local OpenAI token counting, install it yourself:
27
+
28
+ ```sh
29
+ npm install tiktoken
30
+ ```
31
+
32
+ If you don't, do nothing — you now stop paying for a feature you never asked for. Token counting
33
+ still works without it: `countTokens` falls back to the provider count-API (Anthropic/Google) or a
34
+ calibrated heuristic. The error thrown when the package is genuinely needed names it and the
35
+ alternatives.
36
+
37
+ It still works in the **browser** when you do install it; it is deliberately not stubbed out.
38
+
39
+ ## 3. Two unions are now open — add a `default` branch
40
+
41
+ `FinishReason` and `ContentPart` gained members and are now open unions
42
+ (`KnownFinishReason | (string & {})`). If you `switch` over either **exhaustively, with no
43
+ `default`**, TypeScript will now complain.
44
+
45
+ ```ts
46
+ switch (res.finishReason) {
47
+ case 'stop': …
48
+ case 'tool_use': …
49
+ default: … // <- add this
50
+ }
51
+ ```
52
+
53
+ **This is deliberate, and it is the reason most of this release is not breaking.** Providers grew
54
+ four new terminal statuses in a single cycle. With a closed union, every one of those is a breaking
55
+ change for *every* consumer — including consumers of providers that changed nothing. Open unions
56
+ convert that into an additive change, at the cost of one `default` branch written once
57
+ (CONSTITUTION.md R1).
58
+
59
+ New members you can now handle if you want them: `'pending'` (queued / in-progress — previously
60
+ flattened to `'stop'`, claiming a clean finish for a response that had not run), and
61
+ `'malformed_tool_call'`. `ContentPart` gained `program_call` / `program_result`.
62
+
63
+ ## 4. One changed signature
64
+
65
+ `OpenAITranscriptionAdapter.transcribe()` returns `OpenAITranscriptionResult` instead of `string`:
66
+
67
+ ```ts
68
+ // before
69
+ const text = await adapter.transcribe(req, fetch);
70
+
71
+ // after
72
+ const { text } = await adapter.transcribe(req, fetch);
73
+ ```
74
+
75
+ The result also carries optional `segments`, `words`, `languages` and `durationSeconds`.
76
+
77
+ **The `transcribe()` helper is unaffected** — it already returned an object, and `text` is still
78
+ required on it. Only the low-level adapter class changed.
79
+
80
+ ## What did NOT break
81
+
82
+ Worth stating, because it is the whole design goal:
83
+
84
+ - **MCP 2025-11-25 keeps working, untouched.** The 2026-07-28 revision deletes the `initialize`
85
+ handshake, the session id and the entire back-channel — but this client speaks **both** wires and
86
+ prefers neither. No legacy path was removed. Even the WebSocket transport stays, documented as
87
+ non-standard, though upstream deleted theirs.
88
+ - **Every response type only gained optional fields.** Nothing was removed, narrowed, or made
89
+ required.
90
+ - **Every request option is still accepted.** Where a provider stopped taking one, we decide
91
+ internally whether it reaches the wire — your build does not break because of their typings.
92
+
93
+ Full detail in [CHANGELOG.md](./CHANGELOG.md).
package/README.md CHANGED
@@ -10,7 +10,7 @@ A unified, pluggable AI SDK for accessing the LLMs of every major provider —
10
10
  `model:tier` selectors, capability-based `select()`, tiered pricing, and cost
11
11
  tracking.
12
12
  - **Cross-environment.** The same code runs on Node, Bun, and the browser. Zero
13
- runtime dependencies, ESM.
13
+ **required** runtime dependencies, ESM.
14
14
 
15
15
  ## Install
16
16
 
@@ -18,7 +18,22 @@ A unified, pluggable AI SDK for accessing the LLMs of every major provider —
18
18
  npm install @combycode/llm-sdk # or: bun add @combycode/llm-sdk
19
19
  ```
20
20
 
21
- Requires Node 18 or Bun 1.1.
21
+ Upgrading from 1.x? See [MIGRATION.md](./MIGRATION.md) three things can
22
+ require action, and none of them is a provider change.
23
+
24
+ Requires **Node ≥ 22** or Bun ≥ 1.1. (Node 18 and 20 are end-of-life; 22 is also the floor
25
+ `openai-node` 7 adopted.)
26
+
27
+ `dependencies` is empty. The one optional extra is `tiktoken`, an **optional peer** for exact local
28
+ OpenAI token counting — install it only if you want that:
29
+
30
+ ```sh
31
+ npm install tiktoken
32
+ ```
33
+
34
+ Without it everything still works: token counting falls back to the provider count-API
35
+ (Anthropic/Google) or a calibrated heuristic. It is a peer rather than an `optionalDependency` so
36
+ its ~5.6 MB wasm is not installed — or bundled — for people who never use it.
22
37
 
23
38
  ## Quickstart
24
39
 
@@ -4,6 +4,7 @@ import type { LLMClient } from '../llm/client';
4
4
  import type { CacheConfig, ThinkingConfig } from '../llm/types/request';
5
5
  import type { ConversationHistory } from './history';
6
6
  import type { HistorySnapshot } from './history-types';
7
+ import type { ReflectAndRetryConfig } from './reflect-retry';
7
8
  import type { AgentTool } from './types';
8
9
  import type { Guardrail, ToolInputGuardrail } from './guardrail-types';
9
10
  import type { PermissionPolicy } from '../plugins/permissions/policy';
@@ -23,6 +24,27 @@ export interface AgentLoopConfig {
23
24
  context?: string;
24
25
  /** Executable tools. Indexed by function name (FunctionTool) or type (BuiltinTool). */
25
26
  tools?: AgentTool[];
27
+ /** What to do when two tools claim the same registry key.
28
+ *
29
+ * Registration is a map keyed by function name / builtin type, so a collision means one tool
30
+ * SILENTLY replaces another and the model never sees it. The failure then surfaces much later
31
+ * as "the model called the wrong tool", with nothing in the logs pointing at the cause.
32
+ *
33
+ * - `'warn'` (default) — keep last-write-wins, but emit an `onWarning`
34
+ * (`code: 'tool_name_collision'`) naming the key and which tool lost.
35
+ * - `'error'` — throw at construction / `addTool()`, before the model is ever called.
36
+ *
37
+ * Defaults to `'warn'` so an app that unknowingly has a collision keeps working
38
+ * (CONSTITUTION.md R4) — the collision just stops being invisible. */
39
+ toolNameCollisionPolicy?: 'warn' | 'error';
40
+ /** Self-healing recovery from a recoverable MODEL failure (a malformed tool call, a hallucinated
41
+ * tool name, a truncated call). The model is given structured guidance naming the attempt and
42
+ * told not to repeat the same call, then the step is retried within a bounded budget.
43
+ *
44
+ * Off unless configured: a retry costs a real request, so it is the caller's decision. This is
45
+ * NOT a network retry — the engine already handles transport failures. This one is for a request
46
+ * that succeeded and came back unusable, which resending unchanged would never fix. */
47
+ reflectAndRetry?: ReflectAndRetryConfig;
26
48
  /** Reuse an existing history (or rehydrate from a snapshot). New history
27
49
  * is created when omitted. */
28
50
  history?: ConversationHistory | HistorySnapshot;
@@ -12,6 +12,8 @@ export interface ToolCallAccumEntry {
12
12
  /** All mutable state for one streaming step inside AgentLoop.stream(). */
13
13
  export interface StepState {
14
14
  stepText: string;
15
+ /** Commentary deltas, kept apart from stepText so the step's answer excludes narration. */
16
+ stepCommentary: string;
15
17
  stepThinking: string;
16
18
  stepToolCalls: ToolCallPart[];
17
19
  toolCallAccum: Map<string, ToolCallAccumEntry>;