2026-06-10 23:08:32 +07:00
|
|
|
/**
|
|
|
|
|
* Anthropic-compatible AI proxy.
|
|
|
|
|
*
|
|
|
|
|
* Accepts requests in Anthropic Messages API format (POST /v1/messages)
|
|
|
|
|
* and routes them to the same backend AI providers as the OpenAI proxy.
|
|
|
|
|
*
|
|
|
|
|
* Translations:
|
2026-06-11 01:43:23 +07:00
|
|
|
* - Anthropic request -> backend format (OpenAI-compatible)
|
|
|
|
|
* - Backend response -> Anthropic Messages format
|
|
|
|
|
* - Backend SSE stream -> Anthropic SSE events
|
2026-06-10 23:08:32 +07:00
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import type { ProxyPool } from "./proxy-pool";
|
|
|
|
|
import { MODEL_ROUTES, type BackendConfig } from "./ai-proxy";
|
2026-06-11 03:56:26 +07:00
|
|
|
import { fetchWithRetry } from "./fetch-utils";
|
|
|
|
|
import { SSELineBuffer } from "./fetch-utils";
|
|
|
|
|
import { isDevMode } from "./fetch-utils";
|
2026-06-10 23:08:32 +07:00
|
|
|
|
2026-06-11 01:43:23 +07:00
|
|
|
// --- Types -------------------------------------------------------------------
|
2026-06-10 23:08:32 +07:00
|
|
|
|
|
|
|
|
export interface AnthropicRequest {
|
|
|
|
|
model: string;
|
|
|
|
|
max_tokens: number;
|
|
|
|
|
messages: Array<{
|
|
|
|
|
role: "user" | "assistant";
|
|
|
|
|
content: string | Array<{ type: "text"; text: string }>;
|
|
|
|
|
}>;
|
|
|
|
|
stream?: boolean;
|
|
|
|
|
temperature?: number;
|
|
|
|
|
top_p?: number;
|
|
|
|
|
stop_sequences?: string[];
|
|
|
|
|
system?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface AnthropicResponse {
|
|
|
|
|
id: string;
|
|
|
|
|
type: "message";
|
|
|
|
|
role: "assistant";
|
|
|
|
|
content: Array<{ type: "text"; text: string }>;
|
|
|
|
|
model: string;
|
|
|
|
|
stop_reason: "end_turn" | "max_tokens" | "stop_sequence" | null;
|
|
|
|
|
stop_sequence: string | null;
|
|
|
|
|
usage: { input_tokens: number; output_tokens: number };
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-11 03:56:26 +07:00
|
|
|
// --- Model resolution ----------------------------------------------------------
|
2026-06-10 23:08:32 +07:00
|
|
|
|
2026-06-11 00:42:21 +07:00
|
|
|
/** Resolve a model name to a backend config (uses MODEL_ROUTES directly). */
|
2026-06-10 23:08:32 +07:00
|
|
|
function resolveAnthropicModel(
|
|
|
|
|
model: string,
|
|
|
|
|
): { backendModel: string; config: BackendConfig } | undefined {
|
|
|
|
|
const direct = MODEL_ROUTES[model];
|
|
|
|
|
if (direct) return { backendModel: model, config: direct };
|
|
|
|
|
return undefined;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-11 00:42:21 +07:00
|
|
|
/** List all available model names (same as OpenAI endpoint). */
|
2026-06-10 23:08:32 +07:00
|
|
|
export function listAnthropicModels(): string[] {
|
2026-06-11 00:42:21 +07:00
|
|
|
return Object.keys(MODEL_ROUTES);
|
2026-06-10 23:08:32 +07:00
|
|
|
}
|
|
|
|
|
|
2026-06-11 01:43:23 +07:00
|
|
|
// --- Translation: Anthropic -> Backend (OpenAI-format) -------------------------
|
2026-06-10 23:08:32 +07:00
|
|
|
|
|
|
|
|
interface BackendBody {
|
|
|
|
|
model: string;
|
|
|
|
|
messages: Array<{ role: string; content: string }>;
|
|
|
|
|
max_tokens: number;
|
|
|
|
|
temperature?: number;
|
|
|
|
|
top_p?: number;
|
|
|
|
|
stream?: boolean;
|
|
|
|
|
stop?: string | string[];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Convert an Anthropic Messages request into the backend's expected format.
|
|
|
|
|
* Uses the backend config's own adaptRequest if available, otherwise
|
|
|
|
|
* produces an OpenAI-compatible body.
|
|
|
|
|
*/
|
|
|
|
|
function anthropicToBackend(
|
|
|
|
|
anthReq: AnthropicRequest,
|
|
|
|
|
config: BackendConfig,
|
|
|
|
|
backendModel: string,
|
|
|
|
|
): unknown {
|
|
|
|
|
// Flatten Anthropic content blocks to plain text
|
|
|
|
|
const messages: Array<{ role: string; content: string }> = anthReq.messages.map((m) => ({
|
|
|
|
|
role: m.role,
|
|
|
|
|
content:
|
|
|
|
|
typeof m.content === "string"
|
|
|
|
|
? m.content
|
|
|
|
|
: m.content.map((c) => c.text).join(""),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
// Prepend system prompt as a system message if present
|
|
|
|
|
if (anthReq.system) {
|
|
|
|
|
messages.unshift({ role: "system", content: anthReq.system });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const base: BackendBody = {
|
2026-06-11 00:42:21 +07:00
|
|
|
model: backendModel,
|
2026-06-10 23:08:32 +07:00
|
|
|
messages,
|
|
|
|
|
max_tokens: anthReq.max_tokens,
|
|
|
|
|
temperature: anthReq.temperature,
|
|
|
|
|
top_p: anthReq.top_p,
|
|
|
|
|
stream: anthReq.stream,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (anthReq.stop_sequences?.length) {
|
|
|
|
|
base.stop =
|
|
|
|
|
anthReq.stop_sequences.length === 1
|
|
|
|
|
? anthReq.stop_sequences[0]
|
|
|
|
|
: anthReq.stop_sequences;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (config.adaptRequest) {
|
|
|
|
|
return config.adaptRequest({
|
2026-06-11 00:42:21 +07:00
|
|
|
model: backendModel,
|
2026-06-10 23:08:32 +07:00
|
|
|
messages,
|
|
|
|
|
temperature: anthReq.temperature,
|
|
|
|
|
max_tokens: anthReq.max_tokens,
|
|
|
|
|
top_p: anthReq.top_p,
|
|
|
|
|
stream: anthReq.stream,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return base;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-11 03:56:26 +07:00
|
|
|
// --- Translation: Backend -> Anthropic -----------------------------------------
|
2026-06-10 23:08:32 +07:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Convert a backend JSON response body into Anthropic Messages format.
|
|
|
|
|
*/
|
|
|
|
|
function backendToAnthropicResponse(
|
|
|
|
|
raw: any,
|
|
|
|
|
model: string,
|
|
|
|
|
): AnthropicResponse {
|
|
|
|
|
const text =
|
|
|
|
|
raw.choices?.[0]?.message?.content ?? raw.content ?? raw.text ?? "";
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
id: `msg_${Date.now()}`,
|
|
|
|
|
type: "message",
|
|
|
|
|
role: "assistant",
|
|
|
|
|
content: [{ type: "text", text }],
|
|
|
|
|
model,
|
|
|
|
|
stop_reason: raw.choices?.[0]?.finish_reason === "stop" ? "end_turn" : null,
|
|
|
|
|
stop_sequence: raw.stop_sequence ?? null,
|
|
|
|
|
usage: {
|
|
|
|
|
input_tokens: 0,
|
|
|
|
|
output_tokens: 0,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-11 03:56:26 +07:00
|
|
|
// --- Streaming: Backend SSE -> Anthropic SSE -----------------------------------
|
2026-06-11 01:43:23 +07:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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;
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const openai = parsed.choices?.[0]?.delta?.content ??
|
|
|
|
|
parsed.choices?.[0]?.text;
|
|
|
|
|
if (openai) return openai;
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
2026-06-10 23:08:32 +07:00
|
|
|
|
|
|
|
|
/**
|
2026-06-11 01:24:54 +07:00
|
|
|
* Transform a backend SSE line into Anthropic SSE content_block_delta events.
|
2026-06-10 23:08:32 +07:00
|
|
|
*/
|
|
|
|
|
function backendLineToAnthropicSSE(
|
|
|
|
|
line: string,
|
|
|
|
|
_model: string,
|
|
|
|
|
config: BackendConfig,
|
|
|
|
|
): string | null {
|
|
|
|
|
if (!line || line.trim().length === 0) return null;
|
|
|
|
|
|
|
|
|
|
if (config.adaptStreamLine) {
|
|
|
|
|
const adapted = config.adaptStreamLine(line, {} as any);
|
|
|
|
|
if (!adapted) return null;
|
|
|
|
|
if (adapted === "data: [DONE]") {
|
2026-06-11 03:56:26 +07:00
|
|
|
return null;
|
2026-06-10 23:08:32 +07:00
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
const parsed = JSON.parse(adapted.replace(/^data: /, ""));
|
2026-06-11 01:43:23 +07:00
|
|
|
const text = extractTextFromSSE(parsed);
|
|
|
|
|
if (text) return formatContentBlockDelta(text);
|
|
|
|
|
return null;
|
2026-06-10 23:08:32 +07:00
|
|
|
} catch {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (line.startsWith("data: ")) {
|
|
|
|
|
const raw = line.slice(6);
|
|
|
|
|
if (raw === "[DONE]") {
|
2026-06-11 03:56:26 +07:00
|
|
|
return null;
|
2026-06-10 23:08:32 +07:00
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
const parsed = JSON.parse(raw);
|
2026-06-11 01:43:23 +07:00
|
|
|
if (parsed.type === "start" || parsed.type === "start-step" ||
|
|
|
|
|
parsed.type === "data-thinking-step" || parsed.type === "text-start" ||
|
|
|
|
|
parsed.type === "ping") {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
const text = extractTextFromSSE(parsed);
|
|
|
|
|
if (text) return formatContentBlockDelta(text);
|
2026-06-10 23:08:32 +07:00
|
|
|
return null;
|
2026-06-11 01:43:23 +07:00
|
|
|
} catch {
|
|
|
|
|
// Not JSON -- treat as plain text
|
2026-06-10 23:08:32 +07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (line.length > 0) {
|
2026-06-11 01:24:54 +07:00
|
|
|
return formatContentBlockDelta(line);
|
2026-06-10 23:08:32 +07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-11 01:24:54 +07:00
|
|
|
/** 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 },
|
|
|
|
|
})}`;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-11 03:56:26 +07:00
|
|
|
// --- Stream transformer --------------------------------------------------------
|
2026-06-10 23:08:32 +07:00
|
|
|
|
|
|
|
|
function transformAnthropicStream(
|
|
|
|
|
body: ReadableStream,
|
|
|
|
|
model: string,
|
|
|
|
|
config: BackendConfig,
|
|
|
|
|
): ReadableStream {
|
|
|
|
|
const reader = body.getReader();
|
|
|
|
|
const decoder = new TextDecoder();
|
|
|
|
|
const encoder = new TextEncoder();
|
2026-06-11 03:56:26 +07:00
|
|
|
const lineBuffer = new SSELineBuffer();
|
2026-06-11 01:24:54 +07:00
|
|
|
|
|
|
|
|
let phase: "init" | "block" | "done" = "init";
|
|
|
|
|
let messageId = `msg_${Date.now()}`;
|
2026-06-10 23:08:32 +07:00
|
|
|
|
|
|
|
|
return new ReadableStream({
|
|
|
|
|
async pull(controller) {
|
|
|
|
|
try {
|
2026-06-11 01:24:54 +07:00
|
|
|
if (phase === "init") {
|
|
|
|
|
phase = "block";
|
|
|
|
|
messageId = `msg_${Date.now()}`;
|
|
|
|
|
|
2026-06-10 23:08:32 +07:00
|
|
|
const startEvent = `event: message_start\ndata: ${JSON.stringify({
|
|
|
|
|
type: "message_start",
|
|
|
|
|
message: {
|
2026-06-11 01:24:54 +07:00
|
|
|
id: messageId,
|
2026-06-10 23:08:32 +07:00
|
|
|
type: "message",
|
|
|
|
|
role: "assistant",
|
|
|
|
|
content: [],
|
|
|
|
|
model,
|
|
|
|
|
stop_reason: null,
|
|
|
|
|
stop_sequence: null,
|
|
|
|
|
usage: { input_tokens: 0, output_tokens: 0 },
|
|
|
|
|
},
|
|
|
|
|
})}`;
|
|
|
|
|
controller.enqueue(encoder.encode(startEvent + "\n\n"));
|
2026-06-11 01:24:54 +07:00
|
|
|
|
|
|
|
|
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"));
|
2026-06-10 23:08:32 +07:00
|
|
|
}
|
|
|
|
|
|
2026-06-11 01:24:54 +07:00
|
|
|
while (phase === "block") {
|
2026-06-10 23:08:32 +07:00
|
|
|
const { done, value } = await reader.read();
|
|
|
|
|
if (done) {
|
2026-06-11 03:56:26 +07:00
|
|
|
const remaining = lineBuffer.flush();
|
|
|
|
|
if (remaining.length > 0) {
|
|
|
|
|
const adapted = backendLineToAnthropicSSE(remaining, model, config);
|
|
|
|
|
if (adapted) {
|
|
|
|
|
controller.enqueue(encoder.encode(adapted + "\n\n"));
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-06-11 01:24:54 +07:00
|
|
|
phase = "done";
|
|
|
|
|
break;
|
2026-06-10 23:08:32 +07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const chunk = decoder.decode(value, { stream: true });
|
2026-06-11 03:56:26 +07:00
|
|
|
const lines = lineBuffer.add(chunk);
|
2026-06-10 23:08:32 +07:00
|
|
|
|
|
|
|
|
for (const line of lines) {
|
|
|
|
|
const adapted = backendLineToAnthropicSSE(line, model, config);
|
|
|
|
|
if (adapted) {
|
|
|
|
|
controller.enqueue(encoder.encode(adapted + "\n\n"));
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-06-11 01:24:54 +07:00
|
|
|
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (phase === "done") {
|
2026-06-11 03:56:26 +07:00
|
|
|
phase = "done";
|
2026-06-11 01:24:54 +07:00
|
|
|
|
|
|
|
|
controller.enqueue(
|
|
|
|
|
encoder.encode(
|
|
|
|
|
'event: content_block_stop\ndata: {"type":"content_block_stop","index":0}\n\n',
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
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`,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
controller.enqueue(
|
|
|
|
|
encoder.encode(
|
|
|
|
|
'event: message_stop\ndata: {"type":"message_stop"}\n\n',
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
controller.close();
|
2026-06-10 23:08:32 +07:00
|
|
|
}
|
|
|
|
|
} catch (err) {
|
2026-06-11 03:56:26 +07:00
|
|
|
if (isDevMode()) {
|
|
|
|
|
controller.enqueue(
|
|
|
|
|
encoder.encode(
|
|
|
|
|
`event: error\ndata: ${JSON.stringify({ error: String(err) })}\n\n`,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
controller.enqueue(
|
|
|
|
|
encoder.encode(
|
|
|
|
|
'event: error\ndata: {"error":"Stream error"}\n\n',
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-06-10 23:08:32 +07:00
|
|
|
controller.close();
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-11 03:56:26 +07:00
|
|
|
// --- Input validation ----------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
interface ValidationError {
|
|
|
|
|
message: string;
|
|
|
|
|
type: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function validateAnthropicRequest(body: unknown): ValidationError | null {
|
|
|
|
|
const req = body as Record<string, unknown>;
|
|
|
|
|
|
|
|
|
|
if (!req.model || typeof req.model !== "string") {
|
|
|
|
|
return { message: "model is required", type: "invalid_request_error" };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!req.max_tokens || typeof req.max_tokens !== "number") {
|
|
|
|
|
return { message: "max_tokens is required", type: "invalid_request_error" };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!Array.isArray(req.messages) || req.messages.length === 0) {
|
|
|
|
|
return { message: "messages must be a non-empty array", type: "invalid_request_error" };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < req.messages.length; i++) {
|
|
|
|
|
const msg = req.messages[i] as Record<string, unknown> | undefined;
|
|
|
|
|
if (!msg || typeof msg !== "object") {
|
|
|
|
|
return { message: `messages[${i}] must be an object`, type: "invalid_request_error" };
|
|
|
|
|
}
|
|
|
|
|
if (!msg.role || typeof msg.role !== "string") {
|
|
|
|
|
return { message: `messages[${i}].role is required`, type: "invalid_request_error" };
|
|
|
|
|
}
|
|
|
|
|
if (msg.content == null) {
|
|
|
|
|
return { message: `messages[${i}].content is required`, type: "invalid_request_error" };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- Standardized error helper -------------------------------------------------
|
|
|
|
|
|
|
|
|
|
function anthropicError(status: number, message: string, type: string): Response {
|
|
|
|
|
return new Response(
|
|
|
|
|
JSON.stringify({
|
|
|
|
|
type: "error",
|
|
|
|
|
error: { message, type },
|
|
|
|
|
}),
|
|
|
|
|
{
|
|
|
|
|
status,
|
|
|
|
|
headers: {
|
|
|
|
|
"Content-Type": "application/json",
|
|
|
|
|
"Access-Control-Allow-Origin": "*",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- Main handler --------------------------------------------------------------
|
2026-06-10 23:08:32 +07:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Handle an Anthropic-compatible messages request.
|
|
|
|
|
*/
|
|
|
|
|
export async function handleAnthropicMessages(
|
|
|
|
|
body: unknown,
|
|
|
|
|
proxyPool?: ProxyPool,
|
|
|
|
|
): Promise<Response> {
|
2026-06-11 03:56:26 +07:00
|
|
|
// -- Input validation -------------------------------------------------------
|
|
|
|
|
const validationError = validateAnthropicRequest(body);
|
|
|
|
|
if (validationError) {
|
|
|
|
|
return anthropicError(400, validationError.message, validationError.type);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-10 23:08:32 +07:00
|
|
|
const req = body as AnthropicRequest;
|
|
|
|
|
|
|
|
|
|
const resolved = resolveAnthropicModel(req.model);
|
|
|
|
|
if (!resolved) {
|
2026-06-11 03:56:26 +07:00
|
|
|
return anthropicError(
|
|
|
|
|
400,
|
|
|
|
|
`Unknown model: ${req.model}. Available: ${listAnthropicModels().join(", ")}`,
|
|
|
|
|
"invalid_request_error",
|
2026-06-10 23:08:32 +07:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const { config, backendModel } = resolved;
|
|
|
|
|
const wantsStream = req.stream === true;
|
|
|
|
|
const backendBody = anthropicToBackend(req, config, backendModel);
|
|
|
|
|
|
|
|
|
|
const init: RequestInit & { proxy?: string } = {
|
|
|
|
|
method: "POST",
|
|
|
|
|
headers: config.headers,
|
|
|
|
|
body: JSON.stringify(backendBody),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const url = config.url;
|
|
|
|
|
|
2026-06-11 03:56:26 +07:00
|
|
|
// -- Execute (direct -> proxy fallback) with shared retry -------------------
|
|
|
|
|
const result = await fetchWithRetry(
|
|
|
|
|
url,
|
|
|
|
|
init,
|
|
|
|
|
proxyPool,
|
|
|
|
|
`anthropic:${req.model}`,
|
|
|
|
|
);
|
2026-06-10 23:08:32 +07:00
|
|
|
|
2026-06-11 03:56:26 +07:00
|
|
|
if (result.errorClassification) {
|
2026-06-10 23:08:32 +07:00
|
|
|
return new Response(
|
|
|
|
|
JSON.stringify({
|
|
|
|
|
type: "error",
|
|
|
|
|
error: {
|
2026-06-11 03:56:26 +07:00
|
|
|
message: result.errorClassification.message,
|
|
|
|
|
type: "server_error",
|
2026-06-10 23:08:32 +07:00
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
{
|
2026-06-11 03:56:26 +07:00
|
|
|
status: result.errorClassification.status,
|
|
|
|
|
headers: {
|
|
|
|
|
"Content-Type": "application/json",
|
|
|
|
|
"Access-Control-Allow-Origin": "*",
|
|
|
|
|
},
|
2026-06-10 23:08:32 +07:00
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-11 03:56:26 +07:00
|
|
|
const response = result.response!;
|
|
|
|
|
|
|
|
|
|
// -- Handle error responses from backend ------------------------------------
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
const status = response.status;
|
|
|
|
|
const genericMsg = status >= 500 ? "Upstream server error" : "Upstream rejected request";
|
|
|
|
|
return anthropicError(status, genericMsg, "upstream_error");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// -- Handle streaming -------------------------------------------------------
|
2026-06-10 23:08:32 +07:00
|
|
|
if (wantsStream) {
|
|
|
|
|
const transformed = transformAnthropicStream(
|
|
|
|
|
response.body!,
|
|
|
|
|
req.model,
|
|
|
|
|
config,
|
|
|
|
|
);
|
|
|
|
|
return new Response(transformed, {
|
|
|
|
|
status: 200,
|
|
|
|
|
headers: {
|
|
|
|
|
"Content-Type": "text/event-stream",
|
|
|
|
|
"Cache-Control": "no-cache",
|
|
|
|
|
Connection: "keep-alive",
|
|
|
|
|
"Access-Control-Allow-Origin": "*",
|
|
|
|
|
"X-Accel-Buffering": "no",
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-11 03:56:26 +07:00
|
|
|
// -- Handle non-streaming ---------------------------------------------------
|
2026-06-10 23:08:32 +07:00
|
|
|
const text = await response.text();
|
2026-06-11 01:43:23 +07:00
|
|
|
|
|
|
|
|
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": "*",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-10 23:08:32 +07:00
|
|
|
let parsed: any;
|
|
|
|
|
try {
|
|
|
|
|
parsed = JSON.parse(text);
|
|
|
|
|
} catch {
|
|
|
|
|
parsed = { content: text };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const adapted = backendToAnthropicResponse(parsed, req.model);
|
|
|
|
|
|
|
|
|
|
return new Response(JSON.stringify(adapted), {
|
|
|
|
|
status: 200,
|
|
|
|
|
headers: {
|
|
|
|
|
"Content-Type": "application/json",
|
|
|
|
|
"Access-Control-Allow-Origin": "*",
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}
|