fix: sanitize deepseek response tools
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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` sidecar:Service 端口 4000 指向 bridge,bridge 对 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` sidecar:Service 端口 4000 指向 bridge,bridge 对 zstd request body 解压、删除 `Content-Encoding`/重写 `Content-Length` 后转发给 4001 的 LiteLLM,并把 `GET /v1/models` 转成 Codex-style `{ "models": [...] }` 目录。DeepSeek API 只接受 `function` tools,bridge 必须在转发前丢弃 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 值。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user