From b78bf30213006da57e13b6a87f3bef92c1f633cf Mon Sep 17 00:00:00 2001 From: Passerby1011 Date: Sat, 20 Dec 2025 18:45:24 +0800 Subject: [PATCH 1/2] Add files via upload --- Dockerfile | 41 +++++++++++++++++++++++++++ deno-proxy/src/anthropic_to_openai.ts | 36 +++++++++++++++++------ deno-proxy/src/token_counter.ts | 5 +--- docker-compose.yml | 33 +++++++++++++++++++++ 4 files changed, 103 insertions(+), 12 deletions(-) create mode 100644 Dockerfile create mode 100644 docker-compose.yml 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/deno-proxy/src/anthropic_to_openai.ts b/deno-proxy/src/anthropic_to_openai.ts index 4f79f55..8de7596 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 转换的 标签才会带触发信号 @@ -82,7 +98,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; } 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/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 From 65c9866c85da2b61b144e6ede258bf1a054d805d Mon Sep 17 00:00:00 2001 From: Passerby1011 Date: Sat, 20 Dec 2025 19:06:51 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E5=A4=9A?= =?UTF-8?q?=E7=BB=84=E4=B8=8A=E6=B8=B8=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 UPSTREAM_CONFIG_{n}_* 环境变量支持多组配置 - 根据客户端请求的模型名选择对应的上游配置 - 保持向后兼容,单上游配置仍可用 - 更新相关文档 --- README.md | 33 ++++++++++++++- deno-proxy/src/anthropic_to_openai.ts | 10 +++-- deno-proxy/src/config.ts | 59 +++++++++++++++++++++++---- deno-proxy/src/main.ts | 10 +++-- deno-proxy/src/upstream.ts | 45 ++++++++++++++++---- docs/deno-deployment-guide.md | 28 +++++++++++++ 6 files changed, 162 insertions(+), 23 deletions(-) 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 8de7596..46ffca1 100644 --- a/deno-proxy/src/anthropic_to_openai.ts +++ b/deno-proxy/src/anthropic_to_openai.ts @@ -75,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"); } @@ -118,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/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/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 控制台中,点击项目设置