首页
/ OmniRoute 搜索调度修复:凭什么让已配置密钥的搜索供应商优先于 duckduckgo-free 兜底(11524)

OmniRoute 搜索调度修复:凭什么让已配置密钥的搜索供应商优先于 duckduckgo-free 兜底(11524)

2026-09-07 09:15:42作者:毕习沙Eudora

导读

在 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 when duckduckgo-free was 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.tsSEARCH_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.tsselectProvider 实现)是:自动选路必须排除 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 未提供)的旧逻辑大致是:

  1. selectProvider(undefined, searchType) 选出成本最低的常规供应商;
  2. 尝试为其解析凭据;
  3. 若凭据为空,直接进入 fallback-only 循环(此时 duckduckgo-free 空凭据必然命中);
  4. 常规"有凭据供应商"的扫描循环排在这之后,从而永远轮不到。

由此,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;
      }
    }
  }
  ...

修复引入了清晰的两阶段语义:

  1. 第一阶段(主候选扫描):把所有 !fallbackOnly 且支持当前 searchType 的供应商按 costPerQuery 升序排列,逐个调用 resolveSearchCredentials 探测凭据,命中即选中并终止。这一阶段天然满足两层目标:

    • 成本最小化——便宜的配置供应商优先;
    • 可用性优先——只挑选"真正配置了凭据"的供应商,成本为 0 但没配 Key 的供应商会因凭据探测失败被跳过,不再制造"便宜但不可用"的假胜出。
  2. 第二阶段(真正的 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 复用链

自动选路能否命中"已配置凭据",取决于 resolveSearchCredentialsexecuteWebSearch.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。测试策略非常直观:

  1. 只播种一个凭据:调用 seedConnection("brave-search", { apiKey: "brave-key" }) 写入 providers 数据库,模拟"用户只配置了 Brave";
  2. 拦截全局 fetch:记录所有外呼 URL,Brave 域名返回一条固定 JSON 结果,其余 URL 一律抛错;
  3. 不带 provider 调用 executeWebSearch({ query: "..." })
  4. 断言三点
    • 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.tstests/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/search API: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/searchprovider 字段与用量记录(usage.search_cost_usd,见 executeWebSearch.ts 的成本记录逻辑)会如实反映真实使用方。

结语

#11524 是一次典型的"调度顺序即正确性"修复:免费兜底是"开箱即用"的承诺,但绝不能以牺牲用户已配置付费连接为代价。OmniRoute 通过在 executeWebSearch 中把 credentialed 常规供应商扫描前置、fallback-only 列表严格降级为 last resort、并从执行期 alternates 中剔除 fallback 三重手段,重新确立了"凭据可用性高于单次成本"的搜索选路优先级,并用回归测试将其固化为可验证的行为契约。

登录后查看全文
热门项目推荐
相关项目推荐