| import { Request, RequestHandler, Router } from "express"; |
| import { config } from "../config"; |
| import { ipLimiter } from "./rate-limit"; |
| import { |
| addKey, |
| createPreprocessorMiddleware, |
| finalizeBody, |
| } from "./middleware/request"; |
| import { ProxyResHandlerWithBody } from "./middleware/response"; |
| import { createQueuedProxyMiddleware } from "./middleware/request/proxy-middleware-factory"; |
| import { ProxyReqManager } from "./middleware/request/proxy-req-manager"; |
| import { claudeModels } from "../shared/claude-models"; |
|
|
| let modelsCache: any = null; |
| let modelsCacheTime = 0; |
|
|
| const getModelsResponse = () => { |
| if (new Date().getTime() - modelsCacheTime < 1000 * 60) { |
| return modelsCache; |
| } |
|
|
| if (!config.anthropicKey) return { object: "list", data: [], has_more: false, first_id: null, last_id: null }; |
|
|
| const date = new Date() |
| const models = claudeModels.map(model => ({ |
| |
| id: model.anthropicId, |
| owned_by: "anthropic", |
| |
| type: "model", |
| display_name: model.displayName, |
| created_at: date.toISOString(), |
| |
| object: "model", |
| created: date.getTime(), |
| })); |
|
|
| modelsCache = { |
| |
| object: "list", |
| data: models, |
| |
| has_more: false, |
| first_id: models[0]?.id, |
| last_id: models[models.length - 1]?.id, |
| }; |
| modelsCacheTime = date.getTime(); |
|
|
| return modelsCache; |
| }; |
|
|
| const handleModelRequest: RequestHandler = (_req, res) => { |
| res.status(200).json(getModelsResponse()); |
| }; |
|
|
| const anthropicBlockingResponseHandler: ProxyResHandlerWithBody = async ( |
| _proxyRes, |
| req, |
| res, |
| body |
| ) => { |
| if (typeof body !== "object") { |
| throw new Error("Expected body to be an object"); |
| } |
|
|
| let newBody = body; |
| switch (`${req.inboundApi}<-${req.outboundApi}`) { |
| case "openai<-anthropic-text": |
| req.log.info("Transforming Anthropic Text back to OpenAI format"); |
| newBody = transformAnthropicTextResponseToOpenAI(body, req); |
| break; |
| case "openai<-anthropic-chat": |
| req.log.info("Transforming Anthropic Chat back to OpenAI format"); |
| newBody = transformAnthropicChatResponseToOpenAI(body); |
| break; |
| case "anthropic-text<-anthropic-chat": |
| req.log.info("Transforming Anthropic Chat back to Anthropic chat format"); |
| newBody = transformAnthropicChatResponseToAnthropicText(body); |
| break; |
| } |
|
|
| res.status(200).json({ ...newBody, proxy: body.proxy }); |
| }; |
|
|
| function flattenChatResponse( |
| content: { type: string; text: string }[] |
| ): string { |
| return content |
| .map((part: { type: string; text: string }) => |
| part.type === "text" ? part.text : "" |
| ) |
| .join("\n"); |
| } |
|
|
| export function transformAnthropicChatResponseToAnthropicText( |
| anthropicBody: Record<string, any> |
| ): Record<string, any> { |
| return { |
| type: "completion", |
| id: "ant-" + anthropicBody.id, |
| completion: flattenChatResponse(anthropicBody.content), |
| stop_reason: anthropicBody.stop_reason, |
| stop: anthropicBody.stop_sequence, |
| model: anthropicBody.model, |
| usage: anthropicBody.usage, |
| }; |
| } |
|
|
| function transformAnthropicTextResponseToOpenAI( |
| anthropicBody: Record<string, any>, |
| req: Request |
| ): Record<string, any> { |
| const totalTokens = (req.promptTokens ?? 0) + (req.outputTokens ?? 0); |
| return { |
| id: "ant-" + anthropicBody.log_id, |
| object: "chat.completion", |
| created: Date.now(), |
| model: anthropicBody.model, |
| usage: { |
| prompt_tokens: req.promptTokens, |
| completion_tokens: req.outputTokens, |
| total_tokens: totalTokens, |
| }, |
| choices: [ |
| { |
| message: { |
| role: "assistant", |
| content: anthropicBody.completion?.trim(), |
| }, |
| finish_reason: anthropicBody.stop_reason, |
| index: 0, |
| }, |
| ], |
| }; |
| } |
|
|
| export function transformAnthropicChatResponseToOpenAI( |
| anthropicBody: Record<string, any> |
| ): Record<string, any> { |
| return { |
| id: "ant-" + anthropicBody.id, |
| object: "chat.completion", |
| created: Date.now(), |
| model: anthropicBody.model, |
| usage: anthropicBody.usage, |
| choices: [ |
| { |
| message: { |
| role: "assistant", |
| content: flattenChatResponse(anthropicBody.content), |
| }, |
| finish_reason: anthropicBody.stop_reason, |
| index: 0, |
| }, |
| ], |
| }; |
| } |
|
|
| |
| |
| |
| |
| function maybeReassignModel(req: Request) { |
| const model = req.body.model; |
| if (model.includes("claude")) return; |
| req.body.model = "claude-3-5-sonnet-latest"; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| function setAnthropicBetaHeader(req: Request) { |
| const { max_tokens_to_sample } = req.body; |
| |
| |
| const betaHeaders: string[] = []; |
| |
| |
| if (max_tokens_to_sample > 4096) { |
| betaHeaders.push("max-tokens-3-5-sonnet-2024-07-15"); |
| } |
| |
| |
| if (req.body.cache_control?.ttl === "1h") { |
| betaHeaders.push("extended-cache-ttl-2025-04-11"); |
| } |
| |
| |
| if (betaHeaders.length > 0) { |
| req.headers["anthropic-beta"] = betaHeaders.join(","); |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| function addWebSearchTool(req: Request) { |
| |
| const isClaude35 = req.body.model?.includes("claude-3-5") || req.body.model?.includes("claude-3.5"); |
| const isClaude37 = req.body.model?.includes("claude-3-7") || req.body.model?.includes("claude-3.7"); |
| const isClaude4 = req.body.model?.includes("claude-sonnet-4") || req.body.model?.includes("claude-opus-4"); |
| const useWebSearch = (isClaude35 || isClaude37 || isClaude4) && Boolean(req.body.enable_web_search); |
| |
| if (useWebSearch) { |
| |
| const webSearchTool: any = { |
| 'type': 'web_search_20250305', |
| 'name': 'web_search', |
| }; |
| |
| |
| |
| |
| if (typeof req.body.web_search_max_uses === 'number') { |
| webSearchTool.max_uses = req.body.web_search_max_uses; |
| delete req.body.web_search_max_uses; |
| } |
| |
| |
| if (Array.isArray(req.body.web_search_allowed_domains)) { |
| webSearchTool.allowed_domains = req.body.web_search_allowed_domains; |
| delete req.body.web_search_allowed_domains; |
| } |
| |
| |
| if (Array.isArray(req.body.web_search_blocked_domains)) { |
| webSearchTool.blocked_domains = req.body.web_search_blocked_domains; |
| delete req.body.web_search_blocked_domains; |
| } |
| |
| |
| if (req.body.web_search_user_location) { |
| webSearchTool.user_location = req.body.web_search_user_location; |
| delete req.body.web_search_user_location; |
| } |
| |
| |
| req.body.tools = [...(req.body.tools || []), webSearchTool]; |
| } |
| |
| |
| delete req.body.enable_web_search; |
| delete req.body.reasoning_effort; |
| } |
|
|
| function selectUpstreamPath(manager: ProxyReqManager) { |
| const req = manager.request; |
| const pathname = req.url.split("?")[0]; |
| req.log.debug({ pathname }, "Anthropic path filter"); |
| const isText = req.outboundApi === "anthropic-text"; |
| const isChat = req.outboundApi === "anthropic-chat"; |
| if (isChat && pathname === "/v1/complete") { |
| manager.setPath("/v1/messages"); |
| } |
| if (isText && pathname === "/v1/chat/completions") { |
| manager.setPath("/v1/complete"); |
| } |
| if (isChat && pathname === "/v1/chat/completions") { |
| manager.setPath("/v1/messages"); |
| } |
| if (isChat && ["sonnet", "opus"].includes(req.params.type)) { |
| manager.setPath("/v1/messages"); |
| } |
| } |
|
|
| const anthropicProxy = createQueuedProxyMiddleware({ |
| target: "https://api.anthropic.com", |
| mutations: [selectUpstreamPath, addKey, finalizeBody], |
| blockingResponseHandler: anthropicBlockingResponseHandler, |
| }); |
|
|
| const nativeAnthropicChatPreprocessor = createPreprocessorMiddleware( |
| { inApi: "anthropic-chat", outApi: "anthropic-chat", service: "anthropic" }, |
| { afterTransform: [setAnthropicBetaHeader, addWebSearchTool] } |
| ); |
|
|
| const nativeTextPreprocessor = createPreprocessorMiddleware( |
| { |
| inApi: "anthropic-text", |
| outApi: "anthropic-text", |
| service: "anthropic", |
| }, |
| { afterTransform: [setAnthropicBetaHeader, addWebSearchTool] } |
| ); |
|
|
| const textToChatPreprocessor = createPreprocessorMiddleware( |
| { |
| inApi: "anthropic-text", |
| outApi: "anthropic-chat", |
| service: "anthropic", |
| }, |
| { afterTransform: [setAnthropicBetaHeader, addWebSearchTool] } |
| ); |
|
|
| |
| |
| |
| |
| const preprocessAnthropicTextRequest: RequestHandler = (req, res, next) => { |
| const model = req.body.model; |
| const isClaude4Model = model?.includes("claude-sonnet-4") || model?.includes("claude-opus-4"); |
| if (model?.startsWith("claude-3") || isClaude4Model) { |
| textToChatPreprocessor(req, res, next); |
| } else { |
| nativeTextPreprocessor(req, res, next); |
| } |
| }; |
|
|
| const oaiToTextPreprocessor = createPreprocessorMiddleware( |
| { |
| inApi: "openai", |
| outApi: "anthropic-text", |
| service: "anthropic", |
| }, |
| { afterTransform: [setAnthropicBetaHeader] } |
| ); |
|
|
| const oaiToChatPreprocessor = createPreprocessorMiddleware( |
| { |
| inApi: "openai", |
| outApi: "anthropic-chat", |
| service: "anthropic", |
| }, |
| { afterTransform: [setAnthropicBetaHeader, addWebSearchTool] } |
| ); |
|
|
| |
| |
| |
| |
| const preprocessOpenAICompatRequest: RequestHandler = (req, res, next) => { |
| maybeReassignModel(req); |
| const model = req.body.model; |
| const isClaude4 = model?.includes("claude-sonnet-4") || model?.includes("claude-opus-4"); |
| if (model?.includes("claude-3") || isClaude4) { |
| oaiToChatPreprocessor(req, res, next); |
| } else { |
| oaiToTextPreprocessor(req, res, next); |
| } |
| }; |
|
|
| const anthropicRouter = Router(); |
| anthropicRouter.get("/v1/models", handleModelRequest); |
| |
| anthropicRouter.post( |
| "/v1/messages", |
| ipLimiter, |
| nativeAnthropicChatPreprocessor, |
| anthropicProxy |
| ); |
| |
| |
| anthropicRouter.post( |
| "/v1/complete", |
| ipLimiter, |
| preprocessAnthropicTextRequest, |
| anthropicProxy |
| ); |
| |
| |
| |
| anthropicRouter.post( |
| "/v1/chat/completions", |
| ipLimiter, |
| preprocessOpenAICompatRequest, |
| anthropicProxy |
| ); |
|
|
| export const anthropic = anthropicRouter; |
|
|