Skip to content

Commit d24c523

Browse files
authored
fix(claude): accept data-only Responses SSE (#711)
Co-authored-by: snowyukitty <270071858+snowyukitty@users.noreply.github.com>
1 parent 48f2e83 commit d24c523

2 files changed

Lines changed: 119 additions & 3 deletions

File tree

src/claude/outbound.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -453,12 +453,15 @@ export function responsesSseToAnthropicSse(
453453
if (line.startsWith("event: ")) eventName = line.slice(7).trim();
454454
else if (line.startsWith("data: ")) dataLine += line.slice(6);
455455
}
456-
if (!eventName || !dataLine) continue;
456+
if (!dataLine) continue;
457457
let data: unknown;
458458
try { data = JSON.parse(dataLine); } catch { continue; }
459459
if (!isRec(data)) continue;
460-
if (terminated) continue;
461-
handleFrame(eventName, data);
460+
// Responses-compatible gateways may omit the optional SSE event field
461+
// while retaining the event name in the JSON payload's required type.
462+
const resolvedEventName = eventName || (typeof data.type === "string" ? data.type : "");
463+
if (!resolvedEventName || terminated) continue;
464+
handleFrame(resolvedEventName, data);
462465
}
463466
}
464467
// EOF without a terminal frame is a TRUNCATION, not success (devlog 100:

tests/claude-outbound.test.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@ function sse(name: string, data: Record<string, unknown>): string {
1313
return `event: ${name}\ndata: ${JSON.stringify(data)}\n\n`;
1414
}
1515

16+
function dataOnlySse(data: Record<string, unknown>): string {
17+
return `data: ${JSON.stringify(data)}\n\n`;
18+
}
19+
20+
const DONE_SSE = "data: [DONE]\n\n";
21+
1622
function streamFrom(text: string): ReadableStream<Uint8Array> {
1723
const encoder = new TextEncoder();
1824
return new ReadableStream({
@@ -99,6 +105,113 @@ describe("claude outbound SSE", () => {
99105
expect(startIndexes).toEqual([0, 1, 2]);
100106
});
101107

108+
test("data-only Responses frames infer event names from payload types", async () => {
109+
const upstream = [
110+
dataOnlySse({ type: "response.created", response: { id: "resp_data_only", status: "in_progress" } }),
111+
dataOnlySse({ type: "response.output_text.delta", delta: "data-only stream" }),
112+
dataOnlySse({
113+
type: "response.completed",
114+
response: { status: "completed", usage: { input_tokens: 8, output_tokens: 2 } },
115+
}),
116+
DONE_SSE,
117+
].join("");
118+
119+
const events = await collectEvents(responsesSseToAnthropicSse(streamFrom(upstream), "m"));
120+
expect(events.map(e => e.name)).toEqual([
121+
"message_start", "ping",
122+
"content_block_start", "content_block_delta", "content_block_stop",
123+
"message_delta", "message_stop",
124+
]);
125+
expect(events.find(e => e.name === "content_block_delta")!.data.delta).toEqual({
126+
type: "text_delta",
127+
text: "data-only stream",
128+
});
129+
expect(events.find(e => e.name === "message_delta")!.data).toMatchObject({
130+
delta: { stop_reason: "end_turn" },
131+
usage: { input_tokens: 8, output_tokens: 2 },
132+
});
133+
});
134+
135+
test("explicit and data-only Responses frames can interleave", async () => {
136+
const upstream = [
137+
sse("response.created", { response: { id: "resp_mixed", status: "in_progress" } }),
138+
dataOnlySse({ type: "response.output_text.delta", delta: "mixed stream" }),
139+
sse("response.completed", { response: { status: "completed" } }),
140+
DONE_SSE,
141+
].join("");
142+
143+
const events = await collectEvents(responsesSseToAnthropicSse(streamFrom(upstream), "m"));
144+
expect(events.find(e => e.name === "content_block_delta")!.data.delta).toEqual({
145+
type: "text_delta",
146+
text: "mixed stream",
147+
});
148+
expect(events.at(-1)!.name).toBe("message_stop");
149+
});
150+
151+
test("data-only Responses frames survive non-streaming aggregation", async () => {
152+
const upstream = [
153+
dataOnlySse({ type: "response.output_text.delta", delta: "data-only message" }),
154+
dataOnlySse({
155+
type: "response.completed",
156+
response: { status: "completed", usage: { input_tokens: 5, output_tokens: 3 } },
157+
}),
158+
DONE_SSE,
159+
].join("");
160+
161+
const anthropicSse = responsesSseToAnthropicSse(streamFrom(upstream), "m");
162+
const message = await collectAnthropicMessage(anthropicSse, "m");
163+
expect(message).toMatchObject({
164+
type: "message",
165+
role: "assistant",
166+
content: [{ type: "text", text: "data-only message" }],
167+
stop_reason: "end_turn",
168+
usage: { input_tokens: 5, output_tokens: 3 },
169+
});
170+
});
171+
172+
test("explicit event names override payload types and untyped data-only frames stay ignored", async () => {
173+
const upstream = [
174+
dataOnlySse({ delta: "must stay ignored" }),
175+
sse("response.output_text.delta", { type: "response.ignored", delta: "visible" }),
176+
sse("response.completed", {
177+
type: "response.output_text.delta",
178+
delta: "must not override the explicit terminal event",
179+
response: { status: "completed" },
180+
}),
181+
].join("");
182+
183+
const events = await collectEvents(responsesSseToAnthropicSse(streamFrom(upstream), "m"));
184+
const textDeltas = events
185+
.filter(e => e.name === "content_block_delta" && e.data.delta?.type === "text_delta")
186+
.map(e => e.data.delta.text);
187+
expect(textDeltas).toEqual(["visible"]);
188+
expect(events.at(-1)!.name).toBe("message_stop");
189+
});
190+
191+
test("data-only [DONE] without a Responses terminal frame still fails closed", async () => {
192+
const upstream = [
193+
dataOnlySse({ type: "response.output_text.delta", delta: "partial" }),
194+
DONE_SSE,
195+
].join("");
196+
197+
const events = await collectEvents(responsesSseToAnthropicSse(streamFrom(upstream), "m"));
198+
expect(events.some(e => e.name === "message_stop")).toBe(false);
199+
expect(events.find(e => e.name === "content_block_delta")!.data.delta).toEqual({
200+
type: "text_delta",
201+
text: "partial",
202+
});
203+
expect(events.at(-1)).toMatchObject({
204+
name: "error",
205+
data: {
206+
type: "error",
207+
error: {
208+
type: "overloaded_error",
209+
message: "upstream stream ended before a terminal frame (truncated response)",
210+
},
211+
},
212+
});
213+
});
214+
102215
test("failed -> error event with taxonomy type", async () => {
103216
const upstream = [
104217
sse("response.created", { response: {} }),

0 commit comments

Comments
 (0)