From 5d7d4cb9530cd5e5c8fa5004ff9c8a40601a02a8 Mon Sep 17 00:00:00 2001 From: solstxce <48374053+solstxce@users.noreply.github.com> Date: Wed, 26 Aug 2026 16:39:38 +0530 Subject: [PATCH] fix(providers): route duckduckgo-web via duck.ai and self-heal stale model catalog (#11597) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../fixes/11597-duckduckgo-web-duckai.md | 1 + open-sse/config/freeModelCatalog.data.ts | 4 +- .../registry/duckduckgo-web/index.ts | 7 +- open-sse/executors/duckduckgo-web.ts | 138 +++++++++++++----- open-sse/executors/duckduckgo-web/models.ts | 62 ++++++++ tests/unit/ddg-duckai-live-models.test.ts | 84 +++++++++++ tests/unit/duckduckgo-domain-4037.test.ts | 107 ++++++-------- tests/unit/duckduckgo-web-executor.test.ts | 2 +- .../duckduckgo-web-model-catalog-8000.test.ts | 19 ++- 9 files changed, 314 insertions(+), 110 deletions(-) create mode 100644 changelog.d/fixes/11597-duckduckgo-web-duckai.md create mode 100644 open-sse/executors/duckduckgo-web/models.ts create mode 100644 tests/unit/ddg-duckai-live-models.test.ts diff --git a/changelog.d/fixes/11597-duckduckgo-web-duckai.md b/changelog.d/fixes/11597-duckduckgo-web-duckai.md new file mode 100644 index 0000000000..553c08e27a --- /dev/null +++ b/changelog.d/fixes/11597-duckduckgo-web-duckai.md @@ -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)) diff --git a/open-sse/config/freeModelCatalog.data.ts b/open-sse/config/freeModelCatalog.data.ts index 7b3ad6946f..2a0bd5b4c0 100644 --- a/open-sse/config/freeModelCatalog.data.ts +++ b/open-sse/config/freeModelCatalog.data.ts @@ -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" }, diff --git a/open-sse/config/providers/registry/duckduckgo-web/index.ts b/open-sse/config/providers/registry/duckduckgo-web/index.ts index 18e6251916..2a722cf57a 100644 --- a/open-sse/config/providers/registry/duckduckgo-web/index.ts +++ b/open-sse/config/providers/registry/duckduckgo-web/index.ts @@ -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 }, diff --git a/open-sse/executors/duckduckgo-web.ts b/open-sse/executors/duckduckgo-web.ts index e112e4c116..b73a69c3eb 100644 --- a/open-sse/executors/duckduckgo-web.ts +++ b/open-sse/executors/duckduckgo-web.ts @@ -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 = [ ["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> = { - // 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(); + private modelsCache: { ids: Set; fetchedAt: number } | null = null; private buildRequestHeaders(extra: Record = {}): Record { 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; 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 => { 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 | 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 { 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; diff --git a/open-sse/executors/duckduckgo-web/models.ts b/open-sse/executors/duckduckgo-web/models.ts new file mode 100644 index 0000000000..9a66d9dc4e --- /dev/null +++ b/open-sse/executors/duckduckgo-web/models.ts @@ -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> = { + // 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 | 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 { + 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) + ); +} diff --git a/tests/unit/ddg-duckai-live-models.test.ts b/tests/unit/ddg-duckai-live-models.test.ts new file mode 100644 index 0000000000..7aef2e4222 --- /dev/null +++ b/tests/unit/ddg-duckai-live-models.test.ts @@ -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";/); +}); diff --git a/tests/unit/duckduckgo-domain-4037.test.ts b/tests/unit/duckduckgo-domain-4037.test.ts index d3cdaf39e6..c869d5e666 100644 --- a/tests/unit/duckduckgo-domain-4037.test.ts +++ b/tests/unit/duckduckgo-domain-4037.test.ts @@ -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", () => { diff --git a/tests/unit/duckduckgo-web-executor.test.ts b/tests/unit/duckduckgo-web-executor.test.ts index 62438cae1e..06543dac88 100644 --- a/tests/unit/duckduckgo-web-executor.test.ts +++ b/tests/unit/duckduckgo-web-executor.test.ts @@ -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" ); }); diff --git a/tests/unit/duckduckgo-web-model-catalog-8000.test.ts b/tests/unit/duckduckgo-web-model-catalog-8000.test.ts index ef8a95f1a5..d0e7a30c1a 100644 --- a/tests/unit/duckduckgo-web-model-catalog-8000.test.ts +++ b/tests/unit/duckduckgo-web-model-catalog-8000.test.ts @@ -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