Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 12 additions & 40 deletions open-sse/executors/duckduckgo-web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,11 +251,14 @@ export function normalizeDuckDuckGoModel(model: string | undefined): string {
}

function getDuckDuckGoModelCapabilities(model: string): DuckDuckGoModelCapabilities {
// 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).
// `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
// otherwise byte-identical payload (200 with the field, 400 without, repeated).
// The live duck.ai bundle always sends one, so there is no "let the server
// pick a default" path any more.
if (model === "claude-haiku-4-5") return { reasoningEffort: "low" };
if (model === "tinfoil/gpt-oss-120b") return { reasoningEffort: "low" };
return { reasoningEffort: null };
return { reasoningEffort: "none" };
}

function extractDuckDuckGoFeVersion(html: string): string | null {
Expand Down Expand Up @@ -353,7 +356,6 @@ export class DuckDuckGoWebExecutor extends BaseExecutor {
}

private warmed = false;
private seeded = false;
private feVersion = DEFAULT_FE_VERSION;
private pendingVqdHash1: string | null = null;
private readonly cookieJar = new Map<string, string>();
Expand Down Expand Up @@ -561,7 +563,12 @@ export class DuckDuckGoWebExecutor extends BaseExecutor {
}

await this.warmSession(mergedSignal);
await this.seedChallengeChain(upstreamModel, mergedSignal);
// NOTE: the throwaway "seed" chat POST that used to run here has been removed.
// It existed to coax a usable challenge out of the upstream while the solver
// was broken; now that the solver reproduces a real browser's probe vectors
// exactly, the first real request succeeds on its own. Keeping it only doubled
// the chat calls per user request against an IP-rate-limited endpoint, which
// showed up as spurious 429 ERR_RATE_LIMIT.
const vqdHeaders = await this.acquireAuthHeaders(mergedSignal);
if (!vqdHeaders.vqd4 && !vqdHeaders.vqdHash1) {
clearTimeout(timeout);
Expand Down Expand Up @@ -770,41 +777,6 @@ export class DuckDuckGoWebExecutor extends BaseExecutor {
);
}

private async seedChallengeChain(model: string, signal: AbortSignal): Promise<void> {
if (this.seeded || signal.aborted) return;
this.seeded = true;
const seedMessages = [{ role: "user", content: "hi" }];
const previousPending = this.pendingVqdHash1;
try {
const vqdHeaders = await this.acquireAuthHeaders(signal);
if (!vqdHeaders.vqd4 && !vqdHeaders.vqdHash1) {
this.pendingVqdHash1 = previousPending;
return;
}
const response = await fetch(CHAT_URL, {
method: "POST",
headers: mergeHeadersCaseInsensitive(this.buildRequestHeaders(), {
Accept: "text/event-stream",
"Content-Type": "application/json",
"x-ddg-journey-id": randomUUID().replaceAll("-", ""),
"x-fe-signals": makeDuckDuckGoFeSignals(),
"x-fe-version": this.feVersion,
...(vqdHeaders.vqd4 ? { "x-vqd-4": vqdHeaders.vqd4 } : {}),
...(vqdHeaders.vqdHash1 ? { "x-vqd-hash-1": vqdHeaders.vqdHash1 } : {}),
}),
body: JSON.stringify(buildDuckDuckGoPayload(model, seedMessages, false)),
signal,
});
this.rememberResponseCookies(response);
if (response.ok) this.rememberChallengeHeader(response);
else this.pendingVqdHash1 = previousPending;
await response.body?.cancel().catch(() => {});
} catch (error) {
void error;
this.pendingVqdHash1 = previousPending;
}
}

private async processResponse(
response: Response,
streaming: boolean,
Expand Down
Loading
Loading