Skip to content
Closed
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
41 changes: 41 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# syntax=docker/dockerfile:1

# 通过 ARG 控制 Deno 版本,方便统一升级
ARG DENO_VERSION=2.0.0

FROM denoland/deno:${DENO_VERSION} AS builder
ENV DENO_DIR=/deno-dir
WORKDIR /app/deno-proxy

# 仅复制运行必需文件,暂不引入 lockfile 以避免版本兼容问题
COPY deno-proxy/deno.json ./deno.json
COPY deno-proxy/src ./src

# 预热 Deno 依赖缓存(跳过以避免锁文件兼容性导致构建失败)
# RUN deno cache src/main.ts

FROM denoland/deno:alpine-${DENO_VERSION} AS runtime
ENV DENO_DIR=/deno-dir
WORKDIR /app/deno-proxy

# 拷贝源码与缓存,加速冷启动
COPY --from=builder /app/deno-proxy ./
COPY --from=builder /deno-dir /deno-dir

# 创建非 root 用户与日志目录,保证写权限
RUN set -eux; \
addgroup -S app && adduser -S app -G app; \
mkdir -p /app/deno-proxy/logs/req; \
chown -R app:app /app/deno-proxy /deno-dir

EXPOSE 3456

# 提供合理的默认环境变量,可在运行时覆盖
ENV HOST=0.0.0.0 \
PORT=3456 \
LOG_LEVEL=info

USER app

# 运行主进程时仅授予必要权限
CMD ["run", "--allow-net", "--allow-env", "--allow-read=.", "--allow-write=logs", "src/main.ts"]
33 changes: 32 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,9 +84,10 @@ curl http://localhost:3456/healthz

### 环境变量说明

#### 单上游配置(向后兼容)
| 变量名 | 必需 | 默认值 | 说明 |
|--------|------|--------|------|
| `UPSTREAM_BASE_URL` | 是 | - | 上游 OpenAI 兼容 API 地址 |
| `UPSTREAM_BASE_URL` | 是* | - | 上游 OpenAI 兼容 API 地址 |
| `UPSTREAM_API_KEY` | 否 | - | 上游 API 密钥 |
| `UPSTREAM_MODEL` | 否 | - | 强制覆盖请求中的模型名称 |
| `CLIENT_API_KEY` | 否 | - | 客户端认证密钥 |
Expand All @@ -100,6 +101,36 @@ curl http://localhost:3456/healthz
| `LOG_LEVEL` | 否 | info | 日志级别(debug/info/warn/error) |
| `LOGGING_DISABLED` | 否 | false | 是否完全禁用日志 |

#### 多上游配置(新)
支持配置多组上游,每组包含以下四个环境变量,索引从1开始递增:

| 变量名 | 必需 | 默认值 | 说明 |
|--------|------|--------|------|
| `UPSTREAM_CONFIG_{n}_BASE_URL` | 是* | - | 第 n 组上游 API 地址 |
| `UPSTREAM_CONFIG_{n}_API_KEY` | 否 | - | 第 n 组上游 API 密钥 |
| `UPSTREAM_CONFIG_{n}_REQUEST_MODEL` | 是* | - | 第 n 组实际请求的模型名 |
| `UPSTREAM_CONFIG_{n}_NAME_MODEL` | 是* | - | 第 n 组客户端使用的模型名(唯一) |

**注意**:
- 如果配置了多组上游,则单上游配置(`UPSTREAM_BASE_URL` 等)将被忽略。
- 客户端请求的 `model` 字段必须与某个 `NAME_MODEL` 匹配,否则将使用单上游配置(如果存在)或报错。
- 模型名称在配置中必须唯一。
- 带 * 的变量在对应配置组中为必需。

**示例**:
```bash
# 配置两组上游
UPSTREAM_CONFIG_1_BASE_URL=https://api.openai.com/v1/chat/completions
UPSTREAM_CONFIG_1_API_KEY=sk-...
UPSTREAM_CONFIG_1_REQUEST_MODEL=claude-sonnet-4.5
UPSTREAM_CONFIG_1_NAME_MODEL=w1-claude-sonnet-4.5

UPSTREAM_CONFIG_2_BASE_URL=https://api.anthropic.com/v1/messages
UPSTREAM_CONFIG_2_API_KEY=sk-ant-...
UPSTREAM_CONFIG_2_REQUEST_MODEL=claude-sonnet-4.5
UPSTREAM_CONFIG_2_NAME_MODEL=w2-claude-sonnet-4.5
```

### Token 倍数格式

`TOKEN_MULTIPLIER` 支持多种格式:
Expand Down
46 changes: 34 additions & 12 deletions deno-proxy/src/anthropic_to_openai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,17 @@ const THINKING_END_TAG = "</thinking>";
function normalizeBlocks(content: string | ClaudeContentBlock[], triggerSignal?: string): string {
if (typeof content === "string") {
// 过滤掉纯文本中的工具协议标签,防止注入攻击或模型回显协议片段
// 注意:合法的工具调用 / 结果会通过 tool_use / tool_result block 转换,不应该以裸标签形式出现
// 注意:合法的工具调用会通过 tool_use block 转换,tool_result 现在作为纯文本处理
return content
// 过滤掉 <invoke>...</invoke>
.replace(/<invoke\b[^>]*>[\s\S]*?<\/invoke>/gi, "")
// 过滤掉 <tool_result>...</tool_result>,包括模型自己错误输出的 tool_result 片段
// 过滤掉可能残留的 <tool_result>...</tool_result> 标签(虽然我们不再生成这种格式)
.replace(/<tool_result\b[^>]*>[\s\S]*?<\/tool_result>/gi, "");
}
return content.map((block) => {
if (block.type === "text") {
// 即使在 text block 中,也要过滤掉工具协议标签
// 因为这些不是从 tool_use/tool_result 转换来的,可能是用户注入或 assistant 自行输出的协议片段
// 因为这些不是从 tool_use 转换来的,可能是用户注入或 assistant 自行输出的协议片段
return block.text
.replace(/<invoke\b[^>]*>[\s\S]*?<\/invoke>/gi, "")
.replace(/<tool_result\b[^>]*>[\s\S]*?<\/tool_result>/gi, "");
Expand All @@ -35,10 +35,26 @@ function normalizeBlocks(content: string | ClaudeContentBlock[], triggerSignal?:
return `${THINKING_START_TAG}${block.thinking}${THINKING_END_TAG}`;
}
if (block.type === "tool_result") {
const contentStr = typeof block.content === "string"
? block.content
: JSON.stringify(block.content ?? "");
return `<tool_result id="${block.tool_use_id}">${contentStr}</tool_result>`;
// 将工具结果作为纯文本传递,让 OpenAI 模型能够直接读取
// 添加工具ID标识,帮助模型理解这是工具调用的结果
let toolResult = block.content ?? "";

// 处理工具结果的内容格式
if (typeof toolResult === "string") {
// 如果是字符串,直接使用
toolResult = toolResult;
} else if (Array.isArray(toolResult)) {
// 如果是数组(如测试用例中的格式),提取文本内容
toolResult = toolResult
.filter((item: any) => item && item.type === "text")
.map((item: any) => item.text || "")
.join("\n");
} else if (typeof toolResult === "object" && toolResult !== null) {
// 如果是对象,尝试转换为 JSON 字符串
toolResult = JSON.stringify(toolResult, null, 2);
}

return `[工具调用结果 - ID: ${block.tool_use_id}]\n${toolResult}`;
}
if (block.type === "tool_use") {
// 只有从 tool_use 转换的 <invoke> 标签才会带触发信号
Expand All @@ -59,7 +75,11 @@ function mapRole(role: string): "user" | "assistant" {
return role === "assistant" ? "assistant" : "user";
}

export function mapClaudeToOpenAI(body: ClaudeRequest, config: ProxyConfig, triggerSignal?: string): OpenAIChatRequest {
export function mapClaudeToOpenAI(
body: ClaudeRequest,
requestModel: string,
triggerSignal?: string,
): OpenAIChatRequest {
if (typeof body.max_tokens !== "number" || Number.isNaN(body.max_tokens)) {
throw new Error("max_tokens is required for Claude requests");
}
Expand All @@ -82,7 +102,11 @@ export function mapClaudeToOpenAI(body: ClaudeRequest, config: ProxyConfig, trig
let content = normalizeBlocks(message.content, triggerSignal);

// 如果是用户消息且思考模式已启用,在消息末尾添加思考提示符
if (message.role === "user" && body.thinking && body.thinking.type === "enabled") {
// 但排除包含工具结果的消息,避免干扰模型对工具结果的读取
const hasToolResult = Array.isArray(message.content) &&
message.content.some((block: any) => block.type === "tool_result");

if (message.role === "user" && body.thinking && body.thinking.type === "enabled" && !hasToolResult) {
content = content + THINKING_HINT;
}

Expand All @@ -98,10 +122,8 @@ export function mapClaudeToOpenAI(body: ClaudeRequest, config: ProxyConfig, trig
lastMessage.content = lastMessage.content + "\n\n<antml\\b:role>\n\nPlease continue responding as an assistant.\n\n</antml>";
}

const model = config.upstreamModelOverride ?? body.model;

return {
model,
model: requestModel,
stream: true,
temperature: body.temperature ?? 0.2,
top_p: body.top_p ?? 1,
Expand Down
59 changes: 52 additions & 7 deletions deno-proxy/src/config.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,18 @@
/// <reference lib="deno.ns" />

export interface UpstreamConfig {
baseUrl: string;
apiKey?: string;
requestModel: string; // 实际向上游请求的模型名
nameModel: string; // 客户端使用的模型名,唯一
}

export interface ProxyConfig {
port: number;
host: string;
upstreamBaseUrl: string;
upstreamConfigs: UpstreamConfig[];
// 向后兼容的旧字段(如果未设置 upstreamConfigs 则使用)
upstreamBaseUrl?: string;
upstreamApiKey?: string;
upstreamModelOverride?: string;
clientApiKey?: string;
Expand Down Expand Up @@ -50,31 +61,65 @@ function parseTokenMultiplier(raw: string | undefined): number {
return num;
}

export function loadConfig(): ProxyConfig {
const upstreamBaseUrl = Deno.env.get("UPSTREAM_BASE_URL") ?? "http://127.0.0.1:8000/v1/chat/completions";
if (!upstreamBaseUrl) {
throw new Error("UPSTREAM_BASE_URL must be provided");
function loadUpstreamConfigs(): UpstreamConfig[] {
const configs: UpstreamConfig[] = [];
let i = 1;
while (true) {
const baseUrl = Deno.env.get(`UPSTREAM_CONFIG_${i}_BASE_URL`);
const apiKey = Deno.env.get(`UPSTREAM_CONFIG_${i}_API_KEY`);
const requestModel = Deno.env.get(`UPSTREAM_CONFIG_${i}_REQUEST_MODEL`);
const nameModel = Deno.env.get(`UPSTREAM_CONFIG_${i}_NAME_MODEL`);
if (!baseUrl || !requestModel || !nameModel) {
// 如果缺少必要字段,停止搜索(假设没有更多配置)
break;
}
configs.push({
baseUrl,
apiKey,
requestModel,
nameModel,
});
i++;
}
return configs;
}

export function loadConfig(): ProxyConfig {
// 检查是否启用自动端口配置
const autoPort = Deno.env.get("AUTO_PORT") === "true";

// 如果启用自动端口,则使用 0 让系统自动分配端口
// 否则使用环境变量指定的端口或默认端口 3456
const port = autoPort ? 0 : Number(Deno.env.get("PORT") ?? "3456");
const host = Deno.env.get("HOST") ?? "0.0.0.0";
const upstreamApiKey = Deno.env.get("UPSTREAM_API_KEY");
const upstreamModelOverride = Deno.env.get("UPSTREAM_MODEL");
const clientApiKey = Deno.env.get("CLIENT_API_KEY");
const requestTimeoutMs = Number(Deno.env.get("TIMEOUT_MS") ?? "120000");
const aggregationIntervalMs = Number(Deno.env.get("AGGREGATION_INTERVAL_MS") ?? "35");
const maxRequestsPerMinute = Number(Deno.env.get("MAX_REQUESTS_PER_MINUTE") ?? "10");
// 解析 tokenMultiplier,并对非法值进行兜底,避免出现 NaN/Infinity
const tokenMultiplier = parseTokenMultiplier(Deno.env.get("TOKEN_MULTIPLIER"));

// 加载多组上游配置
const upstreamConfigs = loadUpstreamConfigs();

// 向后兼容:如果未设置多组配置,则使用旧的环境变量
let upstreamBaseUrl: string | undefined;
let upstreamApiKey: string | undefined;
let upstreamModelOverride: string | undefined;

if (upstreamConfigs.length === 0) {
upstreamBaseUrl = Deno.env.get("UPSTREAM_BASE_URL") ?? "http://127.0.0.1:8000/v1/chat/completions";
if (!upstreamBaseUrl) {
throw new Error("UPSTREAM_BASE_URL must be provided when no UPSTREAM_CONFIG_* defined");
}
upstreamApiKey = Deno.env.get("UPSTREAM_API_KEY");
upstreamModelOverride = Deno.env.get("UPSTREAM_MODEL");
}

return {
port,
host,
upstreamConfigs,
upstreamBaseUrl,
upstreamApiKey,
upstreamModelOverride,
Expand Down
10 changes: 6 additions & 4 deletions deno-proxy/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { loadConfig, ProxyConfig } from "./config.ts";
import { log, logRequest, closeRequestLog } from "./logging.ts";
import { mapClaudeToOpenAI } from "./anthropic_to_openai.ts";
import { injectPrompt } from "./prompt_inject.ts";
import { callUpstream } from "./upstream.ts";
import { selectUpstreamConfig, callUpstream } from "./upstream.ts";
import { ToolifyParser } from "./parser.ts";
import { ClaudeStream } from "./openai_to_claude.ts";
import { SSEWriter } from "./sse.ts";
Expand Down Expand Up @@ -86,15 +86,17 @@ async function handleMessages(req: Request, requestId: string) {
// 工具解析仅由是否传入 tools 决定:存在 tools 时启用工具协议,否则禁用。
const hasTools = (body.tools ?? []).length > 0;
const triggerSignal = hasTools ? randomTriggerSignal() : undefined;
const openaiBase = mapClaudeToOpenAI(body, config, triggerSignal);
// 选择上游配置
const upstreamConfig = selectUpstreamConfig(config, body.model);
const openaiBase = mapClaudeToOpenAI(body, upstreamConfig.requestModel, triggerSignal);
const injected = injectPrompt(openaiBase, body.tools ?? [], triggerSignal);
const upstreamReq = { ...openaiBase, messages: injected.messages };

await rateLimiter.acquire();
const upstreamRes = await callUpstream(upstreamReq, config, requestId);
const upstreamRes = await callUpstream(upstreamReq, upstreamConfig, config.requestTimeoutMs, requestId);
await logRequest(requestId, "info", "Upstream responded", {
status: upstreamRes.status,
url: config.upstreamBaseUrl,
url: upstreamConfig.baseUrl,
});

if (!upstreamRes.ok) {
Expand Down
5 changes: 1 addition & 4 deletions deno-proxy/src/token_counter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,7 @@ export function extractTextFromMessages(messages: ClaudeMessage[]): string {
return `<invoke name="${block.name}">${JSON.stringify(block.input)}</invoke>`;
}
if (block.type === "tool_result") {
const contentStr = typeof block.content === "string"
? block.content
: JSON.stringify(block.content ?? "");
return `<tool_result>${contentStr}</tool_result>`;
return `<tool_result>${block.content}</tool_result>`;
}
return "";
})
Expand Down
45 changes: 38 additions & 7 deletions deno-proxy/src/upstream.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,61 @@
import { ProxyConfig } from "./config.ts";
import { ProxyConfig, UpstreamConfig } from "./config.ts";
import { OpenAIChatRequest } from "./types.ts";
import { logRequest } from "./logging.ts";

/**
* 根据客户端请求的模型名选择上游配置。
* 如果找到匹配的 nameModel,则返回对应的 UpstreamConfig;
* 否则,如果存在旧配置(upstreamBaseUrl),则返回一个合成的 UpstreamConfig;
* 否则抛出错误。
*/
export function selectUpstreamConfig(
config: ProxyConfig,
clientModel: string,
): UpstreamConfig {
// 在多组配置中查找
for (const upstreamConfig of config.upstreamConfigs) {
if (upstreamConfig.nameModel === clientModel) {
return upstreamConfig;
}
}

// 如果没有多组配置,但存在旧配置,则使用旧配置
if (config.upstreamBaseUrl) {
return {
baseUrl: config.upstreamBaseUrl,
apiKey: config.upstreamApiKey,
requestModel: config.upstreamModelOverride ?? clientModel,
nameModel: clientModel,
};
}

throw new Error(`No upstream configuration found for model "${clientModel}"`);
}

export async function callUpstream(
body: OpenAIChatRequest,
config: ProxyConfig,
upstreamConfig: UpstreamConfig,
requestTimeoutMs: number,
requestId: string,
): Promise<Response> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), config.requestTimeoutMs);
const timeout = setTimeout(() => controller.abort(), requestTimeoutMs);

const headers = new Headers({
"content-type": "application/json",
});
if (config.upstreamApiKey) {
headers.set("authorization", `Bearer ${config.upstreamApiKey}`);
if (upstreamConfig.apiKey) {
headers.set("authorization", `Bearer ${upstreamConfig.apiKey}`);
}

await logRequest(requestId, "debug", "Sending upstream request", {
url: config.upstreamBaseUrl,
url: upstreamConfig.baseUrl,
upstreamRequestBody: body,
});

let response: Response;
try {
response = await fetch(config.upstreamBaseUrl, {
response = await fetch(upstreamConfig.baseUrl, {
method: "POST",
headers,
body: JSON.stringify(body),
Expand Down
Loading
Loading