@combycode/llm-sdk 3.1.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,7 +4,101 @@ 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]
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
+
8
102
 
9
103
  ## [3.1.0] — 2026-08-25
10
104
 
@@ -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
  }
@@ -30969,7 +30969,7 @@ var xai_create_default = {
30969
30969
  var xai_addRequests_default = {
30970
30970
  id: "xai/batch.addRequests",
30971
30971
  extends: "xai/batch.base",
30972
- _note: "Step 2 of submit. Step 1 creates the batch and is NOT modelled: its body embeds Date.now(), so the request is not reproducible. See report 039 A3.",
30972
+ _note: "Step 2 of submit: the batch opened in step 1 is filled here. batch_request is a TAGGED UNION whose variant name is the KEY -- {responses: <body>}, never {endpoint: 'responses', body: <body>}. Measured live 2026-08-28: the endpoint-field form is rejected 422 with 'unknown variant endpoint, expected one of chat_get_completion, responses, image_generation, image_edit, video_generation, video_extension'. That form had never been sent to xAI, here or in the TypeScript adapter this spec came from.",
30973
30973
  envelope: {
30974
30974
  method: "POST",
30975
30975
  url: { $join: [{ $config: "baseURL" }, "/v1/batches/", { $: "batchId" }, "/requests"] },
@@ -30985,7 +30985,7 @@ var xai_addRequests_default = {
30985
30985
  {
30986
30986
  value: {
30987
30987
  batch_request_id: { $: "@customId" },
30988
- batch_request: { endpoint: "responses", body: { $: "@body" } }
30988
+ batch_request: { responses: { $: "@body" } }
30989
30989
  }
30990
30990
  }
30991
30991
  ]
@@ -31337,6 +31337,42 @@ var xai_upload_default = {
31337
31337
  multipart: [{ name: "file", file: true }, { name: "purpose", value: "assistants" }]
31338
31338
  };
31339
31339
 
31340
+ // src/wire/specs/files/xai.delete.json
31341
+ var xai_delete_default = {
31342
+ id: "xai/files.delete",
31343
+ extends: "openai/files.base",
31344
+ _note: "Same shape as OpenAI's, per xai.upload's note that the surface differs only in the upload purpose. baseURL is supplied by the adapter, so the path is all that is declared here.",
31345
+ envelope: {
31346
+ method: "DELETE",
31347
+ bodyKind: "none",
31348
+ url: { $join: [{ $config: "baseURL" }, "/v1/files/", { $: "remoteId" }] }
31349
+ }
31350
+ };
31351
+
31352
+ // src/wire/specs/files/xai.getInfo.json
31353
+ var xai_getInfo_default = {
31354
+ id: "xai/files.getInfo",
31355
+ extends: "openai/files.base",
31356
+ _note: "Same shape as OpenAI's, per xai.upload's note that the surface differs only in the upload purpose. baseURL is supplied by the adapter, so the path is all that is declared here.",
31357
+ envelope: {
31358
+ method: "GET",
31359
+ bodyKind: "none",
31360
+ url: { $join: [{ $config: "baseURL" }, "/v1/files/", { $: "remoteId" }] }
31361
+ }
31362
+ };
31363
+
31364
+ // src/wire/specs/files/xai.list.json
31365
+ var xai_list_default = {
31366
+ id: "xai/files.list",
31367
+ extends: "openai/files.base",
31368
+ _note: "Same shape as OpenAI's, per xai.upload's note that the surface differs only in the upload purpose. baseURL is supplied by the adapter, so the path is all that is declared here.",
31369
+ envelope: {
31370
+ method: "GET",
31371
+ bodyKind: "none",
31372
+ url: { $join: [{ $config: "baseURL" }, "/v1/files"] }
31373
+ }
31374
+ };
31375
+
31340
31376
  // src/wire/specs/files/google.finishUpload.json
31341
31377
  var google_finishUpload_default = {
31342
31378
  id: "google/files.finishUpload",
@@ -31577,6 +31613,9 @@ var DELTAS = new Map(
31577
31613
  google_getInfo_default,
31578
31614
  google_list_default,
31579
31615
  xai_upload_default,
31616
+ xai_delete_default,
31617
+ xai_getInfo_default,
31618
+ xai_list_default,
31580
31619
  google_finishUpload_default,
31581
31620
  videos_status_default,
31582
31621
  videos_cancel_default,
@@ -32387,6 +32426,14 @@ var anthropic_default3 = {
32387
32426
  value: {
32388
32427
  $config: "apiVersion"
32389
32428
  }
32429
+ },
32430
+ {
32431
+ name: "anthropic-dangerous-direct-browser-access",
32432
+ value: "true",
32433
+ _note: "Only in a browser, where the request is otherwise blocked by CORS. The chat path sends it (messages.ts) and so do the files specs; models.list lost it when this request moved to a spec, because the header lived in the helper's own table.",
32434
+ when: {
32435
+ truthy: "browser"
32436
+ }
32390
32437
  }
32391
32438
  ]
32392
32439
  }
@@ -36663,6 +36710,7 @@ var LLMClient = class {
36663
36710
  let moderationReport;
36664
36711
  const files = [];
36665
36712
  const builtinToolCalls = [];
36713
+ const citationsByUrl = /* @__PURE__ */ new Map();
36666
36714
  const fetchStream = this.fetchStreamFn;
36667
36715
  const queueName = this.queueName;
36668
36716
  const priority = this.priority;
@@ -36716,6 +36764,9 @@ var LLMClient = class {
36716
36764
  case "file":
36717
36765
  files.push(event.file);
36718
36766
  break;
36767
+ case "citation":
36768
+ citationsByUrl.set(event.citation.url, event.citation);
36769
+ break;
36719
36770
  case "builtin_tool_end":
36720
36771
  builtinToolCalls.push({
36721
36772
  tool: event.tool,
@@ -36750,6 +36801,7 @@ var LLMClient = class {
36750
36801
  media: [],
36751
36802
  ...files.length ? { files } : {},
36752
36803
  ...builtinToolCalls.length ? { builtinToolCalls } : {},
36804
+ ...citationsByUrl.size ? { citations: [...citationsByUrl.values()] } : {},
36753
36805
  ...moderationReport ? { moderation: moderationReport } : {},
36754
36806
  latencyMs: performance.now() - start,
36755
36807
  raw: null
@@ -38560,6 +38612,87 @@ function pinFor(model, pins) {
38560
38612
  var ANTHROPIC_MESSAGE_PINS = anthropic_messages_default;
38561
38613
  var GOOGLE_GENERATE_PINS = google_generate_default;
38562
38614
 
38615
+ // src/llm/providers/_shared/citations.ts
38616
+ function fromAnthropic(raw) {
38617
+ const out = [];
38618
+ for (const block of raw.content ?? []) {
38619
+ for (const cite of block.citations ?? []) {
38620
+ const url = cite.url;
38621
+ if (url) {
38622
+ out.push({
38623
+ url,
38624
+ ...cite.title ? { title: cite.title } : {},
38625
+ // Anthropic is the only provider that reports the cited passage.
38626
+ ...cite.cited_text ? { text: cite.cited_text } : {}
38627
+ });
38628
+ }
38629
+ }
38630
+ }
38631
+ return out;
38632
+ }
38633
+ function fromGoogle(raw) {
38634
+ const out = [];
38635
+ for (const candidate of raw.candidates ?? []) {
38636
+ const grounding = candidate.groundingMetadata ?? {};
38637
+ for (const chunk of grounding.groundingChunks ?? []) {
38638
+ const web = chunk.web ?? {};
38639
+ const uri = web.uri;
38640
+ if (uri) out.push({ url: uri, ...web.title ? { title: web.title } : {} });
38641
+ }
38642
+ }
38643
+ return out;
38644
+ }
38645
+ function fromResponses(raw) {
38646
+ const out = [];
38647
+ for (const item of raw.output ?? []) {
38648
+ for (const part of item.content ?? []) {
38649
+ for (const note of part.annotations ?? []) {
38650
+ if (note.type === "url_citation" && note.url) {
38651
+ out.push({
38652
+ url: note.url,
38653
+ ...note.title ? { title: note.title } : {}
38654
+ });
38655
+ }
38656
+ }
38657
+ }
38658
+ }
38659
+ return out;
38660
+ }
38661
+ function fromCompletions(raw) {
38662
+ const out = [];
38663
+ const message = (raw.choices ?? [])[0]?.message ?? {};
38664
+ for (const note of message.annotations ?? []) {
38665
+ const detail = note.url_citation ?? note;
38666
+ if (note.type === "url_citation" && detail.url) {
38667
+ out.push({
38668
+ url: detail.url,
38669
+ ...detail.title ? { title: detail.title } : {}
38670
+ });
38671
+ }
38672
+ }
38673
+ for (const url of raw.citations ?? []) {
38674
+ if (typeof url === "string") out.push({ url });
38675
+ }
38676
+ return out;
38677
+ }
38678
+ var READERS = {
38679
+ messages: fromAnthropic,
38680
+ generate: fromGoogle,
38681
+ responses: fromResponses,
38682
+ completions: fromCompletions
38683
+ // `interactions` is DELIBERATELY absent. Google's Interactions API returns a
38684
+ // `steps[]` machine, not `candidates[]`, so `fromGoogle` would read a key that
38685
+ // is never there -- and no recorded Interactions response carries grounding, so
38686
+ // there is nothing to write a reader against. Guessing the shape would produce
38687
+ // a reader that is confidently wrong and passes a test built from the same
38688
+ // guess. The adapter is wired anyway: when the shape is measured, only this
38689
+ // table changes.
38690
+ };
38691
+ function extractCitations(api, raw) {
38692
+ if (raw == null || typeof raw !== "object") return [];
38693
+ return READERS[api]?.(raw) ?? [];
38694
+ }
38695
+
38563
38696
  // src/llm/providers/_shared/builtin-tools.ts
38564
38697
  var NATIVE_TO_UNIFIED = {
38565
38698
  // OpenAI / xAI Responses output-item types
@@ -38778,6 +38911,7 @@ var AnthropicAdapter = class {
38778
38911
  model_context_window_exceeded: "length",
38779
38912
  refusal: "content_filter"
38780
38913
  });
38914
+ const citations = extractCitations("messages", raw);
38781
38915
  return {
38782
38916
  id: r.id,
38783
38917
  model: r.model,
@@ -38787,6 +38921,7 @@ var AnthropicAdapter = class {
38787
38921
  text: content.filter((p) => p.type === "text").map((p) => p.text).join(""),
38788
38922
  toolCalls,
38789
38923
  media: [],
38924
+ ...citations.length ? { citations } : {},
38790
38925
  ...files.length ? { files } : {},
38791
38926
  ...builtinToolCalls.length ? { builtinToolCalls } : {},
38792
38927
  thinking,
@@ -38814,6 +38949,20 @@ var AnthropicAdapter = class {
38814
38949
  if (delta.type === "text_delta") return [{ type: "text", text: delta.text }];
38815
38950
  if (delta.type === "thinking_delta")
38816
38951
  return [{ type: "thinking", text: delta.thinking }];
38952
+ if (delta.type === "citations_delta") {
38953
+ const cite = delta.citation ?? {};
38954
+ const url = cite.url;
38955
+ return url ? [
38956
+ {
38957
+ type: "citation",
38958
+ citation: {
38959
+ url,
38960
+ ...cite.title ? { title: cite.title } : {},
38961
+ ...cite.cited_text ? { text: cite.cited_text } : {}
38962
+ }
38963
+ }
38964
+ ] : [];
38965
+ }
38817
38966
  if (delta.type === "input_json_delta") {
38818
38967
  if (state.current) {
38819
38968
  state.current.json += delta.partial_json ?? "";
@@ -39364,6 +39513,7 @@ var GoogleAdapter = class {
39364
39513
  builtinToolCalls.push({ tool: "web_fetch", ...typeof url === "string" ? { url } : {} });
39365
39514
  }
39366
39515
  }
39516
+ const citations = extractCitations("generate", raw);
39367
39517
  return {
39368
39518
  // generateContent DOES return an id — `responseId`, at the top level. The
39369
39519
  // fallback stays for older payloads, but minting one unconditionally made
@@ -39379,6 +39529,7 @@ var GoogleAdapter = class {
39379
39529
  toolCalls,
39380
39530
  thinking,
39381
39531
  media,
39532
+ ...citations.length ? { citations } : {},
39382
39533
  ...files.length ? { files } : {},
39383
39534
  ...builtinToolCalls.length ? { builtinToolCalls } : {},
39384
39535
  latencyMs,
@@ -39463,6 +39614,18 @@ var GoogleAdapter = class {
39463
39614
  events.push({ type: "tool_call_end", id: "" });
39464
39615
  }
39465
39616
  }
39617
+ for (const chunk of candidate.groundingMetadata?.groundingChunks ?? []) {
39618
+ const web = chunk.web ?? {};
39619
+ if (web.uri) {
39620
+ events.push({
39621
+ type: "citation",
39622
+ citation: {
39623
+ url: web.uri,
39624
+ ...web.title ? { title: web.title } : {}
39625
+ }
39626
+ });
39627
+ }
39628
+ }
39466
39629
  if (candidate.groundingMetadata && !state.webSearchEmitted) {
39467
39630
  state.webSearchEmitted = true;
39468
39631
  const q = candidate.groundingMetadata.webSearchQueries?.[0];
@@ -39685,6 +39848,7 @@ var GoogleInteractionsAdapter = class {
39685
39848
  queued: "pending",
39686
39849
  in_progress: "pending"
39687
39850
  });
39851
+ const citations = extractCitations("interactions", raw);
39688
39852
  return {
39689
39853
  id: r.id ?? crypto.randomUUID(),
39690
39854
  model: "",
@@ -39694,6 +39858,7 @@ var GoogleInteractionsAdapter = class {
39694
39858
  text,
39695
39859
  toolCalls,
39696
39860
  media,
39861
+ ...citations.length ? { citations } : {},
39697
39862
  thinking,
39698
39863
  latencyMs,
39699
39864
  raw
@@ -41035,13 +41200,14 @@ var OpenAIAdapter = class {
41035
41200
  content.push(parsed);
41036
41201
  toolCalls.push(parsed);
41037
41202
  }
41038
- const finishReason = extractFinishReason(
41039
- toolCalls.length > 0,
41040
- choice.finish_reason,
41041
- { tool_calls: "tool_use", length: "length", content_filter: "content_filter" }
41042
- );
41203
+ const finishReason = extractFinishReason(toolCalls.length > 0, choice.finish_reason, {
41204
+ tool_calls: "tool_use",
41205
+ length: "length",
41206
+ content_filter: "content_filter"
41207
+ });
41043
41208
  const reasoningContent = message.reasoning_content ?? null;
41044
41209
  const moderation = parseNativeModeration(r.moderation);
41210
+ const citations = extractCitations("completions", raw);
41045
41211
  return {
41046
41212
  id: r.id,
41047
41213
  model: r.model,
@@ -41051,6 +41217,7 @@ var OpenAIAdapter = class {
41051
41217
  text,
41052
41218
  toolCalls,
41053
41219
  media,
41220
+ ...citations.length ? { citations } : {},
41054
41221
  thinking: reasoningContent,
41055
41222
  ...moderation ? { moderation } : {},
41056
41223
  latencyMs,
@@ -41083,6 +41250,18 @@ var OpenAIAdapter = class {
41083
41250
  if (delta.content) {
41084
41251
  events.push({ type: "text", text: delta.content });
41085
41252
  }
41253
+ for (const note of delta.annotations ?? []) {
41254
+ const detail = note.url_citation ?? note;
41255
+ if (note.type === "url_citation" && detail.url) {
41256
+ events.push({
41257
+ type: "citation",
41258
+ citation: {
41259
+ url: detail.url,
41260
+ ...detail.title ? { title: detail.title } : {}
41261
+ }
41262
+ });
41263
+ }
41264
+ }
41086
41265
  const toolIdByIndex = state?.toolIdByIndex ?? /* @__PURE__ */ new Map();
41087
41266
  const toolCalls = delta.tool_calls ?? [];
41088
41267
  for (const tc of toolCalls) {
@@ -41993,6 +42172,7 @@ var OpenAIResponsesAdapter = class {
41993
42172
  if (text && content.length === 0) content.push({ type: "text", text });
41994
42173
  }
41995
42174
  const moderation = parseNativeModeration(r.moderation);
42175
+ const citations = extractCitations("responses", raw);
41996
42176
  return {
41997
42177
  id: r.id,
41998
42178
  model: r.model ?? "",
@@ -42003,6 +42183,7 @@ var OpenAIResponsesAdapter = class {
42003
42183
  toolCalls,
42004
42184
  thinking,
42005
42185
  media,
42186
+ ...citations.length ? { citations } : {},
42006
42187
  ...files.length ? { files } : {},
42007
42188
  ...builtinToolCalls.length ? { builtinToolCalls } : {},
42008
42189
  ...moderation ? { moderation } : {},
@@ -42015,6 +42196,19 @@ var OpenAIResponsesAdapter = class {
42015
42196
  const data = sseJson(event);
42016
42197
  const type = data.type;
42017
42198
  const events = [];
42199
+ if (type === "response.output_text.annotation.added") {
42200
+ const note = data.annotation ?? {};
42201
+ if (note.type === "url_citation" && note.url) {
42202
+ events.push({
42203
+ type: "citation",
42204
+ citation: {
42205
+ url: note.url,
42206
+ ...note.title ? { title: note.title } : {}
42207
+ }
42208
+ });
42209
+ }
42210
+ return events;
42211
+ }
42018
42212
  if (type === "response.output_text.delta") {
42019
42213
  const itemId = data.item_id;
42020
42214
  const phase = itemId ? phaseByItem?.get(itemId) : void 0;
@@ -42335,10 +42529,6 @@ var XAIBatchAdapter = class {
42335
42529
  throw new Error(`xAI batch create failed: ${JSON.stringify(createRes.body)}`);
42336
42530
  const batch2 = createRes.body ?? {};
42337
42531
  const batchId = batch2.batch_id ?? batch2.id;
42338
- const _batchRequests = requests.map((r) => ({
42339
- batch_request_id: r.customId,
42340
- batch_request: { endpoint: "responses", body: r.body }
42341
- }));
42342
42532
  const addRes = await fetch2(this.buildAddRequestsRequest(batchId, requests));
42343
42533
  if (addRes.status >= 400)
42344
42534
  throw new Error(`xAI batch add requests failed: ${JSON.stringify(addRes.body)}`);
@@ -43853,7 +44043,8 @@ function makeStepState() {
43853
44043
  stepToolCalls: [],
43854
44044
  toolCallAccum: /* @__PURE__ */ new Map(),
43855
44045
  stepUsage: emptyUsage(),
43856
- stepFinishReason: "stop"
44046
+ stepFinishReason: "stop",
44047
+ stepCitations: /* @__PURE__ */ new Map()
43857
44048
  };
43858
44049
  }
43859
44050
  function accumulateStreamEvent(event, state) {
@@ -43887,6 +44078,9 @@ function accumulateStreamEvent(event, state) {
43887
44078
  }
43888
44079
  return null;
43889
44080
  }
44081
+ case "citation":
44082
+ state.stepCitations.set(event.citation.url, event.citation);
44083
+ return null;
43890
44084
  case "usage":
43891
44085
  state.stepUsage = event.usage;
43892
44086
  return null;
@@ -43944,6 +44138,7 @@ function buildStepResponse(state, model, stepStart) {
43944
44138
  toolCalls: state.stepToolCalls,
43945
44139
  thinking: state.stepThinking || null,
43946
44140
  media: [],
44141
+ ...state.stepCitations.size ? { citations: [...state.stepCitations.values()] } : {},
43947
44142
  latencyMs: stepLatency,
43948
44143
  raw: null
43949
44144
  };
@@ -44285,6 +44480,8 @@ var AgentLoop = class _AgentLoop {
44285
44480
  // final LLM response (e.g. code-execution files produced during the run).
44286
44481
  ...last?.files ? { files: last.files } : {},
44287
44482
  ...last?.builtinToolCalls ? { builtinToolCalls: last.builtinToolCalls } : {},
44483
+ // Every step's sources, not just the final step's — see the accumulator.
44484
+ ...args.citations.length ? { citations: args.citations } : {},
44288
44485
  ...last?.moderation ? { moderation: last.moderation } : {},
44289
44486
  latencyMs: performance.now() - args.startPerf,
44290
44487
  raw: args.raw
@@ -44351,6 +44548,7 @@ var AgentLoop = class _AgentLoop {
44351
44548
  let stepCount = 0;
44352
44549
  let toolCallCount = 0;
44353
44550
  let lastResponse = null;
44551
+ const citationsByUrl = /* @__PURE__ */ new Map();
44354
44552
  let reason = "done";
44355
44553
  let errorMsg;
44356
44554
  let caughtError;
@@ -44392,6 +44590,7 @@ var AgentLoop = class _AgentLoop {
44392
44590
  const stepLatency = performance.now() - stepStart;
44393
44591
  totalLlmTimeMs += stepLatency;
44394
44592
  addUsage(totalUsage, lastResponse.usage);
44593
+ for (const cite of lastResponse.citations ?? []) citationsByUrl.set(cite.url, cite);
44395
44594
  if (this._reflectRetry?.handles(lastResponse.finishReason)) {
44396
44595
  const verdict = this._reflectRetry.recordFailure();
44397
44596
  await this.hooks.emit("onWarning", {
@@ -44499,6 +44698,7 @@ var AgentLoop = class _AgentLoop {
44499
44698
  totalUsage,
44500
44699
  lastResponse,
44501
44700
  media: lastResponse?.media ?? [],
44701
+ citations: [...citationsByUrl.values()],
44502
44702
  raw: lastResponse?.raw ?? null
44503
44703
  });
44504
44704
  await this.settleRun({
@@ -44544,6 +44744,7 @@ var AgentLoop = class _AgentLoop {
44544
44744
  let finalText = "";
44545
44745
  let finalContent = [];
44546
44746
  let lastResponse = null;
44747
+ const citationsByUrl = /* @__PURE__ */ new Map();
44547
44748
  let reason = "done";
44548
44749
  let errorMsg;
44549
44750
  let caughtError;
@@ -44598,6 +44799,7 @@ var AgentLoop = class _AgentLoop {
44598
44799
  finalContent = content;
44599
44800
  finalText = state.stepText;
44600
44801
  lastResponse = stepResponse;
44802
+ for (const cite of stepResponse.citations ?? []) citationsByUrl.set(cite.url, cite);
44601
44803
  this._history.append(
44602
44804
  {
44603
44805
  ...buildAssistantMessage(lastResponse, {
@@ -44681,6 +44883,7 @@ var AgentLoop = class _AgentLoop {
44681
44883
  finalContent,
44682
44884
  totalUsage,
44683
44885
  lastResponse,
44886
+ citations: [...citationsByUrl.values()],
44684
44887
  // The streaming path already emitted media as events, and never holds a
44685
44888
  // raw provider payload.
44686
44889
  media: [],
@@ -54674,37 +54877,26 @@ function listModels(opts = {}) {
54674
54877
  var LIVE = {
54675
54878
  openai: {
54676
54879
  url: "https://api.openai.com/v1/models",
54677
- headers: (k) => ({ authorization: `Bearer ${k}` }),
54678
54880
  items: (b) => b.data ?? [],
54679
54881
  id: (m) => m.id
54680
54882
  },
54681
54883
  openrouter: {
54682
54884
  url: "https://openrouter.ai/api/v1/models",
54683
- headers: (k) => ({ authorization: `Bearer ${k}` }),
54684
54885
  items: (b) => b.data ?? [],
54685
54886
  id: (m) => m.id
54686
54887
  },
54687
54888
  xai: {
54688
54889
  url: "https://api.x.ai/v1/models",
54689
- headers: (k) => ({ authorization: `Bearer ${k}` }),
54690
54890
  items: (b) => b.data ?? [],
54691
54891
  id: (m) => m.id
54692
54892
  },
54693
54893
  anthropic: {
54694
54894
  url: "https://api.anthropic.com/v1/models",
54695
- // Mirror the chat adapter: Anthropic rejects browser requests without the
54696
- // explicit opt-in header, so the /models call must send it too.
54697
- headers: (k) => ({
54698
- "x-api-key": k,
54699
- "anthropic-version": ANTHROPIC_API_VERSION,
54700
- ...isBrowser() ? { "anthropic-dangerous-direct-browser-access": "true" } : {}
54701
- }),
54702
54895
  items: (b) => b.data ?? [],
54703
54896
  id: (m) => m.id
54704
54897
  },
54705
54898
  google: {
54706
54899
  url: "https://generativelanguage.googleapis.com/v1beta/models",
54707
- headers: (k) => ({ "x-goog-api-key": k }),
54708
54900
  items: (b) => b.models ?? [],
54709
54901
  id: (m) => m.name.replace(/^models\//, "")
54710
54902
  }
@@ -54797,7 +54989,9 @@ async function fetchLiveBody(opts) {
54797
54989
  const p = (async () => {
54798
54990
  const built = buildFromSpec(
54799
54991
  utilitySpec(`${opts.provider}/models.list`),
54800
- {},
54992
+ // Anthropic refuses a browser request without an explicit opt-in header,
54993
+ // and the spec asks for `browser` to decide whether to send it.
54994
+ { browser: isBrowser() },
54801
54995
  makeRegistry({}),
54802
54996
  opts.provider,
54803
54997
  void 0,
package/dist/index.d.ts CHANGED
@@ -49,7 +49,7 @@ export type { AssistantPhase, Role, MessageOrigin, ContentPart, TextPart, ImageP
49
49
  export { isFunctionTool, isBuiltinTool } from './llm/types/tools';
50
50
  export type { FunctionTool, BuiltinTool, McpToolParams, Tool, ToolChoice, JsonSchema } from './llm/types/tools';
51
51
  export { emptyUsage } from './llm/types/response';
52
- export type { BuiltinToolCall, CompletionResponse, FileOutput, FinishReason, KnownFinishReason, Usage, } from './llm/types/response';
52
+ export type { BuiltinToolCall, Citation, CompletionResponse, FileOutput, FinishReason, KnownFinishReason, Usage, } from './llm/types/response';
53
53
  export type { FileStream, RetrievedFile } from './llm/files/retrieve';
54
54
  export type { CacheConfig, NormalizedRequest, ProviderOptions, ReasoningContext, ThinkingConfig } from './llm/types/request';
55
55
  export type { MediaStreamType, StreamEvent } from './llm/types/stream';
package/dist/index.js CHANGED
@@ -30896,7 +30896,7 @@ var xai_create_default = {
30896
30896
  var xai_addRequests_default = {
30897
30897
  id: "xai/batch.addRequests",
30898
30898
  extends: "xai/batch.base",
30899
- _note: "Step 2 of submit. Step 1 creates the batch and is NOT modelled: its body embeds Date.now(), so the request is not reproducible. See report 039 A3.",
30899
+ _note: "Step 2 of submit: the batch opened in step 1 is filled here. batch_request is a TAGGED UNION whose variant name is the KEY -- {responses: <body>}, never {endpoint: 'responses', body: <body>}. Measured live 2026-08-28: the endpoint-field form is rejected 422 with 'unknown variant endpoint, expected one of chat_get_completion, responses, image_generation, image_edit, video_generation, video_extension'. That form had never been sent to xAI, here or in the TypeScript adapter this spec came from.",
30900
30900
  envelope: {
30901
30901
  method: "POST",
30902
30902
  url: { $join: [{ $config: "baseURL" }, "/v1/batches/", { $: "batchId" }, "/requests"] },
@@ -30912,7 +30912,7 @@ var xai_addRequests_default = {
30912
30912
  {
30913
30913
  value: {
30914
30914
  batch_request_id: { $: "@customId" },
30915
- batch_request: { endpoint: "responses", body: { $: "@body" } }
30915
+ batch_request: { responses: { $: "@body" } }
30916
30916
  }
30917
30917
  }
30918
30918
  ]
@@ -31264,6 +31264,42 @@ var xai_upload_default = {
31264
31264
  multipart: [{ name: "file", file: true }, { name: "purpose", value: "assistants" }]
31265
31265
  };
31266
31266
 
31267
+ // src/wire/specs/files/xai.delete.json
31268
+ var xai_delete_default = {
31269
+ id: "xai/files.delete",
31270
+ extends: "openai/files.base",
31271
+ _note: "Same shape as OpenAI's, per xai.upload's note that the surface differs only in the upload purpose. baseURL is supplied by the adapter, so the path is all that is declared here.",
31272
+ envelope: {
31273
+ method: "DELETE",
31274
+ bodyKind: "none",
31275
+ url: { $join: [{ $config: "baseURL" }, "/v1/files/", { $: "remoteId" }] }
31276
+ }
31277
+ };
31278
+
31279
+ // src/wire/specs/files/xai.getInfo.json
31280
+ var xai_getInfo_default = {
31281
+ id: "xai/files.getInfo",
31282
+ extends: "openai/files.base",
31283
+ _note: "Same shape as OpenAI's, per xai.upload's note that the surface differs only in the upload purpose. baseURL is supplied by the adapter, so the path is all that is declared here.",
31284
+ envelope: {
31285
+ method: "GET",
31286
+ bodyKind: "none",
31287
+ url: { $join: [{ $config: "baseURL" }, "/v1/files/", { $: "remoteId" }] }
31288
+ }
31289
+ };
31290
+
31291
+ // src/wire/specs/files/xai.list.json
31292
+ var xai_list_default = {
31293
+ id: "xai/files.list",
31294
+ extends: "openai/files.base",
31295
+ _note: "Same shape as OpenAI's, per xai.upload's note that the surface differs only in the upload purpose. baseURL is supplied by the adapter, so the path is all that is declared here.",
31296
+ envelope: {
31297
+ method: "GET",
31298
+ bodyKind: "none",
31299
+ url: { $join: [{ $config: "baseURL" }, "/v1/files"] }
31300
+ }
31301
+ };
31302
+
31267
31303
  // src/wire/specs/files/google.finishUpload.json
31268
31304
  var google_finishUpload_default = {
31269
31305
  id: "google/files.finishUpload",
@@ -31504,6 +31540,9 @@ var DELTAS = new Map(
31504
31540
  google_getInfo_default,
31505
31541
  google_list_default,
31506
31542
  xai_upload_default,
31543
+ xai_delete_default,
31544
+ xai_getInfo_default,
31545
+ xai_list_default,
31507
31546
  google_finishUpload_default,
31508
31547
  videos_status_default,
31509
31548
  videos_cancel_default,
@@ -32314,6 +32353,14 @@ var anthropic_default3 = {
32314
32353
  value: {
32315
32354
  $config: "apiVersion"
32316
32355
  }
32356
+ },
32357
+ {
32358
+ name: "anthropic-dangerous-direct-browser-access",
32359
+ value: "true",
32360
+ _note: "Only in a browser, where the request is otherwise blocked by CORS. The chat path sends it (messages.ts) and so do the files specs; models.list lost it when this request moved to a spec, because the header lived in the helper's own table.",
32361
+ when: {
32362
+ truthy: "browser"
32363
+ }
32317
32364
  }
32318
32365
  ]
32319
32366
  }
@@ -36590,6 +36637,7 @@ var LLMClient = class {
36590
36637
  let moderationReport;
36591
36638
  const files = [];
36592
36639
  const builtinToolCalls = [];
36640
+ const citationsByUrl = /* @__PURE__ */ new Map();
36593
36641
  const fetchStream = this.fetchStreamFn;
36594
36642
  const queueName = this.queueName;
36595
36643
  const priority = this.priority;
@@ -36643,6 +36691,9 @@ var LLMClient = class {
36643
36691
  case "file":
36644
36692
  files.push(event.file);
36645
36693
  break;
36694
+ case "citation":
36695
+ citationsByUrl.set(event.citation.url, event.citation);
36696
+ break;
36646
36697
  case "builtin_tool_end":
36647
36698
  builtinToolCalls.push({
36648
36699
  tool: event.tool,
@@ -36677,6 +36728,7 @@ var LLMClient = class {
36677
36728
  media: [],
36678
36729
  ...files.length ? { files } : {},
36679
36730
  ...builtinToolCalls.length ? { builtinToolCalls } : {},
36731
+ ...citationsByUrl.size ? { citations: [...citationsByUrl.values()] } : {},
36680
36732
  ...moderationReport ? { moderation: moderationReport } : {},
36681
36733
  latencyMs: performance.now() - start,
36682
36734
  raw: null
@@ -38487,6 +38539,87 @@ function pinFor(model, pins) {
38487
38539
  var ANTHROPIC_MESSAGE_PINS = anthropic_messages_default;
38488
38540
  var GOOGLE_GENERATE_PINS = google_generate_default;
38489
38541
 
38542
+ // src/llm/providers/_shared/citations.ts
38543
+ function fromAnthropic(raw) {
38544
+ const out = [];
38545
+ for (const block of raw.content ?? []) {
38546
+ for (const cite of block.citations ?? []) {
38547
+ const url = cite.url;
38548
+ if (url) {
38549
+ out.push({
38550
+ url,
38551
+ ...cite.title ? { title: cite.title } : {},
38552
+ // Anthropic is the only provider that reports the cited passage.
38553
+ ...cite.cited_text ? { text: cite.cited_text } : {}
38554
+ });
38555
+ }
38556
+ }
38557
+ }
38558
+ return out;
38559
+ }
38560
+ function fromGoogle(raw) {
38561
+ const out = [];
38562
+ for (const candidate of raw.candidates ?? []) {
38563
+ const grounding = candidate.groundingMetadata ?? {};
38564
+ for (const chunk of grounding.groundingChunks ?? []) {
38565
+ const web = chunk.web ?? {};
38566
+ const uri = web.uri;
38567
+ if (uri) out.push({ url: uri, ...web.title ? { title: web.title } : {} });
38568
+ }
38569
+ }
38570
+ return out;
38571
+ }
38572
+ function fromResponses(raw) {
38573
+ const out = [];
38574
+ for (const item of raw.output ?? []) {
38575
+ for (const part of item.content ?? []) {
38576
+ for (const note of part.annotations ?? []) {
38577
+ if (note.type === "url_citation" && note.url) {
38578
+ out.push({
38579
+ url: note.url,
38580
+ ...note.title ? { title: note.title } : {}
38581
+ });
38582
+ }
38583
+ }
38584
+ }
38585
+ }
38586
+ return out;
38587
+ }
38588
+ function fromCompletions(raw) {
38589
+ const out = [];
38590
+ const message = (raw.choices ?? [])[0]?.message ?? {};
38591
+ for (const note of message.annotations ?? []) {
38592
+ const detail = note.url_citation ?? note;
38593
+ if (note.type === "url_citation" && detail.url) {
38594
+ out.push({
38595
+ url: detail.url,
38596
+ ...detail.title ? { title: detail.title } : {}
38597
+ });
38598
+ }
38599
+ }
38600
+ for (const url of raw.citations ?? []) {
38601
+ if (typeof url === "string") out.push({ url });
38602
+ }
38603
+ return out;
38604
+ }
38605
+ var READERS = {
38606
+ messages: fromAnthropic,
38607
+ generate: fromGoogle,
38608
+ responses: fromResponses,
38609
+ completions: fromCompletions
38610
+ // `interactions` is DELIBERATELY absent. Google's Interactions API returns a
38611
+ // `steps[]` machine, not `candidates[]`, so `fromGoogle` would read a key that
38612
+ // is never there -- and no recorded Interactions response carries grounding, so
38613
+ // there is nothing to write a reader against. Guessing the shape would produce
38614
+ // a reader that is confidently wrong and passes a test built from the same
38615
+ // guess. The adapter is wired anyway: when the shape is measured, only this
38616
+ // table changes.
38617
+ };
38618
+ function extractCitations(api, raw) {
38619
+ if (raw == null || typeof raw !== "object") return [];
38620
+ return READERS[api]?.(raw) ?? [];
38621
+ }
38622
+
38490
38623
  // src/llm/providers/_shared/builtin-tools.ts
38491
38624
  var NATIVE_TO_UNIFIED = {
38492
38625
  // OpenAI / xAI Responses output-item types
@@ -38705,6 +38838,7 @@ var AnthropicAdapter = class {
38705
38838
  model_context_window_exceeded: "length",
38706
38839
  refusal: "content_filter"
38707
38840
  });
38841
+ const citations = extractCitations("messages", raw);
38708
38842
  return {
38709
38843
  id: r.id,
38710
38844
  model: r.model,
@@ -38714,6 +38848,7 @@ var AnthropicAdapter = class {
38714
38848
  text: content.filter((p) => p.type === "text").map((p) => p.text).join(""),
38715
38849
  toolCalls,
38716
38850
  media: [],
38851
+ ...citations.length ? { citations } : {},
38717
38852
  ...files.length ? { files } : {},
38718
38853
  ...builtinToolCalls.length ? { builtinToolCalls } : {},
38719
38854
  thinking,
@@ -38741,6 +38876,20 @@ var AnthropicAdapter = class {
38741
38876
  if (delta.type === "text_delta") return [{ type: "text", text: delta.text }];
38742
38877
  if (delta.type === "thinking_delta")
38743
38878
  return [{ type: "thinking", text: delta.thinking }];
38879
+ if (delta.type === "citations_delta") {
38880
+ const cite = delta.citation ?? {};
38881
+ const url = cite.url;
38882
+ return url ? [
38883
+ {
38884
+ type: "citation",
38885
+ citation: {
38886
+ url,
38887
+ ...cite.title ? { title: cite.title } : {},
38888
+ ...cite.cited_text ? { text: cite.cited_text } : {}
38889
+ }
38890
+ }
38891
+ ] : [];
38892
+ }
38744
38893
  if (delta.type === "input_json_delta") {
38745
38894
  if (state.current) {
38746
38895
  state.current.json += delta.partial_json ?? "";
@@ -39291,6 +39440,7 @@ var GoogleAdapter = class {
39291
39440
  builtinToolCalls.push({ tool: "web_fetch", ...typeof url === "string" ? { url } : {} });
39292
39441
  }
39293
39442
  }
39443
+ const citations = extractCitations("generate", raw);
39294
39444
  return {
39295
39445
  // generateContent DOES return an id — `responseId`, at the top level. The
39296
39446
  // fallback stays for older payloads, but minting one unconditionally made
@@ -39306,6 +39456,7 @@ var GoogleAdapter = class {
39306
39456
  toolCalls,
39307
39457
  thinking,
39308
39458
  media,
39459
+ ...citations.length ? { citations } : {},
39309
39460
  ...files.length ? { files } : {},
39310
39461
  ...builtinToolCalls.length ? { builtinToolCalls } : {},
39311
39462
  latencyMs,
@@ -39390,6 +39541,18 @@ var GoogleAdapter = class {
39390
39541
  events.push({ type: "tool_call_end", id: "" });
39391
39542
  }
39392
39543
  }
39544
+ for (const chunk of candidate.groundingMetadata?.groundingChunks ?? []) {
39545
+ const web = chunk.web ?? {};
39546
+ if (web.uri) {
39547
+ events.push({
39548
+ type: "citation",
39549
+ citation: {
39550
+ url: web.uri,
39551
+ ...web.title ? { title: web.title } : {}
39552
+ }
39553
+ });
39554
+ }
39555
+ }
39393
39556
  if (candidate.groundingMetadata && !state.webSearchEmitted) {
39394
39557
  state.webSearchEmitted = true;
39395
39558
  const q = candidate.groundingMetadata.webSearchQueries?.[0];
@@ -39612,6 +39775,7 @@ var GoogleInteractionsAdapter = class {
39612
39775
  queued: "pending",
39613
39776
  in_progress: "pending"
39614
39777
  });
39778
+ const citations = extractCitations("interactions", raw);
39615
39779
  return {
39616
39780
  id: r.id ?? crypto.randomUUID(),
39617
39781
  model: "",
@@ -39621,6 +39785,7 @@ var GoogleInteractionsAdapter = class {
39621
39785
  text,
39622
39786
  toolCalls,
39623
39787
  media,
39788
+ ...citations.length ? { citations } : {},
39624
39789
  thinking,
39625
39790
  latencyMs,
39626
39791
  raw
@@ -40962,13 +41127,14 @@ var OpenAIAdapter = class {
40962
41127
  content.push(parsed);
40963
41128
  toolCalls.push(parsed);
40964
41129
  }
40965
- const finishReason = extractFinishReason(
40966
- toolCalls.length > 0,
40967
- choice.finish_reason,
40968
- { tool_calls: "tool_use", length: "length", content_filter: "content_filter" }
40969
- );
41130
+ const finishReason = extractFinishReason(toolCalls.length > 0, choice.finish_reason, {
41131
+ tool_calls: "tool_use",
41132
+ length: "length",
41133
+ content_filter: "content_filter"
41134
+ });
40970
41135
  const reasoningContent = message.reasoning_content ?? null;
40971
41136
  const moderation = parseNativeModeration(r.moderation);
41137
+ const citations = extractCitations("completions", raw);
40972
41138
  return {
40973
41139
  id: r.id,
40974
41140
  model: r.model,
@@ -40978,6 +41144,7 @@ var OpenAIAdapter = class {
40978
41144
  text,
40979
41145
  toolCalls,
40980
41146
  media,
41147
+ ...citations.length ? { citations } : {},
40981
41148
  thinking: reasoningContent,
40982
41149
  ...moderation ? { moderation } : {},
40983
41150
  latencyMs,
@@ -41010,6 +41177,18 @@ var OpenAIAdapter = class {
41010
41177
  if (delta.content) {
41011
41178
  events.push({ type: "text", text: delta.content });
41012
41179
  }
41180
+ for (const note of delta.annotations ?? []) {
41181
+ const detail = note.url_citation ?? note;
41182
+ if (note.type === "url_citation" && detail.url) {
41183
+ events.push({
41184
+ type: "citation",
41185
+ citation: {
41186
+ url: detail.url,
41187
+ ...detail.title ? { title: detail.title } : {}
41188
+ }
41189
+ });
41190
+ }
41191
+ }
41013
41192
  const toolIdByIndex = state?.toolIdByIndex ?? /* @__PURE__ */ new Map();
41014
41193
  const toolCalls = delta.tool_calls ?? [];
41015
41194
  for (const tc of toolCalls) {
@@ -41920,6 +42099,7 @@ var OpenAIResponsesAdapter = class {
41920
42099
  if (text && content.length === 0) content.push({ type: "text", text });
41921
42100
  }
41922
42101
  const moderation = parseNativeModeration(r.moderation);
42102
+ const citations = extractCitations("responses", raw);
41923
42103
  return {
41924
42104
  id: r.id,
41925
42105
  model: r.model ?? "",
@@ -41930,6 +42110,7 @@ var OpenAIResponsesAdapter = class {
41930
42110
  toolCalls,
41931
42111
  thinking,
41932
42112
  media,
42113
+ ...citations.length ? { citations } : {},
41933
42114
  ...files.length ? { files } : {},
41934
42115
  ...builtinToolCalls.length ? { builtinToolCalls } : {},
41935
42116
  ...moderation ? { moderation } : {},
@@ -41942,6 +42123,19 @@ var OpenAIResponsesAdapter = class {
41942
42123
  const data = sseJson(event);
41943
42124
  const type = data.type;
41944
42125
  const events = [];
42126
+ if (type === "response.output_text.annotation.added") {
42127
+ const note = data.annotation ?? {};
42128
+ if (note.type === "url_citation" && note.url) {
42129
+ events.push({
42130
+ type: "citation",
42131
+ citation: {
42132
+ url: note.url,
42133
+ ...note.title ? { title: note.title } : {}
42134
+ }
42135
+ });
42136
+ }
42137
+ return events;
42138
+ }
41945
42139
  if (type === "response.output_text.delta") {
41946
42140
  const itemId = data.item_id;
41947
42141
  const phase = itemId ? phaseByItem?.get(itemId) : void 0;
@@ -42262,10 +42456,6 @@ var XAIBatchAdapter = class {
42262
42456
  throw new Error(`xAI batch create failed: ${JSON.stringify(createRes.body)}`);
42263
42457
  const batch2 = createRes.body ?? {};
42264
42458
  const batchId = batch2.batch_id ?? batch2.id;
42265
- const _batchRequests = requests.map((r) => ({
42266
- batch_request_id: r.customId,
42267
- batch_request: { endpoint: "responses", body: r.body }
42268
- }));
42269
42459
  const addRes = await fetch2(this.buildAddRequestsRequest(batchId, requests));
42270
42460
  if (addRes.status >= 400)
42271
42461
  throw new Error(`xAI batch add requests failed: ${JSON.stringify(addRes.body)}`);
@@ -43780,7 +43970,8 @@ function makeStepState() {
43780
43970
  stepToolCalls: [],
43781
43971
  toolCallAccum: /* @__PURE__ */ new Map(),
43782
43972
  stepUsage: emptyUsage(),
43783
- stepFinishReason: "stop"
43973
+ stepFinishReason: "stop",
43974
+ stepCitations: /* @__PURE__ */ new Map()
43784
43975
  };
43785
43976
  }
43786
43977
  function accumulateStreamEvent(event, state) {
@@ -43814,6 +44005,9 @@ function accumulateStreamEvent(event, state) {
43814
44005
  }
43815
44006
  return null;
43816
44007
  }
44008
+ case "citation":
44009
+ state.stepCitations.set(event.citation.url, event.citation);
44010
+ return null;
43817
44011
  case "usage":
43818
44012
  state.stepUsage = event.usage;
43819
44013
  return null;
@@ -43871,6 +44065,7 @@ function buildStepResponse(state, model, stepStart) {
43871
44065
  toolCalls: state.stepToolCalls,
43872
44066
  thinking: state.stepThinking || null,
43873
44067
  media: [],
44068
+ ...state.stepCitations.size ? { citations: [...state.stepCitations.values()] } : {},
43874
44069
  latencyMs: stepLatency,
43875
44070
  raw: null
43876
44071
  };
@@ -44212,6 +44407,8 @@ var AgentLoop = class _AgentLoop {
44212
44407
  // final LLM response (e.g. code-execution files produced during the run).
44213
44408
  ...last?.files ? { files: last.files } : {},
44214
44409
  ...last?.builtinToolCalls ? { builtinToolCalls: last.builtinToolCalls } : {},
44410
+ // Every step's sources, not just the final step's — see the accumulator.
44411
+ ...args.citations.length ? { citations: args.citations } : {},
44215
44412
  ...last?.moderation ? { moderation: last.moderation } : {},
44216
44413
  latencyMs: performance.now() - args.startPerf,
44217
44414
  raw: args.raw
@@ -44278,6 +44475,7 @@ var AgentLoop = class _AgentLoop {
44278
44475
  let stepCount = 0;
44279
44476
  let toolCallCount = 0;
44280
44477
  let lastResponse = null;
44478
+ const citationsByUrl = /* @__PURE__ */ new Map();
44281
44479
  let reason = "done";
44282
44480
  let errorMsg;
44283
44481
  let caughtError;
@@ -44319,6 +44517,7 @@ var AgentLoop = class _AgentLoop {
44319
44517
  const stepLatency = performance.now() - stepStart;
44320
44518
  totalLlmTimeMs += stepLatency;
44321
44519
  addUsage(totalUsage, lastResponse.usage);
44520
+ for (const cite of lastResponse.citations ?? []) citationsByUrl.set(cite.url, cite);
44322
44521
  if (this._reflectRetry?.handles(lastResponse.finishReason)) {
44323
44522
  const verdict = this._reflectRetry.recordFailure();
44324
44523
  await this.hooks.emit("onWarning", {
@@ -44426,6 +44625,7 @@ var AgentLoop = class _AgentLoop {
44426
44625
  totalUsage,
44427
44626
  lastResponse,
44428
44627
  media: lastResponse?.media ?? [],
44628
+ citations: [...citationsByUrl.values()],
44429
44629
  raw: lastResponse?.raw ?? null
44430
44630
  });
44431
44631
  await this.settleRun({
@@ -44471,6 +44671,7 @@ var AgentLoop = class _AgentLoop {
44471
44671
  let finalText = "";
44472
44672
  let finalContent = [];
44473
44673
  let lastResponse = null;
44674
+ const citationsByUrl = /* @__PURE__ */ new Map();
44474
44675
  let reason = "done";
44475
44676
  let errorMsg;
44476
44677
  let caughtError;
@@ -44525,6 +44726,7 @@ var AgentLoop = class _AgentLoop {
44525
44726
  finalContent = content;
44526
44727
  finalText = state.stepText;
44527
44728
  lastResponse = stepResponse;
44729
+ for (const cite of stepResponse.citations ?? []) citationsByUrl.set(cite.url, cite);
44528
44730
  this._history.append(
44529
44731
  {
44530
44732
  ...buildAssistantMessage(lastResponse, {
@@ -44608,6 +44810,7 @@ var AgentLoop = class _AgentLoop {
44608
44810
  finalContent,
44609
44811
  totalUsage,
44610
44812
  lastResponse,
44813
+ citations: [...citationsByUrl.values()],
44611
44814
  // The streaming path already emitted media as events, and never holds a
44612
44815
  // raw provider payload.
44613
44816
  media: [],
@@ -54601,37 +54804,26 @@ function listModels(opts = {}) {
54601
54804
  var LIVE = {
54602
54805
  openai: {
54603
54806
  url: "https://api.openai.com/v1/models",
54604
- headers: (k) => ({ authorization: `Bearer ${k}` }),
54605
54807
  items: (b) => b.data ?? [],
54606
54808
  id: (m) => m.id
54607
54809
  },
54608
54810
  openrouter: {
54609
54811
  url: "https://openrouter.ai/api/v1/models",
54610
- headers: (k) => ({ authorization: `Bearer ${k}` }),
54611
54812
  items: (b) => b.data ?? [],
54612
54813
  id: (m) => m.id
54613
54814
  },
54614
54815
  xai: {
54615
54816
  url: "https://api.x.ai/v1/models",
54616
- headers: (k) => ({ authorization: `Bearer ${k}` }),
54617
54817
  items: (b) => b.data ?? [],
54618
54818
  id: (m) => m.id
54619
54819
  },
54620
54820
  anthropic: {
54621
54821
  url: "https://api.anthropic.com/v1/models",
54622
- // Mirror the chat adapter: Anthropic rejects browser requests without the
54623
- // explicit opt-in header, so the /models call must send it too.
54624
- headers: (k) => ({
54625
- "x-api-key": k,
54626
- "anthropic-version": ANTHROPIC_API_VERSION,
54627
- ...isBrowser() ? { "anthropic-dangerous-direct-browser-access": "true" } : {}
54628
- }),
54629
54822
  items: (b) => b.data ?? [],
54630
54823
  id: (m) => m.id
54631
54824
  },
54632
54825
  google: {
54633
54826
  url: "https://generativelanguage.googleapis.com/v1beta/models",
54634
- headers: (k) => ({ "x-goog-api-key": k }),
54635
54827
  items: (b) => b.models ?? [],
54636
54828
  id: (m) => m.name.replace(/^models\//, "")
54637
54829
  }
@@ -54724,7 +54916,9 @@ async function fetchLiveBody(opts) {
54724
54916
  const p = (async () => {
54725
54917
  const built = buildFromSpec(
54726
54918
  utilitySpec(`${opts.provider}/models.list`),
54727
- {},
54919
+ // Anthropic refuses a browser request without an explicit opt-in header,
54920
+ // and the spec asks for `browser` to decide whether to send it.
54921
+ { browser: isBrowser() },
54728
54922
  makeRegistry({}),
54729
54923
  opts.provider,
54730
54924
  void 0,
@@ -0,0 +1,25 @@
1
+ /** The sources an answer cited, read from four different wire shapes.
2
+ *
3
+ * `builtinToolCalls` already records what the model INVOKED — that it searched,
4
+ * and for what. This is the other half: what it ended up CITING. They are not
5
+ * the same list. A model can run three searches and cite one page, or open a
6
+ * page and cite nothing, and a caller rendering footnotes needs the second
7
+ * list, not the first.
8
+ *
9
+ * Until now this was only reachable by regexing `response.raw`, which is what
10
+ * the web-search example actually did. That is a per-consumer reimplementation
11
+ * of provider knowledge that belongs in the SDK.
12
+ *
13
+ * This function always returns an array. The RESPONSE FIELD it feeds is
14
+ * optional and omitted when empty, like the `files` / `builtinToolCalls` lines
15
+ * it sits beside -- R3 forbids adding a required field to a response type, so
16
+ * the Python port's always-present array is not available here. Callers read
17
+ * `response.citations ?? []`.
18
+ */
19
+ import type { Citation } from '../../types/response';
20
+ /** Every source the answer cited, or `[]`.
21
+ *
22
+ * `api` is the wire surface (`'messages' | 'generate' | 'interactions' |
23
+ * 'responses' | 'completions'`). An unknown surface yields `[]` rather than
24
+ * throwing: a response we cannot read citations from is still a valid answer. */
25
+ export declare function extractCitations(api: string, raw: unknown): Citation[];
@@ -20,6 +20,16 @@ export interface CompletionResponse {
20
20
  * search or code-execution run. A durable trail of what was called (the provider
21
21
  * ran them server-side; nothing for the client to execute). Absent when none. */
22
22
  builtinToolCalls?: BuiltinToolCall[];
23
+ /** Sources the answer cited, unified across providers.
24
+ *
25
+ * Distinct from `builtinToolCalls`, which records what the model INVOKED: a turn
26
+ * can run three searches and cite one page, or open a page and cite nothing.
27
+ * Rendering footnotes needs this list, not that one.
28
+ *
29
+ * Absent when the model cited nothing — R3: a response type grows by OPTIONAL
30
+ * fields only, so this cannot be the always-present array the Python port
31
+ * exposes. Read it as `response.citations ?? []`. */
32
+ citations?: Citation[];
23
33
  /** Inline-moderation outcome, when the `moderation` request option was used.
24
34
  * Report-only: present for observability; it never blocks the call. Absent when
25
35
  * moderation was not requested. */
@@ -58,6 +68,19 @@ export interface FileOutput {
58
68
  * Absent for providers that don't need extra context. */
59
69
  ref?: Record<string, unknown>;
60
70
  }
71
+ /** A source the answer cited.
72
+ *
73
+ * Four providers report these four different ways — Anthropic on the text block,
74
+ * Google in `groundingMetadata`, OpenAI as annotations, xAI as bare top-level URLs.
75
+ * The differences stop at the adapter. */
76
+ export interface Citation {
77
+ url: string;
78
+ /** Page title, when the provider supplies one. */
79
+ title?: string;
80
+ /** The passage the source supports. Only Anthropic reports this today; absent
81
+ * elsewhere rather than faked from the answer text. */
82
+ text?: string;
83
+ }
61
84
  /** A hosted builtin tool the model invoked (provider-run), with its inputs/outputs. */
62
85
  export interface BuiltinToolCall {
63
86
  /** Unified tool name: `'web_search'` | `'code_interpreter'` | … (normalized from
@@ -1,7 +1,7 @@
1
1
  /** Universal streaming event types. */
2
2
  import type { ModerationEntry } from '../moderation/types';
3
3
  import type { AssistantPhase } from './messages';
4
- import type { FileOutput, Usage } from './response';
4
+ import type { Citation, FileOutput, Usage } from './response';
5
5
  export type MediaStreamType = 'image' | 'audio' | 'video';
6
6
  export type StreamEvent =
7
7
  /** `itemId` identifies WHICH output item a delta belongs to, when the provider reports
@@ -62,6 +62,19 @@ export type StreamEvent =
62
62
  type: 'file';
63
63
  file: FileOutput;
64
64
  }
65
+ /** The answer cited a source. Emitted as the citation arrives, which is NOT
66
+ * when the search ran: a provider searches early and cites while it writes, so
67
+ * these interleave with `text` deltas. Distinct from `builtin_tool_end`, which
68
+ * reports the search itself.
69
+ *
70
+ * Measured shapes: Anthropic `citations_delta`, OpenAI/xAI Responses
71
+ * `response.output_text.annotation.added`, chat-completions `delta.annotations`,
72
+ * Google's populated `groundingMetadata` chunk. Also collected onto the streamed
73
+ * final response's `citations`, deduped by url. */
74
+ | {
75
+ type: 'citation';
76
+ citation: Citation;
77
+ }
65
78
  /** A hosted (provider-run) builtin tool began executing server-side — e.g. the
66
79
  * model started a web search or code-execution run. `tool` is the unified name
67
80
  * (`'web_search'` | `'code_interpreter'` | …). Informational progress: unlike
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@combycode/llm-sdk",
3
- "version": "3.1.0",
3
+ "version": "3.2.1",
4
4
  "description": "Unified, pluggable AI SDK for accessing the LLMs of every major provider (Anthropic, OpenAI, Google, xAI, OpenRouter) through one API. Cross-environment: Node, Bun, and the browser.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -34,6 +34,8 @@
34
34
  "prepublishOnly": "bun run build",
35
35
  "test": "bun test",
36
36
  "test:unit": "bun test tests/unit",
37
+ "test:coverage": "bun test tests/unit --coverage",
38
+ "coverage:gate": "bun run scripts/coverage-gate.ts",
37
39
  "test:integration": "bun test tests/integration",
38
40
  "test:helpers": "bun test tests/helpers",
39
41
  "test:live": "bun test tests/live",