@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.
- package/CHANGELOG.md +429 -0
- package/MIGRATION.md +130 -0
- package/dist/bus/hook-bus.d.ts +13 -3
- package/dist/bus/hook-map.d.ts +24 -0
- package/dist/catalog/catalog.d.ts +23 -26
- package/dist/helpers/client-resolver.d.ts +21 -2
- package/dist/helpers/count-tokens.d.ts +1 -1
- package/dist/helpers/engine.d.ts +27 -5
- package/dist/helpers/select-model.d.ts +35 -0
- package/dist/index.browser.js +17394 -4392
- package/dist/index.d.ts +3 -3
- package/dist/index.js +17394 -4392
- package/dist/llm/client-config.d.ts +8 -0
- package/dist/llm/client.d.ts +5 -0
- package/dist/llm/providers/anthropic/batch.d.ts +13 -2
- package/dist/llm/providers/anthropic/constants.d.ts +8 -43
- package/dist/llm/providers/anthropic/files.d.ts +13 -2
- package/dist/llm/providers/anthropic/messages.d.ts +18 -1
- package/dist/llm/providers/google/batch.d.ts +14 -1
- package/dist/llm/providers/google/constants.d.ts +6 -15
- package/dist/llm/providers/google/embeddings.d.ts +8 -1
- package/dist/llm/providers/google/files.d.ts +19 -1
- package/dist/llm/providers/google/generate.d.ts +15 -1
- package/dist/llm/providers/google/interactions.d.ts +5 -1
- package/dist/llm/providers/google/media.d.ts +26 -3
- package/dist/llm/providers/openai/batch.d.ts +19 -2
- package/dist/llm/providers/openai/completions.d.ts +13 -1
- package/dist/llm/providers/openai/embeddings.d.ts +13 -1
- package/dist/llm/providers/openai/files.d.ts +13 -2
- package/dist/llm/providers/openai/media.d.ts +21 -1
- package/dist/llm/providers/openai/moderations.d.ts +11 -1
- package/dist/llm/providers/openai/realtime.d.ts +3 -0
- package/dist/llm/providers/openai/responses.d.ts +10 -2
- package/dist/llm/providers/openai/transcription.d.ts +7 -1
- package/dist/llm/providers/openrouter/completions.d.ts +5 -3
- package/dist/llm/providers/openrouter/embeddings.d.ts +3 -0
- package/dist/llm/providers/openrouter/media.d.ts +14 -4
- package/dist/llm/providers/openrouter/responses.d.ts +5 -3
- package/dist/llm/providers/xai/batch.d.ts +13 -2
- package/dist/llm/providers/xai/completions.d.ts +5 -3
- package/dist/llm/providers/xai/files.d.ts +13 -2
- package/dist/llm/providers/xai/media.d.ts +28 -10
- package/dist/llm/providers/xai/responses.d.ts +5 -3
- package/dist/llm/response-shape.d.ts +96 -0
- package/dist/llm/types/provider.d.ts +11 -0
- package/dist/llm/types/request.d.ts +5 -6
- package/dist/llm/wire-multipart.d.ts +26 -0
- package/dist/{wire/transforms.d.ts → llm/wire-transforms.d.ts} +11 -5
- package/dist/plugins/context-measurer/counter/count-api.d.ts +39 -5
- package/dist/plugins/context-measurer/counter/hybrid.d.ts +33 -0
- package/dist/plugins/context-measurer/counter/tiktoken.d.ts +7 -0
- package/dist/plugins/mcp/transport-http.d.ts +13 -7
- package/dist/plugins/mcp/wire-rules.d.ts +21 -0
- package/dist/plugins/retrieval/document-file.d.ts +15 -0
- package/dist/plugins/retrieval/hosted-google.d.ts +8 -3
- package/dist/plugins/retrieval/hosted-openai.d.ts +9 -1
- package/dist/plugins/retrieval/hosted-xai.d.ts +8 -2
- package/dist/wire/chat-specs.d.ts +32 -0
- package/dist/wire/inherit.d.ts +0 -4
- package/dist/wire/interpreter.d.ts +84 -1
- package/dist/wire/mcp-specs.d.ts +13 -0
- package/dist/wire/media-specs.d.ts +20 -0
- package/dist/wire/pins.d.ts +34 -0
- package/dist/wire/registry.d.ts +6 -4
- package/dist/wire/retrieval-specs.d.ts +14 -0
- package/dist/wire/service-specs.d.ts +18 -0
- package/dist/wire/utility-specs.d.ts +10 -0
- 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
|
package/dist/bus/hook-bus.d.ts
CHANGED
|
@@ -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
|
|
17
|
-
|
|
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;
|
package/dist/bus/hook-map.d.ts
CHANGED
|
@@ -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];
|