@combycode/llm-sdk 2.3.0 → 3.0.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 (67) hide show
  1. package/CHANGELOG.md +373 -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 +8 -23
  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/index.browser.js +17131 -4291
  10. package/dist/index.d.ts +1 -1
  11. package/dist/index.js +17131 -4291
  12. package/dist/llm/client-config.d.ts +8 -0
  13. package/dist/llm/client.d.ts +2 -0
  14. package/dist/llm/providers/anthropic/batch.d.ts +13 -2
  15. package/dist/llm/providers/anthropic/constants.d.ts +8 -43
  16. package/dist/llm/providers/anthropic/files.d.ts +13 -2
  17. package/dist/llm/providers/anthropic/messages.d.ts +18 -1
  18. package/dist/llm/providers/google/batch.d.ts +14 -1
  19. package/dist/llm/providers/google/constants.d.ts +6 -15
  20. package/dist/llm/providers/google/embeddings.d.ts +8 -1
  21. package/dist/llm/providers/google/files.d.ts +19 -1
  22. package/dist/llm/providers/google/generate.d.ts +15 -1
  23. package/dist/llm/providers/google/interactions.d.ts +5 -1
  24. package/dist/llm/providers/google/media.d.ts +26 -3
  25. package/dist/llm/providers/openai/batch.d.ts +19 -2
  26. package/dist/llm/providers/openai/completions.d.ts +13 -1
  27. package/dist/llm/providers/openai/embeddings.d.ts +13 -1
  28. package/dist/llm/providers/openai/files.d.ts +13 -2
  29. package/dist/llm/providers/openai/media.d.ts +21 -1
  30. package/dist/llm/providers/openai/moderations.d.ts +11 -1
  31. package/dist/llm/providers/openai/realtime.d.ts +3 -0
  32. package/dist/llm/providers/openai/responses.d.ts +10 -2
  33. package/dist/llm/providers/openai/transcription.d.ts +7 -1
  34. package/dist/llm/providers/openrouter/completions.d.ts +5 -3
  35. package/dist/llm/providers/openrouter/embeddings.d.ts +3 -0
  36. package/dist/llm/providers/openrouter/media.d.ts +14 -4
  37. package/dist/llm/providers/openrouter/responses.d.ts +5 -3
  38. package/dist/llm/providers/xai/batch.d.ts +13 -2
  39. package/dist/llm/providers/xai/completions.d.ts +5 -3
  40. package/dist/llm/providers/xai/files.d.ts +13 -2
  41. package/dist/llm/providers/xai/media.d.ts +28 -10
  42. package/dist/llm/providers/xai/responses.d.ts +5 -3
  43. package/dist/llm/response-shape.d.ts +96 -0
  44. package/dist/llm/types/provider.d.ts +6 -0
  45. package/dist/llm/types/request.d.ts +5 -6
  46. package/dist/llm/wire-multipart.d.ts +26 -0
  47. package/dist/{wire/transforms.d.ts → llm/wire-transforms.d.ts} +11 -5
  48. package/dist/plugins/context-measurer/counter/count-api.d.ts +39 -5
  49. package/dist/plugins/context-measurer/counter/hybrid.d.ts +33 -0
  50. package/dist/plugins/context-measurer/counter/tiktoken.d.ts +7 -0
  51. package/dist/plugins/mcp/transport-http.d.ts +13 -7
  52. package/dist/plugins/mcp/wire-rules.d.ts +21 -0
  53. package/dist/plugins/retrieval/document-file.d.ts +15 -0
  54. package/dist/plugins/retrieval/hosted-google.d.ts +8 -3
  55. package/dist/plugins/retrieval/hosted-openai.d.ts +9 -1
  56. package/dist/plugins/retrieval/hosted-xai.d.ts +8 -2
  57. package/dist/wire/chat-specs.d.ts +32 -0
  58. package/dist/wire/inherit.d.ts +0 -4
  59. package/dist/wire/interpreter.d.ts +44 -1
  60. package/dist/wire/mcp-specs.d.ts +13 -0
  61. package/dist/wire/media-specs.d.ts +20 -0
  62. package/dist/wire/pins.d.ts +34 -0
  63. package/dist/wire/registry.d.ts +6 -4
  64. package/dist/wire/retrieval-specs.d.ts +14 -0
  65. package/dist/wire/service-specs.d.ts +18 -0
  66. package/dist/wire/utility-specs.d.ts +10 -0
  67. package/package.json +4 -1
package/CHANGELOG.md CHANGED
@@ -4,6 +4,379 @@ 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
+ ## [3.0.0] — 2026-08-24
8
+
9
+ ### Added
10
+
11
+ - **Exact token counting for xAI**, via `/v1/tokenize-text`. Their own SDK reaches the tokenizer over
12
+ gRPC (`xai_api.Tokenize/TokenizeText`), which made it look like exact counts on xAI would cost a
13
+ protobuf dependency and an optional peer. Asking the REST host instead: it answers 200 with the
14
+ same token list, so this is one more spec-built request and the library stays zero-dependency.
15
+
16
+ All seven xAI text models now declare `count_api` — measured per model rather than generalised
17
+ from one success: the five image and video models do not answer the endpoint and stay on the
18
+ heuristic. On a short Cyrillic line the estimate and the tokenizer differ by 30% on emoji and 23%
19
+ on code.
20
+
21
+ One distinction the guide now spells out: Anthropic and Google count the message array a
22
+ completion would send, so their answer is what the completion is billed for, while xAI tokenizes
23
+ a STRING — exact for that text, excluding the chat framing around it.
24
+
25
+ - **`createEngine({ checkResponseShapes: true })` — warn when a provider's response stops looking
26
+ like the one we learned to read.** A bad request returns 400 and you know at once; a bad response
27
+ returns 200, the parse succeeds, and the field we read is simply gone — for `usage.output_tokens`
28
+ that is cost reporting silently going to zero.
29
+
30
+ Four findings, on the warning bus as `response_shape_*`: a field never seen, a field that was
31
+ present in every recording and is now absent (what a rename looks like from outside), a
32
+ discriminator carrying a value nothing branches on (a new content-block type is dropped in
33
+ silence), and a streaming event type the parser does not handle. Off by default, never changes
34
+ what is parsed, and each distinct finding is reported **once per client** — a warning that repeats
35
+ every request is one people switch off.
36
+
37
+ The shapes are DERIVED from the recorded response corpus by `bun run derive:shapes`, never
38
+ hand-written, and a test re-checks every recorded body against them so the description cannot
39
+ drift from the recordings. Stream shapes are keyed per SSE event type: pooling them was the first
40
+ attempt and it cost the missing-field check entirely, since `message_start` and
41
+ `content_block_delta` share almost no fields.
42
+
43
+ The value of deriving rather than writing showed up immediately — a hand-written "normal
44
+ Anthropic response" in the first draft of the test was missing eight fields Anthropic sends on
45
+ every call (`stop_details`, `usage.service_tier`, `usage.cache_read_input_tokens` among them).
46
+ The check was right and the hand-written body was wrong.
47
+
48
+ - **A recorded corpus of what providers send BACK** (`tests/fixtures/response-golden.json`, 42 cells).
49
+ All seven existing corpora describe REQUESTS; the parse side was exercised only against literals
50
+ written by hand in the test files, which tests what the author believed a provider returns. This
51
+ records the real thing — the pre-parse body, or the ordered SSE events — for seven adapters across
52
+ six shapes (text, tool call, parallel tool calls, structured output, streaming text, streaming
53
+ tool call), and replays them through the same `parseResponse` / `createStreamParser` with no
54
+ network.
55
+
56
+ `raw` is the provider's truth and moves only when `bun run record:responses --refresh` is run;
57
+ `parsed` is our behaviour and is recomputed on every test run, so a parser change surfaces as a
58
+ failure instead of as a quiet difference in what consumers receive. Two invariants are checked
59
+ across all providers at once: every non-streaming response yields usage and a finish reason, and
60
+ every stream ends in a terminal event.
61
+
62
+ The corpus was proven to fail on a renamed provider field, on a parser that drops the finish
63
+ reason, and on a cell quietly disappearing. It found both fixes below in its first run.
64
+
65
+ - **The SDK's event stream is one discriminated union** (`HookEvent`). `HookMap` types a
66
+ subscription — `on('onCompletion', h)` has always known its own context — but the STREAM was
67
+ `(name, ctx: unknown)`, which pushed the type back onto the subscriber. `HookEvent` gives it one
68
+ variant per hook, derived from `HookMap` so the 51 cannot drift from the 51:
69
+
70
+ ```ts
71
+ hooks.onAny((e) => {
72
+ if (e.type === 'onCompletion') e.ctx.response?.usage; // narrowed, no cast
73
+ });
74
+ ```
75
+
76
+ This is the shape the Python and Rust ports share: a tagged union and an `enum` over the same
77
+ catalog. A stream typed `ctx: unknown` has no equivalent in either — the consumer's only move is
78
+ to cast, which is exactly what the SDK's own telemetry adapter did.
79
+
80
+ - **A model ships as callable only if it has been called.** The catalog-loader's probe was
81
+ advisory; 366 of 440 names had never been verified. Now `active: true` requires either a real
82
+ request that answered (`verifiedBy: 'probe'`) or the provider's own model list naming it
83
+ (`verifiedBy: 'listed'`, the only evidence available for an image or TTS model). Everything else
84
+ ships `active: false` — present, priced, but not offered to `selectModel`.
85
+
86
+ A name is called ONCE: later runs check the listing instead, which is free. A verified name that
87
+ disappears from the list is marked deprecated rather than re-probed. Verification went from
88
+ 74/440 to 395/470.
89
+
90
+ - **`scripts/pin-catalog.ts` and `freeze-wire-golden.ts --add-new`** — a catalog import brings
91
+ models with no wire-spec pin and no entry in the frozen request corpus, and the library asserts
92
+ both. Deriving the pin and appending only the unseen models keeps those invariants true without
93
+ re-freezing a baseline that exists to be stable.
94
+
95
+ - **The last four hand-built surfaces are spec-driven**: exact token counting, live model listing,
96
+ file-content retrieval and the provenance check. With those, **every request the library sends
97
+ comes from a spec** — 145 of them. Nothing in `src/` assembles a URL, a header set or a body by
98
+ hand any more.
99
+
100
+ - **MCP is spec-driven too, transport and OAuth.** The Streamable-HTTP transport's five requests
101
+ (call, notification, long-lived subscription, event stream, session delete) and the OAuth flow's
102
+ five (two discovery probes, dynamic client registration, code exchange, refresh) now come from 13
103
+ specs — as does the authorization URL the user's browser opens.
104
+
105
+ This is the point where the spec format stops being about LLM providers. A JSON-RPC envelope,
106
+ era-dependent routing headers, a form-urlencoded token grant and an SSE stream are all described
107
+ with the constructs a chat request already used.
108
+
109
+ Verified against two frozen corpora (23 transport artifacts, 11 OAuth), both shown to fail on
110
+ deliberate corruption, plus live runs of the MCP protocol example and the five-provider MCP tool
111
+ scenario. The OAuth half has no live coverage anywhere — that needs a real authorization server
112
+ and a browser — so its frozen bytes are the only oracle it has, which the wire README now says
113
+ out loud.
114
+
115
+ - **Three more spec constructs**, each added because a real request needed it: `bodyKind: 'form'`
116
+ (the spec carries the FIELDS, the runtime encodes them — the same split multipart already used),
117
+ a header entry with `spread` (merge an evaluated object of headers, so a caller's header map and
118
+ a resolved bearer keep their precedence), and `queryEncoding: 'form'` (a space as `+` rather than
119
+ `%20`, which is what RFC 6749 prescribes for an authorization request and what the library
120
+ already sent).
121
+
122
+ - `NormalizedRequest.wireSpec` — the catalog's pin, resolved by `LLMClient` and read by
123
+ the adapter. Absent for an uncatalogued model or an engine with no catalog, in which
124
+ case the adapter derives the spec the way it always derived the shape.
125
+
126
+ - **`envelope.query` in the wire spec** — query parameters as data, each able to drop out on its
127
+ own. Splicing them into a `$join` URL only works while every parameter is present, and it left
128
+ the encoding to each caller: the hand-written backends disagreed about `encodeURIComponent`, so a
129
+ page token containing `+` paged from the wrong place and the API answered 200.
130
+
131
+ - **`$each` in array templates** — the array analogue of `$spread`, for a `$map` that has to sit
132
+ beside literal entries. Google's `tools` array is exactly that shape.
133
+
134
+ - **`scripts/gen-wire-registry.ts` (`bun run gen:registry`)** — regenerates the spec index from the
135
+ files on disk, with `--check` for CI. Specs have landed without their registry entry three times
136
+ now, and it fails silently: the spec becomes invisible to `WIRE_SPECS`, so the chain tests skip it
137
+ and the coverage audit reports it as neither referenced nor executed.
138
+
139
+ - **A live hosted-retrieval example** (`31-hosted-retrieval`), run on OpenAI and Google by the
140
+ quality gate. Hosted retrieval had unit tests and documentation but had never once been executed
141
+ against a provider, which is how both defects below survived.
142
+
143
+ ### Changed
144
+
145
+ - **`HookBus.onAny` receives one event object instead of `(name, ctx)`** (BREAKING). Handlers take
146
+ `(event: HookEvent)`; `event.type` narrows `event.ctx`.
147
+
148
+ ```ts
149
+ hooks.onAny((name, ctx) => { ... }); // before
150
+ hooks.onAny((event) => { ... }); // after
151
+ ```
152
+
153
+ The SDK's telemetry adapter was the only consumer, and it shows why the old shape was worth
154
+ breaking: it opened with `const c = ctx as Record<string, unknown>` and then cast per field —
155
+ `(c.response as { usage?: … })?.usage`, `c.latencyMs as number`, `(c.error as Error)?.message`.
156
+ Rename a context field and every one of those keeps compiling and quietly reads `undefined`,
157
+ which for the token and cost fields is a metric that silently goes to zero. All 26 casts in that
158
+ method are gone (0 left); the switch narrows instead, and the compiler now checks each field against the
159
+ context it actually belongs to.
160
+
161
+ Nothing is allocated when no catch-all is subscribed, so the per-chunk hot path is unchanged.
162
+ - **The catalog is current again, and the chain that maintains it works end to end.** The
163
+ catalog-loader's export pointed at the catalogs' old home, which was BOTH its write target and
164
+ its merge base — so the "never drop a shipped model, never blank a price" guarantee silently
165
+ guaranteed nothing, and a run would have dropped 10 models and all 284 `wireSpec` pins. Repointed,
166
+ and it now refuses to run at all if the merge base is missing.
167
+
168
+ With it fixed, a full run brought the catalog up to date: +99 models discovered, 57 prices
169
+ changed (all four provider pricing pages had moved), 42 tokenizer strategies delivered, 61 models
170
+ marked `active: false`, and 48 given a deprecation date the sources announced. The `wire` field
171
+ removed in 3.0.0 was still riding along on 26 entries and is now dropped.
172
+
173
+ - **The catalog is loaded by default** (BREAKING, behaviour). `createEngine()` and `LLMClient` used
174
+ to start with an EMPTY catalog unless the caller passed `catalog: 'defaults'`. Three things fell
175
+ back silently as a result: the wire spec was derived from the model id instead of read from its
176
+ pin, every price was unknown, and every token count was the 4-chars-per-token estimate. Each is
177
+ the right answer for a model this build has never heard of, which is why nothing looked wrong.
178
+
179
+ This is the prerequisite the 3.0.0 design named (report 037, R1): the adapters can only be driven
180
+ by per-model data if that data is actually there. The bundled catalogs are statically imported
181
+ either way, so leaving them unloaded never saved a byte — it cost about a millisecond of indexing
182
+ per engine and bought silence.
183
+
184
+ Opting out is now the explicit act: `catalog: false` or `catalog: 'empty'`.
185
+
186
+ - **`AnthropicCountApi` and `GoogleCountApi` take an `EngineFetch`, and it is required** (BREAKING).
187
+ They defaulted to `globalThis.fetch`, so every exact token count for Anthropic and Google went out
188
+ AROUND the NetworkEngine: no queue, no rate limiting, no retry, no telemetry span — while every
189
+ other file in the library states that all HTTP goes through the injected fetch. `countTokens()`
190
+ passes `engine.fetch` for you, so the documented path needs no change; a `HybridTokenCounter`
191
+ built with `countApiKeys` but no `fetch` now says so and falls back to the heuristic instead of
192
+ silently leaving the engine.
193
+
194
+ The default is gone rather than replaced, because a default that silently bypasses the engine is
195
+ what produced this.
196
+
197
+ - **MCP header assembly is one ordered list instead of three helpers.** Which headers a call
198
+ carries — session, protocol version, the modern `Mcp-Method` / `Mcp-Name` routing pair — was
199
+ decided by three private methods and by the order their results were spread into an object
200
+ literal. It is now a declared sequence, and the two rules that genuinely are not data (era
201
+ detection, and reading a subject from a different param per method) are named registry entries
202
+ the coverage audit executes.
203
+
204
+ One asymmetry was preserved rather than tidied: a NOTIFICATION carries no routing headers, which
205
+ is what the transport has always sent. The freeze caught the attempt to "fix" it, and there is no
206
+ modern server here to test the change against.
207
+
208
+ - **The chat adapters build their requests from the wire specs.** All five —
209
+ `anthropic/messages`, `google/generateContent`, `google/interactions`,
210
+ `openai/responses` and `openai/chat-completions`, the last two also covering the xAI
211
+ and OpenRouter flavors — now interpret the spec the catalog pins the model to, instead
212
+ of assembling the body by hand. 806 lines of request-building code became 147.
213
+
214
+ The three OpenAI-compatible subclasses are the clearest case: xAI and OpenRouter each
215
+ overrode `buildRequest` to call `super`, then rename `max_tokens`, strip `reasoning`,
216
+ remap the service tier and merge routing options. Every one of those edits is already
217
+ the flavor overlay in the shared spec, so naming the flavor is now the entire override.
218
+
219
+ **The wire did not move.** A corpus frozen from 2.3.0 — 290 subjects x 22 request
220
+ shapes, both the pinned and the id-derived route — is compared on every CI run, and all
221
+ 12,760 comparisons are byte-identical to what 2.3.0 sent. Verified live against all five
222
+ providers as well; the specs are proven, not assumed.
223
+
224
+ Two supporting moves: `wire-transforms` left `src/wire/` (it imports from `src/llm`, and
225
+ the new edge would otherwise have made a cycle), and the runtime loads a chat-only spec
226
+ set rather than the full 71-spec index, so nothing is bundled that nothing executes.
227
+ Cost: +15 KB packed, and ~5 microseconds per request against a network call.
228
+
229
+ - **Model-band selection is data, not code.** Which chain node an UNPINNED model uses now comes
230
+ from `src/wire/pins/*.json` — ordered regex rules plus a default — instead of version arithmetic
231
+ written in TypeScript. The Python and Rust ports read the same file rather than each
232
+ re-implementing the rule and drifting from it, which is how 2.2.1 happened.
233
+
234
+ The fallback itself is unchanged and still matters: it is how the SDK behaves for a model
235
+ released after this build, and for any engine run without a catalog. Verified against every id
236
+ the previous code handled, plus dated snapshots, legacy family-last ids, and plausible future
237
+ releases.
238
+
239
+ One deliberate fix came out of it: the old pair of helpers disagreed on case — one lower-cased
240
+ the model id and the other did not — so `CLAUDE-OPUS-4-6` lost a `top_k` that model accepts. The
241
+ pin table lower-cases consistently.
242
+
243
+ - **The hosted retrieval backends build their requests from wire specs.** OpenAI vector stores,
244
+ Google file search stores and xAI Grok collections — 23 hand-assembled requests across three
245
+ files — now come from 24 specs. This was the largest remaining block of request construction
246
+ written three times over, once per provider.
247
+
248
+ xAI shows why it matters: collections span two hosts with two separate credentials, and which
249
+ pair a call used was decided by whichever bearer helper the author typed next to the URL. It is
250
+ now a property of the endpoint, declared in the spec.
251
+
252
+ Verified against a corpus frozen from the pre-migration commit — 41 requests, byte-identical —
253
+ and by a live end-to-end run on both providers.
254
+
255
+ ### Fixed
256
+
257
+ - **An explicit `provider` was ignored whenever the model id contained a slash — sending the API key
258
+ to the wrong company.** `resolveModel` read the model's `vendor/` prefix first and fell back to the
259
+ explicit argument only for a bare id. Every OpenRouter model id is `vendor/model`, so the most
260
+ ordinary OpenRouter call there is —
261
+
262
+ ```ts
263
+ createLLM({ provider: 'openrouter', model: 'openai/gpt-5.4-nano', apiKey })
264
+ ```
265
+
266
+ — resolved to the provider `openai` and sent the **OpenRouter key to api.openai.com**, which
267
+ answered `Incorrect API key provided: sk-or-v1…`. A vendor outside our five failed differently and
268
+ no better: the prefix was cast to a `ProviderName`, so `qwen/qwen3` produced a provider literally
269
+ named `qwen` and died later as "no default adapter for provider 'qwen'".
270
+
271
+ An explicit provider now wins, and a redundant leading `<provider>/` is stripped, so the catalog's
272
+ own `openrouter/openai/gpt-5.4-nano` slug resolves to the OpenRouter model `openai/gpt-5.4-nano`.
273
+ Prefix parsing without a provider is unchanged and stays permissive — `estimate()` prices models
274
+ catalogued under providers nobody can call. Affects all nine call sites: `createLLM`, `batch`,
275
+ `embed`, `moderate`, `transcribe`, `createRealtime`, `countTokens`, `estimate`, `estimator`.
276
+
277
+ - **Google's `responseId` was thrown away and replaced with a random UUID.** `parseResponse` minted
278
+ `crypto.randomUUID()` under a comment claiming generateContent returns no id — it returns
279
+ `responseId` at the top level, and every recorded response carries one. Two consequences: the parse
280
+ was not deterministic, so the same bytes produced a different `response.id` each time and nothing
281
+ keyed on it could correlate; and a cache hit, which replays the stored body, reported a different
282
+ id than the call that populated it. The provider's id is now used, with the generated one kept as
283
+ a fallback for older payloads.
284
+
285
+ - **`batch`, `embed`, `transcribe` and `moderate` sent our SLUG instead of the provider's id.**
286
+ Only `createLLM` translated through the catalog, so those four worked purely for models whose
287
+ canonical id happens to be the callable one. The moment the sample corpus moved to
288
+ `claude-haiku-4.5`, every batch request came back `not_found_error: model: claude-haiku-4.5` —
289
+ two requests, zero successes, and an exit code of 0 to go with it.
290
+
291
+ All four now send `providerModelName` while keeping the slug for pricing and catalog lookups,
292
+ which are keyed by it. A test asserts the distinction and was checked against the unfixed code.
293
+
294
+ - **Exact token counting works, for the first time.** `HybridTokenCounter` picks its strategy from
295
+ the catalog's `tokenizer.strategy`, and no shipped model declared one — so every count fell back
296
+ to the 4-chars-per-token estimate and neither exact path had ever run. All 455 models now carry a
297
+ strategy: `count_api` for Anthropic and Google chat models, `tiktoken` for OpenAI, `heuristic`
298
+ elsewhere. On one Cyrillic line the difference is 9 estimated versus **19 actual**.
299
+
300
+ Two defects surfaced the moment the strategy was selected:
301
+
302
+ - The count endpoint was sent our canonical SLUG (`claude-haiku-4.5`) rather than the callable id
303
+ (`claude-haiku-4-5-20251001`), so the first model whose ids differed answered 404. The chat path
304
+ translates through the catalog; this one did not.
305
+ - A model marked `tiktoken` threw when the optional `tiktoken` peer was not installed, turning a
306
+ number into an error for anyone who had not opted in — while the guide promised the opposite.
307
+ The counter now falls back to the heuristic and says so once. Only that specific error is
308
+ caught: a network failure inside the count API still surfaces, because quietly answering with
309
+ an estimate when an exact count was asked for is how a wrong number gets believed.
310
+
311
+ - **xAI hosted retrieval said "ready" before anything was searchable.** `indexStatus()` derived
312
+ readiness from the collection's `documents_count`, which reaches 1 the moment a document is
313
+ ATTACHED — measured at about five seconds before that document can actually be found. A caller
314
+ that polled exactly as the guide instructs still searched an empty index, and the model answered
315
+ from its own knowledge with nothing to say why.
316
+
317
+ It now reads the per-document status the API actually exposes (`DOCUMENT_STATUS_PROCESSING` ->
318
+ `PROCESSED`), so `ready` means searchable, and a partial failure reports `error` instead of a
319
+ quietly smaller corpus. xAI now passes the live hosted-retrieval scenario.
320
+
321
+ Found with it: `listCorpora()` threw a `TypeError` on every real response — the API returns
322
+ `{ collections: [...] }` and the code expected a bare array, as did the test fake. Both defects
323
+ survived because the unit tests were written against invented response shapes; they now use the
324
+ ones captured from the live API.
325
+
326
+ - **The Google API key no longer travels in the URL.** Twelve endpoints — files, batch, media
327
+ generation, Imagen, Veo and the long-running-operation polls — sent it as `?key=`, so the
328
+ credential was copied into every access log, proxy log and telemetry span the request passed
329
+ through, and could leak through a `Referer`. They now send `x-goog-api-key`, which is what the
330
+ chat adapter was already fixed to do. One endpoint was sending it BOTH ways.
331
+
332
+ `google/realtime` still uses `?key=` and is the one documented exemption: it is a WebSocket
333
+ handshake and a browser cannot set a header on one. A test enumerates the exemptions and fails if
334
+ one becomes stale, so the next `?key=` cannot arrive quietly.
335
+
336
+ Verified live before the fixtures were re-frozen — google files, image, tts and batch all pass
337
+ with header auth — and the re-freeze was diffed pairwise: 21 artifacts changed, every one only in
338
+ that way.
339
+
340
+ - **The cost ledger recorded a provider call that never happened.** `countTokens()` emitted its
341
+ zero-cost count-API entry whenever the provider was Anthropic or Google and a key was present —
342
+ that is INTENT. The counter picks its strategy from the catalog's `tokenizer.strategy`, and no
343
+ catalogued model declares one, so the heuristic answered and nothing was called. The entry is now
344
+ gated on the strategy that actually ran.
345
+
346
+ - **A test that was describing the bug.** The count-API cost test stubbed `globalThis.fetch` and
347
+ handed the engine a `null` one — it could only pass while the count APIs bypassed the engine. It
348
+ now intercepts the engine's fetch and asserts the endpoint that was called.
349
+
350
+ - **A Google hosted corpus was silently ignored.** `{ type: 'file_search' }` had no mapping in the
351
+ Gemini chain spec, so the tool was dropped from the request and the model answered from its own
352
+ knowledge, with no error anywhere. The live example asks a question only the uploaded document
353
+ can answer and got a plausible wrong number back. `fileSearch` is now mapped from the tool's
354
+ params, matching `Tool.fileSearch` in Google's own SDK.
355
+
356
+ The same call on OpenAI failed loudly instead (`Missing required parameter:
357
+ 'tools[0].vector_store_ids'`): a hosted `asTool()` result has to be passed as the `params` of a
358
+ `file_search` builtin. The retrieval guide now shows that, having previously said "splice into
359
+ the provider's native call" without saying how.
360
+
361
+ - **Hosted document uploads are reproducible.** A document with no `label` was named
362
+ `doc-<random-uuid>.txt`, so the same upload produced a different request every time: it could not
363
+ be asserted in a test, frozen in a fixture, or matched against a log, and a retried upload
364
+ arrived under a new name. The fallback is now derived from the document's content — the same fix
365
+ the xAI batch name got.
366
+
367
+ ### Removed
368
+
369
+ - **`ModelInfo.wire`, `NormalizedRequest.wire` and the `ModelWire` type** (BREAKING, type-level
370
+ only). These carried per-model wire traits; `ModelInfo.wireSpec` carries the same knowledge and
371
+ carries it once. Two
372
+ representations of one fact drift, and this library has shipped two bugs from exactly that. See
373
+ MIGRATION.md — behaviour is unchanged and most codebases need no edit.
374
+
375
+ Removed with them, and never reachable from the package entry point: `anthropicThinkingShape`,
376
+ `anthropicAcceptsTopK`, `ANTHROPIC_ADAPTIVE_THINKING_MIN`, `ANTHROPIC_THINKING_BUDGETS`,
377
+ `DEFAULT_ANTHROPIC_THINKING_BUDGET`, `googleUsesThinkingBudget`, `GOOGLE_THINKING_BUDGETS`,
378
+ `GOOGLE_THINKING_LEVELS`.
379
+
7
380
  ## [2.3.0] — 2026-08-23
8
381
 
9
382
  ### 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];
@@ -73,27 +73,6 @@ export interface TokenizerInfo {
73
73
  countApiAvailable: boolean;
74
74
  tiktokenEncoding?: string;
75
75
  }
76
- /** Per-model WIRE traits: how to say a thing to this model, as opposed to
77
- * `capabilities` / `reasoning`, which say what the model can do.
78
- *
79
- * This is the gap that produced two shipped bugs. The catalog knew a model
80
- * supported reasoning; nothing knew which of two incompatible `thinking` shapes
81
- * it accepted, so adapters parsed the model id with regexes and got it wrong
82
- * twice (2.2.1, and the 4.0 date-suffix defect fixed in 039 A1).
83
- *
84
- * Adapters read these through `NormalizedRequest.wire`, which `LLMClient`
85
- * resolves from the catalog. When the catalog has no entry the adapters fall
86
- * back to parsing the id, so a catalog-less engine still works — the fallback
87
- * is what a future release removes once every model is pinned. */
88
- export interface ModelWire {
89
- /** Which `thinking` shape the wire accepts.
90
- * Anthropic: `adaptive` (4.6+) vs `budgeted` (pre-4.6, needs budget_tokens).
91
- * Google: `level` (3.x, thinkingLevel enum) vs `budget` (2.5, token count). */
92
- thinking?: 'adaptive' | 'budgeted' | 'budget' | 'level';
93
- /** Whether the model accepts a top-k sampling parameter. Anthropic retired it
94
- * after Opus 4.6 and 400s on models that no longer take it. */
95
- topK?: boolean;
96
- }
97
76
  export interface ModelInfo {
98
77
  provider: string;
99
78
  /** Catalog key — our canonical (normalised) slug, e.g. `claude-opus-4.8`. */
@@ -115,8 +94,6 @@ export interface ModelInfo {
115
94
  /** Other callable ids that resolve to this model (dated snapshots, the bare
116
95
  * callable form). Indexed for lookup + accepted as model strings. */
117
96
  aliases?: string[];
118
- /** Per-model wire traits — see ModelWire. */
119
- wire?: ModelWire;
120
97
  /** Which wire spec builds this model's requests, e.g. `anthropic/messages@4.7`.
121
98
  *
122
99
  * The specs live in `src/wire/specs` and are the artifact the Python and Rust
@@ -190,6 +167,14 @@ export declare class ModelCatalog {
190
167
  isStateModelBound(provider: string, model: string): boolean;
191
168
  list(provider?: string): ModelInfo[];
192
169
  load(data: Record<string, unknown>): void;
170
+ /** A catalog with every bundled provider entry already loaded.
171
+ *
172
+ * This is what the engine and the client build when nobody says otherwise. A
173
+ * fresh instance each time rather than a shared one: the catalog is mutable
174
+ * (`set()` is public and examples use it), so sharing would let one engine's
175
+ * edit reach another's request. Indexing all 427 entries costs about a
176
+ * millisecond, against a network call. */
177
+ static withProviderDefaults(): ModelCatalog;
193
178
  /** Load every provider's `catalog.json` shipped with the SDK. Synchronous —
194
179
  * the JSON files are bundled via static import so no I/O at runtime. */
195
180
  loadProviderDefaults(): void;
@@ -46,8 +46,27 @@ export declare class ClientResolver {
46
46
  export declare function parseModelId(modelId: string): [ProviderName, string];
47
47
  export declare function isNamespacedModelId(modelId: string): boolean;
48
48
  /** Resolve a model + optional provider to a concrete { provider, model }.
49
- * A namespaced id ("provider/model") yields its own provider; a bare model
50
- * requires an explicit `provider`. `label` names the caller in the error. */
49
+ *
50
+ * An EXPLICIT provider always wins. It used to lose to the model's prefix, and
51
+ * that is not a preference — every OpenRouter model id is `vendor/model`, so
52
+ * `createLLM({ provider: 'openrouter', model: 'openai/gpt-5.4-nano' })` resolved
53
+ * to the provider `openai` and sent the **OpenRouter key to api.openai.com**.
54
+ * Ids whose vendor is not one of our five (`qwen/qwen3`) fared differently and no
55
+ * better: the prefix was cast to a ProviderName and failed later as "no default
56
+ * adapter for provider 'qwen'".
57
+ *
58
+ * With a provider given, a leading `<provider>/` on the model is redundant and is
59
+ * stripped — `openrouter` + `openrouter/openai/gpt-5.4-nano` is the catalog's own
60
+ * slug form and means the OpenRouter model `openai/gpt-5.4-nano`.
61
+ *
62
+ * Without one, the `provider/model` prefix is still the documented sugar, and it
63
+ * stays permissive about the prefix on purpose: the pricing paths (`estimate`,
64
+ * `estimator`) resolve models that are catalogued under a provider nobody can
65
+ * CALL — a private deployment, a test fixture — and rejecting those would break
66
+ * costing a model you never send. A prefix that is not callable fails where it
67
+ * matters, in the adapter factory, naming the provider it could not build.
68
+ *
69
+ * `label` names the caller in the error. */
51
70
  export declare function resolveModel(model: string, provider: ProviderName | undefined, label: string): {
52
71
  provider: ProviderName;
53
72
  model: string;
@@ -17,7 +17,7 @@ export interface CountTokensOptions {
17
17
  provider?: ProviderName;
18
18
  /** Text or messages to count. */
19
19
  input: string | Message[];
20
- /** Key for the exact count-API path (Anthropic/Google). Falls back to engine.apiKeys. */
20
+ /** Key for the exact count-API path (Anthropic, Google, xAI). Falls back to engine.apiKeys. */
21
21
  apiKey?: string;
22
22
  /** Use the precise counter where available (default true). false = sync estimate. */
23
23
  exact?: boolean;