diff --git a/backend/src/adapters/request/anthropic.ts b/backend/src/adapters/request/anthropic.ts index ffe9e57..10e1e99 100644 --- a/backend/src/adapters/request/anthropic.ts +++ b/backend/src/adapters/request/anthropic.ts @@ -38,6 +38,7 @@ interface AnthropicContentBlock { content?: string | AnthropicContentBlock[]; is_error?: boolean; cache_control?: { type: "ephemeral" }; + signature?: string; } interface AnthropicMessage { @@ -109,6 +110,7 @@ function convertContentBlock( return { type: "thinking", thinking: block.thinking || "", + signature: block.signature, } as ThinkingContentBlock; case "tool_use": @@ -249,6 +251,7 @@ function convertTools( name: tool.name, description: tool.description, inputSchema: tool.input_schema, + cacheControl: tool.cache_control, })); } diff --git a/backend/src/adapters/response/anthropic.ts b/backend/src/adapters/response/anthropic.ts index cf323af..88a3fb7 100644 --- a/backend/src/adapters/response/anthropic.ts +++ b/backend/src/adapters/response/anthropic.ts @@ -191,6 +191,11 @@ export const anthropicResponseAdapter: ResponseAdapter = { type: "thinking_delta", thinking: chunk.delta.thinking || "", }; + } else if (chunk.delta?.type === "signature_delta") { + delta = { + type: "signature_delta", + signature: chunk.delta.signature || "", + }; } else if (chunk.delta?.type === "input_json_delta") { delta = { type: "input_json_delta", diff --git a/backend/src/adapters/types.ts b/backend/src/adapters/types.ts index 0d13fa4..b79bbe1 100644 --- a/backend/src/adapters/types.ts +++ b/backend/src/adapters/types.ts @@ -23,6 +23,8 @@ export interface TextContentBlock { export interface ThinkingContentBlock { type: "thinking"; thinking: string; + /** Signature for thinking blocks (required when replaying in multi-turn) */ + signature?: string; } /** @@ -121,6 +123,8 @@ export interface InternalToolDefinition { name: string; description?: string; inputSchema: JsonSchema; + /** Anthropic cache control for prompt caching */ + cacheControl?: { type: "ephemeral" }; } // ============================================================================= @@ -230,9 +234,10 @@ export interface InternalStreamChunk { contentBlock?: InternalContentBlock; /** Delta content (for content_block_delta) */ delta?: { - type: "text_delta" | "thinking_delta" | "input_json_delta"; + type: "text_delta" | "thinking_delta" | "signature_delta" | "input_json_delta"; text?: string; thinking?: string; + signature?: string; partialJson?: string; }; /** Message delta (for message_delta) */ diff --git a/backend/src/adapters/upstream/anthropic.ts b/backend/src/adapters/upstream/anthropic.ts index ac7469c..c236592 100644 --- a/backend/src/adapters/upstream/anthropic.ts +++ b/backend/src/adapters/upstream/anthropic.ts @@ -3,6 +3,7 @@ * Handles communication with Anthropic Claude API */ +import { parseJsonResponse } from "@/utils/json"; import type { InternalContentBlock, InternalMessage, @@ -17,7 +18,6 @@ import type { ToolUseContentBlock, UpstreamAdapter, } from "../types"; -import { parseJsonResponse } from "@/utils/json"; // ============================================================================= // Anthropic Request/Response Types @@ -40,6 +40,7 @@ interface AnthropicContentBlock { content?: string | AnthropicContentBlock[]; is_error?: boolean; cache_control?: { type: "ephemeral" }; + signature?: string; } interface AnthropicMessage { @@ -96,6 +97,7 @@ interface AnthropicStreamEvent { text?: string; thinking?: string; partial_json?: string; + signature?: string; stop_reason?: string; stop_sequence?: string | null; }; @@ -162,7 +164,11 @@ function convertMessage(msg: InternalMessage): AnthropicMessage | null { if (block.type === "text") { content.push({ type: "text", text: block.text }); } else if (block.type === "thinking") { - content.push({ type: "thinking", thinking: block.thinking }); + content.push({ + type: "thinking", + thinking: block.thinking, + signature: block.signature, + }); } } } @@ -245,6 +251,7 @@ function convertTools( name: tool.name, description: tool.description, input_schema: tool.inputSchema, + cache_control: tool.cacheControl, })); } @@ -304,6 +311,7 @@ function convertContentBlock( return { type: "thinking", thinking: block.thinking || "", + signature: block.signature, } as ThinkingContentBlock; case "tool_use": return { @@ -458,11 +466,15 @@ export const anthropicUpstreamAdapter: UpstreamAdapter = { ...request.extraParams, }; - // Build URL - const baseUrl = provider.baseUrl.endsWith("/") - ? provider.baseUrl.slice(0, -1) - : provider.baseUrl; - const url = `${baseUrl}/messages`; + // Build URL — strip trailing slash and /v1 suffix to normalize, + // then always append /v1/messages. This handles both + // "https://api.anthropic.com" and "https://api.anthropic.com/v1". + // Note: Any path ending with /v1 will have it stripped (e.g., /custom/v1 → /custom). + let baseUrl = provider.baseUrl.replace(/\/+$/, ""); + if (baseUrl.endsWith("/v1")) { + baseUrl = baseUrl.slice(0, -3); + } + const url = `${baseUrl}/v1/messages`; // Build headers (Anthropic uses x-api-key instead of Authorization Bearer) const headers: Record = { @@ -566,6 +578,15 @@ export const anthropicUpstreamAdapter: UpstreamAdapter = { index: event.index, delta: { type: "thinking_delta", thinking: delta.thinking }, }; + } else if (delta?.type === "signature_delta") { + yield { + type: "content_block_delta", + index: event.index, + delta: { + type: "signature_delta", + signature: delta.signature, + }, + }; } else if (delta?.type === "input_json_delta") { yield { type: "content_block_delta", diff --git a/backend/src/api/v1/messages.ts b/backend/src/api/v1/messages.ts index bd36dc5..a9bdae7 100644 --- a/backend/src/api/v1/messages.ts +++ b/backend/src/api/v1/messages.ts @@ -3,14 +3,15 @@ * Provides Anthropic-compatible API format for clients */ +import type { TProperties } from "@sinclair/typebox"; import { Elysia, t } from "elysia"; import type { ModelWithProvider } from "@/adapters/types"; +import type { CachedResponseType } from "@/db/schema"; import { getRequestAdapter, getResponseAdapter, getUpstreamAdapter, } from "@/adapters"; -import { createLogger } from "@/utils/logger"; import { getModelsWithProviderBySystemName } from "@/db"; import { apiKeyPlugin, type ApiKey } from "@/plugins/apiKeyPlugin"; import { @@ -18,6 +19,11 @@ import { consumeTokens, } from "@/plugins/apiKeyRateLimitPlugin"; import { rateLimitPlugin } from "@/plugins/rateLimitPlugin"; +import { + executeWithFailover, + selectMultipleCandidates, + type FailoverConfig, +} from "@/services/failover"; import { extractUpstreamHeaders, filterCandidates, @@ -27,12 +33,8 @@ import { PROVIDER_HEADER, } from "@/utils/api-helpers"; import { addCompletions, type Completion } from "@/utils/completions"; -import { StreamingContext } from "@/utils/streaming-context"; -import { - executeWithFailover, - selectMultipleCandidates, - type FailoverConfig, -} from "@/services/failover"; +import { safeParseToolArgs } from "@/utils/json"; +import { createLogger } from "@/utils/logger"; import { checkReqId, finalizeReqId, @@ -42,8 +44,7 @@ import { type ApiFormat, type ReqIdContext, } from "@/utils/reqIdHandler"; -import type { CachedResponseType } from "@/db/schema"; -import { safeParseToolArgs } from "@/utils/json"; +import { StreamingContext } from "@/utils/streaming-context"; const logger = createLogger("messagesApi"); @@ -51,15 +52,19 @@ const logger = createLogger("messagesApi"); // Request Schema // ============================================================================= +// Helper: t.Object with additionalProperties: true for proxy transparency +const tLooseObject = (properties: T) => + t.Object(properties, { additionalProperties: true }); + // Anthropic content block types -const tAnthropicTextBlock = t.Object({ +const tAnthropicTextBlock = tLooseObject({ type: t.Literal("text"), text: t.String(), }); -const tAnthropicImageBlock = t.Object({ +const tAnthropicImageBlock = tLooseObject({ type: t.Literal("image"), - source: t.Object({ + source: tLooseObject({ type: t.String(), media_type: t.Optional(t.String()), data: t.Optional(t.String()), @@ -67,24 +72,24 @@ const tAnthropicImageBlock = t.Object({ }), }); -const tAnthropicToolUseBlock = t.Object({ +const tAnthropicToolUseBlock = tLooseObject({ type: t.Literal("tool_use"), id: t.String(), name: t.String(), input: t.Record(t.String(), t.Unknown()), }); -const tAnthropicToolResultBlock = t.Object({ +const tAnthropicToolResultBlock = tLooseObject({ type: t.Literal("tool_result"), tool_use_id: t.String(), content: t.Optional(t.Union([t.String(), t.Array(t.Unknown())])), is_error: t.Optional(t.Boolean()), }); -const tAnthropicThinkingBlock = t.Object({ +const tAnthropicThinkingBlock = tLooseObject({ type: t.Literal("thinking"), thinking: t.String(), - signature: t.String(), + signature: t.Optional(t.String()), }); const tAnthropicContentBlock = t.Union([ @@ -96,7 +101,7 @@ const tAnthropicContentBlock = t.Union([ ]); // Anthropic tool definition -const tAnthropicTool = t.Object({ +const tAnthropicTool = tLooseObject({ name: t.String(), description: t.Optional(t.String()), input_schema: t.Record(t.String(), t.Unknown()), @@ -104,42 +109,36 @@ const tAnthropicTool = t.Object({ // Anthropic tool choice const tAnthropicToolChoice = t.Union([ - t.Object({ type: t.Literal("auto") }), - t.Object({ type: t.Literal("any") }), - t.Object({ type: t.Literal("tool"), name: t.String() }), + tLooseObject({ type: t.Literal("auto") }), + tLooseObject({ type: t.Literal("any") }), + tLooseObject({ type: t.Literal("tool"), name: t.String() }), ]); // Anthropic metadata -const tAnthropicMetadata = t.Object({ +const tAnthropicMetadata = tLooseObject({ user_id: t.Optional(t.String()), }); // Anthropic Messages API request schema -const tAnthropicMessageCreate = t.Object( - { - model: t.String(), - messages: t.Array( - t.Object( - { - role: t.String(), - content: t.Union([t.String(), t.Array(tAnthropicContentBlock)]), - }, - { additionalProperties: true }, - ), - ), - max_tokens: t.Number(), - system: t.Optional(t.Union([t.String(), t.Array(tAnthropicTextBlock)])), - stream: t.Optional(t.Boolean()), - temperature: t.Optional(t.Number()), - top_p: t.Optional(t.Number()), - top_k: t.Optional(t.Number()), - stop_sequences: t.Optional(t.Array(t.String())), - tools: t.Optional(t.Array(tAnthropicTool)), - tool_choice: t.Optional(tAnthropicToolChoice), - metadata: t.Optional(tAnthropicMetadata), - }, - { additionalProperties: true }, -); +const tAnthropicMessageCreate = tLooseObject({ + model: t.String(), + messages: t.Array( + tLooseObject({ + role: t.String(), + content: t.Union([t.String(), t.Array(tAnthropicContentBlock)]), + }), + ), + max_tokens: t.Number(), + system: t.Optional(t.Union([t.String(), t.Array(tAnthropicTextBlock)])), + stream: t.Optional(t.Boolean()), + temperature: t.Optional(t.Number()), + top_p: t.Optional(t.Number()), + top_k: t.Optional(t.Number()), + stop_sequences: t.Optional(t.Array(t.String())), + tools: t.Optional(t.Array(tAnthropicTool)), + tool_choice: t.Optional(tAnthropicToolChoice), + metadata: t.Optional(tAnthropicMetadata), +}); /** * Build completion record for logging diff --git a/backend/src/utils/api-helpers.ts b/backend/src/utils/api-helpers.ts index 4b8217f..0d7b330 100644 --- a/backend/src/utils/api-helpers.ts +++ b/backend/src/utils/api-helpers.ts @@ -3,9 +3,9 @@ * Extracted from completions.ts, messages.ts, and responses.ts to reduce code duplication */ -import { createLogger } from "@/utils/logger"; import type { InternalResponse, ModelWithProvider } from "@/adapters/types"; import type { ToolCallType } from "@/db/schema"; +import { createLogger } from "@/utils/logger"; const logger = createLogger("api-helpers"); @@ -31,7 +31,6 @@ export const EXCLUDED_HEADERS = new Set([ "accept", "accept-encoding", "accept-language", - "user-agent", "origin", "referer", "cookie",