@intx/inference 0.1.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +176 -0
- package/dist/actions.d.ts +16 -0
- package/dist/actions.js +200 -0
- package/dist/adapter.d.ts +40 -0
- package/dist/adapter.js +31 -0
- package/dist/assembly.d.ts +75 -0
- package/dist/assembly.js +133 -0
- package/dist/audit-collector.d.ts +10 -0
- package/dist/audit-collector.js +139 -0
- package/dist/auth.d.ts +24 -0
- package/{src/auth.ts → dist/auth.js} +13 -19
- package/dist/authz-extension.d.ts +46 -0
- package/dist/authz-extension.js +184 -0
- package/dist/correlation.d.ts +26 -0
- package/dist/correlation.js +39 -0
- package/dist/default-director.d.ts +111 -0
- package/dist/default-director.js +228 -0
- package/dist/director.d.ts +6 -0
- package/dist/director.js +56 -0
- package/dist/errors.d.ts +18 -0
- package/dist/errors.js +83 -0
- package/dist/gates.d.ts +28 -0
- package/dist/gates.js +103 -0
- package/dist/harness.d.ts +147 -0
- package/dist/harness.js +1407 -0
- package/dist/index.d.ts +37 -0
- package/dist/index.js +21 -0
- package/dist/manifest.d.ts +31 -0
- package/dist/manifest.js +44 -0
- package/dist/providers/anthropic.d.ts +37 -0
- package/dist/providers/anthropic.js +917 -0
- package/dist/providers/google-genai-files.d.ts +48 -0
- package/dist/providers/google-genai-files.js +205 -0
- package/dist/providers/google-genai.d.ts +5 -0
- package/dist/providers/google-genai.js +1205 -0
- package/dist/providers/index.d.ts +38 -0
- package/dist/providers/index.js +56 -0
- package/dist/providers/openai.d.ts +9 -0
- package/dist/providers/openai.js +903 -0
- package/dist/reactor.d.ts +50 -0
- package/dist/reactor.js +1233 -0
- package/dist/retry-policy.d.ts +31 -0
- package/{src/retry-policy.ts → dist/retry-policy.js} +41 -53
- package/dist/sse.d.ts +1 -0
- package/dist/sse.js +63 -0
- package/dist/state.d.ts +23 -0
- package/dist/state.js +100 -0
- package/dist/tool-name.d.ts +6 -0
- package/dist/tool-name.js +110 -0
- package/dist/transform.d.ts +11 -0
- package/dist/transform.js +132 -0
- package/dist/transforms/index.d.ts +2 -0
- package/dist/transforms/index.js +1 -0
- package/dist/transforms/size-cap.d.ts +12 -0
- package/dist/transforms/size-cap.js +80 -0
- package/dist/turns.d.ts +21 -0
- package/dist/turns.js +135 -0
- package/package.json +22 -6
- package/src/actions.ts +0 -245
- package/src/adapter.ts +0 -57
- package/src/assembly.test.ts +0 -728
- package/src/assembly.ts +0 -250
- package/src/audit-collector.test.ts +0 -332
- package/src/audit-collector.ts +0 -172
- package/src/auth.test.ts +0 -117
- package/src/authz-extension.test.ts +0 -269
- package/src/authz-extension.ts +0 -145
- package/src/correlation.ts +0 -61
- package/src/default-director.test.ts +0 -314
- package/src/default-director.ts +0 -344
- package/src/director.ts +0 -87
- package/src/errors.test.ts +0 -133
- package/src/errors.ts +0 -115
- package/src/gates.ts +0 -128
- package/src/harness.test.ts +0 -655
- package/src/harness.ts +0 -1571
- package/src/index.ts +0 -76
- package/src/providers/anthropic.test.ts +0 -771
- package/src/providers/anthropic.ts +0 -810
- package/src/providers/google-genai-files.ts +0 -289
- package/src/providers/google-genai.ts +0 -1518
- package/src/providers/openai.ts +0 -719
- package/src/providers/registry.ts +0 -33
- package/src/reactor.test.ts +0 -3660
- package/src/reactor.ts +0 -1058
- package/src/scheduler.test.ts +0 -41
- package/src/sse.test.ts +0 -133
- package/src/sse.ts +0 -76
- package/src/state.ts +0 -135
- package/src/transform.test.ts +0 -207
- package/src/transform.ts +0 -159
- package/src/transforms/index.ts +0 -2
- package/src/transforms/size-cap.test.ts +0 -172
- package/src/transforms/size-cap.ts +0 -110
- package/src/turns.ts +0 -54
- package/tsconfig.json +0 -4
- 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
|
-
});
|