From 7223fd9f41890dd6369e39a52b152d4eb2d480d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9F=A9=E7=BF=94=E5=AE=87?= Date: Sat, 31 Jan 2026 12:44:06 +0800 Subject: [PATCH 1/4] fix(api): improve Anthropic adapter transparency and compatibility - Forward client User-Agent to upstream providers for gateway transparency - Add additionalProperties: true to Anthropic TypeBox validation schemas so tools, system prompts, and content blocks with cache_control are accepted - Make thinking block signature optional in validation schema - Append /v1/ to Anthropic upstream base URL to match actual API endpoint Co-Authored-By: Claude Opus 4.5 --- backend/src/adapters/upstream/anthropic.ts | 4 +- backend/src/api/v1/messages.ts | 135 ++++++++++++--------- backend/src/utils/api-helpers.ts | 3 +- 3 files changed, 84 insertions(+), 58 deletions(-) diff --git a/backend/src/adapters/upstream/anthropic.ts b/backend/src/adapters/upstream/anthropic.ts index ac7469c..77d0c89 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 @@ -462,7 +462,7 @@ export const anthropicUpstreamAdapter: UpstreamAdapter = { const baseUrl = provider.baseUrl.endsWith("/") ? provider.baseUrl.slice(0, -1) : provider.baseUrl; - const url = `${baseUrl}/messages`; + const url = `${baseUrl}/v1/messages`; // Build headers (Anthropic uses x-api-key instead of Authorization Bearer) const headers: Record = { diff --git a/backend/src/api/v1/messages.ts b/backend/src/api/v1/messages.ts index bd36dc5..16edb8c 100644 --- a/backend/src/api/v1/messages.ts +++ b/backend/src/api/v1/messages.ts @@ -5,12 +5,12 @@ 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 +18,11 @@ import { consumeTokens, } from "@/plugins/apiKeyRateLimitPlugin"; import { rateLimitPlugin } from "@/plugins/rateLimitPlugin"; +import { + executeWithFailover, + selectMultipleCandidates, + type FailoverConfig, +} from "@/services/failover"; import { extractUpstreamHeaders, filterCandidates, @@ -27,12 +32,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 +43,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"); @@ -52,40 +52,58 @@ const logger = createLogger("messagesApi"); // ============================================================================= // Anthropic content block types -const tAnthropicTextBlock = t.Object({ - type: t.Literal("text"), - text: t.String(), -}); - -const tAnthropicImageBlock = t.Object({ - type: t.Literal("image"), - source: t.Object({ - type: t.String(), - media_type: t.Optional(t.String()), - data: t.Optional(t.String()), - url: t.Optional(t.String()), - }), -}); - -const tAnthropicToolUseBlock = t.Object({ - type: t.Literal("tool_use"), - id: t.String(), - name: t.String(), - input: t.Record(t.String(), t.Unknown()), -}); - -const tAnthropicToolResultBlock = t.Object({ - 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({ - type: t.Literal("thinking"), - thinking: t.String(), - signature: t.String(), -}); +const tAnthropicTextBlock = t.Object( + { + type: t.Literal("text"), + text: t.String(), + }, + { additionalProperties: true }, +); + +const tAnthropicImageBlock = t.Object( + { + type: t.Literal("image"), + source: t.Object( + { + type: t.String(), + media_type: t.Optional(t.String()), + data: t.Optional(t.String()), + url: t.Optional(t.String()), + }, + { additionalProperties: true }, + ), + }, + { additionalProperties: true }, +); + +const tAnthropicToolUseBlock = t.Object( + { + type: t.Literal("tool_use"), + id: t.String(), + name: t.String(), + input: t.Record(t.String(), t.Unknown()), + }, + { additionalProperties: true }, +); + +const tAnthropicToolResultBlock = t.Object( + { + 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()), + }, + { additionalProperties: true }, +); + +const tAnthropicThinkingBlock = t.Object( + { + type: t.Literal("thinking"), + thinking: t.String(), + signature: t.Optional(t.String()), + }, + { additionalProperties: true }, +); const tAnthropicContentBlock = t.Union([ tAnthropicTextBlock, @@ -96,23 +114,32 @@ const tAnthropicContentBlock = t.Union([ ]); // Anthropic tool definition -const tAnthropicTool = t.Object({ - name: t.String(), - description: t.Optional(t.String()), - input_schema: t.Record(t.String(), t.Unknown()), -}); +const tAnthropicTool = t.Object( + { + name: t.String(), + description: t.Optional(t.String()), + input_schema: t.Record(t.String(), t.Unknown()), + }, + { additionalProperties: true }, +); // 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() }), + t.Object({ type: t.Literal("auto") }, { additionalProperties: true }), + t.Object({ type: t.Literal("any") }, { additionalProperties: true }), + t.Object( + { type: t.Literal("tool"), name: t.String() }, + { additionalProperties: true }, + ), ]); // Anthropic metadata -const tAnthropicMetadata = t.Object({ - user_id: t.Optional(t.String()), -}); +const tAnthropicMetadata = t.Object( + { + user_id: t.Optional(t.String()), + }, + { additionalProperties: true }, +); // Anthropic Messages API request schema const tAnthropicMessageCreate = t.Object( 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", From e55c4e43d30b5ec40d2a15f4f6278fafc2bb0ae8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9F=A9=E7=BF=94=E5=AE=87?= Date: Sat, 31 Jan 2026 13:19:44 +0800 Subject: [PATCH 2/4] fix(api): preserve cache_control and signature through Anthropic adapter pipeline - Add cacheControl to InternalToolDefinition so tool-level cache_control is forwarded to upstream (was silently dropped after validation) - Add signature to ThinkingContentBlock so thinking blocks can be replayed in multi-turn conversations - Normalize Anthropic baseUrl to handle both with and without /v1 suffix Co-Authored-By: Claude Opus 4.5 --- backend/src/adapters/request/anthropic.ts | 3 +++ backend/src/adapters/types.ts | 4 ++++ backend/src/adapters/upstream/anthropic.ts | 19 ++++++++++++++----- 3 files changed, 21 insertions(+), 5 deletions(-) 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/types.ts b/backend/src/adapters/types.ts index 0d13fa4..98db953 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" }; } // ============================================================================= diff --git a/backend/src/adapters/upstream/anthropic.ts b/backend/src/adapters/upstream/anthropic.ts index 77d0c89..fb68569 100644 --- a/backend/src/adapters/upstream/anthropic.ts +++ b/backend/src/adapters/upstream/anthropic.ts @@ -40,6 +40,7 @@ interface AnthropicContentBlock { content?: string | AnthropicContentBlock[]; is_error?: boolean; cache_control?: { type: "ephemeral" }; + signature?: string; } interface AnthropicMessage { @@ -162,7 +163,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 +250,7 @@ function convertTools( name: tool.name, description: tool.description, input_schema: tool.inputSchema, + cache_control: tool.cacheControl, })); } @@ -458,10 +464,13 @@ export const anthropicUpstreamAdapter: UpstreamAdapter = { ...request.extraParams, }; - // Build URL - const baseUrl = provider.baseUrl.endsWith("/") - ? provider.baseUrl.slice(0, -1) - : provider.baseUrl; + // 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". + 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) From e6ec384ce03d62c3ce26a3ab08ef686b337444d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9F=A9=E7=BF=94=E5=AE=87?= Date: Sat, 31 Jan 2026 13:38:12 +0800 Subject: [PATCH 3/4] fix(api): preserve signature in Anthropic response and streaming pipeline - Extract signature from thinking blocks in non-streaming response parsing - Add signature_delta handling in streaming response parser and serializer - Add signature field to AnthropicStreamEvent delta and InternalStreamChunk - Add clarifying comment on baseUrl /v1 normalization Co-Authored-By: Claude Opus 4.5 --- backend/src/adapters/response/anthropic.ts | 5 +++++ backend/src/adapters/types.ts | 3 ++- backend/src/adapters/upstream/anthropic.ts | 12 ++++++++++++ 3 files changed, 19 insertions(+), 1 deletion(-) 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 98db953..b79bbe1 100644 --- a/backend/src/adapters/types.ts +++ b/backend/src/adapters/types.ts @@ -234,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 fb68569..c236592 100644 --- a/backend/src/adapters/upstream/anthropic.ts +++ b/backend/src/adapters/upstream/anthropic.ts @@ -97,6 +97,7 @@ interface AnthropicStreamEvent { text?: string; thinking?: string; partial_json?: string; + signature?: string; stop_reason?: string; stop_sequence?: string | null; }; @@ -310,6 +311,7 @@ function convertContentBlock( return { type: "thinking", thinking: block.thinking || "", + signature: block.signature, } as ThinkingContentBlock; case "tool_use": return { @@ -467,6 +469,7 @@ export const anthropicUpstreamAdapter: UpstreamAdapter = { // 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); @@ -575,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", From 80181ec3119f162d8a5efbf2582faa30b7502be3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9F=A9=E7=BF=94=E5=AE=87?= Date: Sat, 31 Jan 2026 13:58:54 +0800 Subject: [PATCH 4/4] refactor(api): add tLooseObject helper to reduce schema boilerplate Extract repeated t.Object(..., { additionalProperties: true }) pattern into a reusable tLooseObject helper in the Anthropic Messages endpoint. Reduces 13 occurrences to a single definition. Co-Authored-By: Claude Opus 4.5 --- backend/src/api/v1/messages.ts | 166 ++++++++++++++------------------- 1 file changed, 69 insertions(+), 97 deletions(-) diff --git a/backend/src/api/v1/messages.ts b/backend/src/api/v1/messages.ts index 16edb8c..a9bdae7 100644 --- a/backend/src/api/v1/messages.ts +++ b/backend/src/api/v1/messages.ts @@ -3,6 +3,7 @@ * 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"; @@ -51,59 +52,45 @@ 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( - { - type: t.Literal("text"), - text: t.String(), - }, - { additionalProperties: true }, -); - -const tAnthropicImageBlock = t.Object( - { - type: t.Literal("image"), - source: t.Object( - { - type: t.String(), - media_type: t.Optional(t.String()), - data: t.Optional(t.String()), - url: t.Optional(t.String()), - }, - { additionalProperties: true }, - ), - }, - { additionalProperties: true }, -); - -const tAnthropicToolUseBlock = t.Object( - { - type: t.Literal("tool_use"), - id: t.String(), - name: t.String(), - input: t.Record(t.String(), t.Unknown()), - }, - { additionalProperties: true }, -); - -const tAnthropicToolResultBlock = t.Object( - { - 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()), - }, - { additionalProperties: true }, -); - -const tAnthropicThinkingBlock = t.Object( - { - type: t.Literal("thinking"), - thinking: t.String(), - signature: t.Optional(t.String()), - }, - { additionalProperties: true }, -); +const tAnthropicTextBlock = tLooseObject({ + type: t.Literal("text"), + text: t.String(), +}); + +const tAnthropicImageBlock = tLooseObject({ + type: t.Literal("image"), + source: tLooseObject({ + type: t.String(), + media_type: t.Optional(t.String()), + data: t.Optional(t.String()), + url: t.Optional(t.String()), + }), +}); + +const tAnthropicToolUseBlock = tLooseObject({ + type: t.Literal("tool_use"), + id: t.String(), + name: t.String(), + input: t.Record(t.String(), t.Unknown()), +}); + +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 = tLooseObject({ + type: t.Literal("thinking"), + thinking: t.String(), + signature: t.Optional(t.String()), +}); const tAnthropicContentBlock = t.Union([ tAnthropicTextBlock, @@ -114,59 +101,44 @@ const tAnthropicContentBlock = t.Union([ ]); // Anthropic tool definition -const tAnthropicTool = t.Object( - { - name: t.String(), - description: t.Optional(t.String()), - input_schema: t.Record(t.String(), t.Unknown()), - }, - { additionalProperties: true }, -); +const tAnthropicTool = tLooseObject({ + name: t.String(), + description: t.Optional(t.String()), + input_schema: t.Record(t.String(), t.Unknown()), +}); // Anthropic tool choice const tAnthropicToolChoice = t.Union([ - t.Object({ type: t.Literal("auto") }, { additionalProperties: true }), - t.Object({ type: t.Literal("any") }, { additionalProperties: true }), - t.Object( - { type: t.Literal("tool"), name: t.String() }, - { additionalProperties: true }, - ), + tLooseObject({ type: t.Literal("auto") }), + tLooseObject({ type: t.Literal("any") }), + tLooseObject({ type: t.Literal("tool"), name: t.String() }), ]); // Anthropic metadata -const tAnthropicMetadata = t.Object( - { - user_id: t.Optional(t.String()), - }, - { additionalProperties: true }, -); +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