mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-26 17:12:27 +03:00
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:
1
changelog.d/fixes/11597-duckduckgo-web-duckai.md
Normal file
1
changelog.d/fixes/11597-duckduckgo-web-duckai.md
Normal 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))
|
||||
@@ -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" },
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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;
|
||||
|
||||
62
open-sse/executors/duckduckgo-web/models.ts
Normal file
62
open-sse/executors/duckduckgo-web/models.ts
Normal 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)
|
||||
);
|
||||
}
|
||||
84
tests/unit/ddg-duckai-live-models.test.ts
Normal file
84
tests/unit/ddg-duckai-live-models.test.ts
Normal 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";/);
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user