diff --git a/changelog.d/fixes/8000-duckduckgo-catalog.md b/changelog.d/fixes/8000-duckduckgo-catalog.md new file mode 100644 index 0000000000..0eb43a4996 --- /dev/null +++ b/changelog.d/fixes/8000-duckduckgo-catalog.md @@ -0,0 +1 @@ +- fix(providers): refresh duckduckgo-web free model catalog to Duck.ai's current wire ids, fixing 400 ERR_BAD_REQUEST (#8000) diff --git a/open-sse/config/freeModelCatalog.data.ts b/open-sse/config/freeModelCatalog.data.ts index 5ee692d7e6..3d0e693ae6 100644 --- a/open-sse/config/freeModelCatalog.data.ts +++ b/open-sse/config/freeModelCatalog.data.ts @@ -154,12 +154,12 @@ export const FREE_MODEL_BUDGETS: FreeModelBudget[] = [ { provider: "deepseek", modelId: "deepseek-v4-pro", displayName: "DeepSeek V4 Pro", monthlyTokens: 0, creditTokens: 5000000, freeType: "one-time-initial", poolKey: "deepseek", tos: "ok" }, { 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-4o-mini", displayName: "GPT-4o Mini", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "duckduckgo-web", tos: "avoid" }, - { provider: "duckduckgo-web", modelId: "gpt-5-mini", displayName: "GPT-5 Mini", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "duckduckgo-web", tos: "avoid" }, - { provider: "duckduckgo-web", modelId: "claude-3-5-haiku-20241022", displayName: "Claude 3.5 Haiku", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "duckduckgo-web", tos: "avoid" }, - { provider: "duckduckgo-web", modelId: "llama-4-scout", displayName: "Llama 4 Scout", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "duckduckgo-web", tos: "avoid" }, - { provider: "duckduckgo-web", modelId: "mistral-small-2501", displayName: "Mistral Small", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "duckduckgo-web", tos: "avoid" }, - { provider: "duckduckgo-web", modelId: "o3-mini", displayName: "O3 Mini", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "duckduckgo-web", tos: "avoid" }, + { 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: "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" }, + { provider: "duckduckgo-web", modelId: "tinfoil/gemma4-31b", displayName: "Gemma 4 31B", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "duckduckgo-web", tos: "avoid" }, { provider: "felo-web", modelId: "felo-chat", displayName: "Felo Chat", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "felo-web", tos: "avoid" }, { provider: "felo-web", modelId: "felo-search", displayName: "Felo Search", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "felo-web", tos: "avoid" }, { provider: "felo-web", modelId: "felo-scholar", displayName: "Felo Scholar", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "felo-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 3fbab5688c..18e6251916 100644 --- a/open-sse/config/providers/registry/duckduckgo-web/index.ts +++ b/open-sse/config/providers/registry/duckduckgo-web/index.ts @@ -8,12 +8,13 @@ export const duckduckgo_webProvider: RegistryEntry = { baseUrl: "https://duckduckgo.com/duckchat/v1/chat", authType: "none", authHeader: "none", + // #8000: current Duck.ai free lineup — wire ids per duckchat/v1/models (2026-07-22). models: [ - { id: "gpt-4o-mini", name: "GPT-4o Mini", toolCalling: false }, - { id: "gpt-5-mini", name: "GPT-5 Mini", toolCalling: false }, - { id: "claude-3-5-haiku-20241022", name: "Claude 3.5 Haiku", toolCalling: false }, - { id: "llama-4-scout", name: "Llama 4 Scout", toolCalling: false }, - { id: "mistral-small-2501", name: "Mistral Small", toolCalling: false }, - { id: "o3-mini", name: "O3 Mini", toolCalling: false }, + { 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: "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 }, + { id: "tinfoil/gemma4-31b", name: "Gemma 4 31B", toolCalling: false }, ], }; diff --git a/open-sse/executors/duckduckgo-web.ts b/open-sse/executors/duckduckgo-web.ts index 0d5107013f..a7b1a0ec0d 100644 --- a/open-sse/executors/duckduckgo-web.ts +++ b/open-sse/executors/duckduckgo-web.ts @@ -226,18 +226,37 @@ function mergeHeadersCaseInsensitive( return merged; } -function normalizeDuckDuckGoModel(model: string | undefined): string { - if (!model) return "gpt-4o-mini"; +/** + * #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. + */ +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 function normalizeDuckDuckGoModel(model: string | undefined): string { + if (!model) return DUCKDUCKGO_DEFAULT_MODEL; const clean = model.startsWith("duckduckgo-web/") ? model.slice("duckduckgo-web/".length) : model; - if (clean === "claude-3-5-haiku-20241022") return "claude-haiku-4-5"; - if (clean === "llama-4-scout") return "meta-llama/Llama-4-Scout-17B-16E-Instruct"; - if (clean === "mistral-small-2501") return "mistral-small-2603"; - if (clean === "gpt-oss-120b") return "tinfoil/gpt-oss-120b"; - return clean; + return DUCKDUCKGO_MODEL_ALIASES[clean] ?? clean; } function getDuckDuckGoModelCapabilities(model: string): DuckDuckGoModelCapabilities { - if (model === "gpt-5-mini") return { reasoningEffort: "minimal" }; + // Per duckchat/v1/models (2026-07-22): claude-haiku-4-5 and gpt-oss-120b take a "low" + // reasoningEffort on the free tier; the others omit it (duck.ai applies its own default). if (model === "claude-haiku-4-5") return { reasoningEffort: "low" }; if (model === "tinfoil/gpt-oss-120b") return { reasoningEffort: "low" }; return { reasoningEffort: null }; diff --git a/tests/unit/duckduckgo-web-model-catalog-8000.test.ts b/tests/unit/duckduckgo-web-model-catalog-8000.test.ts new file mode 100644 index 0000000000..ef8a95f1a5 --- /dev/null +++ b/tests/unit/duckduckgo-web-model-catalog-8000.test.ts @@ -0,0 +1,72 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { + normalizeDuckDuckGoModel, + DUCKDUCKGO_DEFAULT_MODEL, +} from "../../open-sse/executors/duckduckgo-web.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/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", + "claude-haiku-4-5", + "mistral-small-2603", + "tinfoil/gpt-oss-120b", + "tinfoil/gemma4-31b", +]); + +// Ids OmniRoute historically advertised that Duck.ai has since retired. +const RETIRED_IDS = [ + "gpt-4o-mini", + "gpt-5-mini", + "o3-mini", + "llama-4-scout", + "claude-3-5-haiku-20241022", + "mistral-small-2501", +]; + +test("#8000: default model is a current free wire id, not retired gpt-4o-mini", () => { + assert.equal(DUCKDUCKGO_DEFAULT_MODEL, "gpt-5.4-mini"); + assert.notEqual(normalizeDuckDuckGoModel(undefined), "gpt-4o-mini"); + assert.ok(CURRENT_FREE_IDS.has(normalizeDuckDuckGoModel(undefined))); +}); + +test("#8000: every retired id normalizes to a current wire id (never passes through)", () => { + for (const retired of RETIRED_IDS) { + const out = normalizeDuckDuckGoModel(retired); + assert.ok(CURRENT_FREE_IDS.has(out), `${retired} → ${out} must be a current free id`); + assert.notEqual(out, retired, `retired ${retired} must not reach the wire unchanged`); + } + // 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"); +}); + +test("#8000: current wire ids pass through unchanged", () => { + for (const id of CURRENT_FREE_IDS) { + assert.equal(normalizeDuckDuckGoModel(id), id); + } +}); + +test("#8000: provider registry advertises exactly the current wire ids", () => { + const ids = duckduckgo_webProvider.models.map((m) => m.id); + assert.deepEqual(new Set(ids), CURRENT_FREE_IDS); + for (const retired of RETIRED_IDS) { + assert.ok(!ids.includes(retired), `registry must drop retired id ${retired}`); + } +}); + +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 + ); + assert.deepEqual(new Set(ids), CURRENT_FREE_IDS); + for (const retired of RETIRED_IDS) { + assert.ok(!ids.includes(retired), `catalog must drop retired id ${retired}`); + } +});