fix(ddgw): normaliza system no upstream da release v3.8.51 (#11692)

Merged via /merge-batch (2026-08-26, v3.8.51). Draft avaliada normalmente (diretiva padrão do dono) — passou na validação combinada. Obrigado pela contribuição.
This commit is contained in:
Rhian Batista
2026-08-26 17:41:28 -03:00
committed by GitHub
parent 14278e301c
commit 68aa69bc21
4 changed files with 174 additions and 9 deletions

View File

@@ -16,6 +16,7 @@ import { prepareToolMessages, buildToolAwareResult } from "../translator/webTool
import type { Session } from "../services/sessionPool/session.ts";
import { tryBackedChat } from "../services/browserBackedChat.ts";
import { sanitizeErrorMessage } from "../utils/error.ts";
import { normalizeSystemRole } from "../services/roleNormalizer.ts";
// Issue #6999: Lightweight circuit breaker for the DuckDuckGo executor.
// After CB_THRESHOLD consecutive failures (429, 5xx, or network errors),
@@ -559,8 +560,17 @@ export class DuckDuckGoWebExecutor extends BaseExecutor {
}
}
// #ddgw defense-in-depth: duckchat/v1/chat accepts only user/assistant roles.
// Normalize after catalog resolution so the effective upstream model is used.
// This also shields the system tool prompt injected by prepareToolMessages.
const normalizedMessages = normalizeSystemRole(
messages,
"duckduckgo-web",
upstreamModel
) as typeof messages;
const sendChat = async (vqdHeaders: DuckDuckGoAuthHeaders): Promise<Response> => {
const payload = buildDuckDuckGoPayload(upstreamModel, messages);
const payload = buildDuckDuckGoPayload(upstreamModel, normalizedMessages);
const response = await fetch(CHAT_URL, {
method: "POST",
headers: mergeHeadersCaseInsensitive(
@@ -786,10 +796,7 @@ export class DuckDuckGoWebExecutor extends BaseExecutor {
try {
return {
vqd4: retry.vqd4,
vqdHash1: await solveDuckDuckGoChallenge(
retry.vqdHash1,
FAKE_HEADERS["User-Agent"]
),
vqdHash1: await solveDuckDuckGoChallenge(retry.vqdHash1, FAKE_HEADERS["User-Agent"]),
status: retry.status,
retryAfter: retry.retryAfter,
};

View File

@@ -23,6 +23,11 @@ const PROVIDERS_WITHOUT_SYSTEM_ROLE = new Set([
// Known to reject system role (from troubleshooting report)
// GLM uses Claude format, so this is handled through claude translator
// But if accessed through OpenAI-format providers like nvidia, it needs this:
// DuckDuckGo duck.ai (duckchat/v1/chat) accepts only user/assistant roles — a
// system/developer message yields 400 ERR_BAD_REQUEST (#ddgw). Registry id +
// alias are both listed because either may arrive as the routing provider id.
"duckduckgo-web",
"ddgw",
]);
/**

View File

@@ -1,9 +1,10 @@
import { describe, it } from "node:test";
import { describe, it, type TestContext } from "node:test";
import assert from "node:assert/strict";
import { FETCH_TIMEOUT_MS } from "../../open-sse/config/constants.ts";
import {
DuckDuckGoWebExecutor,
DUCKDUCKGO_BASE,
CHAT_URL,
normalizeDuckDuckGoMessages,
STATUS_URL,
} from "../../open-sse/executors/duckduckgo-web.ts";
@@ -231,6 +232,122 @@ describe("DuckDuckGoWebExecutor", () => {
});
});
describe("system-role shielding (#ddgw)", () => {
type ExecuteArgs = Parameters<DuckDuckGoWebExecutor["execute"]>[0];
// duck.ai's duckchat/v1/chat rejects role:"system" with 400 ERR_BAD_REQUEST.
// The translator-side normalizer folds system/developer into the first user
// message; this shield guarantees the executor never forwards such roles
// upstream even when a future bypass reintroduces them after translation
// (e.g. prepareToolMessages' injected tool prompt).
function mockDuckChat(t: TestContext, capturedBodies: unknown[]): void {
t.mock.method(globalThis, "fetch", async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
const method = init?.method ?? "GET";
if (method === "GET" && url === STATUS_URL) {
return new Response(null, {
status: 200,
headers: { "x-vqd-4": "test-vqd-4" },
});
}
if (method === "POST" && url === CHAT_URL) {
capturedBodies.push(JSON.parse(String(init?.body)));
return new Response('data: {"message":"OK"}\n\ndata: [DONE]\n\n', {
status: 200,
headers: { "Content-Type": "text/event-stream" },
});
}
// warmSession + country/token fetches tolerate empty 2xx responses.
return new Response(null, { status: 200 });
});
}
it("folds a leading system message into the first user message before the upstream POST", async (t) => {
const captured: unknown[] = [];
mockDuckChat(t, captured);
await new DuckDuckGoWebExecutor().execute({
model: "gpt-5.4-nano",
body: {
messages: [
{ role: "system", content: "You are a helpful assistant." },
{ role: "user", content: "Reply with OK" },
],
},
stream: false,
} as unknown as ExecuteArgs);
assert.equal(captured.length, 1, "chat POST should be captured");
const upstreamMessages = (captured[0] as { messages: Array<{ role: string }> }).messages;
const roles = upstreamMessages.map((m) => m.role);
assert.equal(
roles.some((r) => r === "system" || r === "developer"),
false,
"no system/developer role may reach the upstream payload"
);
assert.deepEqual(roles, ["user"]);
assert.match(
String((upstreamMessages[0] as { content: string }).content),
/^\[System Instructions\]\n/
);
});
it("preserves plain user/assistant conversations untouched", async (t) => {
const captured: unknown[] = [];
mockDuckChat(t, captured);
await new DuckDuckGoWebExecutor().execute({
model: "gpt-5.4-nano",
body: {
messages: [
{ role: "user", content: "hi" },
{ role: "assistant", content: "hello" },
{ role: "user", content: "bye" },
],
},
stream: false,
} as unknown as ExecuteArgs);
const upstream = (captured[0] as { messages: Array<{ role: string; content: string }> })
.messages;
assert.deepEqual(
upstream.map((m) => [m.role, m.content]),
[
["user", "hi"],
["assistant", "hello"],
["user", "bye"],
]
);
});
it("shields the executor-injected tool prompt system message too", async (t) => {
const captured: unknown[] = [];
mockDuckChat(t, captured);
await new DuckDuckGoWebExecutor().execute({
model: "grole",
body: {
messages: [{ role: "user", content: "list files" }],
tools: [
{
type: "function",
function: { name: "list_files", description: "lists files", parameters: {} },
},
],
},
stream: false,
} as unknown as ExecuteArgs);
const upstream = (captured[0] as { messages: Array<{ role: string; content: string }> })
.messages;
assert.equal(
upstream.some((m) => m.role === "system" || m.role === "developer"),
false,
"the tool-prompt system message must be folded before dispatch"
);
assert.match(String(upstream.at(-1)?.content), /list_files/);
});
});
describe("integration checks", () => {
it("should be properly exported from executor module", async () => {
// Import the singleton as well

View File

@@ -110,9 +110,7 @@ test("normalizeSystemRole still strips the system role for pre-5.1 GLM and bare
{ role: "system", content: "policy" },
{ role: "user", content: "ok" },
];
const merged = [
{ role: "user", content: "[System Instructions]\npolicy\n\n[User Message]\nok" },
];
const merged = [{ role: "user", content: "[System Instructions]\npolicy\n\n[User Message]\nok" }];
for (const model of ["glm", "glm-4.7", "glm-5", "glm-5-turbo", "glm-5.0", "glm-5.0-turbo"]) {
assert.deepEqual(
normalizeSystemRole(messages, "openai", model),
@@ -186,6 +184,44 @@ test("normalizeRoles composes model, developer and system normalization in order
]);
});
test("normalizeSystemRole folds system/developer into the first user message for DuckDuckGo duck.ai providers (#ddgw)", () => {
// duckchat/v1/chat accepts only user/assistant — a system/developer message
// yields 400 ERR_BAD_REQUEST. Both the registry id and its alias must fold.
const messages = [
{ role: "system", content: "You are a helpful assistant." },
{ role: "user", content: "Reply with OK" },
];
for (const provider of ["duckduckgo-web", "ddgw"]) {
const result = normalizeSystemRole(messages, provider, "gpt-5.4-nano");
assert.deepEqual(
result,
[
{
role: "user",
content:
"[System Instructions]\nYou are a helpful assistant.\n\n[User Message]\nReply with OK",
},
],
`expected system folded for provider ${provider}`
);
}
});
test("normalizeRoles keeps folding for every current duck.ai wire model via the ddgw alias (#ddgw)", () => {
const messages = [
{ role: "developer", content: "policy" },
{ role: "user", content: "hello" },
];
for (const model of ["gpt-5.4-mini", "gpt-5.4-nano", "claude-haiku-4-5", "mistral-small-2603"]) {
const result = normalizeRoles(messages, "ddgw", model, "openai");
assert.deepEqual(
result,
[{ role: "user", content: "[System Instructions]\npolicy\n\n[User Message]\nhello" }],
`expected developer folded for ${model}`
);
}
});
test("role normalization returns non-arrays unchanged", () => {
assert.equal(normalizeDeveloperRole(null, "openai"), null);
assert.equal(normalizeModelRole("invalid"), "invalid");