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:
+123
-34
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user