diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..fb123b3
--- /dev/null
+++ b/Dockerfile
@@ -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"]
\ No newline at end of file
diff --git a/README.md b/README.md
index 33e58f6..1529422 100644
--- a/README.md
+++ b/README.md
@@ -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` | 否 | - | 客户端认证密钥 |
@@ -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` 支持多种格式:
diff --git a/deno-proxy/src/anthropic_to_openai.ts b/deno-proxy/src/anthropic_to_openai.ts
index 4f79f55..46ffca1 100644
--- a/deno-proxy/src/anthropic_to_openai.ts
+++ b/deno-proxy/src/anthropic_to_openai.ts
@@ -15,17 +15,17 @@ const THINKING_END_TAG = "";
function normalizeBlocks(content: string | ClaudeContentBlock[], triggerSignal?: string): string {
if (typeof content === "string") {
// 过滤掉纯文本中的工具协议标签,防止注入攻击或模型回显协议片段
- // 注意:合法的工具调用 / 结果会通过 tool_use / tool_result block 转换,不应该以裸标签形式出现
+ // 注意:合法的工具调用会通过 tool_use block 转换,tool_result 现在作为纯文本处理
return content
// 过滤掉 ...
.replace(/]*>[\s\S]*?<\/invoke>/gi, "")
- // 过滤掉 ...,包括模型自己错误输出的 tool_result 片段
+ // 过滤掉可能残留的 ... 标签(虽然我们不再生成这种格式)
.replace(/]*>[\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(/]*>[\s\S]*?<\/invoke>/gi, "")
.replace(/]*>[\s\S]*?<\/tool_result>/gi, "");
@@ -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 `${contentStr}`;
+ // 将工具结果作为纯文本传递,让 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 转换的 标签才会带触发信号
@@ -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");
}
@@ -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;
}
@@ -98,10 +122,8 @@ export function mapClaudeToOpenAI(body: ClaudeRequest, config: ProxyConfig, trig
lastMessage.content = lastMessage.content + "\n\n\n\nPlease continue responding as an assistant.\n\n";
}
- const model = config.upstreamModelOverride ?? body.model;
-
return {
- model,
+ model: requestModel,
stream: true,
temperature: body.temperature ?? 0.2,
top_p: body.top_p ?? 1,
diff --git a/deno-proxy/src/config.ts b/deno-proxy/src/config.ts
index 534df73..e63fc62 100644
--- a/deno-proxy/src/config.ts
+++ b/deno-proxy/src/config.ts
@@ -1,7 +1,18 @@
+///
+
+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;
@@ -50,12 +61,30 @@ 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";
@@ -63,8 +92,6 @@ export function loadConfig(): ProxyConfig {
// 否则使用环境变量指定的端口或默认端口 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");
@@ -72,9 +99,27 @@ export function loadConfig(): ProxyConfig {
// 解析 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,
diff --git a/deno-proxy/src/main.ts b/deno-proxy/src/main.ts
index fe04a22..debf325 100644
--- a/deno-proxy/src/main.ts
+++ b/deno-proxy/src/main.ts
@@ -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";
@@ -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) {
diff --git a/deno-proxy/src/token_counter.ts b/deno-proxy/src/token_counter.ts
index 9cb4b3b..33aa67f 100644
--- a/deno-proxy/src/token_counter.ts
+++ b/deno-proxy/src/token_counter.ts
@@ -44,10 +44,7 @@ export function extractTextFromMessages(messages: ClaudeMessage[]): string {
return `${JSON.stringify(block.input)}`;
}
if (block.type === "tool_result") {
- const contentStr = typeof block.content === "string"
- ? block.content
- : JSON.stringify(block.content ?? "");
- return `${contentStr}`;
+ return `${block.content}`;
}
return "";
})
diff --git a/deno-proxy/src/upstream.ts b/deno-proxy/src/upstream.ts
index 91dbeb0..23a3d56 100644
--- a/deno-proxy/src/upstream.ts
+++ b/deno-proxy/src/upstream.ts
@@ -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 {
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),
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..661bfe6
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,33 @@
+# 使用 docker compose 快速启动 b4u2cc 代理
+version: "3.8"
+
+services:
+ b4u2cc-proxy:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ args:
+ DENO_VERSION: ${DENO_VERSION:-2.0.0}
+ image: b4u2cc-proxy:local
+ container_name: b4u2cc-proxy
+ restart: unless-stopped
+ environment:
+ HOST: 0.0.0.0
+ PORT: 3456
+ LOG_LEVEL: info
+ UPSTREAM_BASE_URL: https://api.openai.com/v1/chat/completions
+ UPSTREAM_API_KEY: changeme-upstream-key
+ CLIENT_API_KEY: changeme-client-key
+ TIMEOUT_MS: 120000
+ MAX_REQUESTS_PER_MINUTE: 60
+ TOKEN_MULTIPLIER: "1.0"
+ ports:
+ - "3456:3456"
+ volumes:
+ - ./logs:/app/deno-proxy/logs
+ networks:
+ - b4u2cc-net
+
+networks:
+ b4u2cc-net:
+ driver: bridge
\ No newline at end of file
diff --git a/docs/deno-deployment-guide.md b/docs/deno-deployment-guide.md
index e212d01..208f1e1 100644
--- a/docs/deno-deployment-guide.md
+++ b/docs/deno-deployment-guide.md
@@ -93,6 +93,7 @@ deployctl deploy --project=b4u2cc-proxy deno-proxy/src/main.ts
在 Deno Deploy 中配置以下环境变量:
+#### 单上游配置(向后兼容)
| 变量名 | 说明 | 示例值 |
|--------|------|--------|
| `UPSTREAM_BASE_URL` | 上游 API 地址 | `https://api.openai.com/v1/chat/completions` |
@@ -103,6 +104,33 @@ deployctl deploy --project=b4u2cc-proxy deno-proxy/src/main.ts
| `TOKEN_MULTIPLIER` | Token 计数倍数 | `1.0` |
| `LOG_LEVEL` | 日志级别 | `info` |
+#### 多上游配置(新)
+支持配置多组上游,每组包含以下四个环境变量,索引从1开始递增:
+
+| 变量名 | 说明 | 示例值 |
+|--------|------|--------|
+| `UPSTREAM_CONFIG_{n}_BASE_URL` | 第 n 组上游 API 地址 | `https://api.openai.com/v1/chat/completions` |
+| `UPSTREAM_CONFIG_{n}_API_KEY` | 第 n 组上游 API 密钥(可选) | `sk-...` |
+| `UPSTREAM_CONFIG_{n}_REQUEST_MODEL` | 第 n 组实际请求的模型名 | `claude-sonnet-4.5` |
+| `UPSTREAM_CONFIG_{n}_NAME_MODEL` | 第 n 组客户端使用的模型名(唯一) | `w1-claude-sonnet-4.5` |
+
+例如:
+```
+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
+```
+
+**注意**:
+- 如果配置了多组上游,则单上游配置(`UPSTREAM_BASE_URL` 等)将被忽略。
+- 客户端请求的 `model` 字段必须与某个 `NAME_MODEL` 匹配,否则将使用单上游配置(如果存在)或报错。
+- 模型名称在配置中必须唯一。
+
### 自定义域名
1. 在 Deno Deploy 控制台中,点击项目设置