Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions backend/src/adapters/request/anthropic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ interface AnthropicContentBlock {
content?: string | AnthropicContentBlock[];
is_error?: boolean;
cache_control?: { type: "ephemeral" };
signature?: string;
}

interface AnthropicMessage {
Expand Down Expand Up @@ -109,6 +110,7 @@ function convertContentBlock(
return {
type: "thinking",
thinking: block.thinking || "",
signature: block.signature,
} as ThinkingContentBlock;

case "tool_use":
Expand Down Expand Up @@ -249,6 +251,7 @@ function convertTools(
name: tool.name,
description: tool.description,
inputSchema: tool.input_schema,
cacheControl: tool.cache_control,
}));
}

Expand Down
5 changes: 5 additions & 0 deletions backend/src/adapters/response/anthropic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,11 @@ export const anthropicResponseAdapter: ResponseAdapter<AnthropicMessage> = {
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",
Expand Down
7 changes: 6 additions & 1 deletion backend/src/adapters/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -121,6 +123,8 @@ export interface InternalToolDefinition {
name: string;
description?: string;
inputSchema: JsonSchema;
/** Anthropic cache control for prompt caching */
cacheControl?: { type: "ephemeral" };
}

// =============================================================================
Expand Down Expand Up @@ -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) */
Expand Down
35 changes: 28 additions & 7 deletions backend/src/adapters/upstream/anthropic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
* Handles communication with Anthropic Claude API
*/

import { parseJsonResponse } from "@/utils/json";
import type {
InternalContentBlock,
InternalMessage,
Expand All @@ -17,7 +18,6 @@ import type {
ToolUseContentBlock,
UpstreamAdapter,
} from "../types";
import { parseJsonResponse } from "@/utils/json";

// =============================================================================
// Anthropic Request/Response Types
Expand All @@ -40,6 +40,7 @@ interface AnthropicContentBlock {
content?: string | AnthropicContentBlock[];
is_error?: boolean;
cache_control?: { type: "ephemeral" };
signature?: string;
}

interface AnthropicMessage {
Expand Down Expand Up @@ -96,6 +97,7 @@ interface AnthropicStreamEvent {
text?: string;
thinking?: string;
partial_json?: string;
signature?: string;
stop_reason?: string;
stop_sequence?: string | null;
};
Expand Down Expand Up @@ -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,
});
}
}
}
Expand Down Expand Up @@ -245,6 +251,7 @@ function convertTools(
name: tool.name,
description: tool.description,
input_schema: tool.inputSchema,
cache_control: tool.cacheControl,
}));
}

Expand Down Expand Up @@ -304,6 +311,7 @@ function convertContentBlock(
return {
type: "thinking",
thinking: block.thinking || "",
signature: block.signature,
} as ThinkingContentBlock;
case "tool_use":
return {
Expand Down Expand Up @@ -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);
}
Comment thread
pescn marked this conversation as resolved.
const url = `${baseUrl}/v1/messages`;

// Build headers (Anthropic uses x-api-key instead of Authorization Bearer)
const headers: Record<string, string> = {
Expand Down Expand Up @@ -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",
Expand Down
91 changes: 45 additions & 46 deletions backend/src/api/v1/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,27 @@
* 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 {
apiKeyRateLimitPlugin,
consumeTokens,
} from "@/plugins/apiKeyRateLimitPlugin";
import { rateLimitPlugin } from "@/plugins/rateLimitPlugin";
import {
executeWithFailover,
selectMultipleCandidates,
type FailoverConfig,
} from "@/services/failover";
import {
extractUpstreamHeaders,
filterCandidates,
Expand All @@ -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,
Expand All @@ -42,49 +44,52 @@ 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");

// =============================================================================
// Request Schema
// =============================================================================

// Helper: t.Object with additionalProperties: true for proxy transparency
const tLooseObject = <T extends TProperties>(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()),
url: t.Optional(t.String()),
}),
});

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([
Expand All @@ -96,50 +101,44 @@ 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()),
});

// 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
Expand Down
3 changes: 1 addition & 2 deletions backend/src/utils/api-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand All @@ -31,7 +31,6 @@ export const EXCLUDED_HEADERS = new Set([
"accept",
"accept-encoding",
"accept-language",
"user-agent",
"origin",
"referer",
"cookie",
Expand Down