@combycode/llm-sdk 2.2.2 → 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 (95) hide show
  1. package/CHANGELOG.md +555 -0
  2. package/MIGRATION.md +130 -0
  3. package/dist/agent/loop-internals.d.ts +4 -0
  4. package/dist/agent/loop.d.ts +35 -0
  5. package/dist/bus/hook-bus.d.ts +13 -3
  6. package/dist/bus/hook-map.d.ts +24 -0
  7. package/dist/{llm/providers → catalog}/builtin-tools.d.ts +1 -1
  8. package/dist/{plugins/model-catalog → catalog}/catalog.d.ts +19 -0
  9. package/dist/helpers/client-pool.d.ts +1 -1
  10. package/dist/helpers/client-resolver.d.ts +22 -3
  11. package/dist/helpers/count-tokens.d.ts +1 -1
  12. package/dist/helpers/engine.d.ts +39 -6
  13. package/dist/helpers/mcp.d.ts +6 -1
  14. package/dist/helpers/models.d.ts +1 -1
  15. package/dist/helpers/one-shot.d.ts +2 -2
  16. package/dist/helpers/select-model.d.ts +1 -1
  17. package/dist/index.browser.js +17611 -4247
  18. package/dist/index.d.ts +5 -5
  19. package/dist/index.js +17611 -4247
  20. package/dist/llm/client-config.d.ts +9 -1
  21. package/dist/llm/client-internal.d.ts +11 -0
  22. package/dist/llm/client.d.ts +6 -0
  23. package/dist/llm/providers/_shared/sse.d.ts +19 -0
  24. package/dist/llm/providers/anthropic/batch.d.ts +13 -2
  25. package/dist/llm/providers/anthropic/constants.d.ts +8 -43
  26. package/dist/llm/providers/anthropic/files.d.ts +13 -2
  27. package/dist/llm/providers/anthropic/messages.d.ts +18 -1
  28. package/dist/llm/providers/google/batch.d.ts +14 -1
  29. package/dist/llm/providers/google/constants.d.ts +6 -15
  30. package/dist/llm/providers/google/embeddings.d.ts +8 -1
  31. package/dist/llm/providers/google/files.d.ts +34 -1
  32. package/dist/llm/providers/google/generate.d.ts +15 -1
  33. package/dist/llm/providers/google/interactions.d.ts +5 -1
  34. package/dist/llm/providers/google/media.d.ts +45 -4
  35. package/dist/llm/providers/google/realtime.d.ts +15 -2
  36. package/dist/llm/providers/openai/batch.d.ts +19 -2
  37. package/dist/llm/providers/openai/completions.d.ts +13 -1
  38. package/dist/llm/providers/openai/embeddings.d.ts +13 -1
  39. package/dist/llm/providers/openai/files.d.ts +13 -2
  40. package/dist/llm/providers/openai/media.d.ts +31 -2
  41. package/dist/llm/providers/openai/moderations.d.ts +11 -1
  42. package/dist/llm/providers/openai/realtime.d.ts +18 -2
  43. package/dist/llm/providers/openai/responses.d.ts +10 -2
  44. package/dist/llm/providers/openai/transcription.d.ts +7 -1
  45. package/dist/llm/providers/openrouter/completions.d.ts +5 -3
  46. package/dist/llm/providers/openrouter/embeddings.d.ts +3 -0
  47. package/dist/llm/providers/openrouter/media.d.ts +14 -4
  48. package/dist/llm/providers/openrouter/responses.d.ts +5 -3
  49. package/dist/llm/providers/xai/batch.d.ts +13 -2
  50. package/dist/llm/providers/xai/completions.d.ts +5 -3
  51. package/dist/llm/providers/xai/files.d.ts +13 -2
  52. package/dist/llm/providers/xai/media.d.ts +28 -10
  53. package/dist/llm/providers/xai/responses.d.ts +5 -3
  54. package/dist/llm/response-shape.d.ts +96 -0
  55. package/dist/llm/server-state.d.ts +1 -1
  56. package/dist/llm/types/options.d.ts +2 -2
  57. package/dist/llm/types/provider.d.ts +6 -0
  58. package/dist/llm/types/request.d.ts +49 -1
  59. package/dist/llm/wire-multipart.d.ts +26 -0
  60. package/dist/llm/wire-transforms.d.ts +28 -0
  61. package/dist/plugins/context-measurer/counter/count-api.d.ts +40 -6
  62. package/dist/plugins/context-measurer/counter/heuristic.d.ts +1 -1
  63. package/dist/plugins/context-measurer/counter/hybrid.d.ts +34 -1
  64. package/dist/plugins/context-measurer/counter/tiktoken.d.ts +7 -0
  65. package/dist/plugins/context-measurer/measurer.d.ts +1 -1
  66. package/dist/plugins/cost-collector/collector.d.ts +1 -1
  67. package/dist/plugins/cost-collector/cost-collector-internal.d.ts +1 -1
  68. package/dist/plugins/cost-collector/cost-collector-types.d.ts +1 -1
  69. package/dist/plugins/files/registry.d.ts +1 -1
  70. package/dist/plugins/files/strategy.d.ts +1 -1
  71. package/dist/plugins/internal-tools/registry.d.ts +1 -1
  72. package/dist/plugins/internal-tools/runner/types.d.ts +1 -1
  73. package/dist/plugins/mcp/sampling.d.ts +23 -1
  74. package/dist/plugins/mcp/transport-http.d.ts +13 -7
  75. package/dist/plugins/mcp/wire-rules.d.ts +21 -0
  76. package/dist/plugins/media/output.d.ts +1 -1
  77. package/dist/plugins/retrieval/document-file.d.ts +15 -0
  78. package/dist/plugins/retrieval/hosted-google.d.ts +8 -3
  79. package/dist/plugins/retrieval/hosted-openai.d.ts +9 -1
  80. package/dist/plugins/retrieval/hosted-xai.d.ts +8 -2
  81. package/dist/plugins/telemetry/telemetry.d.ts +2 -133
  82. package/dist/plugins/telemetry/types.d.ts +139 -0
  83. package/dist/util/hash.d.ts +8 -0
  84. package/dist/{plugins/media → util}/source-image.d.ts +1 -1
  85. package/dist/wire/chat-specs.d.ts +32 -0
  86. package/dist/wire/inherit.d.ts +43 -0
  87. package/dist/wire/interpreter.d.ts +279 -0
  88. package/dist/wire/mcp-specs.d.ts +13 -0
  89. package/dist/wire/media-specs.d.ts +20 -0
  90. package/dist/wire/pins.d.ts +34 -0
  91. package/dist/wire/registry.d.ts +20 -0
  92. package/dist/wire/retrieval-specs.d.ts +14 -0
  93. package/dist/wire/service-specs.d.ts +18 -0
  94. package/dist/wire/utility-specs.d.ts +10 -0
  95. package/package.json +6 -3
package/CHANGELOG.md CHANGED
@@ -4,6 +4,561 @@ 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
+
380
+ ## [2.3.0] — 2026-08-23
381
+
382
+ ### Added
383
+
384
+ - **Every catalogued chat model is pinned to a wire spec.** `ModelInfo.wireSpec` names the spec
385
+ that builds that model's requests — `anthropic/messages@4.7`, `google/generate@2.5`,
386
+ `openai/responses`, and so on. All 289 chat models across five providers carry one.
387
+
388
+ The pin is what lets this SDK and the Python and Rust ports agree on a model without each
389
+ re-deriving its wire shape from the model id — the derivation that produced the 2.2.1 and 2.2.2
390
+ bugs.
391
+
392
+ Carried and validated, not yet authoritative: the hand-written adapters still build requests
393
+ from `wire` traits. Because that is two representations of one fact, and two representations
394
+ drift, a test drives the PINNED SPEC and asserts the request it produces matches what the traits
395
+ say the model takes. When the adapters become spec-driven in 3.0.0, `wire` goes away and that
396
+ test is what makes the swap safe.
397
+
398
+ - **Wire specs ship in the repo** (`src/wire/`). 71 JSON specs describe how to talk to each
399
+ provider API — field names, enum values, defaults, versioned tool-type strings, which shape a
400
+ model version takes — covering every adapter the SDK has: chat, interactions, media, realtime,
401
+ embeddings, files and batch.
402
+
403
+ They exist so the Python and Rust ports consume one artifact instead of re-deriving the same
404
+ knowledge three times, and so a provider change is one reviewable diff rather than three code
405
+ changes. This is the knowledge that, living in regexes, produced the 2.2.1 and 2.2.2 bugs.
406
+
407
+ **Oracle, not yet authority:** the adapters remain hand-written, and a test
408
+ (`tests/unit/wire`) proves the specs and the adapters agree on every CI run — data that is
409
+ never executed rots. Making the specs authoritative is the 3.0.0 step.
410
+
411
+ They are not exported from `index.ts` and are tree-shaken out of `dist`, so they add **no bytes**
412
+ to the published package (verified: package size unchanged).
413
+
414
+ - **Every catalogued chat model is checked against its pinned spec on every run.** The previous
415
+ pin test drove Anthropic and Google models only — 26 of 289. The other 263, 224 of them
416
+ OpenRouter, were covered by nothing stronger than "the pin names a spec that resolves", which a
417
+ typo satisfies. `tests/unit/wire/every-model-reproduces-its-adapter.test.ts` now builds 17
418
+ request shapes for every chat model, through the spec the CATALOG pins it to, and requires the
419
+ payload to equal the one the real adapter produces — ~4,900 comparisons, under a second.
420
+
421
+ The adapter is chosen from `preferredApi`, never from the pin. Choosing it from the pin is
422
+ circular and silently so: mis-pin an OpenRouter model to `openai/responses` and the adapter
423
+ moves with it, both sides agree, and the sweep stays green on a broken pin. It did exactly that
424
+ until a deliberate corruption caught it. Mis-pinning any single model in any of the five
425
+ providers now fails, as does a provider losing its pins entirely.
426
+
427
+ - **A consumer example for traces** (`telemetry-traces`, in the examples corpus): subscribing with
428
+ `onTrace`, filtering by span type at the subscription rather than in the handler, head sampling,
429
+ keeping prompt content out, joining an inbound `traceparent`, naming a run with
430
+ `label`/`source`/`attributes`, and reading `client.routing`. The trace feed had no example at
431
+ all, which the quality gate's example-first check was reporting.
432
+
433
+ - **Request builders on the media and realtime adapters.** These adapters used to assemble each
434
+ request *inside* the method that also fetched and parsed it, so the only way to see what the SDK
435
+ would send was to intercept the network. Construction is now separated:
436
+
437
+ - `GoogleMediaAdapter`: `buildImageRequest`, `buildEditImageRequest`, `buildAudioRequest`,
438
+ `buildVideoRequest`, plus the lower-level `buildImagenRequest` / `buildGenerateContentRequest`.
439
+ - `OpenAIMediaAdapter`: `buildGenerateImageRequest`, `buildEditImageRequest`, `buildAudioRequest`,
440
+ `buildVideoRequest`.
441
+ - Realtime: `buildConnectRequest` on both adapters, and free functions
442
+ `buildOpenAISessionUpdate` / `buildOpenAITurnFrames` and `buildGoogleSetupFrame` /
443
+ `buildGoogleTurnFrames` for the handshake and per-turn frames.
444
+
445
+ The public methods now call these, so the two cannot drift — and a test
446
+ (`media-request-builders.test.ts`) asserts that what a builder returns is byte-identical to what
447
+ its method actually sends.
448
+
449
+ Additive: no existing signature changed.
450
+
451
+ - **The catalog now knows how to TALK to a model, not just what it can do.** `ModelInfo` gains a
452
+ `wire` block carrying per-model wire traits — which `thinking` shape the model accepts, whether
453
+ it takes `top_k` — and `LLMClient` resolves it onto every request as `NormalizedRequest.wire`.
454
+ Adapters read that instead of parsing the model id.
455
+
456
+ This is the gap behind two shipped bugs. The catalog already recorded that a model supported
457
+ reasoning; nothing recorded which of two incompatible `thinking` shapes it accepted, so adapters
458
+ matched on the id and got it wrong twice — in 2.2.1, and again in the 4.0 date-suffix defect
459
+ fixed this release. Wire knowledge is now reviewable data that can be diffed and generated,
460
+ rather than a regex nobody re-reads.
461
+
462
+ The bundled catalog carries `wire` for all 14 Anthropic and 12 Google chat models, generated
463
+ from the existing rules so behaviour is unchanged on day one. When the catalog is silent —
464
+ an engine running without one, or an uncatalogued model — adapters fall back to parsing the id
465
+ exactly as before, so nothing breaks. Removing that fallback is a later step, once every model
466
+ is pinned.
467
+
468
+ - `EngineHandle.createClient(options)` — build an `LLMClient` bound to that engine. Added so
469
+ `plugins/internal-tools` can obtain a client without importing `createLLM` from the helpers
470
+ layer. Additive on a handle callers receive rather than implement.
471
+
472
+ - `util/hash` — FNV-1a 32-bit, deterministic and dependency-free, for deriving stable short ids
473
+ from content instead of from a clock.
474
+
475
+ ### Changed
476
+
477
+ - **The module graph is now a DAG.** Two dependency cycles between top-level layers were closed:
478
+ `llm <-> plugins` and `helpers <-> plugins`. They were harmless in TypeScript and are not
479
+ harmless in Rust, where crates cannot express a cycle, so they blocked the port.
480
+
481
+ Shared code moved DOWN rather than sideways: the model catalog and its bundled data now live in
482
+ `src/catalog/` instead of `src/plugins/model-catalog/` + `src/llm/providers/*/catalog.json`, and
483
+ image-source normalisation moved from `src/plugins/media/source-image` to `src/util/source-image`.
484
+ Where a lower layer genuinely needs a capability from a higher one it is now passed down instead
485
+ of imported up.
486
+
487
+ **No public API changed** — the package has a single root export and every moved symbol is
488
+ re-exported from the same place as before. A new test (`tests/unit/architecture/layers.test.ts`)
489
+ fails if any cycle returns.
490
+
491
+ - **Internal cleanups carried over from the 1.0 backlog.**
492
+ - `buildContext` read `LLMClient`'s private `queueName` / `configName` / `cacheName` through
493
+ `as unknown as` casts, which compile happily and would silently yield `undefined` the day a
494
+ field is renamed. The client now exposes them deliberately as `client.routing`, so a rename is
495
+ a type error.
496
+ - Telemetry's ten exported types moved from the 1,225-line `telemetry.ts` to
497
+ `plugins/telemetry/types.ts`, matching the rest of the codebase. Re-exported from the old path,
498
+ so no import — public or internal — changed.
499
+ - `sseJson()` in `providers/_shared` replaces the one line every provider's stream parser
500
+ repeated verbatim.
501
+
502
+ - **`providerOptions` is typed.** It was `Record<string, unknown>` — the one untyped hole in the
503
+ request, and so the one place a typo produced silence rather than an error:
504
+ `promtCacheOptions` type-checked and was simply never sent.
505
+
506
+ The new `ProviderOptions` interface documents every key an adapter actually reads
507
+ (`userProfileId`; `moderationPolicy`, `promptCacheOptions`, `reasoningMode`;
508
+ `responseModalities`, `speechConfig`, `imageConfig`, `translationConfig`, `cachedContent`;
509
+ `openrouter`), derived from the read sites rather than invented. Two `as` casts at those sites
510
+ became unnecessary and were removed.
511
+
512
+ **Not breaking:** the index signature stays, so an unmodelled key is still accepted — providers
513
+ ship parameters before the SDK models them, and refusing those would make the escape hatch
514
+ useless. What changed is that the keys we do know are checked and discoverable.
515
+
516
+ - **`AgentLoop.complete()` and `stream()` no longer duplicate their scaffolding.** The two are the
517
+ same loop with different plumbing, and they had drifted into near-duplicates — 167 identical
518
+ lines across ~560. Five shared pieces are now extracted: `recordRunError`, `resolveFinalText`,
519
+ `buildFinalResponse`, `settleRun`, and `buildStepOptions`.
520
+
521
+ This is not tidying. The `ctx` block in `buildStepOptions` is what stops one conversation
522
+ arriving at a collector as several unrelated traces; duplicated, a fix to one path would have
523
+ left the other silently splitting. The same applies to the run's final-text rules and its
524
+ error reporting.
525
+
526
+ `complete()` 303 -> 234 lines, `stream()` 261 -> 209, identical shared lines 167 -> ~118.
527
+ Behaviour unchanged: all 1,953 tests pass untouched.
528
+
529
+ ### Fixed
530
+
531
+ - **Anthropic 4.0 models were handed the 4.6+ `thinking` shape.** `anthropicThinkingShape()`
532
+ parsed a model id with `/^claude-[a-z]+-(\d+)(?:[-.](\d+))?/`, so an id carrying a release date
533
+ but no minor version read the date AS the minor: `claude-opus-4-20250514` became major 4 /
534
+ minor 20250514, cleared the `>= 4.6` test, and was sent `thinking: {type:'adaptive'}` — the
535
+ opposite of what the file's own `ANTHROPIC_ADAPTIVE_THINKING_MIN = {major:4, minor:6}` says.
536
+
537
+ This never shipped a failure: `claude-opus-4-20250514` and `claude-sonnet-4-20250514` are the
538
+ only affected ids and both are `active: false` in the catalog (deprecated 2026-05-14). It is
539
+ nonetheless the 2.2.1 regression in mirror image, so it is fixed rather than left latent. The
540
+ minor is now bounded to one or two digits and must not be followed by another digit. Checked
541
+ against every id in the catalog plus aliases and future-shaped ids (`claude-opus-4-10`,
542
+ `claude-sonnet-6-1-20270101`): exactly those two change classification, the other 17 are
543
+ untouched.
544
+
545
+ - **xAI batch creation was not reproducible.** The create call named the batch
546
+ `` `batch_${Date.now()}` ``, which made it the only request in the provider surface that was not
547
+ a pure function of its input: it could not be asserted in a test or reproduced from a log, and a
548
+ retried create produced a second batch under a different name that nothing could deduplicate.
549
+ The name is now derived from the batch contents, so identical submissions produce identical
550
+ requests and differing ones still differ.
551
+
552
+ - **`google/files` rejected Google's own resource-name format.** `delete()` and `getInfo()`
553
+ normalised the file id only when it contained `/files/` *with* a leading slash — true of the
554
+ full `uri` this adapter returns from `upload()` and `list()`, and false of `files/abc`, the
555
+ canonical `name` the Google API itself returns. Passing that back produced
556
+ `/v1beta/files/files/abc` and a 404.
557
+
558
+ It never broke the library's own round-trip, which is why it survived: the only way to reach it
559
+ was to use the provider's own id format. All three forms — full uri, `files/abc`, and a bare
560
+ name — now normalise to the same request.
561
+
7
562
  ## [2.2.2] — 2026-08-17
8
563
 
9
564
  ### Fixed