Skip to content
3 changes: 2 additions & 1 deletion config/quality/file-size-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"_rebaseline_2026_06_17_4034_usage_command": "PR #4034 own growth: apiKeys.ts 1633->1661 (+28 = the allow_usage_command additive column — fallback definition, parseAllowUsageCommand, prepared-statement SELECT column, and the metadata/create/update wiring, mirroring the existing disable_non_public_models accessor pattern) and chat.ts 1425->1432 (+7 = the handleInternalUsageCommand intercept hook at the existing post-auth chokepoint in handleChat). The reusable command logic lives in the new src/lib/usage/internalUsageCommand.ts (well under cap). Cohesive opt-in feature at the established column/dispatch boundaries; not extractable without splitting the api-keys domain module.",
"_rebaseline_2026_06_17_ctx_editing_relays": "Context Editing relay coverage + 400-fallback (F4.2/F4.3) own growth: base.ts 1244->1292 (+48 at the existing dispatch chokepoint: extend the injection gate to anthropic-compatible-cc-* relays with an expanded comment documenting the claude-web / generic-anthropic-compatible exclusions; the contextEditingDisabled loop flag; and the 400-fallback block that, when an upstream rejects context_management with a 400, strips the param, re-serializes + re-signs (CCH), and retries the same URL once). Both edits sit on the per-URL request-build/fetch path and cannot be extracted without splitting the executor's fetch loop. No new file warranted — the logic is a few lines tightly coupled to the in-loop transformedBody/bodyString/response locals.",
"_rebaseline_2026_06_17_ctx_editing_telemetry": "Context Editing telemetry (F4.1) own growth: chatCore.ts 5875->5898 (+23 = a best-effort, Claude-only, non-streaming block at the existing non-streaming response handler that extracts the provider's server-side context-editing receipt from responseBody and records it under engine \"context-editing\"). The pure extractor (extractContextEditingTelemetry) lives in the existing small open-sse/config/contextEditing.ts and the DB writer (recordContextEditingTelemetry) in src/lib/db/compressionAnalytics.ts — both under cap. Cohesive at the response-handling chokepoint next to the existing attachCompressionUsageReceipt call; not extractable without hiding the receipt-recording boundary.",
"_rebaseline_2026_06_17_duckduckgo_free": "Free web search (free-claude-code port, Fase 6) own growth: handlers/search.ts 1442->1546 (+104 = tryDuckDuckGoFreeProvider, a dedicated provider path mirroring the existing tryZaiMCPProvider special-case: the lite endpoint returns HTML, not the JSON the generic tryProvider()/normalizeResponse() flow expects, so it cannot reuse that path). The reusable parser + guarded fetch (parseDuckDuckGoLite, freeWebSearch) live in the new small open-sse/services/freeWebSearch.ts (well under cap). Cohesive at the per-provider dispatch chokepoint; not extractable without hiding the provider boundary.",
"_rebaseline_2026_06_17_stream_recovery": "Stream recovery wiring (free-claude-code port, Fase 4) own growth: chatCore.ts 5898->5980 (+82 at the existing streaming-return chokepoint: read the resolved streamRecovery.enabled setting once; when ON, a reopenStream() thunk re-executes the SAME upstream — it closes over the ~15 per-attempt executor locals (executor/provider/modelToCall/bodyToSend/upstreamStream/execCreds/extendedContext/headers builders/onCredentialsRefreshed/skipUpstreamRetry/contextEditingEnabled) — and the body is wrapped with createRecoverableStream for transparent early-retry; OFF keeps the byte-identical wrapReadableStreamWithFinalize path). All reusable logic (HoldbackBuffer, createRecoverableStream, isRetryableStreamError, hasTerminalMarker) lives in the new open-sse/services/streamRecovery.ts (well under cap). Not extractable without hiding the per-attempt executor-dispatch boundary; opt-in (default OFF). Structural shrink of chatCore.ts tracked in #3501.",
"cap": 800,
"frozen": {
Expand All @@ -51,7 +52,7 @@
"open-sse/handlers/chatCore.ts": 5980,
"open-sse/handlers/imageGeneration.ts": 3777,
"open-sse/handlers/responseSanitizer.ts": 1103,
"open-sse/handlers/search.ts": 1442,
"open-sse/handlers/search.ts": 1546,
"open-sse/handlers/sseParser.ts": 812,
"open-sse/handlers/videoGeneration.ts": 1078,
"open-sse/mcp-server/schemas/tools.ts": 1437,
Expand Down
34 changes: 32 additions & 2 deletions open-sse/config/searchRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ export interface SearchProviderConfig {
maxMaxResults: number;
timeoutMs: number;
cacheTTLMs: number;
/**
* Last-resort provider: excluded from automatic (cost-based) selection so a
* cost-0 free provider never overrides a configured paid one. Only used when no
* credentialed provider is available, or when requested explicitly by id.
*/
fallbackOnly?: boolean;
}

export const SEARCH_PROVIDERS: Record<string, SearchProviderConfig> = {
Expand Down Expand Up @@ -218,6 +224,26 @@ export const SEARCH_PROVIDERS: Record<string, SearchProviderConfig> = {
timeoutMs: 10_000,
cacheTTLMs: 5 * 60 * 1000,
},

// Free, no-API-key DuckDuckGo lite scraping (free-claude-code port). Last-resort
// only (fallbackOnly): never auto-selected over a configured provider; served by
// the dedicated HTML path in open-sse/handlers/search.ts (not the generic JSON one).
"duckduckgo-free": {
id: "duckduckgo-free",
name: "DuckDuckGo (free)",
baseUrl: "https://lite.duckduckgo.com/lite/",
method: "POST",
authType: "none",
authHeader: "none",
costPerQuery: 0,
freeMonthlyQuota: 999999,
searchTypes: ["web"],
defaultMaxResults: 5,
maxMaxResults: 25,
timeoutMs: 10_000,
cacheTTLMs: 5 * 60 * 1000,
fallbackOnly: true,
},
};

/**
Expand Down Expand Up @@ -278,8 +304,12 @@ export function selectProvider(
return provider;
}

const providers = Object.values(SEARCH_PROVIDERS).filter((provider) =>
searchType ? supportsSearchType(provider, searchType) : true
// Auto-selection excludes fallbackOnly providers so a free cost-0 provider never
// overrides a configured paid one — they are reached only via explicit id or the
// route handler's last-resort step.
const providers = Object.values(SEARCH_PROVIDERS).filter(
(provider) =>
!provider.fallbackOnly && (searchType ? supportsSearchType(provider, searchType) : true)
);
if (providers.length === 0) return null;

Expand Down
104 changes: 104 additions & 0 deletions open-sse/handlers/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { randomUUID } from "crypto";
*/

import { getSearchProvider, type SearchProviderConfig } from "../config/searchRegistry.ts";
import { freeWebSearch } from "../services/freeWebSearch.ts";
import { saveCallLog } from "@/lib/usageDb";
import { safeOutboundFetch } from "@/shared/network/safeOutboundFetch";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
Expand Down Expand Up @@ -1279,6 +1280,105 @@ export async function handleSearch(options: SearchHandlerOptions): Promise<Searc
return result;
}

/**
* Free DuckDuckGo lite provider — no API key, HTML scraping (free-claude-code port).
* Dedicated path because the lite endpoint returns HTML, not the JSON the generic
* tryProvider() flow expects. See open-sse/services/freeWebSearch.ts.
*/
async function tryDuckDuckGoFreeProvider(
config: SearchProviderConfig,
params: Omit<SearchRequestParams, "token">,
startTime: number,
globalStartTime: number,
log?: {
info?: (tag: string, message: string) => void;
error?: (tag: string, message: string) => void;
} | null
): Promise<SearchHandlerResult> {
const { query, searchType, maxResults } = params;
const remainingGlobal = GLOBAL_TIMEOUT_MS - (Date.now() - globalStartTime);
const timeout = Math.min(config.timeoutMs, Math.max(remainingGlobal, 1000));

if (log) {
log.info?.("SEARCH", `${config.id} | query: "${query.slice(0, 80)}" | type: ${searchType}`);
}

const requestBody = {
query: query.slice(0, 200),
search_type: searchType,
max_results: maxResults,
};

try {
const freeResults = await freeWebSearch(query, maxResults, timeout);
const now = new Date().toISOString();
const results = freeResults
.slice(0, maxResults)
.map((r, idx) =>
makeResult(config.id, { title: r.title, url: r.url, snippet: r.snippet }, idx, now)
);
const duration = Date.now() - startTime;

saveCallLog({
method: config.method,
path: "/v1/search",
status: 200,
model: config.id,
provider: config.id,
duration,
requestType: "search",
tokens: { prompt_tokens: 0, completion_tokens: 0 },
requestBody,
responseBody: { results_count: results.length, cached: false },
}).catch(() => {
/* non-critical — logging must not block search response */
});

return {
success: true,
data: {
provider: config.id,
query,
results,
answer: null,
usage: { queries_used: 1, search_cost_usd: 0 },
metrics: {
response_time_ms: duration,
upstream_latency_ms: duration,
total_results_available: results.length,
},
errors: [],
},
};
} catch (err) {
const duration = Date.now() - startTime;
const message = sanitizeErrorMessage(err);
if (log) {
log.error?.("SEARCH", `${config.id} error: ${message}`);
}

saveCallLog({
method: config.method,
path: "/v1/search",
status: 502,
model: config.id,
provider: config.id,
duration,
requestType: "search",
error: message.slice(0, 500),
requestBody,
}).catch(() => {
/* non-critical */
});

return {
success: false,
status: 502,
error: `DuckDuckGo free search failed: ${message}`,
};
}
}

async function tryProvider(
config: SearchProviderConfig,
params: Omit<SearchRequestParams, "token">,
Expand All @@ -1303,6 +1403,10 @@ async function tryProvider(

const { query, searchType, maxResults } = params;

if (config.id === "duckduckgo-free") {
return tryDuckDuckGoFreeProvider(config, params, startTime, globalStartTime, log);
}

if (config.id === "zai-search" && token) {
return tryZaiMCPProvider(
config,
Expand Down
135 changes: 135 additions & 0 deletions open-sse/services/freeWebSearch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
/**
* Free, no-API-key web search via DuckDuckGo's HTML "lite" endpoint
* (free-claude-code port). Used as a LAST-RESORT fallback search provider
* (`duckduckgo-free`, `fallbackOnly`) when no credentialed search provider is
* configured — see open-sse/config/searchRegistry.ts.
*
* Best-effort HTML scraping: the lite endpoint's markup can drift, so the parser
* is tolerant (quote styles, attribute order, `<b>` highlights, entities) and the
* unit test pins the contract against a real captured response. The network call
* goes through `safeOutboundFetch` with the public-only SSRF guard.
*/
import { safeOutboundFetch } from "@/shared/network/safeOutboundFetch";

export interface FreeSearchResult {
title: string;
url: string;
snippet: string;
}

const DUCKDUCKGO_LITE_URL = "https://lite.duckduckgo.com/lite/";
// A browser-like UA — the lite endpoint rejects obvious bot agents.
const DDG_USER_AGENT =
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36";

// Real lite shape: `<a ... href="URL" class='result-link'>Title</a>` (href usually
// before class; quotes may be single or double) and `<td class='result-snippet'>…</td>`.
// The inner-content captures are HARD-BOUNDED ({0,N}?) and the whole body is truncated
// (MAX_HTML_BYTES) so adversarial/unclosed HTML can't cause catastrophic backtracking
// (ReDoS) — real titles/snippets are short. See CLAUDE.md PII learnings §1.
const ANCHOR_RE =
/<a\b([^>]*?class=['"][^'"]*result-link[^'"]*['"][^>]*)>([\s\S]{0,512}?)<\/a>/gi;
const HREF_RE = /href=['"]([^'"]+)['"]/i;
const SNIPPET_RE =
/<td\b[^>]*?class=['"][^'"]*result-snippet[^'"]*['"][^>]*>([\s\S]{0,2048}?)<\/td>/gi;
// Generous cap for the lite endpoint (~50 KB real) — bounds the regex input size.
const MAX_HTML_BYTES = 256 * 1024;

function decodeEntities(text: string): string {
return text
.replace(/&nbsp;/gi, " ")
.replace(/&amp;/gi, "&")
.replace(/&lt;/gi, "<")
.replace(/&gt;/gi, ">")
.replace(/&quot;/gi, '"')
.replace(/&#0*39;|&#x0*27;|&apos;/gi, "'");
}

function stripTags(html: string): string {
return decodeEntities(html.replace(/<[^>]+>/g, "")).replace(/\s+/g, " ").trim();
}

/**
* Resolve a lite result href to a real absolute URL. Returns "" for anything that
* is not a plain http(s) URL — the decoded `uddg` value is external data, so we must
* never surface a `javascript:`/`data:`/`file:`/internal-IP URL to the caller (LLM
* or client); the parser skips empty URLs.
*/
function resolveResultUrl(href: string): string {
let candidate = href;
// Older/HTML endpoints wrap the target in a redirect: //duckduckgo.com/l/?uddg=<enc>&…
const redirect = href.match(/[?&]uddg=([^&]+)/);
if (redirect) {
try {
candidate = decodeURIComponent(redirect[1]);
} catch {
candidate = href; // malformed encoding — fall back to the raw href
}
} else if (href.startsWith("//")) {
candidate = `https:${href}`;
}
return /^https?:\/\//i.test(candidate) ? candidate : "";
}

/**
* Parse the DuckDuckGo lite HTML into ordered results. Pure (no network) so it is
* fully unit-testable. Result link N aligns with snippet N (the lite layout emits
* them 1:1); a missing snippet yields an empty string rather than a crash.
*/
export function parseDuckDuckGoLite(rawHtml: string): FreeSearchResult[] {
if (!rawHtml) return [];
// Bound the regex input size as the first ReDoS guard.
const html = rawHtml.length > MAX_HTML_BYTES ? rawHtml.slice(0, MAX_HTML_BYTES) : rawHtml;

const snippets = [...html.matchAll(SNIPPET_RE)].map((m) => stripTags(m[1]));
const results: FreeSearchResult[] = [];
let index = 0;

for (const match of html.matchAll(ANCHOR_RE)) {
const attrs = match[1];
const inner = match[2];
const hrefMatch = attrs.match(HREF_RE);
const title = stripTags(inner);
if (hrefMatch && title) {
const url = resolveResultUrl(hrefMatch[1]);
if (url) results.push({ url, title, snippet: snippets[index] ?? "" });
}
index += 1;
}

return results;
}

/**
* Run a free DuckDuckGo lite search and return up to `maxResults` parsed results.
* Throws on a non-2xx upstream so the search handler can record the failure and
* fall through. The URL is fixed, but the call still goes through the public-only
* SSRF guard for defense in depth.
*/
export async function freeWebSearch(
query: string,
maxResults = 5,
timeoutMs = 10_000
): Promise<FreeSearchResult[]> {
const response = await safeOutboundFetch(DUCKDUCKGO_LITE_URL, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"User-Agent": DDG_USER_AGENT,
Accept: "text/html",
},
body: new URLSearchParams({ q: query }).toString(),
guard: "public-only",
// Keep redirects manual: the public-only guard only validates the initial URL,
// so following a 3xx could reach an internal host. DDG lite answers POST with 200.
allowRedirect: false,
timeoutMs,
});

if (!response.ok) {
throw new Error(`DuckDuckGo lite search returned HTTP ${response.status}`);
}

const html = await response.text();
return parseDuckDuckGoLite(html).slice(0, Math.max(1, maxResults));
}
26 changes: 22 additions & 4 deletions src/app/api/v1/search/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,9 +177,10 @@ async function postHandler(request: Request, context: unknown) {
}

if (!credentials) {
// Sort by cost to find cheapest with credentials
// Sort by cost to find cheapest with credentials (fallback-only providers
// are reached via the last-resort step below, never the primary pick).
const sortedIds = Object.values(SEARCH_PROVIDERS)
.filter((provider) => supportsSearchType(provider, body.search_type))
.filter((provider) => !provider.fallbackOnly && supportsSearchType(provider, body.search_type))
.sort((a, b) => a.costPerQuery - b.costPerQuery)
.map((p) => p.id);

Expand Down Expand Up @@ -212,9 +213,10 @@ async function postHandler(request: Request, context: unknown) {
);
}

// Find alternate for failover — must bind credentials to the matched provider
// Find alternate for failover — must bind credentials to the matched provider.
// Exclude fallback-only providers; they are only used by the last-resort step.
const otherIds = Object.values(SEARCH_PROVIDERS)
.filter((provider) => supportsSearchType(provider, body.search_type))
.filter((provider) => !provider.fallbackOnly && supportsSearchType(provider, body.search_type))
.sort((a, b) => a.costPerQuery - b.costPerQuery)
.map((p) => p.id)
.filter((id) => id !== providerConfig.id);
Expand All @@ -229,6 +231,22 @@ async function postHandler(request: Request, context: unknown) {
break;
}
}

// Last-resort: guarantee a free no-key fallback (e.g. duckduckgo-free) as the
// failover so out-of-the-box search still works when no credentialed provider
// is configured. Only used when no real alternate was found above.
if (!alternateProviderId) {
for (const provider of Object.values(SEARCH_PROVIDERS)) {
if (!provider.fallbackOnly || provider.id === providerConfig.id) continue;
if (!supportsSearchType(provider, body.search_type)) continue;
const fallbackCreds = await resolveSearchExecutionCredentials(provider);
if (fallbackCreds && !isAllRateLimitedCredentials(fallbackCreds)) {
alternateProviderId = provider.id;
alternateCredentials = fallbackCreds;
break;
}
}
}
}

// Clamp max_results to provider limit
Expand Down
1 change: 1 addition & 0 deletions src/shared/validation/schemas/apiV1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ export const v1SearchSchema = z
"youcom-search",
"searxng-search",
"zai-search",
"duckduckgo-free",
])
.optional(),
max_results: z.coerce.number().int().min(1).max(100).default(5),
Expand Down
Loading
Loading