fix(providers): refresh duckduckgo-web catalog to current Duck.ai wire ids (#8000) (#8079)

duckduckgo-web returned 400 ERR_BAD_REQUEST on every request because the model
catalog advertised ids DuckDuckGo has retired from the free Duck.ai lineup
(gpt-4o-mini, gpt-5-mini, llama-4-scout, mistral-small-2501, o3-mini,
claude-3-5-haiku-20241022). duckchat/v1/chat validates `model` server-side and
rejects retired ids, and normalizeDuckDuckGoModel() defaulted to / passed through
gpt-4o-mini, so the retired id reached the wire verbatim.

Update all three id sources to the current free wire ids captured live from
duckchat/v1/models (2026-07-22): gpt-5.4-mini, gpt-5.4-nano, claude-haiku-4-5,
mistral-small-2603, tinfoil/gpt-oss-120b, tinfoil/gemma4-31b —
  - executor: default gpt-4o-mini -> gpt-5.4-mini; legacy ids aliased to the
    nearest current model via a lookup map; dropped the invalid gpt-5-mini
    "minimal" reasoningEffort;
  - freeModelCatalog.data.ts + providers/registry/duckduckgo-web: current ids.

Regression test duckduckgo-web-model-catalog-8000.test.ts asserts no retired id
ever reaches the wire and all three catalogs match the current set (RED on the
old default/passthrough + retired catalogs). Live 200 confirmation remains a
recommended VPS smoke per the plan-file.
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-22 01:22:08 -03:00
committed by GitHub
parent 19c3ff51e7
commit 0f342f41fe
5 changed files with 113 additions and 20 deletions

View File

@@ -0,0 +1 @@
- fix(providers): refresh duckduckgo-web free model catalog to Duck.ai's current wire ids, fixing 400 ERR_BAD_REQUEST (#8000)

View File

@@ -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" },

View File

@@ -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 },
],
};

View File

@@ -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<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 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 };

View File

@@ -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}`);
}
});