Skip to content

Commit cc083fa

Browse files
pescnclaude
andauthored
fix(api): improve Anthropic adapter transparency and compatibility (#66)
* 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 6e22343 commit cc083fa

6 files changed

Lines changed: 88 additions & 56 deletions

File tree

backend/src/adapters/request/anthropic.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ interface AnthropicContentBlock {
3838
content?: string | AnthropicContentBlock[];
3939
is_error?: boolean;
4040
cache_control?: { type: "ephemeral" };
41+
signature?: string;
4142
}
4243

4344
interface AnthropicMessage {
@@ -109,6 +110,7 @@ function convertContentBlock(
109110
return {
110111
type: "thinking",
111112
thinking: block.thinking || "",
113+
signature: block.signature,
112114
} as ThinkingContentBlock;
113115

114116
case "tool_use":
@@ -249,6 +251,7 @@ function convertTools(
249251
name: tool.name,
250252
description: tool.description,
251253
inputSchema: tool.input_schema,
254+
cacheControl: tool.cache_control,
252255
}));
253256
}
254257

backend/src/adapters/response/anthropic.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,11 @@ export const anthropicResponseAdapter: ResponseAdapter<AnthropicMessage> = {
191191
type: "thinking_delta",
192192
thinking: chunk.delta.thinking || "",
193193
};
194+
} else if (chunk.delta?.type === "signature_delta") {
195+
delta = {
196+
type: "signature_delta",
197+
signature: chunk.delta.signature || "",
198+
};
194199
} else if (chunk.delta?.type === "input_json_delta") {
195200
delta = {
196201
type: "input_json_delta",

backend/src/adapters/types.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ export interface TextContentBlock {
2323
export interface ThinkingContentBlock {
2424
type: "thinking";
2525
thinking: string;
26+
/** Signature for thinking blocks (required when replaying in multi-turn) */
27+
signature?: string;
2628
}
2729

2830
/**
@@ -121,6 +123,8 @@ export interface InternalToolDefinition {
121123
name: string;
122124
description?: string;
123125
inputSchema: JsonSchema;
126+
/** Anthropic cache control for prompt caching */
127+
cacheControl?: { type: "ephemeral" };
124128
}
125129

126130
// =============================================================================
@@ -230,9 +234,10 @@ export interface InternalStreamChunk {
230234
contentBlock?: InternalContentBlock;
231235
/** Delta content (for content_block_delta) */
232236
delta?: {
233-
type: "text_delta" | "thinking_delta" | "input_json_delta";
237+
type: "text_delta" | "thinking_delta" | "signature_delta" | "input_json_delta";
234238
text?: string;
235239
thinking?: string;
240+
signature?: string;
236241
partialJson?: string;
237242
};
238243
/** Message delta (for message_delta) */

backend/src/adapters/upstream/anthropic.ts

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
* Handles communication with Anthropic Claude API
44
*/
55

6+
import { parseJsonResponse } from "@/utils/json";
67
import type {
78
InternalContentBlock,
89
InternalMessage,
@@ -17,7 +18,6 @@ import type {
1718
ToolUseContentBlock,
1819
UpstreamAdapter,
1920
} from "../types";
20-
import { parseJsonResponse } from "@/utils/json";
2121

2222
// =============================================================================
2323
// Anthropic Request/Response Types
@@ -40,6 +40,7 @@ interface AnthropicContentBlock {
4040
content?: string | AnthropicContentBlock[];
4141
is_error?: boolean;
4242
cache_control?: { type: "ephemeral" };
43+
signature?: string;
4344
}
4445

4546
interface AnthropicMessage {
@@ -96,6 +97,7 @@ interface AnthropicStreamEvent {
9697
text?: string;
9798
thinking?: string;
9899
partial_json?: string;
100+
signature?: string;
99101
stop_reason?: string;
100102
stop_sequence?: string | null;
101103
};
@@ -162,7 +164,11 @@ function convertMessage(msg: InternalMessage): AnthropicMessage | null {
162164
if (block.type === "text") {
163165
content.push({ type: "text", text: block.text });
164166
} else if (block.type === "thinking") {
165-
content.push({ type: "thinking", thinking: block.thinking });
167+
content.push({
168+
type: "thinking",
169+
thinking: block.thinking,
170+
signature: block.signature,
171+
});
166172
}
167173
}
168174
}
@@ -245,6 +251,7 @@ function convertTools(
245251
name: tool.name,
246252
description: tool.description,
247253
input_schema: tool.inputSchema,
254+
cache_control: tool.cacheControl,
248255
}));
249256
}
250257

@@ -304,6 +311,7 @@ function convertContentBlock(
304311
return {
305312
type: "thinking",
306313
thinking: block.thinking || "",
314+
signature: block.signature,
307315
} as ThinkingContentBlock;
308316
case "tool_use":
309317
return {
@@ -458,11 +466,15 @@ export const anthropicUpstreamAdapter: UpstreamAdapter = {
458466
...request.extraParams,
459467
};
460468

461-
// Build URL
462-
const baseUrl = provider.baseUrl.endsWith("/")
463-
? provider.baseUrl.slice(0, -1)
464-
: provider.baseUrl;
465-
const url = `${baseUrl}/messages`;
469+
// Build URL — strip trailing slash and /v1 suffix to normalize,
470+
// then always append /v1/messages. This handles both
471+
// "https://api.anthropic.com" and "https://api.anthropic.com/v1".
472+
// Note: Any path ending with /v1 will have it stripped (e.g., /custom/v1 → /custom).
473+
let baseUrl = provider.baseUrl.replace(/\/+$/, "");
474+
if (baseUrl.endsWith("/v1")) {
475+
baseUrl = baseUrl.slice(0, -3);
476+
}
477+
const url = `${baseUrl}/v1/messages`;
466478

467479
// Build headers (Anthropic uses x-api-key instead of Authorization Bearer)
468480
const headers: Record<string, string> = {
@@ -566,6 +578,15 @@ export const anthropicUpstreamAdapter: UpstreamAdapter = {
566578
index: event.index,
567579
delta: { type: "thinking_delta", thinking: delta.thinking },
568580
};
581+
} else if (delta?.type === "signature_delta") {
582+
yield {
583+
type: "content_block_delta",
584+
index: event.index,
585+
delta: {
586+
type: "signature_delta",
587+
signature: delta.signature,
588+
},
589+
};
569590
} else if (delta?.type === "input_json_delta") {
570591
yield {
571592
type: "content_block_delta",

backend/src/api/v1/messages.ts

Lines changed: 45 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,27 @@
33
* Provides Anthropic-compatible API format for clients
44
*/
55

6+
import type { TProperties } from "@sinclair/typebox";
67
import { Elysia, t } from "elysia";
78
import type { ModelWithProvider } from "@/adapters/types";
9+
import type { CachedResponseType } from "@/db/schema";
810
import {
911
getRequestAdapter,
1012
getResponseAdapter,
1113
getUpstreamAdapter,
1214
} from "@/adapters";
13-
import { createLogger } from "@/utils/logger";
1415
import { getModelsWithProviderBySystemName } from "@/db";
1516
import { apiKeyPlugin, type ApiKey } from "@/plugins/apiKeyPlugin";
1617
import {
1718
apiKeyRateLimitPlugin,
1819
consumeTokens,
1920
} from "@/plugins/apiKeyRateLimitPlugin";
2021
import { rateLimitPlugin } from "@/plugins/rateLimitPlugin";
22+
import {
23+
executeWithFailover,
24+
selectMultipleCandidates,
25+
type FailoverConfig,
26+
} from "@/services/failover";
2127
import {
2228
extractUpstreamHeaders,
2329
filterCandidates,
@@ -27,12 +33,8 @@ import {
2733
PROVIDER_HEADER,
2834
} from "@/utils/api-helpers";
2935
import { addCompletions, type Completion } from "@/utils/completions";
30-
import { StreamingContext } from "@/utils/streaming-context";
31-
import {
32-
executeWithFailover,
33-
selectMultipleCandidates,
34-
type FailoverConfig,
35-
} from "@/services/failover";
36+
import { safeParseToolArgs } from "@/utils/json";
37+
import { createLogger } from "@/utils/logger";
3638
import {
3739
checkReqId,
3840
finalizeReqId,
@@ -42,49 +44,52 @@ import {
4244
type ApiFormat,
4345
type ReqIdContext,
4446
} from "@/utils/reqIdHandler";
45-
import type { CachedResponseType } from "@/db/schema";
46-
import { safeParseToolArgs } from "@/utils/json";
47+
import { StreamingContext } from "@/utils/streaming-context";
4748

4849
const logger = createLogger("messagesApi");
4950

5051
// =============================================================================
5152
// Request Schema
5253
// =============================================================================
5354

55+
// Helper: t.Object with additionalProperties: true for proxy transparency
56+
const tLooseObject = <T extends TProperties>(properties: T) =>
57+
t.Object(properties, { additionalProperties: true });
58+
5459
// Anthropic content block types
55-
const tAnthropicTextBlock = t.Object({
60+
const tAnthropicTextBlock = tLooseObject({
5661
type: t.Literal("text"),
5762
text: t.String(),
5863
});
5964

60-
const tAnthropicImageBlock = t.Object({
65+
const tAnthropicImageBlock = tLooseObject({
6166
type: t.Literal("image"),
62-
source: t.Object({
67+
source: tLooseObject({
6368
type: t.String(),
6469
media_type: t.Optional(t.String()),
6570
data: t.Optional(t.String()),
6671
url: t.Optional(t.String()),
6772
}),
6873
});
6974

70-
const tAnthropicToolUseBlock = t.Object({
75+
const tAnthropicToolUseBlock = tLooseObject({
7176
type: t.Literal("tool_use"),
7277
id: t.String(),
7378
name: t.String(),
7479
input: t.Record(t.String(), t.Unknown()),
7580
});
7681

77-
const tAnthropicToolResultBlock = t.Object({
82+
const tAnthropicToolResultBlock = tLooseObject({
7883
type: t.Literal("tool_result"),
7984
tool_use_id: t.String(),
8085
content: t.Optional(t.Union([t.String(), t.Array(t.Unknown())])),
8186
is_error: t.Optional(t.Boolean()),
8287
});
8388

84-
const tAnthropicThinkingBlock = t.Object({
89+
const tAnthropicThinkingBlock = tLooseObject({
8590
type: t.Literal("thinking"),
8691
thinking: t.String(),
87-
signature: t.String(),
92+
signature: t.Optional(t.String()),
8893
});
8994

9095
const tAnthropicContentBlock = t.Union([
@@ -96,50 +101,44 @@ const tAnthropicContentBlock = t.Union([
96101
]);
97102

98103
// Anthropic tool definition
99-
const tAnthropicTool = t.Object({
104+
const tAnthropicTool = tLooseObject({
100105
name: t.String(),
101106
description: t.Optional(t.String()),
102107
input_schema: t.Record(t.String(), t.Unknown()),
103108
});
104109

105110
// Anthropic tool choice
106111
const tAnthropicToolChoice = t.Union([
107-
t.Object({ type: t.Literal("auto") }),
108-
t.Object({ type: t.Literal("any") }),
109-
t.Object({ type: t.Literal("tool"), name: t.String() }),
112+
tLooseObject({ type: t.Literal("auto") }),
113+
tLooseObject({ type: t.Literal("any") }),
114+
tLooseObject({ type: t.Literal("tool"), name: t.String() }),
110115
]);
111116

112117
// Anthropic metadata
113-
const tAnthropicMetadata = t.Object({
118+
const tAnthropicMetadata = tLooseObject({
114119
user_id: t.Optional(t.String()),
115120
});
116121

117122
// Anthropic Messages API request schema
118-
const tAnthropicMessageCreate = t.Object(
119-
{
120-
model: t.String(),
121-
messages: t.Array(
122-
t.Object(
123-
{
124-
role: t.String(),
125-
content: t.Union([t.String(), t.Array(tAnthropicContentBlock)]),
126-
},
127-
{ additionalProperties: true },
128-
),
129-
),
130-
max_tokens: t.Number(),
131-
system: t.Optional(t.Union([t.String(), t.Array(tAnthropicTextBlock)])),
132-
stream: t.Optional(t.Boolean()),
133-
temperature: t.Optional(t.Number()),
134-
top_p: t.Optional(t.Number()),
135-
top_k: t.Optional(t.Number()),
136-
stop_sequences: t.Optional(t.Array(t.String())),
137-
tools: t.Optional(t.Array(tAnthropicTool)),
138-
tool_choice: t.Optional(tAnthropicToolChoice),
139-
metadata: t.Optional(tAnthropicMetadata),
140-
},
141-
{ additionalProperties: true },
142-
);
123+
const tAnthropicMessageCreate = tLooseObject({
124+
model: t.String(),
125+
messages: t.Array(
126+
tLooseObject({
127+
role: t.String(),
128+
content: t.Union([t.String(), t.Array(tAnthropicContentBlock)]),
129+
}),
130+
),
131+
max_tokens: t.Number(),
132+
system: t.Optional(t.Union([t.String(), t.Array(tAnthropicTextBlock)])),
133+
stream: t.Optional(t.Boolean()),
134+
temperature: t.Optional(t.Number()),
135+
top_p: t.Optional(t.Number()),
136+
top_k: t.Optional(t.Number()),
137+
stop_sequences: t.Optional(t.Array(t.String())),
138+
tools: t.Optional(t.Array(tAnthropicTool)),
139+
tool_choice: t.Optional(tAnthropicToolChoice),
140+
metadata: t.Optional(tAnthropicMetadata),
141+
});
143142

144143
/**
145144
* Build completion record for logging

backend/src/utils/api-helpers.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@
33
* Extracted from completions.ts, messages.ts, and responses.ts to reduce code duplication
44
*/
55

6-
import { createLogger } from "@/utils/logger";
76
import type { InternalResponse, ModelWithProvider } from "@/adapters/types";
87
import type { ToolCallType } from "@/db/schema";
8+
import { createLogger } from "@/utils/logger";
99

1010
const logger = createLogger("api-helpers");
1111

@@ -31,7 +31,6 @@ export const EXCLUDED_HEADERS = new Set([
3131
"accept",
3232
"accept-encoding",
3333
"accept-language",
34-
"user-agent",
3534
"origin",
3635
"referer",
3736
"cookie",

0 commit comments

Comments
 (0)