@intx/inference 0.1.2 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/LICENSE +176 -0
  2. package/dist/actions.d.ts +16 -0
  3. package/dist/actions.js +200 -0
  4. package/dist/adapter.d.ts +38 -0
  5. package/dist/adapter.js +31 -0
  6. package/dist/assembly.d.ts +68 -0
  7. package/dist/assembly.js +132 -0
  8. package/dist/audit-collector.d.ts +10 -0
  9. package/dist/audit-collector.js +139 -0
  10. package/dist/auth.d.ts +24 -0
  11. package/{src/auth.ts → dist/auth.js} +13 -19
  12. package/dist/authz-extension.d.ts +32 -0
  13. package/dist/authz-extension.js +100 -0
  14. package/dist/correlation.d.ts +25 -0
  15. package/dist/correlation.js +32 -0
  16. package/dist/default-director.d.ts +111 -0
  17. package/dist/default-director.js +199 -0
  18. package/dist/director.d.ts +6 -0
  19. package/dist/director.js +56 -0
  20. package/dist/errors.d.ts +18 -0
  21. package/dist/errors.js +83 -0
  22. package/dist/gates.d.ts +27 -0
  23. package/dist/gates.js +80 -0
  24. package/dist/harness.d.ts +147 -0
  25. package/dist/harness.js +1319 -0
  26. package/dist/index.d.ts +37 -0
  27. package/dist/index.js +21 -0
  28. package/dist/manifest.d.ts +31 -0
  29. package/dist/manifest.js +44 -0
  30. package/dist/providers/anthropic.d.ts +33 -0
  31. package/dist/providers/anthropic.js +670 -0
  32. package/dist/providers/google-genai-files.d.ts +48 -0
  33. package/dist/providers/google-genai-files.js +205 -0
  34. package/dist/providers/google-genai.d.ts +3 -0
  35. package/dist/providers/google-genai.js +1196 -0
  36. package/dist/providers/index.d.ts +38 -0
  37. package/dist/providers/index.js +56 -0
  38. package/dist/providers/openai.d.ts +3 -0
  39. package/dist/providers/openai.js +609 -0
  40. package/dist/reactor.d.ts +50 -0
  41. package/dist/reactor.js +920 -0
  42. package/dist/retry-policy.d.ts +31 -0
  43. package/{src/retry-policy.ts → dist/retry-policy.js} +41 -53
  44. package/dist/sse.d.ts +1 -0
  45. package/dist/sse.js +63 -0
  46. package/dist/state.d.ts +23 -0
  47. package/dist/state.js +100 -0
  48. package/dist/tool-name.d.ts +6 -0
  49. package/dist/tool-name.js +110 -0
  50. package/dist/transform.d.ts +11 -0
  51. package/dist/transform.js +117 -0
  52. package/dist/transforms/index.d.ts +2 -0
  53. package/dist/transforms/index.js +1 -0
  54. package/dist/transforms/size-cap.d.ts +12 -0
  55. package/dist/transforms/size-cap.js +80 -0
  56. package/dist/turns.d.ts +21 -0
  57. package/dist/turns.js +135 -0
  58. package/package.json +21 -6
  59. package/src/actions.ts +0 -245
  60. package/src/adapter.ts +0 -57
  61. package/src/assembly.test.ts +0 -728
  62. package/src/assembly.ts +0 -250
  63. package/src/audit-collector.test.ts +0 -332
  64. package/src/audit-collector.ts +0 -172
  65. package/src/auth.test.ts +0 -117
  66. package/src/authz-extension.test.ts +0 -269
  67. package/src/authz-extension.ts +0 -145
  68. package/src/correlation.ts +0 -61
  69. package/src/default-director.test.ts +0 -314
  70. package/src/default-director.ts +0 -344
  71. package/src/director.ts +0 -87
  72. package/src/errors.test.ts +0 -133
  73. package/src/errors.ts +0 -115
  74. package/src/gates.ts +0 -128
  75. package/src/harness.test.ts +0 -655
  76. package/src/harness.ts +0 -1571
  77. package/src/index.ts +0 -76
  78. package/src/providers/anthropic.test.ts +0 -771
  79. package/src/providers/anthropic.ts +0 -810
  80. package/src/providers/google-genai-files.ts +0 -289
  81. package/src/providers/google-genai.ts +0 -1518
  82. package/src/providers/openai.ts +0 -719
  83. package/src/providers/registry.ts +0 -33
  84. package/src/reactor.test.ts +0 -3660
  85. package/src/reactor.ts +0 -1058
  86. package/src/scheduler.test.ts +0 -41
  87. package/src/sse.test.ts +0 -133
  88. package/src/sse.ts +0 -76
  89. package/src/state.ts +0 -135
  90. package/src/transform.test.ts +0 -207
  91. package/src/transform.ts +0 -159
  92. package/src/transforms/index.ts +0 -2
  93. package/src/transforms/size-cap.test.ts +0 -172
  94. package/src/transforms/size-cap.ts +0 -110
  95. package/src/turns.ts +0 -54
  96. package/tsconfig.json +0 -4
  97. package/tsconfig.tsbuildinfo +0 -1
@@ -1,771 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
-
3
- import type {
4
- ConversationTurn,
5
- InferenceEvent,
6
- LastCycleSource,
7
- } from "@intx/types/runtime";
8
-
9
- import { ProtocolMismatchError } from "../errors";
10
- import { createAnthropicAdapter } from "./anthropic";
11
-
12
- const TEST_SOURCE: LastCycleSource = {
13
- sourceId: "test-anthropic",
14
- provider: "anthropic",
15
- model: "test-anthropic-model",
16
- };
17
-
18
- // The parser is exercised through the public adapter rather than imported
19
- // directly. parseResponse is intentionally not exported — the adapter
20
- // owns the per-request `blockIndexToCallId` map and creating it through
21
- // the same factory production uses keeps tests honest about the lifecycle.
22
-
23
- function parse(
24
- adapter: ReturnType<typeof createAnthropicAdapter>,
25
- sse: object,
26
- ): InferenceEvent[] {
27
- return adapter.parseResponse(JSON.stringify(sse));
28
- }
29
-
30
- // Inline narrowing utilities. Each `pickFirst*` returns the strongly-typed
31
- // variant by exhausting the failure modes — empty result or wrong type
32
- // throw a descriptive error, leaving the body of the test to operate on
33
- // the narrowed value without an unsafe cast.
34
-
35
- function pickFirstTextDelta(
36
- events: InferenceEvent[],
37
- ): Extract<InferenceEvent, { type: "inference.text.delta" }> {
38
- const ev = events[0];
39
- if (ev === undefined) throw new Error("expected at least one event");
40
- if (ev.type !== "inference.text.delta") {
41
- throw new Error(`expected inference.text.delta, got ${ev.type}`);
42
- }
43
- return ev;
44
- }
45
-
46
- function pickFirstThinkingDelta(
47
- events: InferenceEvent[],
48
- ): Extract<InferenceEvent, { type: "inference.thinking.delta" }> {
49
- const ev = events[0];
50
- if (ev === undefined) throw new Error("expected at least one event");
51
- if (ev.type !== "inference.thinking.delta") {
52
- throw new Error(`expected inference.thinking.delta, got ${ev.type}`);
53
- }
54
- return ev;
55
- }
56
-
57
- function pickFirstThinkingSignature(
58
- events: InferenceEvent[],
59
- ): Extract<InferenceEvent, { type: "inference.thinking.signature" }> {
60
- const ev = events[0];
61
- if (ev === undefined) throw new Error("expected at least one event");
62
- if (ev.type !== "inference.thinking.signature") {
63
- throw new Error(`expected inference.thinking.signature, got ${ev.type}`);
64
- }
65
- return ev;
66
- }
67
-
68
- function pickFirstToolCallStart(
69
- events: InferenceEvent[],
70
- ): Extract<InferenceEvent, { type: "inference.tool_call.start" }> {
71
- const ev = events[0];
72
- if (ev === undefined) throw new Error("expected at least one event");
73
- if (ev.type !== "inference.tool_call.start") {
74
- throw new Error(`expected inference.tool_call.start, got ${ev.type}`);
75
- }
76
- return ev;
77
- }
78
-
79
- function pickFirstToolCallDelta(
80
- events: InferenceEvent[],
81
- ): Extract<InferenceEvent, { type: "inference.tool_call.delta" }> {
82
- const ev = events[0];
83
- if (ev === undefined) throw new Error("expected at least one event");
84
- if (ev.type !== "inference.tool_call.delta") {
85
- throw new Error(`expected inference.tool_call.delta, got ${ev.type}`);
86
- }
87
- return ev;
88
- }
89
-
90
- describe("Anthropic parser — index propagation on delta events", () => {
91
- test("text_delta carries the SSE block index forward as data.index", () => {
92
- const adapter = createAnthropicAdapter(TEST_SOURCE);
93
- const events = parse(adapter, {
94
- type: "content_block_delta",
95
- index: 1,
96
- delta: { type: "text_delta", text: "hi" },
97
- });
98
- expect(events).toHaveLength(1);
99
- const ev = pickFirstTextDelta(events);
100
- expect(ev.data.index).toBe(1);
101
- expect(ev.data.token).toBe("hi");
102
- });
103
-
104
- test("thinking_delta carries the SSE block index forward as data.index", () => {
105
- const adapter = createAnthropicAdapter(TEST_SOURCE);
106
- const events = parse(adapter, {
107
- type: "content_block_delta",
108
- index: 2,
109
- delta: { type: "thinking_delta", thinking: "reasoning" },
110
- });
111
- expect(events).toHaveLength(1);
112
- const ev = pickFirstThinkingDelta(events);
113
- expect(ev.data.index).toBe(2);
114
- expect(ev.data.token).toBe("reasoning");
115
- });
116
-
117
- test("signature_delta carries the SSE block index forward as data.index", () => {
118
- const adapter = createAnthropicAdapter(TEST_SOURCE);
119
- const events = parse(adapter, {
120
- type: "content_block_delta",
121
- index: 3,
122
- delta: { type: "signature_delta", signature: "sig-abc" },
123
- });
124
- expect(events).toHaveLength(1);
125
- const ev = pickFirstThinkingSignature(events);
126
- expect(ev.data.index).toBe(3);
127
- expect(ev.data.signature).toBe("sig-abc");
128
- });
129
-
130
- test("tool_call.start carries the SSE block index forward as data.index", () => {
131
- const adapter = createAnthropicAdapter(TEST_SOURCE);
132
- const events = parse(adapter, {
133
- type: "content_block_start",
134
- index: 4,
135
- content_block: { type: "tool_use", id: "call_xyz", name: "search" },
136
- });
137
- expect(events).toHaveLength(1);
138
- const ev = pickFirstToolCallStart(events);
139
- expect(ev.data.index).toBe(4);
140
- expect(ev.data.callId).toBe("call_xyz");
141
- });
142
-
143
- test("tool_call.delta carries the SSE block index forward as data.index", () => {
144
- const adapter = createAnthropicAdapter(TEST_SOURCE);
145
- parse(adapter, {
146
- type: "content_block_start",
147
- index: 5,
148
- content_block: { type: "tool_use", id: "call_abc", name: "search" },
149
- });
150
- const events = parse(adapter, {
151
- type: "content_block_delta",
152
- index: 5,
153
- delta: { type: "input_json_delta", partial_json: '{"q":' },
154
- });
155
- expect(events).toHaveLength(1);
156
- const ev = pickFirstToolCallDelta(events);
157
- expect(ev.data.index).toBe(5);
158
- expect(ev.data.callId).toBe("call_abc");
159
- expect(ev.data.argumentFragment).toBe('{"q":');
160
- });
161
- });
162
-
163
- describe("Anthropic parser — multi-tool callId routing across indices", () => {
164
- // Regression target: when two tool_use blocks open at distinct indices,
165
- // the input_json_delta lookup must resolve to the correct callId per
166
- // index. Collapsing the indices into one slot would route the second
167
- // tool's argument fragments to the first tool's callId.
168
- test("input_json_deltas at distinct indices resolve to distinct callIds", () => {
169
- const adapter = createAnthropicAdapter(TEST_SOURCE);
170
- parse(adapter, {
171
- type: "content_block_start",
172
- index: 0,
173
- content_block: { type: "tool_use", id: "call_first", name: "alpha" },
174
- });
175
- parse(adapter, {
176
- type: "content_block_start",
177
- index: 1,
178
- content_block: { type: "tool_use", id: "call_second", name: "beta" },
179
- });
180
-
181
- const firstFrag = parse(adapter, {
182
- type: "content_block_delta",
183
- index: 0,
184
- delta: { type: "input_json_delta", partial_json: '{"a":1}' },
185
- });
186
- const secondFrag = parse(adapter, {
187
- type: "content_block_delta",
188
- index: 1,
189
- delta: { type: "input_json_delta", partial_json: '{"b":2}' },
190
- });
191
-
192
- const first = pickFirstToolCallDelta(firstFrag);
193
- const second = pickFirstToolCallDelta(secondFrag);
194
- expect(first.data.callId).toBe("call_first");
195
- expect(first.data.index).toBe(0);
196
- expect(first.data.argumentFragment).toBe('{"a":1}');
197
- expect(second.data.callId).toBe("call_second");
198
- expect(second.data.index).toBe(1);
199
- expect(second.data.argumentFragment).toBe('{"b":2}');
200
- });
201
- });
202
-
203
- describe("Anthropic parser — required-index schema enforcement", () => {
204
- // Anthropic's SSE protocol guarantees `index` on every content_block_*
205
- // event. A missing `index` is a protocol violation, not a "default to
206
- // 0" situation — the parser's `blockIndexToCallId` cache is
207
- // load-bearing on the index being real, not synthesized.
208
-
209
- test("content_block_delta without index throws ProtocolMismatchError", () => {
210
- const adapter = createAnthropicAdapter(TEST_SOURCE);
211
- let thrown: unknown;
212
- try {
213
- parse(adapter, {
214
- type: "content_block_delta",
215
- delta: { type: "text_delta", text: "hi" },
216
- });
217
- } catch (e) {
218
- thrown = e;
219
- }
220
- expect(thrown).toBeInstanceOf(ProtocolMismatchError);
221
- if (thrown instanceof ProtocolMismatchError) {
222
- expect(thrown.message).toMatch(/schema validation/);
223
- }
224
- });
225
-
226
- test("content_block_start without index throws ProtocolMismatchError", () => {
227
- const adapter = createAnthropicAdapter(TEST_SOURCE);
228
- let thrown: unknown;
229
- try {
230
- parse(adapter, {
231
- type: "content_block_start",
232
- content_block: { type: "tool_use", id: "x", name: "y" },
233
- });
234
- } catch (e) {
235
- thrown = e;
236
- }
237
- expect(thrown).toBeInstanceOf(ProtocolMismatchError);
238
- });
239
-
240
- test("content_block_stop without index throws ProtocolMismatchError", () => {
241
- const adapter = createAnthropicAdapter(TEST_SOURCE);
242
- let thrown: unknown;
243
- try {
244
- parse(adapter, { type: "content_block_stop" });
245
- } catch (e) {
246
- thrown = e;
247
- }
248
- expect(thrown).toBeInstanceOf(ProtocolMismatchError);
249
- });
250
- });
251
-
252
- // All redacted_thinking test fixtures below are SYNTHETIC: derived
253
- // from Anthropic's documented wire shape rather than from a real
254
- // captured response. The fixture corpus carries no captured
255
- // redacted_thinking bytes today because Anthropic's documented canary
256
- // did not trigger the safety classifier on capture day — every
257
- // `redacted-thinking[-streaming]` row landed in the corpus with
258
- // `outcome: "misled"` and contains regular `thinking` blocks instead.
259
- //
260
- // The opaque `data` payload below mimics Anthropic's format
261
- // (long base64-looking string) but carries no real cryptographic
262
- // content. Round-trip tests assert the harness/adapter pass the
263
- // bytes verbatim — the actual contents are irrelevant to the
264
- // invariant being tested.
265
- const SYNTHETIC_REDACTED_DATA =
266
- "ErUBCkYIBxgCKkABEHk1RmZpaWlsOXJxN0Z6cVB" +
267
- "QcjBQYS9wQUdBQUFBQUFBQUFBQUFRQUFBQUFBQU" +
268
- "FBQUFBQUFBQUFBQT09EhJYWXpBOXJxN0Z6cVBQc" +
269
- "jBQYS9wAAA=";
270
-
271
- function pickFirstThinkingRedacted(
272
- events: InferenceEvent[],
273
- ): Extract<InferenceEvent, { type: "inference.thinking.redacted" }> {
274
- const ev = events[0];
275
- if (ev === undefined) throw new Error("expected at least one event");
276
- if (ev.type !== "inference.thinking.redacted") {
277
- throw new Error(`expected inference.thinking.redacted, got ${ev.type}`);
278
- }
279
- return ev;
280
- }
281
-
282
- describe("Anthropic parser — redacted_thinking content_block_start", () => {
283
- test("emits inference.thinking.redacted carrying the data and source index", () => {
284
- const adapter = createAnthropicAdapter(TEST_SOURCE);
285
- const events = parse(adapter, {
286
- type: "content_block_start",
287
- index: 0,
288
- content_block: {
289
- type: "redacted_thinking",
290
- data: SYNTHETIC_REDACTED_DATA,
291
- },
292
- });
293
- expect(events).toHaveLength(1);
294
- const ev = pickFirstThinkingRedacted(events);
295
- expect(ev.data.index).toBe(0);
296
- expect(ev.data.redactedThinking.type).toBe("redacted_thinking");
297
- expect(ev.data.redactedThinking.data).toBe(SYNTHETIC_REDACTED_DATA);
298
- });
299
-
300
- test("preserves the data verbatim — no normalization or transformation", () => {
301
- const adapter = createAnthropicAdapter(TEST_SOURCE);
302
- // The data is an opaque blob; any mutation breaks the round-trip.
303
- // Use a string with characters that an over-eager normalizer would
304
- // touch (newlines, whitespace, base64 padding).
305
- const adversarial = "abc\n ==\r\n\tdef==";
306
- const events = parse(adapter, {
307
- type: "content_block_start",
308
- index: 2,
309
- content_block: { type: "redacted_thinking", data: adversarial },
310
- });
311
- const ev = pickFirstThinkingRedacted(events);
312
- expect(ev.data.redactedThinking.data).toBe(adversarial);
313
- });
314
-
315
- test("missing `data` field throws ProtocolMismatchError naming the field", () => {
316
- const adapter = createAnthropicAdapter(TEST_SOURCE);
317
- let thrown: unknown;
318
- try {
319
- parse(adapter, {
320
- type: "content_block_start",
321
- index: 4,
322
- content_block: { type: "redacted_thinking" },
323
- });
324
- } catch (e) {
325
- thrown = e;
326
- }
327
- expect(thrown).toBeInstanceOf(ProtocolMismatchError);
328
- if (thrown instanceof ProtocolMismatchError) {
329
- expect(thrown.message).toMatch(/redacted_thinking/);
330
- expect(thrown.message).toMatch(/data/);
331
- expect(thrown.message).toMatch(/index 4/);
332
- }
333
- });
334
- });
335
-
336
- describe("Anthropic adapter — redacted_thinking parser-to-builder round-trip", () => {
337
- // The parser-side wire shape and the request-builder-side wire shape
338
- // are tested independently elsewhere. This test closes the loop: it
339
- // proves that the opaque `data` blob the parser surfaces in
340
- // `inference.thinking.redacted` reconstructs back to a request body
341
- // that carries the same bytes verbatim. That round-trip is the
342
- // invariant Anthropic requires on every follow-up turn.
343
- test("data survives parse → reconstruct → buildRequest unchanged", () => {
344
- const adapter = createAnthropicAdapter(TEST_SOURCE);
345
- const events = parse(adapter, {
346
- type: "content_block_start",
347
- index: 0,
348
- content_block: {
349
- type: "redacted_thinking",
350
- data: SYNTHETIC_REDACTED_DATA,
351
- },
352
- });
353
- const ev = pickFirstThinkingRedacted(events);
354
- const reconstructed: ConversationTurn = {
355
- role: "assistant",
356
- content: [ev.data.redactedThinking],
357
- timestamp: 0,
358
- };
359
- const req = adapter.buildRequest([reconstructed], "claude-test", {
360
- maxTokens: 100,
361
- });
362
- // The structural shape of the body is asserted elsewhere — here
363
- // we care only that the opaque `data` survives the round-trip.
364
- // Use a structural extraction via JSON.parse + cast through unknown
365
- // because the integration-style assertion lives in the broader
366
- // tests/inference/providers/anthropic.test.ts and is already
367
- // exercised.
368
- const bodyText = req.body;
369
- expect(bodyText).toContain(SYNTHETIC_REDACTED_DATA);
370
- expect(bodyText).toContain(`"type":"redacted_thinking"`);
371
- });
372
- });
373
-
374
- function pickFirstCitation(
375
- events: InferenceEvent[],
376
- ): Extract<InferenceEvent, { type: "inference.citation" }> {
377
- const ev = events[0];
378
- if (ev === undefined) throw new Error("expected at least one event");
379
- if (ev.type !== "inference.citation") {
380
- throw new Error(`expected inference.citation, got ${ev.type}`);
381
- }
382
- return ev;
383
- }
384
-
385
- describe("Anthropic parser — citations_delta to inference.citation", () => {
386
- test("web_search_result_location maps to source.uri + title with no location", () => {
387
- // Anthropic's web_search citations carry url + title + cited_text +
388
- // encrypted_index. The encrypted_index has no echo-back target in
389
- // CitationBlock today and is intentionally dropped at the adapter.
390
- const adapter = createAnthropicAdapter(TEST_SOURCE);
391
- const events = parse(adapter, {
392
- type: "content_block_delta",
393
- index: 3,
394
- delta: {
395
- type: "citations_delta",
396
- citation: {
397
- type: "web_search_result_location",
398
- cited_text: "Quoted span from the web search result.",
399
- url: "https://example.com/article",
400
- title: "Example Article Title",
401
- encrypted_index: "EncryptedIndexAAAA==",
402
- },
403
- },
404
- });
405
- const ev = pickFirstCitation(events);
406
- expect(ev.data.citation.type).toBe("citation");
407
- expect(ev.data.citation.citedText).toBe(
408
- "Quoted span from the web search result.",
409
- );
410
- expect(ev.data.citation.source.uri).toBe("https://example.com/article");
411
- expect(ev.data.citation.source.title).toBe("Example Article Title");
412
- expect(ev.data.citation.location).toBeUndefined();
413
- // textOffset is intentionally never populated at this layer.
414
- expect(ev.data.citation.textOffset).toBeUndefined();
415
- // The content_block_delta.index from the wire propagates onto the
416
- // emitted event so the harness can interleave the citation at the
417
- // matching block position in the finalized turn.
418
- expect(ev.data.index).toBe(3);
419
- });
420
-
421
- test("citations on two different block indices preserve their respective indices", () => {
422
- // Catches a regression where the parser caches the most recent
423
- // content_block_delta.index across subsequent citation events
424
- // instead of reading it fresh for each.
425
- const adapter = createAnthropicAdapter(TEST_SOURCE);
426
- const events = [
427
- ...parse(adapter, {
428
- type: "content_block_delta",
429
- index: 1,
430
- delta: {
431
- type: "citations_delta",
432
- citation: {
433
- type: "web_search_result_location",
434
- cited_text: "First citation.",
435
- url: "https://example.com/a",
436
- title: "A",
437
- encrypted_index: "EA==",
438
- },
439
- },
440
- }),
441
- ...parse(adapter, {
442
- type: "content_block_delta",
443
- index: 5,
444
- delta: {
445
- type: "citations_delta",
446
- citation: {
447
- type: "web_search_result_location",
448
- cited_text: "Second citation.",
449
- url: "https://example.com/b",
450
- title: "B",
451
- encrypted_index: "EB==",
452
- },
453
- },
454
- }),
455
- ];
456
- const citations = events.filter(
457
- (ev): ev is Extract<InferenceEvent, { type: "inference.citation" }> =>
458
- ev.type === "inference.citation",
459
- );
460
- expect(citations).toHaveLength(2);
461
- expect(citations[0]?.data.index).toBe(1);
462
- expect(citations[1]?.data.index).toBe(5);
463
- });
464
-
465
- test("page_location maps to location.kind=page with start/end page numbers", () => {
466
- const adapter = createAnthropicAdapter(TEST_SOURCE);
467
- const events = parse(adapter, {
468
- type: "content_block_delta",
469
- index: 0,
470
- delta: {
471
- type: "citations_delta",
472
- citation: {
473
- type: "page_location",
474
- cited_text: "Excerpt from page 4.",
475
- document_index: 0,
476
- document_title: "Quarterly Report",
477
- start_page_number: 4,
478
- end_page_number: 5,
479
- },
480
- },
481
- });
482
- const ev = pickFirstCitation(events);
483
- expect(ev.data.citation.citedText).toBe("Excerpt from page 4.");
484
- expect(ev.data.citation.source.title).toBe("Quarterly Report");
485
- expect(ev.data.citation.source.documentRef).toEqual({ index: 0 });
486
- expect(ev.data.citation.location).toEqual({
487
- kind: "page",
488
- start: 4,
489
- end: 5,
490
- });
491
- });
492
-
493
- test("char_location maps to location.kind=char with character offsets", () => {
494
- const adapter = createAnthropicAdapter(TEST_SOURCE);
495
- const events = parse(adapter, {
496
- type: "content_block_delta",
497
- index: 1,
498
- delta: {
499
- type: "citations_delta",
500
- citation: {
501
- type: "char_location",
502
- cited_text: "Inline quote.",
503
- document_index: 2,
504
- document_title: "Spec",
505
- start_char_index: 1024,
506
- end_char_index: 1037,
507
- },
508
- },
509
- });
510
- const ev = pickFirstCitation(events);
511
- expect(ev.data.citation.source.documentRef).toEqual({ index: 2 });
512
- expect(ev.data.citation.location).toEqual({
513
- kind: "char",
514
- start: 1024,
515
- end: 1037,
516
- });
517
- });
518
-
519
- test("content_block_location maps to location.kind=content-block", () => {
520
- const adapter = createAnthropicAdapter(TEST_SOURCE);
521
- const events = parse(adapter, {
522
- type: "content_block_delta",
523
- index: 0,
524
- delta: {
525
- type: "citations_delta",
526
- citation: {
527
- type: "content_block_location",
528
- cited_text: "Block-indexed quote.",
529
- document_index: 1,
530
- document_title: "Structured doc",
531
- start_block_index: 7,
532
- end_block_index: 8,
533
- },
534
- },
535
- });
536
- const ev = pickFirstCitation(events);
537
- expect(ev.data.citation.source.documentRef).toEqual({ index: 1 });
538
- expect(ev.data.citation.location).toEqual({
539
- kind: "content-block",
540
- start: 7,
541
- end: 8,
542
- });
543
- });
544
-
545
- test("unrecognized citation variant throws ProtocolMismatchError naming the variant", () => {
546
- const adapter = createAnthropicAdapter(TEST_SOURCE);
547
- let thrown: unknown;
548
- try {
549
- parse(adapter, {
550
- type: "content_block_delta",
551
- index: 0,
552
- delta: {
553
- type: "citations_delta",
554
- citation: {
555
- type: "future_unknown_location",
556
- cited_text: "x",
557
- },
558
- },
559
- });
560
- } catch (e) {
561
- thrown = e;
562
- }
563
- expect(thrown).toBeInstanceOf(ProtocolMismatchError);
564
- if (thrown instanceof ProtocolMismatchError) {
565
- expect(thrown.message).toMatch(/unrecognized citation variant/);
566
- expect(thrown.message).toMatch(/future_unknown_location/);
567
- }
568
- });
569
-
570
- test("citations_delta missing citation payload throws ProtocolMismatchError", () => {
571
- const adapter = createAnthropicAdapter(TEST_SOURCE);
572
- let thrown: unknown;
573
- try {
574
- parse(adapter, {
575
- type: "content_block_delta",
576
- index: 0,
577
- delta: { type: "citations_delta" },
578
- });
579
- } catch (e) {
580
- thrown = e;
581
- }
582
- expect(thrown).toBeInstanceOf(ProtocolMismatchError);
583
- if (thrown instanceof ProtocolMismatchError) {
584
- expect(thrown.message).toMatch(/missing citation payload/);
585
- }
586
- });
587
-
588
- test("citation missing cited_text throws ProtocolMismatchError", () => {
589
- // CitationBlock.citedText is required ("Both providers emit it;
590
- // required for inspection and for fallback offset reconstruction"
591
- // — runtime.ts). Surfacing a missing wire field as a thrown error
592
- // is the load-bearing alternative to coalescing to an empty
593
- // string and silently emitting a content-free citation.
594
- const adapter = createAnthropicAdapter(TEST_SOURCE);
595
- let thrown: unknown;
596
- try {
597
- parse(adapter, {
598
- type: "content_block_delta",
599
- index: 0,
600
- delta: {
601
- type: "citations_delta",
602
- citation: {
603
- type: "web_search_result_location",
604
- url: "https://example.com/",
605
- title: "Title",
606
- },
607
- },
608
- });
609
- } catch (e) {
610
- thrown = e;
611
- }
612
- expect(thrown).toBeInstanceOf(ProtocolMismatchError);
613
- if (thrown instanceof ProtocolMismatchError) {
614
- expect(thrown.message).toMatch(/cited_text/);
615
- expect(thrown.message).toMatch(/block 0/);
616
- }
617
- });
618
-
619
- test("citations interleave with text deltas preserving arrival order", () => {
620
- // Anthropic streams citations attached to the most recent text
621
- // run as `citations_delta` events interleaved with subsequent
622
- // `text_delta`s. Downstream consumers building citation-aware UI
623
- // re-attach each citation to the text region preceding it, so
624
- // the parser-emitted event stream must preserve the wire order
625
- // exactly. Feed a mixed sequence and assert the emitted events
626
- // come out in the same order they went in.
627
- const adapter = createAnthropicAdapter(TEST_SOURCE);
628
- const events: InferenceEvent[] = [];
629
- events.push(
630
- ...parse(adapter, {
631
- type: "content_block_delta",
632
- index: 0,
633
- delta: { type: "text_delta", text: "First claim. " },
634
- }),
635
- );
636
- events.push(
637
- ...parse(adapter, {
638
- type: "content_block_delta",
639
- index: 0,
640
- delta: {
641
- type: "citations_delta",
642
- citation: {
643
- type: "web_search_result_location",
644
- cited_text: "supporting quote one",
645
- url: "https://example.com/one",
646
- title: "Source One",
647
- },
648
- },
649
- }),
650
- );
651
- events.push(
652
- ...parse(adapter, {
653
- type: "content_block_delta",
654
- index: 0,
655
- delta: { type: "text_delta", text: "Second claim. " },
656
- }),
657
- );
658
- events.push(
659
- ...parse(adapter, {
660
- type: "content_block_delta",
661
- index: 0,
662
- delta: {
663
- type: "citations_delta",
664
- citation: {
665
- type: "web_search_result_location",
666
- cited_text: "supporting quote two",
667
- url: "https://example.com/two",
668
- title: "Source Two",
669
- },
670
- },
671
- }),
672
- );
673
-
674
- const types = events.map((e) => e.type);
675
- expect(types).toEqual([
676
- "inference.text.delta",
677
- "inference.citation",
678
- "inference.text.delta",
679
- "inference.citation",
680
- ]);
681
- // Verify each citation's citedText so the alternation isn't just
682
- // type-correct but content-accurate.
683
- const citations = events.filter((e) => e.type === "inference.citation");
684
- expect(citations).toHaveLength(2);
685
- const first = citations[0];
686
- const second = citations[1];
687
- if (
688
- first?.type !== "inference.citation" ||
689
- second?.type !== "inference.citation"
690
- ) {
691
- throw new Error("expected two citation events");
692
- }
693
- expect(first.data.citation.citedText).toBe("supporting quote one");
694
- expect(second.data.citation.citedText).toBe("supporting quote two");
695
- });
696
-
697
- test("page_location missing start_page_number throws ProtocolMismatchError", () => {
698
- const adapter = createAnthropicAdapter(TEST_SOURCE);
699
- let thrown: unknown;
700
- try {
701
- parse(adapter, {
702
- type: "content_block_delta",
703
- index: 0,
704
- delta: {
705
- type: "citations_delta",
706
- citation: {
707
- type: "page_location",
708
- cited_text: "Quote",
709
- document_index: 0,
710
- end_page_number: 5,
711
- },
712
- },
713
- });
714
- } catch (e) {
715
- thrown = e;
716
- }
717
- expect(thrown).toBeInstanceOf(ProtocolMismatchError);
718
- if (thrown instanceof ProtocolMismatchError) {
719
- expect(thrown.message).toMatch(/start_page_number/);
720
- }
721
- });
722
- });
723
-
724
- describe("Anthropic adapter — responseFormat boundary", () => {
725
- const conversation: ConversationTurn[] = [
726
- {
727
- role: "user",
728
- content: [{ type: "text", text: "Extract structured fields." }],
729
- timestamp: 1000,
730
- },
731
- ];
732
-
733
- test("omitted responseFormat builds a request without throwing", () => {
734
- const adapter = createAnthropicAdapter(TEST_SOURCE);
735
- const req = adapter.buildRequest(conversation, "claude-sonnet-4", {});
736
- expect(req.url).toBe("/v1/messages");
737
- });
738
-
739
- test("responseFormat.kind=text builds a request without throwing", () => {
740
- // Free-form text is Anthropic's default; the option is a no-op
741
- // here rather than a throw so the cross-provider call site can
742
- // pass `{ kind: "text" }` uniformly without conditional logic.
743
- const adapter = createAnthropicAdapter(TEST_SOURCE);
744
- const req = adapter.buildRequest(conversation, "claude-sonnet-4", {
745
- responseFormat: { kind: "text" },
746
- });
747
- expect(req.url).toBe("/v1/messages");
748
- });
749
-
750
- test("responseFormat.kind=json throws at the marshaling boundary", () => {
751
- const adapter = createAnthropicAdapter(TEST_SOURCE);
752
- expect(() =>
753
- adapter.buildRequest(conversation, "claude-sonnet-4", {
754
- responseFormat: { kind: "json" },
755
- }),
756
- ).toThrow(/does not support structured outputs/);
757
- });
758
-
759
- test("responseFormat.kind=json-schema throws and names the kind", () => {
760
- const adapter = createAnthropicAdapter(TEST_SOURCE);
761
- expect(() =>
762
- adapter.buildRequest(conversation, "claude-sonnet-4", {
763
- responseFormat: {
764
- kind: "json-schema",
765
- name: "user",
766
- schema: { type: "object" },
767
- },
768
- }),
769
- ).toThrow(/json-schema/);
770
- });
771
- });