mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-21 06:12:17 +03:00
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:
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user