@combycode/llm-sdk 2.3.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/CHANGELOG.md +429 -0
  2. package/MIGRATION.md +130 -0
  3. package/dist/bus/hook-bus.d.ts +13 -3
  4. package/dist/bus/hook-map.d.ts +24 -0
  5. package/dist/catalog/catalog.d.ts +23 -26
  6. package/dist/helpers/client-resolver.d.ts +21 -2
  7. package/dist/helpers/count-tokens.d.ts +1 -1
  8. package/dist/helpers/engine.d.ts +27 -5
  9. package/dist/helpers/select-model.d.ts +35 -0
  10. package/dist/index.browser.js +17394 -4392
  11. package/dist/index.d.ts +3 -3
  12. package/dist/index.js +17394 -4392
  13. package/dist/llm/client-config.d.ts +8 -0
  14. package/dist/llm/client.d.ts +5 -0
  15. package/dist/llm/providers/anthropic/batch.d.ts +13 -2
  16. package/dist/llm/providers/anthropic/constants.d.ts +8 -43
  17. package/dist/llm/providers/anthropic/files.d.ts +13 -2
  18. package/dist/llm/providers/anthropic/messages.d.ts +18 -1
  19. package/dist/llm/providers/google/batch.d.ts +14 -1
  20. package/dist/llm/providers/google/constants.d.ts +6 -15
  21. package/dist/llm/providers/google/embeddings.d.ts +8 -1
  22. package/dist/llm/providers/google/files.d.ts +19 -1
  23. package/dist/llm/providers/google/generate.d.ts +15 -1
  24. package/dist/llm/providers/google/interactions.d.ts +5 -1
  25. package/dist/llm/providers/google/media.d.ts +26 -3
  26. package/dist/llm/providers/openai/batch.d.ts +19 -2
  27. package/dist/llm/providers/openai/completions.d.ts +13 -1
  28. package/dist/llm/providers/openai/embeddings.d.ts +13 -1
  29. package/dist/llm/providers/openai/files.d.ts +13 -2
  30. package/dist/llm/providers/openai/media.d.ts +21 -1
  31. package/dist/llm/providers/openai/moderations.d.ts +11 -1
  32. package/dist/llm/providers/openai/realtime.d.ts +3 -0
  33. package/dist/llm/providers/openai/responses.d.ts +10 -2
  34. package/dist/llm/providers/openai/transcription.d.ts +7 -1
  35. package/dist/llm/providers/openrouter/completions.d.ts +5 -3
  36. package/dist/llm/providers/openrouter/embeddings.d.ts +3 -0
  37. package/dist/llm/providers/openrouter/media.d.ts +14 -4
  38. package/dist/llm/providers/openrouter/responses.d.ts +5 -3
  39. package/dist/llm/providers/xai/batch.d.ts +13 -2
  40. package/dist/llm/providers/xai/completions.d.ts +5 -3
  41. package/dist/llm/providers/xai/files.d.ts +13 -2
  42. package/dist/llm/providers/xai/media.d.ts +28 -10
  43. package/dist/llm/providers/xai/responses.d.ts +5 -3
  44. package/dist/llm/response-shape.d.ts +96 -0
  45. package/dist/llm/types/provider.d.ts +11 -0
  46. package/dist/llm/types/request.d.ts +5 -6
  47. package/dist/llm/wire-multipart.d.ts +26 -0
  48. package/dist/{wire/transforms.d.ts → llm/wire-transforms.d.ts} +11 -5
  49. package/dist/plugins/context-measurer/counter/count-api.d.ts +39 -5
  50. package/dist/plugins/context-measurer/counter/hybrid.d.ts +33 -0
  51. package/dist/plugins/context-measurer/counter/tiktoken.d.ts +7 -0
  52. package/dist/plugins/mcp/transport-http.d.ts +13 -7
  53. package/dist/plugins/mcp/wire-rules.d.ts +21 -0
  54. package/dist/plugins/retrieval/document-file.d.ts +15 -0
  55. package/dist/plugins/retrieval/hosted-google.d.ts +8 -3
  56. package/dist/plugins/retrieval/hosted-openai.d.ts +9 -1
  57. package/dist/plugins/retrieval/hosted-xai.d.ts +8 -2
  58. package/dist/wire/chat-specs.d.ts +32 -0
  59. package/dist/wire/inherit.d.ts +0 -4
  60. package/dist/wire/interpreter.d.ts +84 -1
  61. package/dist/wire/mcp-specs.d.ts +13 -0
  62. package/dist/wire/media-specs.d.ts +20 -0
  63. package/dist/wire/pins.d.ts +34 -0
  64. package/dist/wire/registry.d.ts +6 -4
  65. package/dist/wire/retrieval-specs.d.ts +14 -0
  66. package/dist/wire/service-specs.d.ts +18 -0
  67. package/dist/wire/utility-specs.d.ts +10 -0
  68. package/package.json +4 -1
package/CHANGELOG.md CHANGED
@@ -4,6 +4,435 @@ 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]
8
+
9
+ ## [3.1.0] — 2026-08-25
10
+
11
+ ### Added
12
+
13
+ - **A provider can declare a hosted tool it will not run beside certain content** (`toolConstraints`
14
+ in a wire spec, plus a `hasPartType` condition). Measured 2026-08-25: Google answers 400 `The mime
15
+ type: video/mp4 is not supported for code execution` when `code_interpreter` accompanies a PDF or a
16
+ video. Images are fine, `web_search` is fine, and OpenAI accepts every combination — so it is one
17
+ provider's rule, and it lives in that provider's spec as data rather than as an `if` in the builder.
18
+
19
+ A matched constraint drops the tool — `hasTool` reports it absent, so the spec's existing guard
20
+ omits it with no further edit — and records why on the built request. `LLMClient` emits each as
21
+ `onWarning` with code `request_adjusted`, on both `complete()` and `stream()`. Dropping it quietly
22
+ would trade a confusing error for the silent loss of a capability the caller asked for.
23
+
24
+ - **Any spelling of a model id finds the model, and every helper resolves it the same way.**
25
+ Providers spell one version several ways and users copy whichever they saw: `gpt-4.1` / `gpt-4-1`,
26
+ `gemini-2.5-flash` / `gemini-2-5-flash`, `claude-haiku-4.5` / `claude-haiku-4-5`. Only some are
27
+ callable, and the rest missed the catalog outright — no price, no capabilities — and were then
28
+ forwarded to the provider verbatim, turning a spelling difference into a 404.
29
+
30
+ Catalog lookups are now insensitive to the separator between two digits and to case, so ~556
31
+ previously-unresolvable spellings reach their entry. The rule is deliberately narrow — only a
32
+ separator between two DIGITS moves — and is verified across the shipped catalogs to merge nothing:
33
+ 1016 normalized keys, zero collisions.
34
+
35
+ `resolveModelId()` now always returns something callable. A spelling the provider itself accepts is
36
+ sent unchanged; one it would reject is corrected to the canonical id instead of being forwarded;
37
+ an unknown id still passes through verbatim, so fine-tunes and same-day releases keep working.
38
+
39
+ ### Fixed
40
+
41
+ - **`createRealtime()` never translated the model id.** It parsed the provider but skipped the
42
+ catalog step every other helper performs, so a realtime session was the one path that sent our
43
+ slug instead of the provider's id. All helpers now share the single resolution step, and a test
44
+ calls each one and reads the wire so a helper that forgets fails CI rather than a user's request.
45
+
46
+ - **An Anthropic model is reachable by the name Anthropic documents.** `claude-haiku-4-5` — the
47
+ spelling in Anthropic's own docs, and one the API accepts — matched no catalog key and no alias,
48
+ because `/v1/models` lists only the dated snapshot (`claude-haiku-4-5-20251001`) while our slug
49
+ dots the version (`claude-haiku-4.5`). `get()` and `getPricing()` missed in silence, and an
50
+ unpriced model is indistinguishable from a free one: reported from production as 72k tokens
51
+ billed at $0.00.
52
+
53
+ The undated form is now carried as an alias for the four affected entries (`claude-haiku-4-5`,
54
+ `claude-sonnet-4-5`, `claude-opus-4-5`, `claude-opus-4-1`), derived in the catalog pipeline so a
55
+ regeneration keeps it. Anthropic-only, and deliberately so — the same date-stripping applied to
56
+ other providers invents ids that do not exist (`imagen-4.0-generate`, `command-r7b-12`), since
57
+ only there is the undated form a truncation rather than a real alias. Each was probed live.
58
+
59
+ `providerModelName` is untouched, so **what goes on the wire is unchanged**: a slug still
60
+ translates to its pinned snapshot, and an alias is still sent verbatim as the floating id the
61
+ caller chose. This widens what the catalog recognises, never what it calls.
62
+
63
+ ## [3.0.0] — 2026-08-24
64
+
65
+ ### Added
66
+
67
+ - **Exact token counting for xAI**, via `/v1/tokenize-text`. Their own SDK reaches the tokenizer over
68
+ gRPC (`xai_api.Tokenize/TokenizeText`), which made it look like exact counts on xAI would cost a
69
+ protobuf dependency and an optional peer. Asking the REST host instead: it answers 200 with the
70
+ same token list, so this is one more spec-built request and the library stays zero-dependency.
71
+
72
+ All seven xAI text models now declare `count_api` — measured per model rather than generalised
73
+ from one success: the five image and video models do not answer the endpoint and stay on the
74
+ heuristic. On a short Cyrillic line the estimate and the tokenizer differ by 30% on emoji and 23%
75
+ on code.
76
+
77
+ One distinction the guide now spells out: Anthropic and Google count the message array a
78
+ completion would send, so their answer is what the completion is billed for, while xAI tokenizes
79
+ a STRING — exact for that text, excluding the chat framing around it.
80
+
81
+ - **`createEngine({ checkResponseShapes: true })` — warn when a provider's response stops looking
82
+ like the one we learned to read.** A bad request returns 400 and you know at once; a bad response
83
+ returns 200, the parse succeeds, and the field we read is simply gone — for `usage.output_tokens`
84
+ that is cost reporting silently going to zero.
85
+
86
+ Four findings, on the warning bus as `response_shape_*`: a field never seen, a field that was
87
+ present in every recording and is now absent (what a rename looks like from outside), a
88
+ discriminator carrying a value nothing branches on (a new content-block type is dropped in
89
+ silence), and a streaming event type the parser does not handle. Off by default, never changes
90
+ what is parsed, and each distinct finding is reported **once per client** — a warning that repeats
91
+ every request is one people switch off.
92
+
93
+ The shapes are DERIVED from the recorded response corpus by `bun run derive:shapes`, never
94
+ hand-written, and a test re-checks every recorded body against them so the description cannot
95
+ drift from the recordings. Stream shapes are keyed per SSE event type: pooling them was the first
96
+ attempt and it cost the missing-field check entirely, since `message_start` and
97
+ `content_block_delta` share almost no fields.
98
+
99
+ The value of deriving rather than writing showed up immediately — a hand-written "normal
100
+ Anthropic response" in the first draft of the test was missing eight fields Anthropic sends on
101
+ every call (`stop_details`, `usage.service_tier`, `usage.cache_read_input_tokens` among them).
102
+ The check was right and the hand-written body was wrong.
103
+
104
+ - **A recorded corpus of what providers send BACK** (`tests/fixtures/response-golden.json`, 42 cells).
105
+ All seven existing corpora describe REQUESTS; the parse side was exercised only against literals
106
+ written by hand in the test files, which tests what the author believed a provider returns. This
107
+ records the real thing — the pre-parse body, or the ordered SSE events — for seven adapters across
108
+ six shapes (text, tool call, parallel tool calls, structured output, streaming text, streaming
109
+ tool call), and replays them through the same `parseResponse` / `createStreamParser` with no
110
+ network.
111
+
112
+ `raw` is the provider's truth and moves only when `bun run record:responses --refresh` is run;
113
+ `parsed` is our behaviour and is recomputed on every test run, so a parser change surfaces as a
114
+ failure instead of as a quiet difference in what consumers receive. Two invariants are checked
115
+ across all providers at once: every non-streaming response yields usage and a finish reason, and
116
+ every stream ends in a terminal event.
117
+
118
+ The corpus was proven to fail on a renamed provider field, on a parser that drops the finish
119
+ reason, and on a cell quietly disappearing. It found both fixes below in its first run.
120
+
121
+ - **The SDK's event stream is one discriminated union** (`HookEvent`). `HookMap` types a
122
+ subscription — `on('onCompletion', h)` has always known its own context — but the STREAM was
123
+ `(name, ctx: unknown)`, which pushed the type back onto the subscriber. `HookEvent` gives it one
124
+ variant per hook, derived from `HookMap` so the 51 cannot drift from the 51:
125
+
126
+ ```ts
127
+ hooks.onAny((e) => {
128
+ if (e.type === 'onCompletion') e.ctx.response?.usage; // narrowed, no cast
129
+ });
130
+ ```
131
+
132
+ This is the shape the Python and Rust ports share: a tagged union and an `enum` over the same
133
+ catalog. A stream typed `ctx: unknown` has no equivalent in either — the consumer's only move is
134
+ to cast, which is exactly what the SDK's own telemetry adapter did.
135
+
136
+ - **A model ships as callable only if it has been called.** The catalog-loader's probe was
137
+ advisory; 366 of 440 names had never been verified. Now `active: true` requires either a real
138
+ request that answered (`verifiedBy: 'probe'`) or the provider's own model list naming it
139
+ (`verifiedBy: 'listed'`, the only evidence available for an image or TTS model). Everything else
140
+ ships `active: false` — present, priced, but not offered to `selectModel`.
141
+
142
+ A name is called ONCE: later runs check the listing instead, which is free. A verified name that
143
+ disappears from the list is marked deprecated rather than re-probed. Verification went from
144
+ 74/440 to 395/470.
145
+
146
+ - **`scripts/pin-catalog.ts` and `freeze-wire-golden.ts --add-new`** — a catalog import brings
147
+ models with no wire-spec pin and no entry in the frozen request corpus, and the library asserts
148
+ both. Deriving the pin and appending only the unseen models keeps those invariants true without
149
+ re-freezing a baseline that exists to be stable.
150
+
151
+ - **The last four hand-built surfaces are spec-driven**: exact token counting, live model listing,
152
+ file-content retrieval and the provenance check. With those, **every request the library sends
153
+ comes from a spec** — 145 of them. Nothing in `src/` assembles a URL, a header set or a body by
154
+ hand any more.
155
+
156
+ - **MCP is spec-driven too, transport and OAuth.** The Streamable-HTTP transport's five requests
157
+ (call, notification, long-lived subscription, event stream, session delete) and the OAuth flow's
158
+ five (two discovery probes, dynamic client registration, code exchange, refresh) now come from 13
159
+ specs — as does the authorization URL the user's browser opens.
160
+
161
+ This is the point where the spec format stops being about LLM providers. A JSON-RPC envelope,
162
+ era-dependent routing headers, a form-urlencoded token grant and an SSE stream are all described
163
+ with the constructs a chat request already used.
164
+
165
+ Verified against two frozen corpora (23 transport artifacts, 11 OAuth), both shown to fail on
166
+ deliberate corruption, plus live runs of the MCP protocol example and the five-provider MCP tool
167
+ scenario. The OAuth half has no live coverage anywhere — that needs a real authorization server
168
+ and a browser — so its frozen bytes are the only oracle it has, which the wire README now says
169
+ out loud.
170
+
171
+ - **Three more spec constructs**, each added because a real request needed it: `bodyKind: 'form'`
172
+ (the spec carries the FIELDS, the runtime encodes them — the same split multipart already used),
173
+ a header entry with `spread` (merge an evaluated object of headers, so a caller's header map and
174
+ a resolved bearer keep their precedence), and `queryEncoding: 'form'` (a space as `+` rather than
175
+ `%20`, which is what RFC 6749 prescribes for an authorization request and what the library
176
+ already sent).
177
+
178
+ - `NormalizedRequest.wireSpec` — the catalog's pin, resolved by `LLMClient` and read by
179
+ the adapter. Absent for an uncatalogued model or an engine with no catalog, in which
180
+ case the adapter derives the spec the way it always derived the shape.
181
+
182
+ - **`envelope.query` in the wire spec** — query parameters as data, each able to drop out on its
183
+ own. Splicing them into a `$join` URL only works while every parameter is present, and it left
184
+ the encoding to each caller: the hand-written backends disagreed about `encodeURIComponent`, so a
185
+ page token containing `+` paged from the wrong place and the API answered 200.
186
+
187
+ - **`$each` in array templates** — the array analogue of `$spread`, for a `$map` that has to sit
188
+ beside literal entries. Google's `tools` array is exactly that shape.
189
+
190
+ - **`scripts/gen-wire-registry.ts` (`bun run gen:registry`)** — regenerates the spec index from the
191
+ files on disk, with `--check` for CI. Specs have landed without their registry entry three times
192
+ now, and it fails silently: the spec becomes invisible to `WIRE_SPECS`, so the chain tests skip it
193
+ and the coverage audit reports it as neither referenced nor executed.
194
+
195
+ - **A live hosted-retrieval example** (`31-hosted-retrieval`), run on OpenAI and Google by the
196
+ quality gate. Hosted retrieval had unit tests and documentation but had never once been executed
197
+ against a provider, which is how both defects below survived.
198
+
199
+ ### Changed
200
+
201
+ - **`HookBus.onAny` receives one event object instead of `(name, ctx)`** (BREAKING). Handlers take
202
+ `(event: HookEvent)`; `event.type` narrows `event.ctx`.
203
+
204
+ ```ts
205
+ hooks.onAny((name, ctx) => { ... }); // before
206
+ hooks.onAny((event) => { ... }); // after
207
+ ```
208
+
209
+ The SDK's telemetry adapter was the only consumer, and it shows why the old shape was worth
210
+ breaking: it opened with `const c = ctx as Record<string, unknown>` and then cast per field —
211
+ `(c.response as { usage?: … })?.usage`, `c.latencyMs as number`, `(c.error as Error)?.message`.
212
+ Rename a context field and every one of those keeps compiling and quietly reads `undefined`,
213
+ which for the token and cost fields is a metric that silently goes to zero. All 26 casts in that
214
+ method are gone (0 left); the switch narrows instead, and the compiler now checks each field against the
215
+ context it actually belongs to.
216
+
217
+ Nothing is allocated when no catch-all is subscribed, so the per-chunk hot path is unchanged.
218
+ - **The catalog is current again, and the chain that maintains it works end to end.** The
219
+ catalog-loader's export pointed at the catalogs' old home, which was BOTH its write target and
220
+ its merge base — so the "never drop a shipped model, never blank a price" guarantee silently
221
+ guaranteed nothing, and a run would have dropped 10 models and all 284 `wireSpec` pins. Repointed,
222
+ and it now refuses to run at all if the merge base is missing.
223
+
224
+ With it fixed, a full run brought the catalog up to date: +99 models discovered, 57 prices
225
+ changed (all four provider pricing pages had moved), 42 tokenizer strategies delivered, 61 models
226
+ marked `active: false`, and 48 given a deprecation date the sources announced. The `wire` field
227
+ removed in 3.0.0 was still riding along on 26 entries and is now dropped.
228
+
229
+ - **The catalog is loaded by default** (BREAKING, behaviour). `createEngine()` and `LLMClient` used
230
+ to start with an EMPTY catalog unless the caller passed `catalog: 'defaults'`. Three things fell
231
+ back silently as a result: the wire spec was derived from the model id instead of read from its
232
+ pin, every price was unknown, and every token count was the 4-chars-per-token estimate. Each is
233
+ the right answer for a model this build has never heard of, which is why nothing looked wrong.
234
+
235
+ This is the prerequisite the 3.0.0 design named (report 037, R1): the adapters can only be driven
236
+ by per-model data if that data is actually there. The bundled catalogs are statically imported
237
+ either way, so leaving them unloaded never saved a byte — it cost about a millisecond of indexing
238
+ per engine and bought silence.
239
+
240
+ Opting out is now the explicit act: `catalog: false` or `catalog: 'empty'`.
241
+
242
+ - **`AnthropicCountApi` and `GoogleCountApi` take an `EngineFetch`, and it is required** (BREAKING).
243
+ They defaulted to `globalThis.fetch`, so every exact token count for Anthropic and Google went out
244
+ AROUND the NetworkEngine: no queue, no rate limiting, no retry, no telemetry span — while every
245
+ other file in the library states that all HTTP goes through the injected fetch. `countTokens()`
246
+ passes `engine.fetch` for you, so the documented path needs no change; a `HybridTokenCounter`
247
+ built with `countApiKeys` but no `fetch` now says so and falls back to the heuristic instead of
248
+ silently leaving the engine.
249
+
250
+ The default is gone rather than replaced, because a default that silently bypasses the engine is
251
+ what produced this.
252
+
253
+ - **MCP header assembly is one ordered list instead of three helpers.** Which headers a call
254
+ carries — session, protocol version, the modern `Mcp-Method` / `Mcp-Name` routing pair — was
255
+ decided by three private methods and by the order their results were spread into an object
256
+ literal. It is now a declared sequence, and the two rules that genuinely are not data (era
257
+ detection, and reading a subject from a different param per method) are named registry entries
258
+ the coverage audit executes.
259
+
260
+ One asymmetry was preserved rather than tidied: a NOTIFICATION carries no routing headers, which
261
+ is what the transport has always sent. The freeze caught the attempt to "fix" it, and there is no
262
+ modern server here to test the change against.
263
+
264
+ - **The chat adapters build their requests from the wire specs.** All five —
265
+ `anthropic/messages`, `google/generateContent`, `google/interactions`,
266
+ `openai/responses` and `openai/chat-completions`, the last two also covering the xAI
267
+ and OpenRouter flavors — now interpret the spec the catalog pins the model to, instead
268
+ of assembling the body by hand. 806 lines of request-building code became 147.
269
+
270
+ The three OpenAI-compatible subclasses are the clearest case: xAI and OpenRouter each
271
+ overrode `buildRequest` to call `super`, then rename `max_tokens`, strip `reasoning`,
272
+ remap the service tier and merge routing options. Every one of those edits is already
273
+ the flavor overlay in the shared spec, so naming the flavor is now the entire override.
274
+
275
+ **The wire did not move.** A corpus frozen from 2.3.0 — 290 subjects x 22 request
276
+ shapes, both the pinned and the id-derived route — is compared on every CI run, and all
277
+ 12,760 comparisons are byte-identical to what 2.3.0 sent. Verified live against all five
278
+ providers as well; the specs are proven, not assumed.
279
+
280
+ Two supporting moves: `wire-transforms` left `src/wire/` (it imports from `src/llm`, and
281
+ the new edge would otherwise have made a cycle), and the runtime loads a chat-only spec
282
+ set rather than the full 71-spec index, so nothing is bundled that nothing executes.
283
+ Cost: +15 KB packed, and ~5 microseconds per request against a network call.
284
+
285
+ - **Model-band selection is data, not code.** Which chain node an UNPINNED model uses now comes
286
+ from `src/wire/pins/*.json` — ordered regex rules plus a default — instead of version arithmetic
287
+ written in TypeScript. The Python and Rust ports read the same file rather than each
288
+ re-implementing the rule and drifting from it, which is how 2.2.1 happened.
289
+
290
+ The fallback itself is unchanged and still matters: it is how the SDK behaves for a model
291
+ released after this build, and for any engine run without a catalog. Verified against every id
292
+ the previous code handled, plus dated snapshots, legacy family-last ids, and plausible future
293
+ releases.
294
+
295
+ One deliberate fix came out of it: the old pair of helpers disagreed on case — one lower-cased
296
+ the model id and the other did not — so `CLAUDE-OPUS-4-6` lost a `top_k` that model accepts. The
297
+ pin table lower-cases consistently.
298
+
299
+ - **The hosted retrieval backends build their requests from wire specs.** OpenAI vector stores,
300
+ Google file search stores and xAI Grok collections — 23 hand-assembled requests across three
301
+ files — now come from 24 specs. This was the largest remaining block of request construction
302
+ written three times over, once per provider.
303
+
304
+ xAI shows why it matters: collections span two hosts with two separate credentials, and which
305
+ pair a call used was decided by whichever bearer helper the author typed next to the URL. It is
306
+ now a property of the endpoint, declared in the spec.
307
+
308
+ Verified against a corpus frozen from the pre-migration commit — 41 requests, byte-identical —
309
+ and by a live end-to-end run on both providers.
310
+
311
+ ### Fixed
312
+
313
+ - **An explicit `provider` was ignored whenever the model id contained a slash — sending the API key
314
+ to the wrong company.** `resolveModel` read the model's `vendor/` prefix first and fell back to the
315
+ explicit argument only for a bare id. Every OpenRouter model id is `vendor/model`, so the most
316
+ ordinary OpenRouter call there is —
317
+
318
+ ```ts
319
+ createLLM({ provider: 'openrouter', model: 'openai/gpt-5.4-nano', apiKey })
320
+ ```
321
+
322
+ — resolved to the provider `openai` and sent the **OpenRouter key to api.openai.com**, which
323
+ answered `Incorrect API key provided: sk-or-v1…`. A vendor outside our five failed differently and
324
+ no better: the prefix was cast to a `ProviderName`, so `qwen/qwen3` produced a provider literally
325
+ named `qwen` and died later as "no default adapter for provider 'qwen'".
326
+
327
+ An explicit provider now wins, and a redundant leading `<provider>/` is stripped, so the catalog's
328
+ own `openrouter/openai/gpt-5.4-nano` slug resolves to the OpenRouter model `openai/gpt-5.4-nano`.
329
+ Prefix parsing without a provider is unchanged and stays permissive — `estimate()` prices models
330
+ catalogued under providers nobody can call. Affects all nine call sites: `createLLM`, `batch`,
331
+ `embed`, `moderate`, `transcribe`, `createRealtime`, `countTokens`, `estimate`, `estimator`.
332
+
333
+ - **Google's `responseId` was thrown away and replaced with a random UUID.** `parseResponse` minted
334
+ `crypto.randomUUID()` under a comment claiming generateContent returns no id — it returns
335
+ `responseId` at the top level, and every recorded response carries one. Two consequences: the parse
336
+ was not deterministic, so the same bytes produced a different `response.id` each time and nothing
337
+ keyed on it could correlate; and a cache hit, which replays the stored body, reported a different
338
+ id than the call that populated it. The provider's id is now used, with the generated one kept as
339
+ a fallback for older payloads.
340
+
341
+ - **`batch`, `embed`, `transcribe` and `moderate` sent our SLUG instead of the provider's id.**
342
+ Only `createLLM` translated through the catalog, so those four worked purely for models whose
343
+ canonical id happens to be the callable one. The moment the sample corpus moved to
344
+ `claude-haiku-4.5`, every batch request came back `not_found_error: model: claude-haiku-4.5` —
345
+ two requests, zero successes, and an exit code of 0 to go with it.
346
+
347
+ All four now send `providerModelName` while keeping the slug for pricing and catalog lookups,
348
+ which are keyed by it. A test asserts the distinction and was checked against the unfixed code.
349
+
350
+ - **Exact token counting works, for the first time.** `HybridTokenCounter` picks its strategy from
351
+ the catalog's `tokenizer.strategy`, and no shipped model declared one — so every count fell back
352
+ to the 4-chars-per-token estimate and neither exact path had ever run. All 455 models now carry a
353
+ strategy: `count_api` for Anthropic and Google chat models, `tiktoken` for OpenAI, `heuristic`
354
+ elsewhere. On one Cyrillic line the difference is 9 estimated versus **19 actual**.
355
+
356
+ Two defects surfaced the moment the strategy was selected:
357
+
358
+ - The count endpoint was sent our canonical SLUG (`claude-haiku-4.5`) rather than the callable id
359
+ (`claude-haiku-4-5-20251001`), so the first model whose ids differed answered 404. The chat path
360
+ translates through the catalog; this one did not.
361
+ - A model marked `tiktoken` threw when the optional `tiktoken` peer was not installed, turning a
362
+ number into an error for anyone who had not opted in — while the guide promised the opposite.
363
+ The counter now falls back to the heuristic and says so once. Only that specific error is
364
+ caught: a network failure inside the count API still surfaces, because quietly answering with
365
+ an estimate when an exact count was asked for is how a wrong number gets believed.
366
+
367
+ - **xAI hosted retrieval said "ready" before anything was searchable.** `indexStatus()` derived
368
+ readiness from the collection's `documents_count`, which reaches 1 the moment a document is
369
+ ATTACHED — measured at about five seconds before that document can actually be found. A caller
370
+ that polled exactly as the guide instructs still searched an empty index, and the model answered
371
+ from its own knowledge with nothing to say why.
372
+
373
+ It now reads the per-document status the API actually exposes (`DOCUMENT_STATUS_PROCESSING` ->
374
+ `PROCESSED`), so `ready` means searchable, and a partial failure reports `error` instead of a
375
+ quietly smaller corpus. xAI now passes the live hosted-retrieval scenario.
376
+
377
+ Found with it: `listCorpora()` threw a `TypeError` on every real response — the API returns
378
+ `{ collections: [...] }` and the code expected a bare array, as did the test fake. Both defects
379
+ survived because the unit tests were written against invented response shapes; they now use the
380
+ ones captured from the live API.
381
+
382
+ - **The Google API key no longer travels in the URL.** Twelve endpoints — files, batch, media
383
+ generation, Imagen, Veo and the long-running-operation polls — sent it as `?key=`, so the
384
+ credential was copied into every access log, proxy log and telemetry span the request passed
385
+ through, and could leak through a `Referer`. They now send `x-goog-api-key`, which is what the
386
+ chat adapter was already fixed to do. One endpoint was sending it BOTH ways.
387
+
388
+ `google/realtime` still uses `?key=` and is the one documented exemption: it is a WebSocket
389
+ handshake and a browser cannot set a header on one. A test enumerates the exemptions and fails if
390
+ one becomes stale, so the next `?key=` cannot arrive quietly.
391
+
392
+ Verified live before the fixtures were re-frozen — google files, image, tts and batch all pass
393
+ with header auth — and the re-freeze was diffed pairwise: 21 artifacts changed, every one only in
394
+ that way.
395
+
396
+ - **The cost ledger recorded a provider call that never happened.** `countTokens()` emitted its
397
+ zero-cost count-API entry whenever the provider was Anthropic or Google and a key was present —
398
+ that is INTENT. The counter picks its strategy from the catalog's `tokenizer.strategy`, and no
399
+ catalogued model declares one, so the heuristic answered and nothing was called. The entry is now
400
+ gated on the strategy that actually ran.
401
+
402
+ - **A test that was describing the bug.** The count-API cost test stubbed `globalThis.fetch` and
403
+ handed the engine a `null` one — it could only pass while the count APIs bypassed the engine. It
404
+ now intercepts the engine's fetch and asserts the endpoint that was called.
405
+
406
+ - **A Google hosted corpus was silently ignored.** `{ type: 'file_search' }` had no mapping in the
407
+ Gemini chain spec, so the tool was dropped from the request and the model answered from its own
408
+ knowledge, with no error anywhere. The live example asks a question only the uploaded document
409
+ can answer and got a plausible wrong number back. `fileSearch` is now mapped from the tool's
410
+ params, matching `Tool.fileSearch` in Google's own SDK.
411
+
412
+ The same call on OpenAI failed loudly instead (`Missing required parameter:
413
+ 'tools[0].vector_store_ids'`): a hosted `asTool()` result has to be passed as the `params` of a
414
+ `file_search` builtin. The retrieval guide now shows that, having previously said "splice into
415
+ the provider's native call" without saying how.
416
+
417
+ - **Hosted document uploads are reproducible.** A document with no `label` was named
418
+ `doc-<random-uuid>.txt`, so the same upload produced a different request every time: it could not
419
+ be asserted in a test, frozen in a fixture, or matched against a log, and a retried upload
420
+ arrived under a new name. The fallback is now derived from the document's content — the same fix
421
+ the xAI batch name got.
422
+
423
+ ### Removed
424
+
425
+ - **`ModelInfo.wire`, `NormalizedRequest.wire` and the `ModelWire` type** (BREAKING, type-level
426
+ only). These carried per-model wire traits; `ModelInfo.wireSpec` carries the same knowledge and
427
+ carries it once. Two
428
+ representations of one fact drift, and this library has shipped two bugs from exactly that. See
429
+ MIGRATION.md — behaviour is unchanged and most codebases need no edit.
430
+
431
+ Removed with them, and never reachable from the package entry point: `anthropicThinkingShape`,
432
+ `anthropicAcceptsTopK`, `ANTHROPIC_ADAPTIVE_THINKING_MIN`, `ANTHROPIC_THINKING_BUDGETS`,
433
+ `DEFAULT_ANTHROPIC_THINKING_BUDGET`, `googleUsesThinkingBudget`, `GOOGLE_THINKING_BUDGETS`,
434
+ `GOOGLE_THINKING_LEVELS`.
435
+
7
436
  ## [2.3.0] — 2026-08-23
8
437
 
9
438
  ### Added
package/MIGRATION.md CHANGED
@@ -1,3 +1,133 @@
1
+ # Migrating to 3.0.0
2
+
3
+ **Almost certainly no source changes.** One type member was removed, and it was one the SDK
4
+ set for its own adapters to read — not something an application was ever expected to write.
5
+
6
+ ## `ModelInfo.wire`, `NormalizedRequest.wire` and the `ModelWire` type are gone
7
+
8
+ `wire` carried per-model traits (`{ thinking, topK }`) that told an adapter which shape a model
9
+ takes. `ModelInfo.wireSpec` — which names the wire spec that builds the request — now carries the
10
+ same knowledge, and it carries it exactly once.
11
+
12
+ That duplication was the point of removing it. Two representations of one fact drift apart, and
13
+ this library has already shipped two bugs from precisely that: 2.2.1 sent Anthropic the retired
14
+ `thinking` shape, and 2.2.2 sent Gemini tool schemas on the wrong field. Keeping `wire` alongside
15
+ `wireSpec` would have been the same mistake with better tests.
16
+
17
+ **What to do:** nothing, unless you read `.wire` off a catalog entry. If you did:
18
+
19
+ ```ts
20
+ // before
21
+ const shape = catalog.get('anthropic', model)?.wire?.thinking; // 'adaptive' | 'budgeted'
22
+
23
+ // after — the pin names the spec, and the spec defines the shape
24
+ const spec = catalog.get('anthropic', model)?.wireSpec; // 'anthropic/messages@4.7'
25
+ ```
26
+
27
+ If you were SETTING `wire` on a custom catalog entry to steer an adapter, set `wireSpec` instead:
28
+
29
+ ```ts
30
+ catalog.set('anthropic', 'my-model', { pricing: {}, wireSpec: 'anthropic/messages@4.1' });
31
+ ```
32
+
33
+ **Behaviour is unchanged.** Every catalogued model produces the byte-identical request it produced
34
+ in 2.3.0 — checked on every CI run against a corpus frozen from the 2.3.0 tag: 290 subjects across
35
+ 23 request shapes, on both the pinned and the id-derived route.
36
+
37
+ ## The catalog is loaded by default
38
+
39
+ `createEngine()` and `LLMClient` now start with the bundled provider catalogs instead of an empty
40
+ one. **Almost certainly no source change** — and if you were already passing `catalog: 'defaults'`,
41
+ that still works and now says the same thing twice.
42
+
43
+ What changes if you were NOT passing it: prices become known, token counts can use the exact
44
+ strategies, and requests are built from the model's wire-spec pin rather than from a rule over its
45
+ id. All three were falling back before, silently.
46
+
47
+ ```ts
48
+ // before — an empty catalog unless you asked
49
+ const engine = createEngine({ apiKeys }); // no pricing, no pins
50
+ const engine = createEngine({ apiKeys, catalog: 'defaults' }); // the data
51
+
52
+ // after — the data, unless you opt out
53
+ const engine = createEngine({ apiKeys }); // the data
54
+ const engine = createEngine({ apiKeys, catalog: false }); // no entries, on purpose
55
+ ```
56
+
57
+ If you relied on an empty catalog — to be certain no bundled price was used, say — pass
58
+ `catalog: false` (or `'empty'`).
59
+
60
+ ## The token-count APIs need the engine's fetch
61
+
62
+ `AnthropicCountApi` and `GoogleCountApi` used to default their second argument to
63
+ `globalThis.fetch`. That default is gone: the fetch is required, and it is an `EngineFetch` — the
64
+ same request-object fetch every other adapter takes — rather than a WHATWG `(url, init)` one.
65
+
66
+ **Almost certainly no source change.** `countTokens()` and `HybridTokenCounter` build these for you,
67
+ and `countTokens()` passes `engine.fetch`. You only touch this if you construct one directly:
68
+
69
+ ```ts
70
+ // before — went around the NetworkEngine entirely
71
+ const api = new AnthropicCountApi(apiKey);
72
+
73
+ // after
74
+ const api = new AnthropicCountApi(apiKey, engine.fetch);
75
+ ```
76
+
77
+ If you build a `HybridTokenCounter` yourself and want the exact count APIs, pass `fetch`:
78
+
79
+ ```ts
80
+ new HybridTokenCounter({ catalog, countApiKeys, fetch: engine.fetch });
81
+ ```
82
+
83
+ Without it the exact strategies are unavailable and counting falls back to the heuristic, with a
84
+ warning — rather than quietly calling the provider outside the queue, the rate limiter, the retry
85
+ policy and the telemetry, which is what the old default did.
86
+
87
+ ## `hooks.onAny` receives one event instead of `(name, ctx)`
88
+
89
+ Only affects code that subscribes to the WHOLE event stream. `hooks.on('onCompletion', h)` — the
90
+ named subscription — is unchanged.
91
+
92
+ ```ts
93
+ // before
94
+ hooks.onAny((name, ctx) => {
95
+ if (name === 'onCompletion') {
96
+ const c = ctx as { response?: { usage?: { inputTokens?: number } } };
97
+ record(c.response?.usage?.inputTokens ?? 0);
98
+ }
99
+ });
100
+
101
+ // after — `event.type` narrows `event.ctx`, so the cast is gone
102
+ hooks.onAny((event) => {
103
+ if (event.type === 'onCompletion') {
104
+ record(event.ctx.response.usage?.inputTokens ?? 0);
105
+ }
106
+ });
107
+ ```
108
+
109
+ The old shape forced every subscriber to cast, and a cast keeps compiling after the field it names
110
+ is renamed — which for a usage or cost field is a metric that silently reads zero. `HookEvent` is
111
+ derived from `HookMap`, so a hook added later becomes a variant your `switch` is told about.
112
+
113
+ ## Google requests carry the API key in a header, not the URL
114
+
115
+ Behaviour, not signature: no source change, and no key of yours moves. Twelve Google endpoints —
116
+ files, batch, media, embeddings, count — used to append `?key=…` to the URL; they now send
117
+ `x-goog-api-key`. Nothing to update unless something in your infrastructure reads the key OUT of
118
+ the URL: an allowlist matching on the query string, a log scrubber written against `key=`, or a
119
+ proxy that routes on it. Those stop seeing it, which was the point — a URL travels through logs,
120
+ proxies and error reports that a header does not.
121
+
122
+ ## Nothing else was removed
123
+
124
+ The band helpers that went with it — `anthropicThinkingShape`, `anthropicAcceptsTopK`,
125
+ `googleUsesThinkingBudget`, and the thinking-budget tables — were never exported from the package
126
+ entry point, and the `exports` map has always blocked deep imports, so no application could reach
127
+ them. They now live as data in `src/wire/pins/`, which is what the Python and Rust ports read.
128
+
129
+ ---
130
+
1
131
  # Migrating to 2.0.0
2
132
 
3
133
  **Most codebases need no source changes.** The point of this library is that provider churn is our
@@ -12,9 +12,19 @@
12
12
  * swallow — emitters need to know if a critical handler (like ContextGuard
13
13
  * abort) failed. Plugins that should never break the request must catch
14
14
  * their own errors. */
15
- import type { HookHandler, HookMap, HookName } from './hook-map';
16
- /** Catch-all handler: receives the event name + context for EVERY emit. */
17
- export type AnyHookHandler = (name: HookName, ctx: unknown) => void | Promise<void>;
15
+ import type { HookEvent, HookHandler, HookMap, HookName } from './hook-map';
16
+ /** Catch-all handler: receives EVERY emit as one discriminated union.
17
+ *
18
+ * It used to receive `(name, ctx: unknown)`, which pushed the type back onto the
19
+ * subscriber — and the SDK's own telemetry adapter, the only consumer, answered
20
+ * the way anyone would: `ctx as Record<string, unknown>`, then a cast per field —
21
+ * 26 of them in one method.
22
+ * Renaming a context field left those reads compiling and silently `undefined`,
23
+ * which for the token and cost fields means a metric that quietly goes to zero.
24
+ *
25
+ * With `HookEvent`, `e.type` narrows `e.ctx` and those casts stop being possible
26
+ * to write. */
27
+ export type AnyHookHandler = (event: HookEvent) => void | Promise<void>;
18
28
  export declare class HookBus {
19
29
  private handlers;
20
30
  private anyHandlers;
@@ -621,3 +621,27 @@ export interface HookMap {
621
621
  }
622
622
  export type HookName = keyof HookMap;
623
623
  export type HookHandler<K extends HookName> = (ctx: HookMap[K]) => void | Promise<void>;
624
+ /** One event, as a value. `HookMap` types a SUBSCRIPTION (`on('onCompletion', h)`
625
+ * knows its own ctx); this types the STREAM, where the name is not known until
626
+ * runtime and the payload has to travel with it.
627
+ *
628
+ * Derived from `HookMap` rather than written out, so the 51 variants cannot drift
629
+ * from the 51 hooks: adding an entry above adds a variant here, and a consumer
630
+ * switching exhaustively over `type` stops compiling until it handles the new one.
631
+ *
632
+ * hooks.onAny((e) => {
633
+ * if (e.type === 'onCompletion') e.ctx.response.usage; // narrowed
634
+ * });
635
+ *
636
+ * The payload stays nested under `ctx` instead of being spread onto the event.
637
+ * Spreading would collide with the contexts that already carry their own `type`
638
+ * field, and would copy an object on every emit — including the per-chunk ones. */
639
+ export type HookEventOf<K extends HookName> = {
640
+ readonly type: K;
641
+ readonly ctx: HookMap[K];
642
+ };
643
+ /** The SDK's whole event stream as a single discriminated union — the portable
644
+ * shape: a Rust enum, a Python tagged union, a TS union, all from one catalog. */
645
+ export type HookEvent = {
646
+ [K in HookName]: HookEventOf<K>;
647
+ }[HookName];