@combycode/llm-sdk 3.0.0 → 3.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,156 @@ 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.2.1] — 2026-08-31
8
+
9
+ ### Fixed
10
+
11
+ - **Three TypeScript errors in an MCP test file** that made v3.2.0 unpublishable: `searchParams.get()`
12
+ returns `string | null` while `.at(-1)` returns `string | undefined`, and `toBe` has no overload
13
+ spanning both. The assertions are unchanged; only their types are aligned.
14
+
15
+ - **Two lint warnings** left standing in `network/engine.ts` (an unused type import) and an MCP test
16
+ helper (`let x!` forward declarations that nothing forward-references).
17
+
18
+ ### Internal
19
+
20
+ - **The release gate now runs lint, typecheck and the tests** (`G1 build-green` in CombyCode's shared
21
+ quality-gate). v3.2.0 was tagged with a green gate and a red typecheck, because the gate checked
22
+ documentation and consumers while lint and typecheck lived in a playbook sentence — the half a
23
+ human has to remember. The gate's own README names that failure mode: "a checklist you have to
24
+ remember to read is guarded by the same attention that failed in the first place." The check ships
25
+ with the good/bad/blind fixtures the selftest requires, and was verified against the exact error
26
+ that escaped.
27
+
28
+ ## [3.2.0] — 2026-08-31
29
+
30
+ ### Added
31
+
32
+ - **`response.citations` — the sources an answer cited, unified across providers.** Reaching them
33
+ meant regexing `response.raw`; the SDK's own web-search example did exactly that, which is every
34
+ consumer reimplementing provider knowledge that belongs here. Four wire shapes are read: Anthropic's
35
+ text-block `citations[]` (the only provider that also reports the cited passage), Google's
36
+ `groundingMetadata.groundingChunks[]`, OpenAI Responses/Chat `url_citation` annotations, and xAI's
37
+ bare top-level `citations[]`.
38
+
39
+ Distinct from `builtinToolCalls`, which records what the model *invoked*: a turn can run three
40
+ searches and cite one page. Through an agent run the sources **accumulate across steps**, deduped
41
+ by URL — a run that searches in step 1 and answers in step 3 keeps the sources its answer rests on.
42
+ Optional per R3 (`response.citations ?? []`); absent on `stream()`, which holds no raw payload, and
43
+ on Google's Interactions surface, whose grounding shape has not been measured.
44
+
45
+ **Streaming reports them too**, as a `citation` StreamEvent per source, collected onto the streamed
46
+ final response so `stream()` and `complete()` agree. Four more measured shapes, since a stream never
47
+ assembles the body the buffered reader parses: Anthropic `citations_delta`, Responses
48
+ `response.output_text.annotation.added` (OpenAI and xAI), chat-completions `delta.annotations`, and
49
+ Google's *late* populated `groundingMetadata` chunk — the first one carrying that key is empty, so
50
+ latching on first sight would report a search and no sources.
51
+
52
+ Verified live on Anthropic, OpenAI, Google, xAI and OpenRouter, buffered and streamed: 5/5 providers
53
+ return real cited URLs for the web-search scenario, which previously reported `no-citation` on all
54
+ five. Note Google reports each source as a `grounding-api-redirect` URL, not the page itself.
55
+
56
+ ### Fixed
57
+
58
+ - **xAI file `delete`, `getInfo` and `list` never worked.** `XAIFileAdapter` is exported, and three
59
+ of its four methods rejected with `unknown spec: xai/files.<op>` before any HTTP — only
60
+ `xai/files.upload` had ever been written, so roughly twenty lines of response mapping below them
61
+ had never run. The three specs follow `xai.upload`'s own note that the surface differs from
62
+ OpenAI's only in the upload `purpose`, so they carry the same paths and the same Bearer base.
63
+
64
+ - **`listModelsLive` was blocked by CORS in the browser, for Anthropic only.** Anthropic refuses a
65
+ browser request without an explicit opt-in header. The chat path sends it and the Anthropic files
66
+ specs carry it, but `models.list` lost it when that request moved to a spec: the header had lived
67
+ in the helper's own table, which nothing called any more. Measured from a browser with a raw
68
+ fetch — `/v1/models` returns 200 with the header and fails without it. The sandbox listed models
69
+ through `Promise.allSettled` and kept only the fulfilled results, so the rejection was swallowed
70
+ and Anthropic simply showed no models rather than an error.
71
+
72
+ ### Removed
73
+
74
+ - **Five private functions left behind by the spec migration.** Each was live until its area moved to
75
+ the spec-driven builder, which renamed it with a leading underscore instead of deleting it:
76
+ `_buildForm`, `_batchName`, `_toOpenAIAudioFormat`, `_toResponseModalities` and
77
+ `LiveSpec.headers`. Each was checked field by field against the spec that replaced it before
78
+ removal — and the last of them was **not** equivalent, which is how the CORS bug above was found.
79
+
80
+ ### Internal
81
+
82
+ - **Line coverage 91.62% → 99.53%**, function coverage 87.53% → 97.41%, across 2290 → 4051 tests.
83
+ Eleven modules had no function coverage at all — `batcher.ts` ran 3.5% of its lines,
84
+ `plugins/internal-tools/runner/runner.ts` 3.7%, `transport-ws.ts` 9.6%. Every new test is mutation-verified: the
85
+ source line it claims to cover was broken and the test watched to fail.
86
+
87
+ The exercise found ten defects no existing test caught, each pinned as a `DEFECT:` test rather
88
+ than fixed in the same pass: `ResponseStore.list(null)` returning every user's response ids,
89
+ `chunker.ts` silently dropping space-free text (3000 characters of CJK or base64 yield one
90
+ 400-character chunk), `attachment.ts fromBlob` leaving the MIME type empty because a type-less
91
+ `Blob` reports `''` rather than `null`, and `oauth.ts tryRefresh` discarding the refresh token it
92
+ means to preserve.
93
+
94
+ Two pre-existing tests turned out to execute no code at all — one asserts against `readFileSync`
95
+ of `realtime.ts` with regexes over the source text, which is why that file sat at 0% functions
96
+ while appearing tested.
97
+
98
+ - **A coverage floor that can actually fail.** `bunfig.toml`'s `coverageThreshold` is accepted and
99
+ ignored by bun 1.3.14 — set to an impossible 1.0 the run still exits 0 — so the floor is its own
100
+ step (`bun run coverage:gate`), carrying a `--self-test` that proves it discriminates.
101
+
102
+
103
+ ## [3.1.0] — 2026-08-25
104
+
105
+ ### Added
106
+
107
+ - **A provider can declare a hosted tool it will not run beside certain content** (`toolConstraints`
108
+ in a wire spec, plus a `hasPartType` condition). Measured 2026-08-25: Google answers 400 `The mime
109
+ type: video/mp4 is not supported for code execution` when `code_interpreter` accompanies a PDF or a
110
+ video. Images are fine, `web_search` is fine, and OpenAI accepts every combination — so it is one
111
+ provider's rule, and it lives in that provider's spec as data rather than as an `if` in the builder.
112
+
113
+ A matched constraint drops the tool — `hasTool` reports it absent, so the spec's existing guard
114
+ omits it with no further edit — and records why on the built request. `LLMClient` emits each as
115
+ `onWarning` with code `request_adjusted`, on both `complete()` and `stream()`. Dropping it quietly
116
+ would trade a confusing error for the silent loss of a capability the caller asked for.
117
+
118
+ - **Any spelling of a model id finds the model, and every helper resolves it the same way.**
119
+ Providers spell one version several ways and users copy whichever they saw: `gpt-4.1` / `gpt-4-1`,
120
+ `gemini-2.5-flash` / `gemini-2-5-flash`, `claude-haiku-4.5` / `claude-haiku-4-5`. Only some are
121
+ callable, and the rest missed the catalog outright — no price, no capabilities — and were then
122
+ forwarded to the provider verbatim, turning a spelling difference into a 404.
123
+
124
+ Catalog lookups are now insensitive to the separator between two digits and to case, so ~556
125
+ previously-unresolvable spellings reach their entry. The rule is deliberately narrow — only a
126
+ separator between two DIGITS moves — and is verified across the shipped catalogs to merge nothing:
127
+ 1016 normalized keys, zero collisions.
128
+
129
+ `resolveModelId()` now always returns something callable. A spelling the provider itself accepts is
130
+ sent unchanged; one it would reject is corrected to the canonical id instead of being forwarded;
131
+ an unknown id still passes through verbatim, so fine-tunes and same-day releases keep working.
132
+
133
+ ### Fixed
134
+
135
+ - **`createRealtime()` never translated the model id.** It parsed the provider but skipped the
136
+ catalog step every other helper performs, so a realtime session was the one path that sent our
137
+ slug instead of the provider's id. All helpers now share the single resolution step, and a test
138
+ calls each one and reads the wire so a helper that forgets fails CI rather than a user's request.
139
+
140
+ - **An Anthropic model is reachable by the name Anthropic documents.** `claude-haiku-4-5` — the
141
+ spelling in Anthropic's own docs, and one the API accepts — matched no catalog key and no alias,
142
+ because `/v1/models` lists only the dated snapshot (`claude-haiku-4-5-20251001`) while our slug
143
+ dots the version (`claude-haiku-4.5`). `get()` and `getPricing()` missed in silence, and an
144
+ unpriced model is indistinguishable from a free one: reported from production as 72k tokens
145
+ billed at $0.00.
146
+
147
+ The undated form is now carried as an alias for the four affected entries (`claude-haiku-4-5`,
148
+ `claude-sonnet-4-5`, `claude-opus-4-5`, `claude-opus-4-1`), derived in the catalog pipeline so a
149
+ regeneration keeps it. Anthropic-only, and deliberately so — the same date-stripping applied to
150
+ other providers invents ids that do not exist (`imagen-4.0-generate`, `command-r7b-12`), since
151
+ only there is the undated form a truncation rather than a real alias. Each was probed live.
152
+
153
+ `providerModelName` is untouched, so **what goes on the wire is unchanged**: a slug still
154
+ translates to its pinned snapshot, and an alias is still sent verbatim as the floating id the
155
+ caller chose. This widens what the catalog recognises, never what it calls.
156
+
7
157
  ## [3.0.0] — 2026-08-24
8
158
 
9
159
  ### Added
@@ -1,7 +1,7 @@
1
1
  /** Mutable accumulator for a single agent loop step.
2
2
  * Passed through stream-event helpers so they don't fight closure state. */
3
3
  import type { ToolCallPart } from '../llm/types/messages';
4
- import type { Usage } from '../llm/types/response';
4
+ import type { Citation, Usage } from '../llm/types/response';
5
5
  /** Accumulation bucket for one in-progress tool call (before tool_call_end). */
6
6
  export interface ToolCallAccumEntry {
7
7
  id: string;
@@ -19,4 +19,8 @@ export interface StepState {
19
19
  toolCallAccum: Map<string, ToolCallAccumEntry>;
20
20
  stepUsage: Usage;
21
21
  stepFinishReason: string;
22
+ /** Sources cited during this step, keyed by url. A Map rather than an array
23
+ * because Google repeats its grounding chunks across late chunks, and one page
24
+ * cited twice is one source. */
25
+ stepCitations: Map<string, Citation>;
22
26
  }
@@ -141,14 +141,26 @@ export declare class ModelCatalog {
141
141
  /** `provider/alias` → `provider/canonical-slug`. Lets get()/resolveModelId
142
142
  * accept any callable id (providerModelName, dated snapshot) AND the slug. */
143
143
  private aliasIndex;
144
+ /** `provider/normalized-id` → `provider/canonical-slug`. The last resort, so a
145
+ * user's spelling of a version never decides whether the model is found. */
146
+ private normIndex;
144
147
  private key;
148
+ private normKey;
145
149
  set(provider: string, model: string, info: Partial<Omit<ModelInfo, 'provider' | 'model'>> & {
146
150
  pricing: ModelPricing;
147
151
  }): void;
148
152
  get(provider: string, model: string): ModelInfo | null;
149
- /** The exact id to SEND to the provider for a given model string. Translates
150
- * our slug → providerModelName; passes an already-callable id (alias) through
151
- * verbatim (respects an explicit choice); unknown model verbatim passthrough. */
153
+ /** The exact id to SEND to the provider for a given model string.
154
+ *
155
+ * Three cases, and the difference between them is what the caller ASKED for:
156
+ * - our slug → translate to providerModelName (the pinned snapshot)
157
+ * - an id the provider itself accepts (a listed alias, e.g. a dated snapshot
158
+ * or anthropic's undated name) → verbatim, because it is a deliberate choice
159
+ * and rewriting it would pin a caller who asked to float
160
+ * - a spelling variant that is NOT callable (`gemini-2-5-flash`) → the
161
+ * canonical entry's providerModelName, since forwarding it verbatim only
162
+ * produces a 404 with the user's typo in it
163
+ * - unknown → verbatim, so a model we have never heard of still works */
152
164
  resolveModelId(provider: string, model: string): string;
153
165
  getPricing(provider: string, model: string): ModelPricing | null;
154
166
  getPreferredApi(provider: string, model: string): ApiType | null;
@@ -34,6 +34,41 @@ declare const DEFAULT_THRESHOLDS: {
34
34
  'context.small': number;
35
35
  'context.large': number;
36
36
  };
37
+ /** One filter a UI can offer, and what it accepts. */
38
+ export interface FilterFacet {
39
+ /** The DSL key, e.g. `price`. Write it as `key:value`. */
40
+ key: string;
41
+ /** Group heading for a picker. */
42
+ category: 'what it is' | 'cost' | 'thinking' | 'inputs' | 'hosted tools' | 'availability';
43
+ /** Short human label. */
44
+ label: string;
45
+ /** The values this key accepts. Empty when the key is a bare flag (`vision`),
46
+ * which the parser reads as `key:yes`. */
47
+ values: string[];
48
+ /** True when the key also accepts `> N` / `< N`, so a picker can offer a number. */
49
+ numeric: boolean;
50
+ /** True when a bare `key` (no value) is meaningful — the parser expands it to
51
+ * `key:yes`. */
52
+ bare: boolean;
53
+ }
54
+ /** Every clause the query parser understands, as data.
55
+ *
56
+ * Exported because the alternative is a UI hand-listing the same tags: a second
57
+ * copy of this vocabulary, drifting from the parser the first time either moves,
58
+ * and drifting invisibly because a wrong tag reads as "no models matched" rather
59
+ * than as an error. Here the picker and the parser cannot disagree — both come
60
+ * from `KNOWN_KEYS`, `CAP_KEYS` and `BUILTIN_TOOL_KEYS` above.
61
+ *
62
+ * `type`, `provider`, `status` and `tier` take their values from the CATALOG
63
+ * when one is passed, because those are open sets: a provider ships a new model
64
+ * type and a hard-coded list is wrong that day. Without a catalog they come back
65
+ * empty rather than guessed — an empty list is honest, a stale list is not. */
66
+ export declare function filterFacets(catalog?: {
67
+ list(): ModelInfo[];
68
+ }): FilterFacet[];
69
+ /** The shorthand tags the parser expands before matching (`cheap` → `price:low`).
70
+ * A picker can show these as one-click presets. */
71
+ export declare function filterAliases(): Record<string, string>;
37
72
  /** All matching models, ranked cheapest-first (tiebreak: newest version). */
38
73
  export declare function selectModels(query: string | string[], opts?: SelectOptions): ModelInfo[];
39
74
  /** The single best match as a `provider/slug` string (feedable to complete), or null. */