feat: anthropic cache support — preserve cache_control, forward anthropic-version header, report real usage tokens
- Add anthropicPassthrough flag to BackendConfig for native Anthropic backends - Preserve cache_control on content blocks and system prompt (keep structured) - Extract and forward anthropic-version header from client to backend - Report actual token usage from backend response (input_tokens, output_tokens) - Support native Anthropic passthrough (no translation) for compatible backends - Wire anthropic-version through all entry points: index.ts, router.ts, worker.ts
This commit is contained in:
+3
-2
@@ -552,8 +552,9 @@ const server: Server<WSRelayData> = Bun.serve<WSRelayData>({
|
|||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
const sessionId = crypto.randomUUID();
|
const sessionId = crypto.randomUUID();
|
||||||
const ipv6Source = ipv6Pool.getNext() ?? undefined;
|
const ipv6Source = ipv6Pool.getNext() ?? undefined;
|
||||||
console.log(`[index] POST /v1/messages session=${sessionId.slice(0, 8)} model=${(body as any).model} poolSize=${proxyPool.size} sessionPool.active=${sessionPool.activeSessions} ipv6=${ipv6Source ?? "none"}`);
|
const anthropicVersion = req.headers.get("anthropic-version") ?? undefined;
|
||||||
return handleAnthropicMessages(body, proxyPool, sessionPool, sessionId, ipv6Source);
|
console.log(`[index] POST /v1/messages session=${sessionId.slice(0, 8)} model=${(body as any).model} poolSize=${proxyPool.size} sessionPool.active=${sessionPool.activeSessions} ipv6=${ipv6Source ?? "none"} anthropic-version=${anthropicVersion ?? "none"}`);
|
||||||
|
return handleAnthropicMessages(body, proxyPool, sessionPool, sessionId, ipv6Source, anthropicVersion);
|
||||||
} catch {
|
} catch {
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
|
|||||||
@@ -46,6 +46,13 @@ export interface BackendConfig {
|
|||||||
adaptResponse?: (raw: unknown, req: OpenAIRequest) => unknown;
|
adaptResponse?: (raw: unknown, req: OpenAIRequest) => unknown;
|
||||||
/** Transform a backend SSE/stream line into OpenAI SSE line (or null to skip) */
|
/** Transform a backend SSE/stream line into OpenAI SSE line (or null to skip) */
|
||||||
adaptStreamLine?: (line: string, req: OpenAIRequest) => string | null;
|
adaptStreamLine?: (line: string, req: OpenAIRequest) => string | null;
|
||||||
|
/** When true, the backend natively supports Anthropic Messages API format.
|
||||||
|
* The proxy will pass through the Anthropic request directly without
|
||||||
|
* translating to OpenAI format. Requires anthropicPassthroughRequest to be set. */
|
||||||
|
anthropicPassthrough?: boolean;
|
||||||
|
/** Optional function to transform an Anthropic request for a native-Anthropic backend.
|
||||||
|
* Only used when anthropicPassthrough is true. Can add/modify headers, body fields, etc. */
|
||||||
|
anthropicPassthroughRequest?: (body: unknown, model: string) => { body: unknown; headers?: Record<string, string> };
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Shared aichat.org backend config (all models use the same backend) ------
|
// --- Shared aichat.org backend config (all models use the same backend) ------
|
||||||
|
|||||||
+166
-34
@@ -11,23 +11,39 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ProxyPool, SessionProxyPool } from "./proxy-pool";
|
import type { ProxyPool, SessionProxyPool } from "./proxy-pool";
|
||||||
import { MODEL_ROUTES, type BackendConfig } from "./ai-proxy";
|
import { MODEL_ROUTES, listModels as listOpenAIModels, type BackendConfig } from "./ai-proxy";
|
||||||
import { fetchWithRetry, fetchWithSessionRetry, SSELineBuffer, isDevMode, trackReader, releaseReader, wrapStreamWithCleanup, type FetchWithRetryResult } from "./fetch-utils";
|
import { fetchWithRetry, fetchWithSessionRetry, SSELineBuffer, isDevMode, trackReader, releaseReader, wrapStreamWithCleanup, type FetchWithRetryResult } from "./fetch-utils";
|
||||||
|
|
||||||
// --- Types -------------------------------------------------------------------
|
// --- Types -------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface AnthropicContentBlock {
|
||||||
|
type: "text";
|
||||||
|
text: string;
|
||||||
|
cache_control?: { type: "ephemeral" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnthropicMessage {
|
||||||
|
role: "user" | "assistant";
|
||||||
|
content: string | AnthropicContentBlock[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnthropicSystemBlock {
|
||||||
|
type: "text";
|
||||||
|
text: string;
|
||||||
|
cache_control?: { type: "ephemeral" };
|
||||||
|
}
|
||||||
|
|
||||||
export interface AnthropicRequest {
|
export interface AnthropicRequest {
|
||||||
model: string;
|
model: string;
|
||||||
max_tokens: number;
|
max_tokens: number;
|
||||||
messages: Array<{
|
messages: AnthropicMessage[];
|
||||||
role: "user" | "assistant";
|
|
||||||
content: string | Array<{ type: "text"; text: string }>;
|
|
||||||
}>;
|
|
||||||
stream?: boolean;
|
stream?: boolean;
|
||||||
temperature?: number;
|
temperature?: number;
|
||||||
top_p?: number;
|
top_p?: number;
|
||||||
|
top_k?: number;
|
||||||
stop_sequences?: string[];
|
stop_sequences?: string[];
|
||||||
system?: string;
|
system?: string | AnthropicSystemBlock[];
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AnthropicResponse {
|
interface AnthropicResponse {
|
||||||
@@ -61,10 +77,11 @@ export function listAnthropicModels(): string[] {
|
|||||||
|
|
||||||
interface BackendBody {
|
interface BackendBody {
|
||||||
model: string;
|
model: string;
|
||||||
messages: Array<{ role: string; content: string }>;
|
messages: Array<{ role: string; content: string | Array<Record<string, unknown>> }>;
|
||||||
max_tokens: number;
|
max_tokens: number;
|
||||||
temperature?: number;
|
temperature?: number;
|
||||||
top_p?: number;
|
top_p?: number;
|
||||||
|
top_k?: number;
|
||||||
stream?: boolean;
|
stream?: boolean;
|
||||||
stop?: string | string[];
|
stop?: string | string[];
|
||||||
}
|
}
|
||||||
@@ -73,24 +90,76 @@ interface BackendBody {
|
|||||||
* Convert an Anthropic Messages request into the backend's expected format.
|
* Convert an Anthropic Messages request into the backend's expected format.
|
||||||
* Uses the backend config's own adaptRequest if available, otherwise
|
* Uses the backend config's own adaptRequest if available, otherwise
|
||||||
* produces an OpenAI-compatible body.
|
* produces an OpenAI-compatible body.
|
||||||
|
*
|
||||||
|
* Preserves Anthropic content blocks with cache_control so that
|
||||||
|
* caching directives are not lost during translation.
|
||||||
*/
|
*/
|
||||||
function anthropicToBackend(
|
function anthropicToBackend(
|
||||||
anthReq: AnthropicRequest,
|
anthReq: AnthropicRequest,
|
||||||
config: BackendConfig,
|
config: BackendConfig,
|
||||||
backendModel: string,
|
backendModel: string,
|
||||||
): unknown {
|
anthropicVersion?: string,
|
||||||
// Flatten Anthropic content blocks to plain text
|
): { body: unknown; headers?: Record<string, string> } {
|
||||||
const messages: Array<{ role: string; content: string }> = anthReq.messages.map((m) => ({
|
// If the backend supports Anthropic natively, pass through directly
|
||||||
role: m.role,
|
if (config.anthropicPassthrough) {
|
||||||
content:
|
if (config.anthropicPassthroughRequest) {
|
||||||
typeof m.content === "string"
|
return config.anthropicPassthroughRequest(anthReq, backendModel);
|
||||||
? m.content
|
}
|
||||||
: m.content.map((c) => c.text).join(""),
|
const headers: Record<string, string> = {};
|
||||||
}));
|
if (anthropicVersion) {
|
||||||
|
headers["anthropic-version"] = anthropicVersion;
|
||||||
|
}
|
||||||
|
return { body: { ...anthReq, model: backendModel }, headers };
|
||||||
|
}
|
||||||
|
|
||||||
// Prepend system prompt as a system message if present
|
// Convert Anthropic content blocks for OpenAI-compatible backend.
|
||||||
|
// When a content block has cache_control, we keep it as a structured
|
||||||
|
// content part so the backend (or downstream cache layer) can use it.
|
||||||
|
const messages: Array<{ role: string; content: string | Array<Record<string, unknown>> }> = anthReq.messages.map((m) => {
|
||||||
|
if (typeof m.content === "string") {
|
||||||
|
return { role: m.role, content: m.content };
|
||||||
|
}
|
||||||
|
// Check if any block has cache_control — if so, preserve as structured array
|
||||||
|
const hasCacheControl = m.content.some((c) => c.cache_control);
|
||||||
|
if (hasCacheControl) {
|
||||||
|
return {
|
||||||
|
role: m.role,
|
||||||
|
content: m.content.map((c) => {
|
||||||
|
const part: Record<string, unknown> = { type: "text", text: c.text };
|
||||||
|
if (c.cache_control) {
|
||||||
|
part.cache_control = c.cache_control;
|
||||||
|
}
|
||||||
|
return part;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// No cache_control — flatten to plain text for simpler backend processing
|
||||||
|
return { role: m.role, content: m.content.map((c) => c.text).join("") };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Prepend system prompt as a system message if present.
|
||||||
|
// Preserve cache_control on system blocks when present.
|
||||||
if (anthReq.system) {
|
if (anthReq.system) {
|
||||||
messages.unshift({ role: "system", content: anthReq.system });
|
if (typeof anthReq.system === "string") {
|
||||||
|
messages.unshift({ role: "system", content: anthReq.system });
|
||||||
|
} else if (Array.isArray(anthReq.system)) {
|
||||||
|
const hasCacheControl = anthReq.system.some((s) => s.cache_control);
|
||||||
|
if (hasCacheControl) {
|
||||||
|
messages.unshift({
|
||||||
|
role: "system",
|
||||||
|
content: anthReq.system.map((s) => {
|
||||||
|
const part: Record<string, unknown> = { type: "text", text: s.text };
|
||||||
|
if (s.cache_control) part.cache_control = s.cache_control;
|
||||||
|
return part;
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
messages.unshift({
|
||||||
|
role: "system",
|
||||||
|
content: anthReq.system.map((s) => s.text).join(""),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const base: BackendBody = {
|
const base: BackendBody = {
|
||||||
@@ -99,6 +168,7 @@ function anthropicToBackend(
|
|||||||
max_tokens: anthReq.max_tokens,
|
max_tokens: anthReq.max_tokens,
|
||||||
temperature: anthReq.temperature,
|
temperature: anthReq.temperature,
|
||||||
top_p: anthReq.top_p,
|
top_p: anthReq.top_p,
|
||||||
|
top_k: anthReq.top_k,
|
||||||
stream: anthReq.stream,
|
stream: anthReq.stream,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -110,23 +180,31 @@ function anthropicToBackend(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (config.adaptRequest) {
|
if (config.adaptRequest) {
|
||||||
return config.adaptRequest({
|
return {
|
||||||
model: backendModel,
|
body: config.adaptRequest({
|
||||||
messages,
|
model: backendModel,
|
||||||
temperature: anthReq.temperature,
|
messages,
|
||||||
max_tokens: anthReq.max_tokens,
|
temperature: anthReq.temperature,
|
||||||
top_p: anthReq.top_p,
|
max_tokens: anthReq.max_tokens,
|
||||||
stream: anthReq.stream,
|
top_p: anthReq.top_p,
|
||||||
});
|
stream: anthReq.stream,
|
||||||
|
}),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return base;
|
const headers: Record<string, string> = {};
|
||||||
|
if (anthropicVersion) {
|
||||||
|
headers["anthropic-version"] = anthropicVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { body: base, headers };
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 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.
|
||||||
|
* Extracts token usage from the backend response when available.
|
||||||
*/
|
*/
|
||||||
function backendToAnthropicResponse(
|
function backendToAnthropicResponse(
|
||||||
raw: any,
|
raw: any,
|
||||||
@@ -135,8 +213,17 @@ function backendToAnthropicResponse(
|
|||||||
const text =
|
const text =
|
||||||
raw.choices?.[0]?.message?.content ?? raw.content ?? raw.text ?? "";
|
raw.choices?.[0]?.message?.content ?? raw.content ?? raw.text ?? "";
|
||||||
|
|
||||||
|
// Extract usage from various backend formats
|
||||||
|
const usage = raw.usage ?? {};
|
||||||
|
let inputTokens = 0;
|
||||||
|
let outputTokens = 0;
|
||||||
|
if (usage.input_tokens !== undefined) inputTokens = usage.input_tokens;
|
||||||
|
else if (usage.prompt_tokens !== undefined) inputTokens = usage.prompt_tokens;
|
||||||
|
if (usage.output_tokens !== undefined) outputTokens = usage.output_tokens;
|
||||||
|
else if (usage.completion_tokens !== undefined) outputTokens = usage.completion_tokens;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: `msg_${Date.now()}`,
|
id: raw.id ?? `msg_${Date.now()}`,
|
||||||
type: "message",
|
type: "message",
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
content: [{ type: "text", text }],
|
content: [{ type: "text", text }],
|
||||||
@@ -144,8 +231,8 @@ function backendToAnthropicResponse(
|
|||||||
stop_reason: raw.choices?.[0]?.finish_reason === "stop" ? "end_turn" : null,
|
stop_reason: raw.choices?.[0]?.finish_reason === "stop" ? "end_turn" : null,
|
||||||
stop_sequence: raw.stop_sequence ?? null,
|
stop_sequence: raw.stop_sequence ?? null,
|
||||||
usage: {
|
usage: {
|
||||||
input_tokens: 0,
|
input_tokens: inputTokens,
|
||||||
output_tokens: 0,
|
output_tokens: outputTokens,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -499,6 +586,10 @@ function anthropicError(status: number, message: string, type: string): Response
|
|||||||
* When both `sessionPool` and `sessionId` are present the request uses
|
* When both `sessionPool` and `sessionId` are present the request uses
|
||||||
* session-sticky proxy allocation via `fetchWithSessionRetry`; otherwise
|
* session-sticky proxy allocation via `fetchWithSessionRetry`; otherwise
|
||||||
* the existing `fetchWithRetry` path is used (backward-compatible).
|
* the existing `fetchWithRetry` path is used (backward-compatible).
|
||||||
|
*
|
||||||
|
* The optional `anthropicVersion` parameter lets callers forward the
|
||||||
|
* `anthropic-version` header from the client request, enabling prompt
|
||||||
|
* caching and other version-gated features.
|
||||||
*/
|
*/
|
||||||
export async function handleAnthropicMessages(
|
export async function handleAnthropicMessages(
|
||||||
body: unknown,
|
body: unknown,
|
||||||
@@ -523,6 +614,15 @@ export async function handleAnthropicMessages(
|
|||||||
sessionPool?: SessionProxyPool,
|
sessionPool?: SessionProxyPool,
|
||||||
sessionId?: string,
|
sessionId?: string,
|
||||||
ipv6Source?: string,
|
ipv6Source?: string,
|
||||||
|
anthropicVersion?: string,
|
||||||
|
): Promise<Response>;
|
||||||
|
export async function handleAnthropicMessages(
|
||||||
|
body: unknown,
|
||||||
|
proxyPool?: ProxyPool,
|
||||||
|
sessionPool?: SessionProxyPool,
|
||||||
|
sessionId?: string,
|
||||||
|
ipv6Source?: string,
|
||||||
|
anthropicVersion?: string,
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
// -- Input validation -------------------------------------------------------
|
// -- Input validation -------------------------------------------------------
|
||||||
const validationError = validateAnthropicRequest(body);
|
const validationError = validateAnthropicRequest(body);
|
||||||
@@ -543,11 +643,16 @@ export async function handleAnthropicMessages(
|
|||||||
|
|
||||||
const { config, backendModel } = resolved;
|
const { config, backendModel } = resolved;
|
||||||
const wantsStream = req.stream === true;
|
const wantsStream = req.stream === true;
|
||||||
const backendBody = anthropicToBackend(req, config, backendModel);
|
|
||||||
|
// Translate Anthropic -> backend, preserving cache_control directives
|
||||||
|
// and forwarding anthropic-version when available
|
||||||
|
const { body: backendBody, headers: extraHeaders } = anthropicToBackend(
|
||||||
|
req, config, backendModel, anthropicVersion,
|
||||||
|
);
|
||||||
|
|
||||||
const init: RequestInit & { proxy?: string } = {
|
const init: RequestInit & { proxy?: string } = {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: config.headers,
|
headers: { ...config.headers, ...extraHeaders },
|
||||||
body: JSON.stringify(backendBody),
|
body: JSON.stringify(backendBody),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -593,7 +698,34 @@ export async function handleAnthropicMessages(
|
|||||||
return anthropicError(status, genericMsg, "upstream_error");
|
return anthropicError(status, genericMsg, "upstream_error");
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- Handle streaming -------------------------------------------------------
|
// -- For native Anthropic passthrough, relay the raw backend response -------
|
||||||
|
if (config.anthropicPassthrough) {
|
||||||
|
if (wantsStream) {
|
||||||
|
return new Response(wrapAnthropicStreamMaybe(response.body!, sessionPool, sessionId), {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/event-stream",
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
Connection: "keep-alive",
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
"X-Accel-Buffering": "no",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const rawBody = await response.text();
|
||||||
|
if (sessionPool && sessionId) {
|
||||||
|
sessionPool.release(sessionId);
|
||||||
|
}
|
||||||
|
return new Response(rawBody, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Handle streaming (OpenAI-compatible backend) ---------------------------
|
||||||
if (wantsStream) {
|
if (wantsStream) {
|
||||||
let transformed = transformAnthropicStream(
|
let transformed = transformAnthropicStream(
|
||||||
response.body!,
|
response.body!,
|
||||||
@@ -613,7 +745,7 @@ export async function handleAnthropicMessages(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- Handle non-streaming ---------------------------------------------------
|
// -- Handle non-streaming (OpenAI-compatible backend) -----------------------
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
if (sessionPool && sessionId) {
|
if (sessionPool && sessionId) {
|
||||||
sessionPool.release(sessionId);
|
sessionPool.release(sessionId);
|
||||||
|
|||||||
+2
-1
@@ -478,7 +478,8 @@ export async function handleRequest(
|
|||||||
try {
|
try {
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
const sessionId = crypto.randomUUID();
|
const sessionId = crypto.randomUUID();
|
||||||
return handleAnthropicMessages(body, proxyPool!, sessionPool!, sessionId);
|
const anthropicVersion = req.headers.get("anthropic-version") ?? undefined;
|
||||||
|
return handleAnthropicMessages(body, proxyPool!, sessionPool!, sessionId, undefined, anthropicVersion);
|
||||||
} catch {
|
} catch {
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
|
|||||||
+2
-1
@@ -453,7 +453,8 @@ export default {
|
|||||||
if (authErr) return authErr;
|
if (authErr) return authErr;
|
||||||
try {
|
try {
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
return handleAnthropicMessages(body);
|
const anthropicVersion = req.headers.get("anthropic-version") ?? undefined;
|
||||||
|
return handleAnthropicMessages(body, undefined, undefined, undefined, undefined, anthropicVersion);
|
||||||
} catch {
|
} catch {
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ error: { message: "Invalid JSON body", type: "invalid_request_error" } }),
|
JSON.stringify({ error: { message: "Invalid JSON body", type: "invalid_request_error" } }),
|
||||||
|
|||||||
Reference in New Issue
Block a user