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 @@
- **fix(providers):** route DuckDuckGo Web Chat through the verified `duck.ai` endpoint, refresh its free model catalog, and avoid forwarding unsolved anti-abuse challenges ([#11597](https://github.com/diegosouzapw/OmniRoute/pull/11597))

View File

@@ -16,7 +16,7 @@ import type { FreeModelBudget } from "./freeModelCatalog.ts";
* rewrites file timestamps on every deploy, which would report a months-old
* catalog as "updated today". Bump this whenever the entries below change.
*/
export const FREE_CATALOG_CURATED_AT = "2026-08-20";
export const FREE_CATALOG_CURATED_AT = "2026-08-26";
export const FREE_MODEL_BUDGETS: FreeModelBudget[] = [
{ provider: "chatgpt-web", modelId: "gpt-5.6-luna-free", displayName: "GPT-5.6 Luna (Free)", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "chatgpt-web-free", tos: "caution" },
@@ -154,7 +154,7 @@ export const FREE_MODEL_BUDGETS: FreeModelBudget[] = [
{ provider: "deepseek", modelId: "deepseek-v4-flash", displayName: "DeepSeek V4 Flash", monthlyTokens: 0, creditTokens: 5000000, freeType: "one-time-initial", poolKey: "deepseek", tos: "ok" },
{ provider: "doubao", modelId: "doubao-pro-32k", displayName: "Doubao Pro 32K", monthlyTokens: 0, creditTokens: 15000000, freeType: "one-time-initial", poolKey: "doubao", tos: "ambiguous" },
{ provider: "duckduckgo-web", modelId: "gpt-5.4-mini", displayName: "GPT-5.4 Mini", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "duckduckgo-web", tos: "avoid" },
{ provider: "duckduckgo-web", modelId: "gpt-5.4-nano", displayName: "GPT-5.4 Nano", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "duckduckgo-web", tos: "avoid" },
{ provider: "duckduckgo-web", modelId: "gpt-5.6-luna", displayName: "GPT-5.6 Luna", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "duckduckgo-web", tos: "avoid" },
{ provider: "duckduckgo-web", modelId: "claude-haiku-4-5", displayName: "Claude Haiku 4.5", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "duckduckgo-web", tos: "avoid" },
{ provider: "duckduckgo-web", modelId: "mistral-small-2603", displayName: "Mistral Small 4", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "duckduckgo-web", tos: "avoid" },
{ provider: "duckduckgo-web", modelId: "tinfoil/gpt-oss-120b", displayName: "gpt-oss 120B", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "duckduckgo-web", tos: "avoid" },

View File

@@ -5,13 +5,14 @@ export const duckduckgo_webProvider: RegistryEntry = {
alias: "ddgw",
format: "openai",
executor: "duckduckgo-web",
baseUrl: "https://duckduckgo.com/duckchat/v1/chat",
baseUrl: "https://duck.ai/duckchat/v1/chat",
authType: "none",
authHeader: "none",
// #8000: current Duck.ai free lineup — wire ids per duckchat/v1/models (2026-07-22).
// #8000: current Duck.ai free lineup — wire ids per duckchat/v1/models (2026-08-26):
// gpt-5.4-nano was retired upstream and gpt-5.6-luna joined the free tier.
models: [
{ id: "gpt-5.4-mini", name: "GPT-5.4 Mini", toolCalling: false },
{ id: "gpt-5.4-nano", name: "GPT-5.4 Nano", toolCalling: false },
{ id: "gpt-5.6-luna", name: "GPT-5.6 Luna", toolCalling: false },
{ id: "claude-haiku-4-5", name: "Claude Haiku 4.5", toolCalling: false },
{ id: "mistral-small-2603", name: "Mistral Small 4", toolCalling: false },
{ id: "tinfoil/gpt-oss-120b", name: "gpt-oss 120B", toolCalling: false },

View File

@@ -2,6 +2,14 @@ import { Buffer } from "node:buffer";
import { generateKeyPairSync, randomUUID } from "node:crypto";
import vm from "node:vm";
import { solveDuckDuckGoChallenge, makeDuckDuckGoFeSignals } from "./duckduckgo-web/challenge.ts";
import {
DUCKDUCKGO_DEFAULT_MODEL,
DUCKDUCKGO_MODEL_ALIASES,
FE_VERSION_PATTERN,
extractFreeDuckDuckGoModelIds,
normalizeDuckDuckGoModel,
pickDuckDuckGoModel,
} from "./duckduckgo-web/models.ts";
import { BaseExecutor, type ExecuteInput } from "./base.ts";
import { FETCH_TIMEOUT_MS } from "../config/constants.ts";
import { prepareToolMessages, buildToolAwareResult } from "../translator/webTools.ts";
@@ -63,22 +71,23 @@ export function __getDdgCircuitBreakerStateForTests(): CircuitBreakerState {
return { ...circuitBreaker };
}
export const DUCKDUCKGO_BASE = "https://duckduckgo.com";
// #4037: the live DuckDuckGo AI Chat backend is served from duckduckgo.com. The
// status/chat fetches, Origin, and Referer must all use this host so the request's
// same-origin triplet (host + Origin + Referer) stays consistent with
// `Sec-Fetch-Site: same-origin`; pointing them at duck.ai produced an inconsistent
// triplet the backend rejected with HTTP 400.
// Primary host moved to https://duck.ai (live-verified 2026-08-26): with the
// same-origin triplet kept consistent (host + Origin + Referer all duck.ai), the
// full status -> challenge -> chat flow returns 200 there, and the challenge solver
// already stamps meta.origin = https://duck.ai, so request host and token origin
// agree by construction. #4037's HTTP 400 came from a MIXED triplet (duck.ai host
// with duckduckgo.com Origin/Referer), not from the duck.ai host itself.
export const DUCKDUCKGO_BASE = "https://duck.ai";
const AUTH_TOKEN_URL = `${DUCKDUCKGO_BASE}/duckchat/v1/auth/token`;
const COUNTRY_URL = `${DUCKDUCKGO_BASE}/country.json`;
export const STATUS_URL = `${DUCKDUCKGO_BASE}/duckchat/v1/status`;
export const CHAT_URL = `${DUCKDUCKGO_BASE}/duckchat/v1/chat`;
// Token-free model list (no VQD/challenge required) used to self-heal catalog drift.
export const MODELS_URL = `${DUCKDUCKGO_BASE}/duckchat/v1/models`;
const DEFAULT_FE_VERSION = "serp_20260424_180649_ET-0bdc33b2a02ebf8f235def65d887787f694720a1";
// #4037: the real served x-fe-version token has a 20-hex tail (e.g.
// `serp_20250401_100419_ET-19d438eb199b2bf7c300`); the previous `{40}` requirement
// never matched the live token, so the scrape silently fell back to DEFAULT_FE_VERSION.
// Bounded `{20,40}` keeps the pattern ReDoS-safe.
export const FE_VERSION_PATTERN = /serp_\d{8}_\d{6}_[A-Z]{2}-[0-9a-f]{20,40}/;
// Live-served x-fe-version matcher moved to ./duckduckgo-web/models.ts; re-exported
// for existing importers.
export { FE_VERSION_PATTERN };
const DEFAULT_USER_AGENT =
"Mozilla/5.0 (X11; Linux x86_64) " +
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36";
@@ -109,6 +118,10 @@ const SEEDED_COOKIES: ReadonlyArray<readonly [string, string]> = [
["isRecentChatOn", "1"],
];
// GET /duckchat/v1/models needs no VQD/challenge token; cache it briefly so combo
// fan-out doesn't refetch per request and never counts toward chat rate limits.
const MODEL_IDS_CACHE_TTL_MS = 10 * 60 * 1000;
function shouldUseBrowserBacked(): boolean {
const flag = process.env.WEB_COOKIE_USE_BROWSER;
if (flag === "1" || flag === "true" || flag === "on") return true;
@@ -238,33 +251,19 @@ function mergeHeadersCaseInsensitive(
}
/**
* #8000: DuckDuckGo's free Duck.ai lineup churns and the catalog fell behind. Map every
* retired id OmniRoute historically advertised to the current wire id served by
* `duckchat/v1/models` (captured 2026-07-22) — a retired/unknown `model` yields a 400
* `ERR_BAD_REQUEST` from `duckchat/v1/chat`. Current free wire ids: gpt-5.4-mini,
* gpt-5.4-nano, claude-haiku-4-5, mistral-small-2603, tinfoil/gpt-oss-120b, tinfoil/gemma4-31b.
* #8000: DuckDuckGo's free Duck.ai lineup churns. The static alias map now lives in
* ./duckduckgo-web/models.ts (wire ids re-captured live 2026-08-26 — gpt-5.4-nano
* retired, gpt-5.6-luna added); re-exported here for existing importers. Runtime
* validation against the token-free /duckchat/v1/models handles future churn
* without shipping a new catalog snapshot every time.
*/
export const DUCKDUCKGO_DEFAULT_MODEL = "gpt-5.4-mini";
export const DUCKDUCKGO_MODEL_ALIASES: Readonly<Record<string, string>> = {
// retired OpenAI ids → current GPT-5.4 free tier
"gpt-4o-mini": "gpt-5.4-mini",
"gpt-5-mini": "gpt-5.4-mini",
"o3-mini": "gpt-5.4-nano",
// retired Llama (dropped from Duck.ai free) → nearest general free model
"llama-4-scout": "gpt-5.4-mini",
// renamed/versioned ids
"claude-3-5-haiku-20241022": "claude-haiku-4-5",
"mistral-small-2501": "mistral-small-2603",
"gpt-oss-120b": "tinfoil/gpt-oss-120b",
"gemma4-31b": "tinfoil/gemma4-31b",
export {
DUCKDUCKGO_DEFAULT_MODEL,
DUCKDUCKGO_MODEL_ALIASES,
extractFreeDuckDuckGoModelIds,
normalizeDuckDuckGoModel,
};
export function normalizeDuckDuckGoModel(model: string | undefined): string {
if (!model) return DUCKDUCKGO_DEFAULT_MODEL;
const clean = model.startsWith("duckduckgo-web/") ? model.slice("duckduckgo-web/".length) : model;
return DUCKDUCKGO_MODEL_ALIASES[clean] ?? clean;
}
function getDuckDuckGoModelCapabilities(model: string): DuckDuckGoModelCapabilities {
// `reasoningEffort` is REQUIRED on every duckchat/v1/chat request. Omitting it
// returns 400 ERR_BAD_REQUEST — A/B verified live against duck.ai with an
@@ -374,6 +373,7 @@ export class DuckDuckGoWebExecutor extends BaseExecutor {
private feVersion = DEFAULT_FE_VERSION;
private pendingVqdHash1: string | null = null;
private readonly cookieJar = new Map<string, string>();
private modelsCache: { ids: Set<string>; fetchedAt: number } | null = null;
private buildRequestHeaders(extra: Record<string, string> = {}): Record<string, string> {
const headers = { ...FAKE_HEADERS, ...extra };
@@ -455,7 +455,7 @@ export class DuckDuckGoWebExecutor extends BaseExecutor {
// `return` statements as errors.
async execute(input: ExecuteInput) {
const { model, body, stream, signal, upstreamExtraHeaders } = input;
const upstreamModel = normalizeDuckDuckGoModel(model);
const requestedModel = normalizeDuckDuckGoModel(model);
const bodyObj = (body || {}) as Record<string, unknown>;
const rawMessages = normalizeDuckDuckGoMessages(bodyObj.messages);
const { hasTools, requestedTools, effectiveMessages } = prepareToolMessages(
@@ -544,6 +544,21 @@ export class DuckDuckGoWebExecutor extends BaseExecutor {
? AbortSignal.any([signal, controller.signal])
: controller.signal;
// Self-heal against catalog churn (#8000 recurred with gpt-5.4-nano): when the
// resolved id isn't in the live token-free /models list, reroute through the
// alias map / default instead of burning a doomed chat call (400/429 fodder).
let upstreamModel = requestedModel;
const liveModelIds = await this.getLiveModelIds(mergedSignal);
if (liveModelIds && !liveModelIds.has(upstreamModel)) {
const fallbackModel = pickDuckDuckGoModel(upstreamModel, liveModelIds);
if (fallbackModel !== upstreamModel) {
console.warn(
`[duckduckgo-web] model "${upstreamModel}" absent from the live duckchat catalog — routing as "${fallbackModel}"`
);
upstreamModel = fallbackModel;
}
}
const sendChat = async (vqdHeaders: DuckDuckGoAuthHeaders): Promise<Response> => {
const payload = buildDuckDuckGoPayload(upstreamModel, messages);
const response = await fetch(CHAT_URL, {
@@ -674,6 +689,28 @@ export class DuckDuckGoWebExecutor extends BaseExecutor {
}
}
private async getLiveModelIds(signal: AbortSignal): Promise<Set<string> | null> {
const now = Date.now();
if (this.modelsCache && now - this.modelsCache.fetchedAt < MODEL_IDS_CACHE_TTL_MS) {
return this.modelsCache.ids;
}
try {
const resp = await fetch(MODELS_URL, {
method: "GET",
headers: this.buildRequestHeaders({ Accept: "application/json" }),
signal,
});
if (!resp.ok) return null;
const ids = extractFreeDuckDuckGoModelIds(await resp.json());
if (ids.size === 0) return null;
this.modelsCache = { ids, fetchedAt: now };
return ids;
} catch (error) {
void error;
return null;
}
}
private async acquireVqdHeaders(signal: AbortSignal): Promise<DuckDuckGoVqdHeaders> {
try {
if (signal.aborted) throw new DOMException("Aborted", "AbortError");
@@ -738,7 +775,34 @@ export class DuckDuckGoWebExecutor extends BaseExecutor {
};
} catch (error) {
void error;
return headers;
// NEVER forward the raw unsolved x-vqd-hash-1: upstream answers it with
// 418 ERR_CHALLENGE and the wasted call still counts toward the IP rate
// limit (spurious 429s). Retry once with a fresh /status challenge, then
// fail cleanly — a null vqdHash1 makes execute() surface 503/429 without
// another doomed call. A standalone x-vqd-4 is still passed through for
// the legacy header path.
const retry = await this.acquireVqdHeaders(signal);
if (retry.vqdHash1) {
try {
return {
vqd4: retry.vqd4,
vqdHash1: await solveDuckDuckGoChallenge(
retry.vqdHash1,
FAKE_HEADERS["User-Agent"]
),
status: retry.status,
retryAfter: retry.retryAfter,
};
} catch (retryError) {
void retryError;
}
}
return {
vqd4: retry.vqd4 ?? headers.vqd4,
vqdHash1: null,
status: retry.status ?? headers.status,
retryAfter: retry.retryAfter ?? headers.retryAfter,
};
}
}
return headers;

View File

@@ -0,0 +1,62 @@
// DuckDuckGo Duck.ai model catalog helpers (pure — no network, no DOM stubs).
// Wire ids captured live from GET /duckchat/v1/models on 2026-08-26; that
// endpoint requires no VQD/challenge token, so the executor re-validates the
// requested model against it at runtime (see getLiveModelIds in duckduckgo-web.ts)
// and this static map stays as the offline fallback.
// #4037: the real served x-fe-version token has a 20-hex tail (e.g.
// `serp_20250401_100419_ET-19d438eb199b2bf7c300`); an earlier `{40}` requirement
// never matched the live token, so the scrape silently fell back to a hardcoded
// default. Bounded `{20,40}` keeps the pattern ReDoS-safe.
export const FE_VERSION_PATTERN = /serp_\d{8}_\d{6}_[A-Z]{2}-[0-9a-f]{20,40}/;
export const DUCKDUCKGO_DEFAULT_MODEL = "gpt-5.4-mini";
export const DUCKDUCKGO_MODEL_ALIASES: Readonly<Record<string, string>> = {
// retired OpenAI ids → current GPT-5.x free tier
"gpt-4o-mini": "gpt-5.4-mini",
"gpt-5-mini": "gpt-5.4-mini",
"o3-mini": "gpt-5.4-mini",
// gpt-5.4-nano left the free lineup between the 2026-07-22 and 2026-08-26 captures
"gpt-5.4-nano": "gpt-5.4-mini",
// retired Llama (dropped from Duck.ai free) → nearest general free model
"llama-4-scout": "gpt-5.4-mini",
// renamed/versioned ids
"claude-3-5-haiku-20241022": "claude-haiku-4-5",
"mistral-small-2501": "mistral-small-2603",
"gpt-oss-120b": "tinfoil/gpt-oss-120b",
"gemma4-31b": "tinfoil/gemma4-31b",
};
export function normalizeDuckDuckGoModel(model: string | undefined): string {
if (!model) return DUCKDUCKGO_DEFAULT_MODEL;
const clean = model.startsWith("duckduckgo-web/") ? model.slice("duckduckgo-web/".length) : model;
return DUCKDUCKGO_MODEL_ALIASES[clean] ?? clean;
}
// Resolve a requested model id against a live wire-id set from
// /duckchat/v1/models. When the live list is unavailable (`null` or empty),
// pass the request through untouched so an offline probe can never silently
// rewrite an otherwise valid model id.
export function pickDuckDuckGoModel(requested: string, liveIds: ReadonlySet<string> | null): string {
if (!liveIds || liveIds.size === 0) return requested;
if (liveIds.has(requested)) return requested;
const aliased = DUCKDUCKGO_MODEL_ALIASES[requested] ?? requested;
return liveIds.has(aliased) ? aliased : DUCKDUCKGO_DEFAULT_MODEL;
}
export function extractFreeDuckDuckGoModelIds(value: unknown): Set<string> {
if (!value || typeof value !== "object") return new Set();
const models = (value as { models?: unknown }).models;
if (!Array.isArray(models)) return new Set();
return new Set(
models
.filter((model) => {
if (!model || typeof model !== "object") return false;
const tiers = (model as { accessTier?: unknown }).accessTier;
return Array.isArray(tiers) && tiers.some((tier) => tier === "free");
})
.map((model) => String((model as { id?: unknown }).id ?? ""))
.filter(Boolean)
);
}

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