fix: sanitize deepseek response tools

This commit is contained in:
Codex
2026-05-26 08:49:43 +08:00
parent f9efaa0b20
commit 3b209f0ab6
3 changed files with 50 additions and 12 deletions
+35 -4
View File
@@ -81,7 +81,7 @@ async function sendCodexModels(clientReq, clientRes, url) {
async function proxyRequest(clientReq, clientRes, url) {
const startedAt = Date.now();
const originalBody = await readRequestBody(clientReq);
const { body, headers, decompressed } = decodeRequestBody(originalBody, clientReq.headers);
const { body, headers, decompressed, sanitizedToolTypes } = decodeRequestBody(originalBody, clientReq.headers, url.pathname);
const targetUrl = new URL(`${url.pathname}${url.search}`, upstreamBaseUrl);
const requestImpl = targetUrl.protocol === "https:" ? httpsRequest : httpRequest;
@@ -103,6 +103,7 @@ async function proxyRequest(clientReq, clientRes, url) {
path: url.pathname,
status: upstreamRes.statusCode || null,
decompressed,
sanitizedToolTypes,
elapsedMs: Date.now() - startedAt
}) + "\n");
});
@@ -125,14 +126,44 @@ async function proxyRequest(clientReq, clientRes, url) {
upstreamReq.end(body);
}
function decodeRequestBody(body, headers) {
function decodeRequestBody(body, headers, pathname = "") {
const encoding = String(headers["content-encoding"] || "").trim().toLowerCase();
const decoded = encoding === "zstd" ? Buffer.from(decompress(new Uint8Array(body))) : body;
const sanitized = sanitizeResponsesBody(decoded, headers, pathname);
const forwarded = filterRequestHeaders(headers);
if (encoding === "zstd") delete forwarded["content-encoding"];
forwarded["content-length"] = String(decoded.length);
forwarded["content-length"] = String(sanitized.body.length);
forwarded.host = upstreamBaseUrl.host;
return { body: decoded, headers: forwarded, decompressed: encoding === "zstd" };
return { body: sanitized.body, headers: forwarded, decompressed: encoding === "zstd", sanitizedToolTypes: sanitized.droppedToolTypes };
}
function sanitizeResponsesBody(body, headers, pathname) {
const contentType = String(headers["content-type"] || "").toLowerCase();
if (pathname !== "/v1/responses" || !contentType.includes("application/json")) {
return { body, droppedToolTypes: [] };
}
let payload = null;
try {
payload = JSON.parse(body.toString("utf8"));
} catch {
return { body, droppedToolTypes: [] };
}
if (!payload || typeof payload !== "object" || !Array.isArray(payload.tools)) {
return { body, droppedToolTypes: [] };
}
const keptTools = [];
const droppedToolTypes = [];
for (const tool of payload.tools) {
const type = String(tool?.type || "").trim();
if (type === "function") {
keptTools.push(tool);
} else {
droppedToolTypes.push(type || "unknown");
}
}
if (droppedToolTypes.length === 0) return { body, droppedToolTypes: [] };
const sanitized = { ...payload, tools: keptTools };
return { body: Buffer.from(JSON.stringify(sanitized), "utf8"), droppedToolTypes };
}
async function readRequestBody(req) {
@@ -4,13 +4,18 @@ import { createServer } from "node:http";
import test from "node:test";
const zstdPayload = Buffer.from([
0x28, 0xb5, 0x2f, 0xfd, 0x04, 0x58, 0x49, 0x01, 0x00, 0x7b, 0x22, 0x6d,
0x6f, 0x64, 0x65, 0x6c, 0x22, 0x3a, 0x22, 0x64, 0x65, 0x65, 0x70, 0x73,
0x65, 0x65, 0x6b, 0x2d, 0x63, 0x68, 0x61, 0x74, 0x22, 0x2c, 0x22, 0x69,
0x6e, 0x70, 0x75, 0x74, 0x22, 0x3a, 0x22, 0x68, 0x65, 0x6c, 0x6c, 0x6f,
0x22, 0x7d, 0xc0, 0x09, 0xab, 0xba
0x28, 0xb5, 0x2f, 0xfd, 0x04, 0x58, 0x75, 0x03, 0x00, 0x32, 0x47, 0x16,
0x18, 0xa0, 0xa7, 0x39, 0x64, 0x06, 0x2d, 0xdf, 0x85, 0x36, 0x31, 0xd8,
0xd4, 0x6c, 0x92, 0xb2, 0x4d, 0x00, 0x06, 0xdd, 0x98, 0xe9, 0x7f, 0x11,
0x02, 0x4d, 0xd0, 0x9a, 0x4d, 0x59, 0xd4, 0xe1, 0x48, 0x55, 0x64, 0x87,
0x85, 0xa7, 0x17, 0xc3, 0x0b, 0x44, 0x87, 0x34, 0x67, 0xde, 0x2c, 0x9f,
0x8f, 0x30, 0x99, 0x38, 0xa9, 0xb3, 0x25, 0x19, 0x03, 0x9f, 0xaf, 0xc7,
0x9a, 0x05, 0xce, 0x2b, 0x95, 0x5a, 0x13, 0xab, 0xcc, 0x1b, 0x9f, 0xd7,
0xd0, 0x74, 0x4c, 0xbc, 0x68, 0x0a, 0x80, 0xf0, 0x2f, 0xfd, 0x09, 0x9f,
0xf3, 0x84, 0x42, 0x66, 0x01, 0x06, 0x00, 0x4e, 0x20, 0xe8, 0x2a, 0x21,
0x04, 0x03, 0x3f, 0x0d, 0x38, 0x75, 0xb3, 0x32, 0x85, 0x32, 0x03, 0xdb,
0x5b, 0xed, 0x15
]);
const decodedPayload = JSON.stringify({ model: "deepseek-chat", input: "hello" });
test("DeepSeek Responses bridge decompresses Codex zstd body and maps models catalog", async () => {
const upstream = await startUpstream();
@@ -26,7 +31,9 @@ test("DeepSeek Responses bridge decompresses Codex zstd body and maps models cat
});
assert.equal(response.status, 200);
assert.equal(upstream.captured.encoding, null);
assert.equal(upstream.captured.body, decodedPayload);
const forwarded = JSON.parse(upstream.captured.body);
assert.equal(forwarded.model, "deepseek-chat");
assert.deepEqual(forwarded.tools.map((tool) => tool.type), ["function", "function"]);
const modelsResponse = await fetch(`http://127.0.0.1:${bridge.port}/v1/models?client_version=test`);
assert.equal(modelsResponse.status, 200);
+1 -1
View File
@@ -50,7 +50,7 @@ G14 Code Agent 通过同一个 repo-owned Codex app-server stdio runner 承载
`hwlab-cloud-api` 仍以 `HWLAB_CODE_AGENT_PROVIDER=codex-stdio` 运行。`HWLAB_CODE_AGENT_MODEL``HWLAB_CODE_AGENT_OPENAI_BASE_URL` 可作为 runtime-default 兜底,但前端默认 profile 是 `deepseek`,用户可以切到 `codex-api`。Codex app-server 启动参数必须同时设置 provider base URL、provider `name``model``review_model`,否则 Codex/LiteLLM 链路可能在模型目录或 `/v1/responses` 阶段退化成 `model=None`。旧 `codex-api` profile 指向 `172.26.26.227`cloud-api 的 `NO_PROXY/no_proxy` 必须包含该 host 或私网段,避免旧通道被 G14 外网代理误拦截。
Codex app-server 当前要求 provider `wire_api="responses"`,不得把 DeepSeek profile 切到旧 `chat` wire API。Codex 发往 `/v1/responses` 的请求体可能带 `Content-Encoding: zstd`,而 LiteLLM DeepSeek bridge 默认不能按 Codex 期望解压该请求体,也不能直接返回 Codex 期望的 `models` 目录格式。因此 GitOps 中的 `hwlab-deepseek-proxy` Pod 必须包含 repo-owned `hwlab-deepseek-responses-bridge` sidecarService 端口 4000 指向 bridgebridge 对 zstd request body 解压、删除 `Content-Encoding`/重写 `Content-Length` 后转发给 4001 的 LiteLLM,并把 `GET /v1/models` 转成 Codex-style `{ "models": [...] }` 目录。模型、密钥和真实推理仍由 LiteLLM 管理;bridge 不新增业务 gate,也不得把兼容失败伪装成 SOURCE/legacy blocker。
Codex app-server 当前要求 provider `wire_api="responses"`,不得把 DeepSeek profile 切到旧 `chat` wire API。Codex 发往 `/v1/responses` 的请求体可能带 `Content-Encoding: zstd`,而 LiteLLM DeepSeek bridge 默认不能按 Codex 期望解压该请求体,也不能直接返回 Codex 期望的 `models` 目录格式。因此 GitOps 中的 `hwlab-deepseek-proxy` Pod 必须包含 repo-owned `hwlab-deepseek-responses-bridge` sidecarService 端口 4000 指向 bridgebridge 对 zstd request body 解压、删除 `Content-Encoding`/重写 `Content-Length` 后转发给 4001 的 LiteLLM,并把 `GET /v1/models` 转成 Codex-style `{ "models": [...] }` 目录。DeepSeek API 只接受 `function` toolsbridge 必须在转发前丢弃 Codex Responses 请求里的非 `function` tool 类型(例如 `image_generation`),但不得移除 shell/apply-patch 等 function tools。模型、密钥和真实推理仍由 LiteLLM 管理;bridge 不新增业务 gate,也不得把兼容失败伪装成 SOURCE/legacy blocker。
DeepSeek proxy manifest 是 GitOps desired state 的一部分,DEV/PROD 分别生成在 `runtime-dev/deepseek-proxy.yaml``runtime-prod/deepseek-proxy.yaml`。DeepSeek key 仍通过 `hwlab-code-agent-provider/openai-api-key` Secret 注入到 proxy 容器;文档、trace、health、issue 和日志不得打印 Secret 值。