@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.
- package/README.md +69 -0
- package/package.json +16 -0
- package/src/auth.ts +17 -0
- package/src/endpoint.ts +19 -0
- package/src/index.ts +308 -0
- package/src/media.ts +35 -0
- package/src/plugin.test.ts +681 -0
- package/src/reasoning.ts +64 -0
- package/src/request-body.ts +637 -0
- package/src/sse.fixture-contract.test.ts +51 -0
- package/src/sse.test.ts +188 -0
- package/src/sse.ts +273 -0
- package/tsconfig.json +4 -0
- package/tsconfig.tsbuildinfo +1 -0
|
@@ -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
|
+
});
|
package/src/sse.test.ts
ADDED
|
@@ -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