style(anthropic-proxy): update comment formatting and add SSE utility functions

Standardize comment separators and arrow usage in documentation headers.
Additionally, introduce `accumulateSSEText` and `extractTextFromSSE` to
provide robust parsing for various SSE stream formats, including
Claude Code and OpenAI-compatible deltas.
This commit is contained in:
MythEclipse
2026-06-11 01:43:23 +07:00
parent 261f84a96e
commit db065f788d
+123 -34
View File
@@ -5,15 +5,15 @@
* and routes them to the same backend AI providers as the OpenAI proxy. * and routes them to the same backend AI providers as the OpenAI proxy.
* *
* Translations: * Translations:
* - Anthropic request backend format (OpenAI-compatible) * - Anthropic request -> backend format (OpenAI-compatible)
* - Backend response Anthropic Messages format * - Backend response -> Anthropic Messages format
* - Backend SSE stream Anthropic SSE events * - Backend SSE stream -> Anthropic SSE events
*/ */
import type { ProxyPool } from "./proxy-pool"; import type { ProxyPool } from "./proxy-pool";
import { MODEL_ROUTES, type BackendConfig } from "./ai-proxy"; import { MODEL_ROUTES, type BackendConfig } from "./ai-proxy";
// ─── Types ─────────────────────────────────────────────────────────────────────── // --- Types -------------------------------------------------------------------
export interface AnthropicRequest { export interface AnthropicRequest {
model: string; model: string;
@@ -40,7 +40,7 @@ interface AnthropicResponse {
usage: { input_tokens: number; output_tokens: number }; usage: { input_tokens: number; output_tokens: number };
} }
// ─── Model resolution ───────────────────────────────────────────────────────── // --- Model resolution ---------------------------------------------------------
/** Resolve a model name to a backend config (uses MODEL_ROUTES directly). */ /** Resolve a model name to a backend config (uses MODEL_ROUTES directly). */
function resolveAnthropicModel( function resolveAnthropicModel(
@@ -56,7 +56,7 @@ export function listAnthropicModels(): string[] {
return Object.keys(MODEL_ROUTES); return Object.keys(MODEL_ROUTES);
} }
// ─── Translation: Anthropic Backend (OpenAI-format) ─────────────────────── // --- Translation: Anthropic -> Backend (OpenAI-format) -------------------------
interface BackendBody { interface BackendBody {
model: string; model: string;
@@ -123,7 +123,7 @@ function anthropicToBackend(
return base; return base;
} }
// ─── Translation: Backend Anthropic ────────────────────────────────────── // --- Translation: Backend -> Anthropic ----------------------------------------
/** /**
* Convert a backend JSON response body into Anthropic Messages format. * Convert a backend JSON response body into Anthropic Messages format.
@@ -150,7 +150,64 @@ function backendToAnthropicResponse(
}; };
} }
// ─── Streaming: Backend SSE Anthropic SSE ─────────────────────────────── // --- Streaming: Backend SSE -> Anthropic SSE ---------------------------------
/**
* Accumulate text from an SSE response body (data: lines) into a single string.
* Handles Claude Code SSE format: {"type":"text-delta","delta":"..."}
*/
function accumulateSSEText(sseBody: string): string {
let accumulated = "";
for (const rawLine of sseBody.split("\n")) {
const trimmed = rawLine.trim();
if (!trimmed.startsWith("data: ")) continue;
const raw = trimmed.slice(6);
if (raw === "[DONE]") continue;
try {
const parsed = JSON.parse(raw);
if (parsed.type === "text-delta" && parsed.delta) {
accumulated += parsed.delta;
}
} catch {
// skip unparseable lines
}
}
return accumulated;
}
/**
* Extract text content from a parsed SSE data object regardless of format.
*
* Handles multiple SSE formats:
* - Claude Code format: { "type": "text-delta", "delta": "..." }
* - OpenAI format: { "choices": [{ "delta": { "content": "..." } }] }
* - Generic JSON: { "content": "..." } or { "text": "..." }
*/
function extractTextFromSSE(parsed: any): string | null {
if (parsed == null) return null;
// Claude Code / Anthropic SSE: type-based events
if (typeof parsed === "object") {
switch (parsed.type) {
case "text-delta":
return parsed.delta ?? null;
case "content_block_delta":
return parsed.delta?.text ?? parsed.delta?.delta ?? null;
}
}
// OpenAI format: choices[0].delta.content
const openai = parsed.choices?.[0]?.delta?.content ??
parsed.choices?.[0]?.text;
if (openai) return openai;
// Generic fallbacks
if (typeof parsed.content === "string") return parsed.content;
if (typeof parsed.text === "string") return parsed.text;
if (typeof parsed.delta === "string") return parsed.delta;
return null;
}
/** /**
* Transform a backend SSE line into Anthropic SSE content_block_delta events. * Transform a backend SSE line into Anthropic SSE content_block_delta events.
@@ -171,18 +228,18 @@ function backendLineToAnthropicSSE(
if (adapted === "data: [DONE]") { if (adapted === "data: [DONE]") {
return null; // let the stream transformer handle DONE return null; // let the stream transformer handle DONE
} }
// Parse the OpenAI-format chunk and convert to Anthropic // Parse the adapted line
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 = extractTextFromSSE(parsed);
if (!text) return null; if (text) return formatContentBlockDelta(text);
return formatContentBlockDelta(text); return null;
} catch { } catch {
return null; return null;
} }
} }
// OpenAI-compatible SSE (opencode.ai) // All data: lines -- try to parse as JSON in any format
if (line.startsWith("data: ")) { if (line.startsWith("data: ")) {
const raw = line.slice(6); const raw = line.slice(6);
if (raw === "[DONE]") { if (raw === "[DONE]") {
@@ -190,15 +247,21 @@ function backendLineToAnthropicSSE(
} }
try { try {
const parsed = JSON.parse(raw); const parsed = JSON.parse(raw);
const text = parsed.choices?.[0]?.delta?.content ?? ""; // Skip lifecycle/non-content events
if (!text) return null; if (parsed.type === "start" || parsed.type === "start-step" ||
return formatContentBlockDelta(text); parsed.type === "data-thinking-step" || parsed.type === "text-start" ||
} catch { parsed.type === "ping") {
return null;
}
const text = extractTextFromSSE(parsed);
if (text) return formatContentBlockDelta(text);
return null; return null;
} catch {
// Not JSON -- treat as plain text
} }
} }
// Plain text chunks // Plain text chunks (or non-data lines)
if (line.length > 0) { if (line.length > 0) {
return formatContentBlockDelta(line); return formatContentBlockDelta(line);
} }
@@ -215,7 +278,7 @@ function formatContentBlockDelta(text: string): string {
})}`; })}`;
} }
// ─── Stream transformer ──────────────────────────────────────────────────── // --- Stream transformer -------------------------------------------------------
function transformAnthropicStream( function transformAnthropicStream(
body: ReadableStream, body: ReadableStream,
@@ -229,12 +292,11 @@ function transformAnthropicStream(
// State machine for Anthropic SSE protocol // State machine for Anthropic SSE protocol
let phase: "init" | "block" | "done" = "init"; let phase: "init" | "block" | "done" = "init";
let messageId = `msg_${Date.now()}`; let messageId = `msg_${Date.now()}`;
let _hasContent = false;
return new ReadableStream({ return new ReadableStream({
async pull(controller) { async pull(controller) {
try { try {
// ── Phase: emit message_start + content_block_start ────── // --- Phase: emit message_start + content_block_start ------------
if (phase === "init") { if (phase === "init") {
phase = "block"; phase = "block";
messageId = `msg_${Date.now()}`; messageId = `msg_${Date.now()}`;
@@ -255,7 +317,7 @@ function transformAnthropicStream(
})}`; })}`;
controller.enqueue(encoder.encode(startEvent + "\n\n")); controller.enqueue(encoder.encode(startEvent + "\n\n"));
// content_block_start must precede any deltas // content_block_start -- must precede any deltas
const blockStart = `event: content_block_start\ndata: ${JSON.stringify({ const blockStart = `event: content_block_start\ndata: ${JSON.stringify({
type: "content_block_start", type: "content_block_start",
index: 0, index: 0,
@@ -264,7 +326,7 @@ function transformAnthropicStream(
controller.enqueue(encoder.encode(blockStart + "\n\n")); controller.enqueue(encoder.encode(blockStart + "\n\n"));
} }
// ── Phase: read stream and emit content_block_delta events // --- Phase: read stream and emit content_block_delta events -----
while (phase === "block") { while (phase === "block") {
const { done, value } = await reader.read(); const { done, value } = await reader.read();
if (done) { if (done) {
@@ -278,16 +340,15 @@ 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 // Yield control so we don't block -- let next pull() continue
return; return;
} }
// ── Phase: emit closing events (content_block_stop, message_delta, message_stop) // --- Phase: emit closing events (content_block_stop, message_delta, message_stop) -
if (phase === "done") { if (phase === "done") {
phase = "done"; // prevent re-entry phase = "done"; // prevent re-entry
@@ -298,7 +359,7 @@ function transformAnthropicStream(
), ),
); );
// message_delta required before message_stop // message_delta -- required before message_stop
controller.enqueue( controller.enqueue(
encoder.encode( encoder.encode(
`event: message_delta\ndata: ${JSON.stringify({ `event: message_delta\ndata: ${JSON.stringify({
@@ -330,7 +391,7 @@ function transformAnthropicStream(
}); });
} }
// ─── Main handler ───────────────────────────────────────────────────────── // --- Main handler -------------------------------------------------------------
/** /**
* Handle an Anthropic-compatible messages request. * Handle an Anthropic-compatible messages request.
@@ -396,7 +457,7 @@ export async function handleAnthropicMessages(
const url = config.url; const url = config.url;
// ── Execute (direct proxy fallback) ───────────────────────── // ---- Execute (direct -> proxy fallback) --------------------------------
let response: Response | undefined; let response: Response | undefined;
for (let attempt = 0; attempt < 3; attempt++) { for (let attempt = 0; attempt < 3; attempt++) {
@@ -415,18 +476,18 @@ export async function handleAnthropicMessages(
try { try {
response = await fetch(url, init); response = await fetch(url, init);
if (response.ok) { if (response.ok) {
// Success reset proxy failure if we used one // Success -- reset proxy failure if we used one
if (proxyPool && proxyPool.size > 0 && init.proxy && attempt > 0) { if (proxyPool && proxyPool.size > 0 && init.proxy && attempt > 0) {
proxyPool.markSuccess(); proxyPool.markSuccess();
} }
break; break;
} }
// Non-2xx mark proxy as failed so next attempt rotates // Non-2xx -- mark proxy as failed so next attempt rotates
if (proxyPool && proxyPool.size > 0 && init.proxy) { if (proxyPool && proxyPool.size > 0 && init.proxy) {
proxyPool.markFailed(); proxyPool.markFailed();
} }
} catch { } catch {
// Network error mark proxy as failed, retry // Network error -- mark proxy as failed, retry
if (proxyPool && proxyPool.size > 0 && init.proxy) { if (proxyPool && proxyPool.size > 0 && init.proxy) {
proxyPool.markFailed(); proxyPool.markFailed();
} }
@@ -460,7 +521,7 @@ export async function handleAnthropicMessages(
); );
} }
// ── Handle streaming ───────────────────────────────────────── // ---- Handle streaming -------------------------------------------------
if (wantsStream) { if (wantsStream) {
const transformed = transformAnthropicStream( const transformed = transformAnthropicStream(
response.body!, response.body!,
@@ -479,8 +540,36 @@ export async function handleAnthropicMessages(
}); });
} }
// ── Handle non-streaming ───────────────────────────────────── // ---- Handle non-streaming ---------------------------------------------
const text = await response.text(); const text = await response.text();
// Backend may return SSE (data: lines) even for non-streaming requests.
// Accumulate all text-delta events to reconstruct the response body.
if (text.trimStart().startsWith("data: ")) {
const accumulated = accumulateSSEText(text);
if (accumulated) {
return new Response(
JSON.stringify({
id: `msg_${Date.now()}`,
type: "message",
role: "assistant",
content: [{ type: "text", text: accumulated }],
model: req.model,
stop_reason: "end_turn",
stop_sequence: null,
usage: { input_tokens: 0, output_tokens: 0 },
}),
{
status: 200,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
},
},
);
}
}
let parsed: any; let parsed: any;
try { try {
parsed = JSON.parse(text); parsed = JSON.parse(text);