OmniRoute 搜索调度修复:凭什么让已配置密钥的搜索供应商优先于 duckduckgo-free 兜底(11524)
导读
在 OmniRoute 的 /v1/search 与内置 web_search 技能中,搜索供应商遵循"按单次成本从低到高 + 凭据可用性"的自动调度逻辑。changelog.d/fixes/11524-search-credentialed-over-fallback.md 记录了一次关键回归修复:当省略 provider 参数让系统自动选路时,免密钥的 duckduckgo-free 兜底供应商曾抢先于"已配置了 API Key 的付费供应商"执行,导致运营商配置好的 Serper、Brave、Tavily 连接被静默闲置,甚至让搜索结果"假健康"。阅读本文后,你将理解该缺陷的根因、两层候选者扫描(credentialed 优先、fallback 兜底)的修复逻辑,以及对应的回归测试与可观测后果。
一、问题背景:#11524 发生了什么
原始变更记录(changelog.d/fixes/11524-search-credentialed-over-fallback.md)指出:
fix(search): prefer credentialed providers over duckduckgo-free fallback — the fallback-only loop ran before the credentialed-providers loop in
executeWebSearch, making configured providers unreachable whenduckduckgo-freewas available.
翻译过来即:在 executeWebSearch 中,"仅兜底(fallback-only)供应商"循环先于"具备凭据的常规供应商"循环执行。只要 duckduckgo-free 存在,配置过的供应商就永远走不到。
CHANGELOG.md 第 1190 行附近的条目把该缺陷的实际影响讲得更直白:
- 当请求没有显式指定
provider,系统按costPerQuery挑出"最便宜"的常规供应商,但该供应商若没有配置凭据,随后的 last-resort 兜底循环就会接管; duckduckgo-free单次成本为 0 且authType: "none",凭空就能胜出,并用空凭据执行;- 在
/v1/responses路径上,该调用返回success: true但结果是 0 条——网络搜索看起来一切正常,而运营商实际配置的付费连接从未被调用。
这正是本修复要消除的"虚假健康"陷阱。
二、根因定位:为什么兜底循环会跑在凭据循环前面
要理解缺陷,需要看清搜索供应商的注册模型。与 LLM/Embedding 供应商不同,搜索供应商没有"模型"概念——供应商本身就是模型(Serper = Google SERP,Brave = Brave 索引)。它们统一注册在 open-sse/config/searchRegistry.ts 的 SEARCH_PROVIDERS 表中,每个条目携带一组路由元数据:
export interface SearchProviderConfig {
id: string;
name: string;
baseUrl: string;
method: "GET" | "POST";
authType: "apikey" | "none";
authHeader: string;
costPerQuery: number; // 单次成本,用于自动选路排序
freeMonthlyQuota: number;
searchTypes: string[];
defaultMaxResults: number;
maxMaxResults: number;
timeoutMs: number;
cacheTTLMs: number;
/**
* Last-resort provider: excluded from automatic (cost-based) selection ...
* Only used when no credentialed provider is available, or when requested explicitly by id.
*/
fallbackOnly?: boolean;
disabled?: boolean;
}
关键的 fallbackOnly 语义(searchRegistry.ts 与 selectProvider 实现)是:自动选路必须排除 fallback-only 供应商,保证成本为 0 的免费供应商永远不可能因为"更便宜"而覆盖一个已配置付费供应商。仓库中被标记为 fallbackOnly 的搜索供应商包括:
| provider id | 名称 | 定位 |
|---|---|---|
duckduckgo-free |
DuckDuckGo(免费 lite 抓取) | 开箱即用、免密钥的最后兜底 |
searxng-search |
SearXNG(本地回环 localhost:8888) |
本地自建聚合兜底 |
context7 |
Context7(库文档检索) | 文档语料专属,不参与通用 web 自动选路 |
anysearch-search |
AnySearch 免费公共搜索 | cost-0,不覆盖已配置付费供应商 |
xquik-search |
Xquik X 搜索 | 避免取代默认 SuperGrok 的 x 搜索 |
例如 duckduckgo-free 的注册信息(searchRegistry.ts):
"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,
}
修复前的缺陷路径
在 src/lib/search/executeWebSearch.ts 中,executeWebSearch 是面向 Agent 技能的封装入口。修复前,自动选路(input.provider 未提供)的旧逻辑大致是:
- 用
selectProvider(undefined, searchType)选出成本最低的常规供应商; - 尝试为其解析凭据;
- 若凭据为空,直接进入 fallback-only 循环(此时
duckduckgo-free空凭据必然命中); - 常规"有凭据供应商"的扫描循环排在这之后,从而永远轮不到。
由此,duckduckgo-free 就像一道"抢先的免费闸门":只要它在,配置过 Key 的付费供应商全部不可达。同时 handleSearch 对空凭据 DuckDuckGo 的成功空结果返回,掩盖了失败真相。
三、修复方案:credentialed 扫描前置,fallback 沦为真正的 last resort
修复后的核心逻辑集中在 src/lib/search/executeWebSearch.ts 的自动选路分支。先看代码骨架:
} else {
// Auto-select: prefer the cheapest non-fallback provider that actually has
// credentials. Fallback-only free providers are a last resort, so a
// configured paid provider is never skipped just because a cheaper
// no-credentials provider appears first in the cost sort (issue #11524).
const candidateProviders = Object.values(SEARCH_PROVIDERS)
.filter((provider) => !provider.fallbackOnly && supportsSearchType(provider, searchType))
.sort((a, b) => a.costPerQuery - b.costPerQuery);
for (const candidate of candidateProviders) {
const candidateCredentials = await resolveSearchCredentials(candidate.id);
if (candidateCredentials) {
providerConfig = candidate;
credentials = candidateCredentials;
break;
}
}
if (!credentials) {
// Last resort: fallback-only providers so out-of-the-box search
// still works when no credentialed provider is configured.
const fallbackProviders = Object.values(SEARCH_PROVIDERS)
.filter((provider) => provider.fallbackOnly && supportsSearchType(provider, searchType))
.sort((a, b) => a.costPerQuery - b.costPerQuery);
for (const fallbackProvider of fallbackProviders) {
providerConfig = fallbackProvider;
if (fallbackProvider.id === "duckduckgo-free") {
credentials = {};
break;
}
const fallbackCredentials = await resolveSearchCredentials(fallbackProvider.id);
if (fallbackCredentials) {
credentials = fallbackCredentials;
break;
}
}
}
...
修复引入了清晰的两阶段语义:
-
第一阶段(主候选扫描):把所有
!fallbackOnly且支持当前searchType的供应商按costPerQuery升序排列,逐个调用resolveSearchCredentials探测凭据,命中即选中并终止。这一阶段天然满足两层目标:- 成本最小化——便宜的配置供应商优先;
- 可用性优先——只挑选"真正配置了凭据"的供应商,成本为 0 但没配 Key 的供应商会因凭据探测失败被跳过,不再制造"便宜但不可用"的假胜出。
-
第二阶段(真正的 last resort):仅当第一阶段一个凭据都没有时,才进入 fallback-only 列表。其中
duckduckgo-free作为免密钥供应商被特判直接赋空凭据{}(其余 fallback 仍需探测凭据)。这样既保证了全新部署下无 Key 也能"开箱即搜索",又确保它永远排在任何已配置供应商之后。
修复的连带影响:执行期备用供应商也剔除 fallback
若不做处理,执行期的故障切换(alternate)仍可能把 fallback-only 供应商当作备用目标。因此同一提交还在选完主供应商后补上了过滤(executeWebSearch.ts):
// Exclude fallback-only providers from execution-time alternates.
// They are reserved for last-resort primary selection.
const otherIds = Object.values(SEARCH_PROVIDERS)
.filter((provider) => !provider.fallbackOnly && supportsSearchType(provider, searchType))
.sort((a, b) => a.costPerQuery - b.costPerQuery)
.map((provider) => provider.id)
.filter((providerId) => providerId !== providerConfig!.id);
注释与实现均明确:fallback-only 供应商被保留给"last-resort 主选",不得进入执行期 alternates,避免免费兜底通过故障切换的后门再次抢占付费连接。
四、凭据解析的另一层:SEARCH_CREDENTIAL_FALLBACKS 复用链
自动选路能否命中"已配置凭据",取决于 resolveSearchCredentials(executeWebSearch.ts)。它先按供应商自身 ID 查凭据,失败后再沿 getSearchCredentialFallbacks 的复用链逐一尝试兄弟供应商的凭据:
async function resolveSearchCredentials(providerId: string) {
const creds = await getProviderCredentials(providerId).catch(() => null);
if (creds) return creds;
for (const fallbackId of getSearchCredentialFallbacks(providerId)) {
const fallback = await getProviderCredentials(fallbackId).catch(() => null);
if (fallback) return fallback;
}
return null;
}
复用映射定义在 searchRegistry.ts:
export const SEARCH_CREDENTIAL_FALLBACKS: Record<string, string | string[]> = {
"perplexity-search": "perplexity", // 复用 perplexity 聊天供应商的 Key
"ollama-search": "ollama-cloud",
"zai-search": "zai",
"jina-search": "jina-ai", // 复用 Foundation jina-ai / JINA_AI_API_KEY
"x-search": ["xai-oauth", "xao", "xai"], // 支持多个候选
};
export function getSearchCredentialFallbacks(providerId: string): string[] {
const mapped = SEARCH_CREDENTIAL_FALLBACKS[providerId];
if (!mapped) return [];
return Array.isArray(mapped) ? mapped : [mapped];
}
这意味着:用户即便没有在 Dashboard 单独为 perplexity-search 建卡片,只要 perplexity 聊天供应商已配置,自动选路依然会认为 perplexity-search "有凭据可用",从而稳定排在免密钥的 duckduckgo-free 之前。这正是"凭据优先"策略能够覆盖长尾场景的原因。
五、显式 provider 分支不受影响
值得注意的是,本次修复只涉及自动选路路径。当调用方显式传入 provider 时,逻辑走独立分支(executeWebSearch.ts):先做别名解析与 supportsSearchType 校验,再解析凭据;duckduckgo-free 通过别名 duckduckgo(见 SEARCH_PROVIDER_ALIASES)仍可被显式点名为可选目标。fallback-only 供应商"仅在显式指定或 last-resort 主选时使用"的语义在修复后更加彻底。
六、回归测试:如何锁定"Brave 必须先于 DuckDuckGo"
该修复配套了专项回归测试 tests/unit/execute-web-search-fallback-11524.test.ts。测试策略非常直观:
- 只播种一个凭据:调用
seedConnection("brave-search", { apiKey: "brave-key" })写入 providers 数据库,模拟"用户只配置了 Brave"; - 拦截全局 fetch:记录所有外呼 URL,Brave 域名返回一条固定 JSON 结果,其余 URL 一律抛错;
- 不带 provider 调用
executeWebSearch({ query: "..." }); - 断言三点:
result.data.provider === "brave-search",即主供应商必须是配置过的 Brave,而不是duckduckgo-free;- 出现过
api.search.brave.com调用; - 没有出现过任何
duckduckgo.com调用。
// Regression test for #11524 — executeWebSearch must prefer a credentialed
// provider over duckduckgo-free when the initially selected provider has no credentials.
test("auto-selects credentialed provider before duckduckgo-free fallback (#11524)", async () => {
await seedConnection("brave-search", { apiKey: "brave-key" });
...
const result = await executeWebSearch({ query: "latest omniroute roadmap" });
assert.equal(result.data.provider, "brave-search", "...");
assert.ok(fetchCalls.some((url) => url.includes("api.search.brave.com")), "...");
assert.ok(!fetchCalls.some((url) => url.includes("duckduckgo.com")),
"duckduckgo-free must NOT be invoked when a credentialed provider is available (#11524)");
...
});
测试文件顶部通过 process.env.DATA_DIR = TEST_DATA_DIR 指向临时目录,resetStorage() 在每条用例前重建干净的 SQLite 状态,确保凭据探测结果只受本用例影响。这条用例与 tests/unit/search-registry.test.ts、tests/unit/search-select-provider-searxng-bug-9543.test.ts 等共同构成搜索选路的行为契约,防止调度顺序在未来重构中再次倒退。
七、调用链与落地范围
executeWebSearch 是搜索能力的"总入口",本次修复因此惠及所有下游消费方:
- 内置技能
web_search:在 src/lib/skills/builtins.ts 中把工具参数(query/provider/max_results/search_type/filters等)直接透传给executeWebSearch,Agent 调用该技能时即获得修复后的自动选路语义; - Dashboard 搜索工具 / 组合(combo)路由:搜索作为 combo 调度的一环,同样受益于"配置过的付费供应商稳定可达";
/v1/searchAPI:HTTP 层路由 src/app/api/v1/search/route.ts 独立实现了带getProviderCredentialsWithQuotaPreflight(配额预检)与限流(isAllRateLimitedCredentials)的选路,与executeWebSearch共享同一份SEARCH_PROVIDERS注册表与fallbackOnly语义,保证两层入口的行为一致。
对自托管运维者的可观测信号是:修复后,只要你在 Dashboard 中为任意搜索供应商(Serper、Brave、Tavily、Perplexity、Exa、Firecrawl 等)填过 Key,系统自动选路就会真正用上它;duckduckgo-free 只在没有任何凭据的全新环境里充当静默兜底,/v1/search 的 provider 字段与用量记录(usage.search_cost_usd,见 executeWebSearch.ts 的成本记录逻辑)会如实反映真实使用方。
结语
#11524 是一次典型的"调度顺序即正确性"修复:免费兜底是"开箱即用"的承诺,但绝不能以牺牲用户已配置付费连接为代价。OmniRoute 通过在 executeWebSearch 中把 credentialed 常规供应商扫描前置、fallback-only 列表严格降级为 last resort、并从执行期 alternates 中剔除 fallback 三重手段,重新确立了"凭据可用性高于单次成本"的搜索选路优先级,并用回归测试将其固化为可验证的行为契约。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0625
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00