refactor(anthropic-proxy): improve SSE stream transformation logic
Refactor the backend-to-Anthropic SSE transformation to use a more robust state machine approach. This replaces manual string building with a structured phase-based system (`init`, `block`, `done`) to better manage the Anthropic streaming protocol, including `message_start` and `content_block_delta` events. - Extract `formatContentBlockDelta` helper for consistent event formatting - Implement phase-based state machine in `transformAnthropicStream` - Delegate `[DONE]` handling to the stream transformer instead of manually emitting `message_stop` - Improve reliability of message ID generation and event sequencing
This commit is contained in:
+76
-46
@@ -153,17 +153,9 @@ function backendToAnthropicResponse(
|
|||||||
// ─── Streaming: Backend SSE → Anthropic SSE ───────────────────────────────
|
// ─── Streaming: Backend SSE → Anthropic SSE ───────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Transform a backend SSE line into Anthropic SSE format.
|
* Transform a backend SSE line into Anthropic SSE content_block_delta events.
|
||||||
*
|
*
|
||||||
* Anthropic streaming protocol:
|
* Returns the SSE event string, or null to skip the line.
|
||||||
* event: message_start
|
|
||||||
* data: {"type":"message_start","message":{...}}
|
|
||||||
*
|
|
||||||
* event: content_block_delta
|
|
||||||
* data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"Hello"}}
|
|
||||||
*
|
|
||||||
* event: message_stop
|
|
||||||
* data: {"type":"message_stop"}
|
|
||||||
*/
|
*/
|
||||||
function backendLineToAnthropicSSE(
|
function backendLineToAnthropicSSE(
|
||||||
line: string,
|
line: string,
|
||||||
@@ -177,20 +169,14 @@ function backendLineToAnthropicSSE(
|
|||||||
const adapted = config.adaptStreamLine(line, {} as any);
|
const adapted = config.adaptStreamLine(line, {} as any);
|
||||||
if (!adapted) return null;
|
if (!adapted) return null;
|
||||||
if (adapted === "data: [DONE]") {
|
if (adapted === "data: [DONE]") {
|
||||||
return "event: message_stop\ndata: {\"type\":\"message_stop\"}";
|
return null; // let the stream transformer handle DONE
|
||||||
}
|
}
|
||||||
// Parse the OpenAI-format chunk and convert to Anthropic
|
// Parse the OpenAI-format chunk and convert to Anthropic
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(adapted.replace(/^data: /, ""));
|
const parsed = JSON.parse(adapted.replace(/^data: /, ""));
|
||||||
const text = parsed.choices?.[0]?.delta?.content ?? "";
|
const text = parsed.choices?.[0]?.delta?.content ?? "";
|
||||||
if (!text) return null;
|
if (!text) return null;
|
||||||
return (
|
return formatContentBlockDelta(text);
|
||||||
`event: content_block_delta\ndata: ${JSON.stringify({
|
|
||||||
type: "content_block_delta",
|
|
||||||
index: 0,
|
|
||||||
delta: { type: "text_delta", text },
|
|
||||||
})}`
|
|
||||||
);
|
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -200,19 +186,13 @@ function backendLineToAnthropicSSE(
|
|||||||
if (line.startsWith("data: ")) {
|
if (line.startsWith("data: ")) {
|
||||||
const raw = line.slice(6);
|
const raw = line.slice(6);
|
||||||
if (raw === "[DONE]") {
|
if (raw === "[DONE]") {
|
||||||
return "event: message_stop\ndata: {\"type\":\"message_stop\"}";
|
return null; // let the stream transformer handle DONE
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(raw);
|
const parsed = JSON.parse(raw);
|
||||||
const text = parsed.choices?.[0]?.delta?.content ?? "";
|
const text = parsed.choices?.[0]?.delta?.content ?? "";
|
||||||
if (!text) return null;
|
if (!text) return null;
|
||||||
return (
|
return formatContentBlockDelta(text);
|
||||||
`event: content_block_delta\ndata: ${JSON.stringify({
|
|
||||||
type: "content_block_delta",
|
|
||||||
index: 0,
|
|
||||||
delta: { type: "text_delta", text },
|
|
||||||
})}`
|
|
||||||
);
|
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -220,18 +200,21 @@ function backendLineToAnthropicSSE(
|
|||||||
|
|
||||||
// Plain text chunks
|
// Plain text chunks
|
||||||
if (line.length > 0) {
|
if (line.length > 0) {
|
||||||
return (
|
return formatContentBlockDelta(line);
|
||||||
`event: content_block_delta\ndata: ${JSON.stringify({
|
|
||||||
type: "content_block_delta",
|
|
||||||
index: 0,
|
|
||||||
delta: { type: "text_delta", text: line },
|
|
||||||
})}`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Format a content_block_delta SSE event for a text delta. */
|
||||||
|
function formatContentBlockDelta(text: string): string {
|
||||||
|
return `event: content_block_delta\ndata: ${JSON.stringify({
|
||||||
|
type: "content_block_delta",
|
||||||
|
index: 0,
|
||||||
|
delta: { type: "text_delta", text },
|
||||||
|
})}`;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Stream transformer ────────────────────────────────────────────────────
|
// ─── Stream transformer ────────────────────────────────────────────────────
|
||||||
|
|
||||||
function transformAnthropicStream(
|
function transformAnthropicStream(
|
||||||
@@ -242,18 +225,25 @@ function transformAnthropicStream(
|
|||||||
const reader = body.getReader();
|
const reader = body.getReader();
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
let sentStart = false;
|
|
||||||
|
// State machine for Anthropic SSE protocol
|
||||||
|
let phase: "init" | "block" | "done" = "init";
|
||||||
|
let messageId = `msg_${Date.now()}`;
|
||||||
|
let _hasContent = false;
|
||||||
|
|
||||||
return new ReadableStream({
|
return new ReadableStream({
|
||||||
async pull(controller) {
|
async pull(controller) {
|
||||||
try {
|
try {
|
||||||
// Emit message_start event first
|
// ── Phase: emit message_start + content_block_start ──────
|
||||||
if (!sentStart) {
|
if (phase === "init") {
|
||||||
sentStart = true;
|
phase = "block";
|
||||||
|
messageId = `msg_${Date.now()}`;
|
||||||
|
|
||||||
|
// message_start
|
||||||
const startEvent = `event: message_start\ndata: ${JSON.stringify({
|
const startEvent = `event: message_start\ndata: ${JSON.stringify({
|
||||||
type: "message_start",
|
type: "message_start",
|
||||||
message: {
|
message: {
|
||||||
id: `msg_${Date.now()}`,
|
id: messageId,
|
||||||
type: "message",
|
type: "message",
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
content: [],
|
content: [],
|
||||||
@@ -264,18 +254,22 @@ function transformAnthropicStream(
|
|||||||
},
|
},
|
||||||
})}`;
|
})}`;
|
||||||
controller.enqueue(encoder.encode(startEvent + "\n\n"));
|
controller.enqueue(encoder.encode(startEvent + "\n\n"));
|
||||||
|
|
||||||
|
// content_block_start — must precede any deltas
|
||||||
|
const blockStart = `event: content_block_start\ndata: ${JSON.stringify({
|
||||||
|
type: "content_block_start",
|
||||||
|
index: 0,
|
||||||
|
content_block: { type: "text", text: "" },
|
||||||
|
})}`;
|
||||||
|
controller.enqueue(encoder.encode(blockStart + "\n\n"));
|
||||||
}
|
}
|
||||||
|
|
||||||
while (true) {
|
// ── Phase: read stream and emit content_block_delta events ─
|
||||||
|
while (phase === "block") {
|
||||||
const { done, value } = await reader.read();
|
const { done, value } = await reader.read();
|
||||||
if (done) {
|
if (done) {
|
||||||
controller.enqueue(
|
phase = "done";
|
||||||
encoder.encode(
|
break;
|
||||||
'event: message_stop\ndata: {"type":"message_stop"}\n\n',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
controller.close();
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const chunk = decoder.decode(value, { stream: true });
|
const chunk = decoder.decode(value, { stream: true });
|
||||||
@@ -284,9 +278,45 @@ function transformAnthropicStream(
|
|||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
const adapted = backendLineToAnthropicSSE(line, model, config);
|
const adapted = backendLineToAnthropicSSE(line, model, config);
|
||||||
if (adapted) {
|
if (adapted) {
|
||||||
|
hasContent = true;
|
||||||
controller.enqueue(encoder.encode(adapted + "\n\n"));
|
controller.enqueue(encoder.encode(adapted + "\n\n"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Yield control so we don't block — let next pull() continue
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Phase: emit closing events (content_block_stop, message_delta, message_stop) ─
|
||||||
|
if (phase === "done") {
|
||||||
|
phase = "done"; // prevent re-entry
|
||||||
|
|
||||||
|
// content_block_stop
|
||||||
|
controller.enqueue(
|
||||||
|
encoder.encode(
|
||||||
|
'event: content_block_stop\ndata: {"type":"content_block_stop","index":0}\n\n',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// message_delta — required before message_stop
|
||||||
|
controller.enqueue(
|
||||||
|
encoder.encode(
|
||||||
|
`event: message_delta\ndata: ${JSON.stringify({
|
||||||
|
type: "message_delta",
|
||||||
|
delta: { stop_reason: "end_turn", stop_sequence: null },
|
||||||
|
usage: { output_tokens: 0 },
|
||||||
|
})}\n\n`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// message_stop
|
||||||
|
controller.enqueue(
|
||||||
|
encoder.encode(
|
||||||
|
'event: message_stop\ndata: {"type":"message_stop"}\n\n',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
controller.close();
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
controller.enqueue(
|
controller.enqueue(
|
||||||
|
|||||||
Reference in New Issue
Block a user