@intx/inference-discovery-anthropic 0.1.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.
@@ -0,0 +1,51 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { readFileSync } from "node:fs";
3
+ import { resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { extractContentBlocksFromSSE } from "./sse";
6
+
7
+ // Pins the empirical contract behind the throw in sse.ts:applyDelta:
8
+ // Anthropic's server-side tool blocks (server_tool_use,
9
+ // web_search_tool_result, code_execution_tool_use) arrive with partial
10
+ // deltas in real streams — input_json_delta events build up the
11
+ // `input` field across content_block_delta events. This parser does
12
+ // not yet implement delta application for those block types, so any
13
+ // caller that invokes the parser on these fixtures must surface a
14
+ // loud failure rather than silently dropping the delta payloads.
15
+ //
16
+ // As long as this test exists, anyone refactoring the parser to "pass
17
+ // through unknown deltas" will see it fail and have to confront the
18
+ // actual wire shape. It also blocks the regression where someone
19
+ // widens streaming-multi-turn to pair with a server-side tool without
20
+ // first teaching the parser the delta application for the affected
21
+ // block type.
22
+
23
+ // The test file is at packages/inference-discovery-anthropic/src/; the
24
+ // fixtures it reads live under packages/inference-testing/wire/, so the
25
+ // repo root is three directories up from this file's parent.
26
+ const REPO_ROOT = resolve(
27
+ fileURLToPath(new URL(".", import.meta.url)),
28
+ "..",
29
+ "..",
30
+ "..",
31
+ );
32
+
33
+ const FIXTURES = [
34
+ "packages/inference-testing/wire/anthropic/claude-opus-4-1-20250805/grounding-streaming/response.sse",
35
+ "packages/inference-testing/wire/anthropic/claude-sonnet-4-5-20250929/grounding-streaming/response.sse",
36
+ "packages/inference-testing/wire/anthropic/claude-haiku-4-5-20251001/grounding-streaming/response.sse",
37
+ "packages/inference-testing/wire/anthropic/claude-opus-4-1-20250805/code-execution-streaming/response.sse",
38
+ "packages/inference-testing/wire/anthropic/claude-sonnet-4-5-20250929/code-execution-streaming/response.sse",
39
+ "packages/inference-testing/wire/anthropic/claude-haiku-4-5-20251001/code-execution-streaming/response.sse",
40
+ ];
41
+
42
+ describe("extractContentBlocksFromSSE — server-side tool fixture contract", () => {
43
+ for (const relPath of FIXTURES) {
44
+ test(`throws on ${relPath}`, () => {
45
+ const bytes = readFileSync(resolve(REPO_ROOT, relPath));
46
+ expect(() => extractContentBlocksFromSSE(new Uint8Array(bytes))).toThrow(
47
+ /non-enumerated block type/,
48
+ );
49
+ });
50
+ }
51
+ });
@@ -0,0 +1,188 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { extractContentBlocksFromSSE } from "./sse";
3
+
4
+ function bytes(text: string): Uint8Array {
5
+ return new TextEncoder().encode(text);
6
+ }
7
+
8
+ describe("extractContentBlocksFromSSE", () => {
9
+ test("returns a single text block with concatenated text_delta payloads", () => {
10
+ const stream = [
11
+ "event: message_start",
12
+ 'data: {"type":"message_start","message":{"id":"msg_1","type":"message","role":"assistant","content":[]}}',
13
+ "",
14
+ "event: content_block_start",
15
+ 'data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}',
16
+ "",
17
+ "event: content_block_delta",
18
+ 'data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}',
19
+ "",
20
+ "event: content_block_delta",
21
+ 'data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":", world"}}',
22
+ "",
23
+ "event: content_block_stop",
24
+ 'data: {"type":"content_block_stop","index":0}',
25
+ "",
26
+ "event: message_stop",
27
+ 'data: {"type":"message_stop"}',
28
+ "",
29
+ ].join("\n");
30
+ const blocks = extractContentBlocksFromSSE(bytes(stream));
31
+ expect(blocks).toEqual([{ type: "text", text: "Hello, world" }]);
32
+ });
33
+
34
+ test("returns a tool_use block with input reconstructed from input_json_delta chunks", () => {
35
+ const stream = [
36
+ "event: content_block_start",
37
+ 'data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"tool_1","name":"get_weather","input":{}}}',
38
+ "",
39
+ "event: content_block_delta",
40
+ 'data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\\"loc"}}',
41
+ "",
42
+ "event: content_block_delta",
43
+ 'data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"ation\\":\\"Boston, MA\\"}"}}',
44
+ "",
45
+ "event: content_block_stop",
46
+ 'data: {"type":"content_block_stop","index":0}',
47
+ "",
48
+ ].join("\n");
49
+ const blocks = extractContentBlocksFromSSE(bytes(stream));
50
+ expect(blocks).toEqual([
51
+ {
52
+ type: "tool_use",
53
+ id: "tool_1",
54
+ name: "get_weather",
55
+ input: { location: "Boston, MA" },
56
+ },
57
+ ]);
58
+ });
59
+
60
+ test("returns a thinking block with concatenated thinking_delta and signature_delta", () => {
61
+ const stream = [
62
+ "event: content_block_start",
63
+ 'data: {"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}',
64
+ "",
65
+ "event: content_block_delta",
66
+ 'data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"Let me think."}}',
67
+ "",
68
+ "event: content_block_delta",
69
+ 'data: {"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":"sig-abc"}}',
70
+ "",
71
+ "event: content_block_stop",
72
+ 'data: {"type":"content_block_stop","index":0}',
73
+ "",
74
+ ].join("\n");
75
+ const blocks = extractContentBlocksFromSSE(bytes(stream));
76
+ expect(blocks).toEqual([
77
+ { type: "thinking", thinking: "Let me think.", signature: "sig-abc" },
78
+ ]);
79
+ });
80
+
81
+ test("returns a redacted_thinking block from a one-shot content_block_start (no deltas)", () => {
82
+ const stream = [
83
+ "event: content_block_start",
84
+ 'data: {"type":"content_block_start","index":0,"content_block":{"type":"redacted_thinking","data":"opaque-encrypted-bytes"}}',
85
+ "",
86
+ "event: content_block_stop",
87
+ 'data: {"type":"content_block_stop","index":0}',
88
+ "",
89
+ ].join("\n");
90
+ const blocks = extractContentBlocksFromSSE(bytes(stream));
91
+ expect(blocks).toEqual([
92
+ { type: "redacted_thinking", data: "opaque-encrypted-bytes" },
93
+ ]);
94
+ });
95
+
96
+ test("returns blocks in their original index order across interleaved deltas", () => {
97
+ const stream = [
98
+ "event: content_block_start",
99
+ 'data: {"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}',
100
+ "",
101
+ "event: content_block_start",
102
+ 'data: {"type":"content_block_start","index":1,"content_block":{"type":"text","text":""}}',
103
+ "",
104
+ "event: content_block_delta",
105
+ 'data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"answer"}}',
106
+ "",
107
+ "event: content_block_delta",
108
+ 'data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"reasoning"}}',
109
+ "",
110
+ "event: content_block_stop",
111
+ 'data: {"type":"content_block_stop","index":0}',
112
+ "",
113
+ "event: content_block_stop",
114
+ 'data: {"type":"content_block_stop","index":1}',
115
+ "",
116
+ ].join("\n");
117
+ const blocks = extractContentBlocksFromSSE(bytes(stream));
118
+ expect(blocks).toEqual([
119
+ { type: "thinking", thinking: "reasoning" },
120
+ { type: "text", text: "answer" },
121
+ ]);
122
+ });
123
+
124
+ test("ignores ping and message_* envelope events", () => {
125
+ const stream = [
126
+ "event: message_start",
127
+ 'data: {"type":"message_start","message":{"id":"msg_1"}}',
128
+ "",
129
+ "event: ping",
130
+ 'data: {"type":"ping"}',
131
+ "",
132
+ "event: content_block_start",
133
+ 'data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}',
134
+ "",
135
+ "event: content_block_delta",
136
+ 'data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hi"}}',
137
+ "",
138
+ "event: content_block_stop",
139
+ 'data: {"type":"content_block_stop","index":0}',
140
+ "",
141
+ "event: message_delta",
142
+ 'data: {"type":"message_delta","delta":{"stop_reason":"end_turn"}}',
143
+ "",
144
+ "event: message_stop",
145
+ 'data: {"type":"message_stop"}',
146
+ "",
147
+ ].join("\n");
148
+ const blocks = extractContentBlocksFromSSE(bytes(stream));
149
+ expect(blocks).toEqual([{ type: "text", text: "hi" }]);
150
+ });
151
+
152
+ test("throws when an unknown block type receives partial-delta events", () => {
153
+ const stream = [
154
+ "event: content_block_start",
155
+ 'data: {"type":"content_block_start","index":0,"content_block":{"type":"server_tool_use","id":"srv_1","name":"web_search","input":{}}}',
156
+ "",
157
+ "event: content_block_delta",
158
+ 'data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{}"}}',
159
+ "",
160
+ "event: content_block_stop",
161
+ 'data: {"type":"content_block_stop","index":0}',
162
+ "",
163
+ ].join("\n");
164
+ expect(() => extractContentBlocksFromSSE(bytes(stream))).toThrow(
165
+ /non-enumerated block type/,
166
+ );
167
+ });
168
+
169
+ test("forwards unknown content_block types verbatim", () => {
170
+ const stream = [
171
+ "event: content_block_start",
172
+ 'data: {"type":"content_block_start","index":0,"content_block":{"type":"server_tool_use","id":"srv_1","name":"web_search","input":{"query":"x"}}}',
173
+ "",
174
+ "event: content_block_stop",
175
+ 'data: {"type":"content_block_stop","index":0}',
176
+ "",
177
+ ].join("\n");
178
+ const blocks = extractContentBlocksFromSSE(bytes(stream));
179
+ expect(blocks).toEqual([
180
+ {
181
+ type: "server_tool_use",
182
+ id: "srv_1",
183
+ name: "web_search",
184
+ input: { query: "x" },
185
+ },
186
+ ]);
187
+ });
188
+ });
package/src/sse.ts ADDED
@@ -0,0 +1,273 @@
1
+ // Anthropic streams /v1/messages as Server-Sent Events whose payloads
2
+ // arrive as named events: message_start, content_block_start,
3
+ // content_block_delta, content_block_stop, message_delta, message_stop,
4
+ // ping. To build a turn-2 multi-turn body for a streaming capability we
5
+ // need the assistant's content blocks reconstructed from those events.
6
+ // This module parses the event stream, applies the per-block deltas
7
+ // (text_delta, input_json_delta, thinking_delta, signature_delta), and
8
+ // returns the resolved content blocks in their original index order.
9
+
10
+ interface BlockAccumulatorBase {
11
+ type: string;
12
+ index: number;
13
+ }
14
+
15
+ interface TextAcc extends BlockAccumulatorBase {
16
+ type: "text";
17
+ text: string;
18
+ }
19
+
20
+ interface ToolUseAcc extends BlockAccumulatorBase {
21
+ type: "tool_use";
22
+ id: string;
23
+ name: string;
24
+ partialJson: string;
25
+ }
26
+
27
+ interface ThinkingAcc extends BlockAccumulatorBase {
28
+ type: "thinking";
29
+ thinking: string;
30
+ signature?: string;
31
+ }
32
+
33
+ interface RedactedThinkingAcc extends BlockAccumulatorBase {
34
+ type: "redacted_thinking";
35
+ data: string;
36
+ }
37
+
38
+ interface UnknownAcc extends BlockAccumulatorBase {
39
+ type: "unknown";
40
+ contentBlock: Record<string, unknown>;
41
+ }
42
+
43
+ type BlockAcc =
44
+ | TextAcc
45
+ | ToolUseAcc
46
+ | ThinkingAcc
47
+ | RedactedThinkingAcc
48
+ | UnknownAcc;
49
+
50
+ function isRecord(value: unknown): value is Record<string, unknown> {
51
+ return typeof value === "object" && value !== null && !Array.isArray(value);
52
+ }
53
+
54
+ // These guards omit the offending value from the thrown message: SSE
55
+ // payloads can carry large opaque blobs (e.g. a redacted_thinking
56
+ // `data` field, a multi-KB signature) and stringifying them into an
57
+ // error message produces unreadable output. The typeof + the call-site
58
+ // in the stack trace are enough to localise the bad field.
59
+
60
+ function asString(value: unknown): string {
61
+ if (typeof value !== "string") {
62
+ throw new Error(`anthropic SSE: expected string, got ${typeof value}`);
63
+ }
64
+ return value;
65
+ }
66
+
67
+ function asNumber(value: unknown): number {
68
+ if (typeof value !== "number") {
69
+ throw new Error(`anthropic SSE: expected number, got ${typeof value}`);
70
+ }
71
+ return value;
72
+ }
73
+
74
+ interface ParsedEvent {
75
+ event: string;
76
+ data: unknown;
77
+ }
78
+
79
+ function parseEvents(text: string): ParsedEvent[] {
80
+ const events: ParsedEvent[] = [];
81
+ // Normalize CRLF, then split on the blank-line event separator.
82
+ const normalized = text.replace(/\r\n/g, "\n");
83
+ for (const chunk of normalized.split("\n\n")) {
84
+ if (chunk.length === 0) continue;
85
+ let eventName: string | null = null;
86
+ const dataLines: string[] = [];
87
+ for (const line of chunk.split("\n")) {
88
+ if (line.startsWith(":")) continue;
89
+ if (line.startsWith("event:")) {
90
+ eventName = line.slice("event:".length).trim();
91
+ } else if (line.startsWith("data:")) {
92
+ dataLines.push(line.slice("data:".length).replace(/^ /, ""));
93
+ }
94
+ }
95
+ if (eventName === null || dataLines.length === 0) continue;
96
+ const joined = dataLines.join("\n");
97
+ // Anthropic emits trailing whitespace inside the closing brace on
98
+ // every data: line (e.g. `…} }`). JSON.parse tolerates it. If
99
+ // this parser is ever swapped for a stricter one, trim here first.
100
+ const data: unknown = JSON.parse(joined);
101
+ events.push({ event: eventName, data });
102
+ }
103
+ return events;
104
+ }
105
+
106
+ function initialAccumulator(index: number, contentBlock: unknown): BlockAcc {
107
+ if (!isRecord(contentBlock)) {
108
+ throw new Error(
109
+ "anthropic SSE: content_block_start missing content_block object",
110
+ );
111
+ }
112
+ const type = contentBlock.type;
113
+ if (type === "text") {
114
+ return { type: "text", index, text: asString(contentBlock.text ?? "") };
115
+ }
116
+ if (type === "tool_use") {
117
+ return {
118
+ type: "tool_use",
119
+ index,
120
+ id: asString(contentBlock.id),
121
+ name: asString(contentBlock.name),
122
+ partialJson: "",
123
+ };
124
+ }
125
+ if (type === "thinking") {
126
+ const acc: ThinkingAcc = {
127
+ type: "thinking",
128
+ index,
129
+ thinking: asString(contentBlock.thinking ?? ""),
130
+ };
131
+ if (typeof contentBlock.signature === "string") {
132
+ acc.signature = contentBlock.signature;
133
+ }
134
+ return acc;
135
+ }
136
+ if (type === "redacted_thinking") {
137
+ return {
138
+ type: "redacted_thinking",
139
+ index,
140
+ data: asString(contentBlock.data),
141
+ };
142
+ }
143
+ // Pass-through for server_tool_use, web_search_tool_result,
144
+ // code_execution_tool_use, etc. The wire round-trip is what matters.
145
+ return { type: "unknown", index, contentBlock };
146
+ }
147
+
148
+ function applyDelta(acc: BlockAcc, delta: unknown): BlockAcc {
149
+ if (!isRecord(delta)) {
150
+ throw new Error("anthropic SSE: delta is not an object");
151
+ }
152
+ const dtype = delta.type;
153
+ if (dtype === "text_delta" && acc.type === "text") {
154
+ return { ...acc, text: acc.text + asString(delta.text) };
155
+ }
156
+ if (dtype === "input_json_delta" && acc.type === "tool_use") {
157
+ return {
158
+ ...acc,
159
+ partialJson: acc.partialJson + asString(delta.partial_json),
160
+ };
161
+ }
162
+ if (dtype === "thinking_delta" && acc.type === "thinking") {
163
+ return { ...acc, thinking: acc.thinking + asString(delta.thinking) };
164
+ }
165
+ if (dtype === "signature_delta" && acc.type === "thinking") {
166
+ return {
167
+ ...acc,
168
+ signature: (acc.signature ?? "") + asString(delta.signature),
169
+ };
170
+ }
171
+ // Anthropic's server-side tool blocks (server_tool_use,
172
+ // web_search_tool_result, code_execution_tool_use) DO arrive with
173
+ // partial deltas in real streams — the grounding-streaming and
174
+ // code-execution-streaming fixtures in this repo carry
175
+ // input_json_delta events building up the tool input field. This
176
+ // parser does not yet implement delta application for those block
177
+ // types; the streaming-multi-turn capabilities currently in scope
178
+ // (function-calling-multi-turn-streaming,
179
+ // function-calling-with-thinking-streaming, redacted-thinking-
180
+ // streaming) do not pair with server-side tools, so the parser is
181
+ // not invoked on those fixtures today. Fail loud here so that any
182
+ // future expansion that does invoke the parser on a server-side
183
+ // tool stream surfaces the gap at parse time rather than silently
184
+ // dropping the delta payloads.
185
+ if (acc.type === "unknown") {
186
+ throw new Error(
187
+ `anthropic SSE: received ${String(dtype)} for a non-enumerated block type at index ${String(acc.index)}; partial-delta streaming for server-side tool blocks is not implemented`,
188
+ );
189
+ }
190
+ throw new Error(
191
+ `anthropic SSE: delta type ${String(dtype)} does not match block type ${acc.type}`,
192
+ );
193
+ }
194
+
195
+ function finalize(acc: BlockAcc): Record<string, unknown> {
196
+ if (acc.type === "text") {
197
+ return { type: "text", text: acc.text };
198
+ }
199
+ if (acc.type === "tool_use") {
200
+ const input: unknown =
201
+ acc.partialJson.length === 0 ? {} : JSON.parse(acc.partialJson);
202
+ return { type: "tool_use", id: acc.id, name: acc.name, input };
203
+ }
204
+ if (acc.type === "thinking") {
205
+ const block: Record<string, unknown> = {
206
+ type: "thinking",
207
+ thinking: acc.thinking,
208
+ };
209
+ if (acc.signature !== undefined) block.signature = acc.signature;
210
+ return block;
211
+ }
212
+ if (acc.type === "redacted_thinking") {
213
+ return { type: "redacted_thinking", data: acc.data };
214
+ }
215
+ return acc.contentBlock;
216
+ }
217
+
218
+ // Parses Anthropic's named-event SSE stream and reconstructs the
219
+ // assistant's content blocks in their original index order. Used by
220
+ // the streaming multi-turn iterators to build turn-2 bodies that echo
221
+ // the assistant content blocks verbatim — without a turn-1 JSON body to
222
+ // read from, this is the only path to those blocks.
223
+ export function extractContentBlocksFromSSE(
224
+ bytes: Uint8Array,
225
+ ): Record<string, unknown>[] {
226
+ const text = new TextDecoder().decode(bytes);
227
+ const events = parseEvents(text);
228
+ const accumulators = new Map<number, BlockAcc>();
229
+ for (const { event, data } of events) {
230
+ if (
231
+ event === "ping" ||
232
+ event === "message_start" ||
233
+ event === "message_delta" ||
234
+ event === "message_stop"
235
+ ) {
236
+ continue;
237
+ }
238
+ if (!isRecord(data)) {
239
+ throw new Error(`anthropic SSE: event ${event} payload is not an object`);
240
+ }
241
+ if (event === "content_block_start") {
242
+ const index = asNumber(data.index);
243
+ accumulators.set(index, initialAccumulator(index, data.content_block));
244
+ continue;
245
+ }
246
+ if (event === "content_block_delta") {
247
+ const index = asNumber(data.index);
248
+ const acc = accumulators.get(index);
249
+ if (acc === undefined) {
250
+ throw new Error(
251
+ `anthropic SSE: content_block_delta for index ${String(index)} has no matching content_block_start`,
252
+ );
253
+ }
254
+ accumulators.set(index, applyDelta(acc, data.delta));
255
+ continue;
256
+ }
257
+ if (event === "content_block_stop") {
258
+ // No-op; finalization happens after the loop in index order.
259
+ continue;
260
+ }
261
+ throw new Error(`anthropic SSE: unexpected event ${event}`);
262
+ }
263
+ const sortedIndices = Array.from(accumulators.keys()).sort((a, b) => a - b);
264
+ return sortedIndices.map((i) => {
265
+ const acc = accumulators.get(i);
266
+ if (acc === undefined) {
267
+ throw new Error(
268
+ `anthropic SSE: missing accumulator for index ${String(i)}`,
269
+ );
270
+ }
271
+ return finalize(acc);
272
+ });
273
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,4 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "include": ["src/**/*.ts"]
4
+ }