fix(providers): route duckduckgo-web via duck.ai and self-heal stale model catalog (#11597)

Merged via /merge-batch (lote 2026-08-26, v3.8.51). Boarded no worktree combinado junto com outras ~30 PRs; validação única: typecheck/complexity/cognitive-complexity/changelog-integrity verdes, file-size rebaseado onde necessário (crescimento legítimo), lint com os mesmos 228 achados pré-existentes confirmados via sonda contra o tip puro (não introduzidos por este lote), e ~370 testes focados (unit + vitest) passando. Obrigado pela contribuição.
This commit is contained in:
solstxce
2026-08-26 16:39:38 +05:30
committed by GitHub
parent bd4a7629b6
commit 5d7d4cb953
9 changed files with 314 additions and 110 deletions

View File

@@ -0,0 +1,84 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import {
extractFreeDuckDuckGoModelIds,
pickDuckDuckGoModel,
normalizeDuckDuckGoModel,
} from "../../open-sse/executors/duckduckgo-web/models.ts";
// Structural guard (challenge-split precedent): keeps this suite runnable without
// the full executor dependency graph while still pinning the endpoint wiring.
const EXECUTOR_SOURCE = readFileSync(
fileURLToPath(new URL("../../open-sse/executors/duckduckgo-web.ts", import.meta.url)),
"utf8"
);
// Live lineup per GET /duckchat/v1/models (2026-08-26).
const LIVE_IDS = new Set([
"gpt-5.4",
"gpt-5.6-luna",
"gpt-5.4-mini",
"claude-sonnet-4-6",
"claude-haiku-4-5",
"claude-opus-4-8",
"mistral-small-2603",
"tinfoil/gpt-oss-120b",
"tinfoil/gemma4-31b",
]);
test("live validation: current wire ids pass through untouched", () => {
for (const id of ["gpt-5.4-mini", "gpt-5.6-luna", "claude-haiku-4-5"]) {
assert.equal(pickDuckDuckGoModel(id, LIVE_IDS), id);
}
});
test("live catalog: only models with free access are routable", () => {
assert.deepEqual(
extractFreeDuckDuckGoModelIds({
models: [
{ id: "gpt-5.6-luna", accessTier: ["free", "pro"] },
{ id: "gpt-5.4", accessTier: ["internal", "pro"] },
{ id: "claude-haiku-4-5", accessTier: ["free"] },
{ id: "missing-tier" },
],
}),
new Set(["gpt-5.6-luna", "claude-haiku-4-5"])
);
});
test("live validation: retired ids resolve through aliases when still live elsewhere", () => {
assert.equal(pickDuckDuckGoModel("gpt-5.4-nano", LIVE_IDS), "gpt-5.4-mini");
assert.equal(pickDuckDuckGoModel("gpt-4o-mini", LIVE_IDS), "gpt-5.4-mini");
assert.equal(pickDuckDuckGoModel("gpt-oss-120b", LIVE_IDS), "tinfoil/gpt-oss-120b");
});
test("live validation: fully unknown id falls back to the default free model", () => {
assert.equal(pickDuckDuckGoModel("totally-made-up-model", LIVE_IDS), "gpt-5.4-mini");
});
test("live validation: unavailable live list degrades to passthrough (no silent rewrite)", () => {
assert.equal(pickDuckDuckGoModel("gpt-5.4-mini", null), "gpt-5.4-mini");
assert.equal(pickDuckDuckGoModel("some-new-upstream-id", new Set()), "some-new-upstream-id");
});
test("live validation: normalize keeps prefix-strip + alias order stable", () => {
assert.equal(normalizeDuckDuckGoModel(undefined), "gpt-5.4-mini");
assert.equal(normalizeDuckDuckGoModel("duckduckgo-web/gpt-5.6-luna"), "gpt-5.6-luna");
});
test("no-raw-hash guard: solver failure must not fall back to the unsolved challenge", () => {
// Regression: acquireAuthHeaders' catch used to `return headers;`, forwarding the
// RAW x-vqd-hash-1 challenge upstream — a guaranteed 418 ERR_CHALLENGE whose wasted
// call still counted toward the IP rate limit (spurious 429s). The fixed source
// retries via acquireVqdHeaders instead; pin its absence structurally.
assert.doesNotMatch(EXECUTOR_SOURCE, /catch \(error\) \{\s*void error;\s*return headers;/);
assert.match(EXECUTOR_SOURCE, /acquireAuthHeaders/);
});
test("models endpoint: token-free /models shares the executor host", () => {
assert.match(EXECUTOR_SOURCE, /export const MODELS_URL = `\$\{DUCKDUCKGO_BASE\}\/duckchat\/v1\/models`;/);
assert.match(EXECUTOR_SOURCE, /export const DUCKDUCKGO_BASE = "https:\/\/duck\.ai";/);
});

View File

@@ -1,82 +1,63 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import {
DUCKDUCKGO_BASE,
STATUS_URL,
CHAT_URL,
FAKE_HEADERS,
FE_VERSION_PATTERN,
} from "../../open-sse/executors/duckduckgo-web.ts";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
// Regression for GitHub #4037 (DuckDuckGo half only): DuckDuckGo AI Chat returns HTTP 400.
// Root cause 1 (primary): the executor's STATUS_URL/CHAT_URL/Origin/Referer pointed at
// `https://duck.ai` while `Sec-Fetch-Site: same-origin` was sent and the request hit
// duck.ai — an inconsistent same-origin triplet the backend rejects with 400. Every current
// DDG reverse-engineering reference (and the registry baseUrl) uses `https://duckduckgo.com`.
// Root cause 2 (secondary): FE_VERSION_PATTERN required a 40-hex tail, but the real served
// x-fe-version token has a 20-hex tail, so the scrape silently fell back to a hardcoded
// future-dated default.
import { FE_VERSION_PATTERN } from "../../open-sse/executors/duckduckgo-web/models.ts";
// Structural guard (same pattern as duckduckgo-challenge-split.test.ts): this file
// must stay runnable without the full executor dependency graph, so host invariants
// are pinned against the SOURCE instead of runtime imports.
const EXECUTOR_SOURCE = readFileSync(
fileURLToPath(new URL("../../open-sse/executors/duckduckgo-web.ts", import.meta.url)),
"utf8"
);
// Regression for GitHub #4037 (DuckDuckGo half only), updated 2026-08-26.
// Original bug: STATUS_URL/CHAT_URL/Origin/Referer formed a MIXED same-origin
// triplet (duck.ai host with duckduckgo.com Origin/Referer), rejected with 400.
// The fix unified everything on duckduckgo.com. Live verification on 2026-08-26
// showed the full status -> challenge -> chat flow also returns 200 with a fully
// consistent duck.ai triplet — and the challenge solver already stamps
// meta.origin = https://duck.ai — so the primary host moved to duck.ai, putting
// host and token origin in agreement by construction.
describe("DuckDuckGo AI Chat domain consistency (#4037)", () => {
describe("URL/header host is duckduckgo.com (not duck.ai)", () => {
it("STATUS_URL uses duckduckgo.com", () => {
assert.ok(
STATUS_URL.startsWith(`${DUCKDUCKGO_BASE}/`),
`STATUS_URL should start with ${DUCKDUCKGO_BASE}, got ${STATUS_URL}`
);
assert.ok(!STATUS_URL.includes("duck.ai"), `STATUS_URL must not reference duck.ai: ${STATUS_URL}`);
});
it("primary host is duck.ai", () => {
assert.match(EXECUTOR_SOURCE, /export const DUCKDUCKGO_BASE = "https:\/\/duck\.ai";/);
});
it("CHAT_URL uses duckduckgo.com", () => {
assert.ok(
CHAT_URL.startsWith(`${DUCKDUCKGO_BASE}/`),
`CHAT_URL should start with ${DUCKDUCKGO_BASE}, got ${CHAT_URL}`
);
assert.ok(!CHAT_URL.includes("duck.ai"), `CHAT_URL must not reference duck.ai: ${CHAT_URL}`);
});
it("all duckchat endpoints derive from DUCKDUCKGO_BASE (triplet consistent by construction)", () => {
assert.match(
EXECUTOR_SOURCE,
/export const STATUS_URL = `\$\{DUCKDUCKGO_BASE\}\/duckchat\/v1\/status`;/
);
assert.match(
EXECUTOR_SOURCE,
/export const CHAT_URL = `\$\{DUCKDUCKGO_BASE\}\/duckchat\/v1\/chat`;/
);
assert.match(
EXECUTOR_SOURCE,
/export const MODELS_URL = `\$\{DUCKDUCKGO_BASE\}\/duckchat\/v1\/models`;/
);
});
it("Origin header points at duckduckgo.com", () => {
assert.equal(FAKE_HEADERS.Origin, "https://duckduckgo.com");
assert.ok(!FAKE_HEADERS.Origin.includes("duck.ai"), "Origin must not be duck.ai");
});
it("Referer header points at duckduckgo.com", () => {
assert.equal(FAKE_HEADERS.Referer, "https://duckduckgo.com/");
assert.ok(!FAKE_HEADERS.Referer.includes("duck.ai"), "Referer must not be duck.ai");
});
it("keeps Sec-Fetch-Site: same-origin consistent with duckduckgo.com Origin/Referer", () => {
// The same-origin triplet (request host + Origin + Referer) must all agree.
assert.equal(FAKE_HEADERS["Sec-Fetch-Site"], "same-origin");
const originHost = new URL(FAKE_HEADERS.Origin).host;
const refererHost = new URL(FAKE_HEADERS.Referer).host;
const statusHost = new URL(STATUS_URL).host;
const chatHost = new URL(CHAT_URL).host;
assert.equal(originHost, refererHost, "Origin and Referer hosts must match");
assert.equal(originHost, statusHost, "Origin host must match STATUS_URL host");
assert.equal(originHost, chatHost, "Origin host must match CHAT_URL host");
assert.equal(originHost, "duckduckgo.com");
});
it("FAKE_HEADERS Origin/Referer derive from DUCKDUCKGO_BASE (no mixed domains)", () => {
assert.match(EXECUTOR_SOURCE, /Origin: DUCKDUCKGO_BASE,/);
assert.match(EXECUTOR_SOURCE, /Referer: `\$\{DUCKDUCKGO_BASE\}\/`,/);
assert.doesNotMatch(EXECUTOR_SOURCE, /Origin: "https:\/\/duckduckgo\.com"/);
assert.match(EXECUTOR_SOURCE, /"Sec-Fetch-Site": "same-origin"/);
});
describe("FE_VERSION_PATTERN matches the real served token", () => {
it("matches a real 20-hex-tail token", () => {
// Real served example from the DDG SERP HTML.
const realToken = "serp_20250401_100419_ET-19d438eb199b2bf7c300";
assert.equal(
FE_VERSION_PATTERN.test(realToken),
true,
`FE_VERSION_PATTERN should match the real 20-hex token: ${realToken}`
);
assert.equal(FE_VERSION_PATTERN.test(realToken), true);
});
it("still matches a 40-hex-tail token (backward compatible)", () => {
const fortyHexToken =
"serp_20260424_180649_ET-0bdc33b2a02ebf8f235def65d887787f694720a1";
assert.equal(
FE_VERSION_PATTERN.test(fortyHexToken),
true,
"FE_VERSION_PATTERN should still match a 40-hex token"
);
assert.equal(FE_VERSION_PATTERN.test(fortyHexToken), true);
});
it("extracts the token from surrounding HTML", () => {

View File

@@ -32,7 +32,7 @@ describe("DuckDuckGoWebExecutor", () => {
it("should export DUCKDUCKGO_BASE constant", () => {
assert.equal(
DUCKDUCKGO_BASE,
"https://duckduckgo.com",
"https://duck.ai",
"DUCKDUCKGO_BASE should be correct URL"
);
});

View File

@@ -4,16 +4,17 @@ import assert from "node:assert/strict";
import {
normalizeDuckDuckGoModel,
DUCKDUCKGO_DEFAULT_MODEL,
} from "../../open-sse/executors/duckduckgo-web.ts";
} from "../../open-sse/executors/duckduckgo-web/models.ts";
import { duckduckgo_webProvider } from "../../open-sse/config/providers/registry/duckduckgo-web/index.ts";
import { FREE_MODEL_BUDGETS } from "../../open-sse/config/freeModelCatalog.data.ts";
// #8000 — the current free Duck.ai lineup, wire ids captured live from
// duckchat/v1/models (2026-07-22). A retired/unknown model id is rejected by
// duckchat/v1/models (re-captured 2026-08-26: gpt-5.4-nano retired upstream,
// gpt-5.6-luna added to the free tier). A retired/unknown model id is rejected by
// duckchat/v1/chat with 400 ERR_BAD_REQUEST, which is the exact reported symptom.
const CURRENT_FREE_IDS = new Set([
"gpt-5.4-mini",
"gpt-5.4-nano",
"gpt-5.6-luna",
"claude-haiku-4-5",
"mistral-small-2603",
"tinfoil/gpt-oss-120b",
@@ -28,6 +29,7 @@ const RETIRED_IDS = [
"llama-4-scout",
"claude-3-5-haiku-20241022",
"mistral-small-2501",
"gpt-5.4-nano",
];
test("#8000: default model is a current free wire id, not retired gpt-4o-mini", () => {
@@ -44,7 +46,9 @@ test("#8000: every retired id normalizes to a current wire id (never passes thro
}
// the `duckduckgo-web/` routing prefix is stripped before aliasing
assert.ok(CURRENT_FREE_IDS.has(normalizeDuckDuckGoModel("duckduckgo-web/gpt-4o-mini")));
assert.equal(normalizeDuckDuckGoModel("duckduckgo-web/gpt-5.4-nano"), "gpt-5.4-nano");
// gpt-5.4-nano was retired upstream between the 2026-07-22 and 2026-08-26 captures;
// existing selections must alias forward instead of hard-failing with 400.
assert.equal(normalizeDuckDuckGoModel("duckduckgo-web/gpt-5.4-nano"), "gpt-5.4-mini");
});
test("#8000: current wire ids pass through unchanged", () => {
@@ -61,6 +65,13 @@ test("#8000: provider registry advertises exactly the current wire ids", () => {
}
});
test("#8000: provider registry baseUrl tracks the executor host", () => {
assert.ok(
duckduckgo_webProvider.baseUrl.startsWith("https://duck.ai/"),
`registry baseUrl must point at duck.ai, got ${duckduckgo_webProvider.baseUrl}`
);
});
test("#8000: free-model catalog advertises exactly the current wire ids", () => {
const ids = FREE_MODEL_BUDGETS.filter((e) => e.provider === "duckduckgo-web").map(
(e) => e.modelId