From 735be36df69cddf9177f63a3fad35f346d2782ec Mon Sep 17 00:00:00 2001 From: Alexander Kirilin Date: Sat, 18 Jul 2026 20:12:26 -0400 Subject: [PATCH 1/2] feat(omp): configure web search provider order --- docs/settings.md | 2 ++ docs/tools/web_search.md | 9 +++--- packages/coding-agent/CHANGELOG.md | 4 +++ .../src/config/provider-globals.ts | 6 ++++ .../src/config/settings-schema.ts | 10 +++++++ .../modes/controllers/selector-controller.ts | 6 ++++ packages/coding-agent/src/sdk.ts | 6 ++++ packages/coding-agent/src/web/search/index.ts | 8 +++-- .../coding-agent/src/web/search/provider.ts | 17 ++++++++++- .../test/config/provider-globals.test.ts | 7 ++++- .../test/web/search/abort-and-timeout.test.ts | 30 +++++++++++++++++-- .../test/web/search/provider-chain.test.ts | 28 +++++++++++++---- 12 files changed, 116 insertions(+), 17 deletions(-) diff --git a/docs/settings.md b/docs/settings.md index eecd5e942..a1aa439d2 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -626,6 +626,7 @@ For a custom status line, set `statusLine.preset: custom` and configure `statusL ```yaml providers: webSearch: auto + webSearchOrder: [perplexity, exa, gemini] image: auto fetch: auto webSearchGeminiModel: gemini-2.5-flash @@ -653,6 +654,7 @@ searxng: | Key | Type | Default | Values / notes | |---|---|---|---| | `providers.webSearch` | enum | `auto` | `auto` plus the configured search providers (`perplexity`, `gemini`, `anthropic`, `codex`, `zai`, `exa`, `jina`, `kagi`, `tavily`, `brave`, `kimi`, `parallel`, `synthetic`, `searxng`). | +| `providers.webSearchOrder` | array | `[]` | Provider IDs to prioritize after the preferred provider. Duplicates and unknown IDs are ignored; unlisted providers retain their built-in relative order afterward. | | `providers.webSearchGeminiModel` | string | _(unset)_ | Gemini model ID for Google Search grounding when `web_search` uses Gemini; defaults to `gemini-2.5-flash`, overridden by `GEMINI_SEARCH_MODEL`. | | `providers.image` | enum | `auto` | `auto`, `openai`, `antigravity`, `xai`, `gemini`, `openrouter`. | | `providers.fetch` | enum | `auto` | `auto`, `native`, `trafilatura`, `lynx`, `parallel`, `jina`. | diff --git a/docs/tools/web_search.md b/docs/tools/web_search.md index d17f0ad42..4172d40bc 100644 --- a/docs/tools/web_search.md +++ b/docs/tools/web_search.md @@ -85,7 +85,7 @@ Streaming: none. `WebSearchTool.execute()` forwards its `AbortSignal` into `exec 2. `executeSearch()` computes ordered provider candidates without loading their modules: - if `params.provider` is set and not `"auto"`, it loads that provider only to check `isExplicitlyAvailable()`; if false, it uses the auto candidates. - otherwise it uses the module-global preferred provider from `packages/coding-agent/src/web/search/provider.ts`. -3. `resolveProviderCandidates()` puts an included preferred provider first (gated by `isExplicitlyAvailable()`), then `SEARCH_PROVIDER_ORDER` excluding it. Excluded providers are skipped entirely, including as the preferred candidate. As `executeSearch()` walks those candidates, it loads a module and checks availability only when the candidate is reached. +3. `resolveProviderCandidates()` puts an included preferred provider first (gated by `isExplicitlyAvailable()`), then the effective provider order excluding it. `providers.webSearchOrder` prioritizes listed providers and appends unlisted providers in `SEARCH_PROVIDER_ORDER`; an empty list preserves the built-in order. Excluded providers are skipped entirely, including as the preferred candidate. As `executeSearch()` walks those candidates, it loads a module and checks availability only when the candidate is reached. 4. If no providers are available (for example, after excluding DuckDuckGo and lacking configured keyed/OAuth providers), `executeSearch()` returns `Error: No web search provider configured.` with `details.response.provider = "none"`. 5. For each provider in order, `executeSearch()` calls `provider.search()` with: - `query`, @@ -101,10 +101,11 @@ Streaming: none. `WebSearchTool.execute()` forwards its `AbortSignal` into `exec ## Modes / Variants - **Provider selection** - - **Forced provider**: internal callers may pass `provider`; unavailable forced providers fall back to the auto chain instead of hard-failing (`packages/coding-agent/src/web/search/index.ts`). This field is not in the model-facing schema. - - **Preferred provider**: `setPreferredSearchProvider()` sets a module-global default used by `resolveProviderCandidates()`. `packages/coding-agent/src/sdk.ts` and `packages/coding-agent/src/modes/controllers/selector-controller.ts` wire this from settings. + - **Forced provider**: internal callers may pass `provider`; a non-`auto` value is the only attempted provider, while `auto` bypasses the configured preference and uses the ordered fallback chain. This field is not in the model-facing schema. + - **Preferred provider**: `setPreferredSearchProvider()` sets a module-global first choice used by `resolveProviderCandidates()`. Failure continues through the configured fallback order. `packages/coding-agent/src/sdk.ts` and `packages/coding-agent/src/modes/controllers/selector-controller.ts` wire this from settings. + - **Configured order**: `setSearchProviderOrder()` prioritizes the valid, first-occurrence provider IDs in `providers.webSearchOrder`; providers omitted from the setting follow in their built-in relative order. `providers.webSearch` remains the first-choice override. - **Excluded providers**: `setExcludedSearchProviders()` records providers `resolveProviderCandidates()` must skip, including as fallbacks. Wired from the `providers.webSearchExclude` setting (`providers.webSearch` drives the preferred provider) in `packages/coding-agent/src/sdk.ts`, `packages/coding-agent/src/modes/interactive-mode.ts`, and `packages/coding-agent/src/modes/controllers/selector-controller.ts`. - - **Auto chain order** (25 providers): `perplexity`, `gemini`, `anthropic`, `codex`, `xai`, `zai`, `exa`, `tinyfish`, `jina`, `kagi`, `tavily`, `firecrawl`, `brave`, `kimi`, `parallel`, `synthetic`, `searxng`, `duckduckgo`, `bing`, `yahoo`, `startpage`, `google`, `ecosia`, `mojeek`, `public` (`SEARCH_PROVIDER_ORDER` in `packages/coding-agent/src/web/search/types.ts`). `public` is explicit-only: its `isAvailable()` returns `false` so the auto chain never fans out implicitly. + - **Default auto chain order** (25 providers): `perplexity`, `gemini`, `anthropic`, `codex`, `xai`, `zai`, `exa`, `tinyfish`, `jina`, `kagi`, `tavily`, `firecrawl`, `brave`, `kimi`, `parallel`, `synthetic`, `searxng`, `duckduckgo`, `bing`, `yahoo`, `startpage`, `google`, `ecosia`, `mojeek`, `public` (`SEARCH_PROVIDER_ORDER` in `packages/coding-agent/src/web/search/types.ts`). `public` is explicit-only: its `isAvailable()` returns `false` so the auto chain never fans out implicitly. - **Provider adapters** - **Perplexity** — `packages/coding-agent/src/web/search/providers/perplexity.ts` - Availability: auth precedence is `PERPLEXITY_COOKIES` -> OAuth token in `agent.db` -> `PERPLEXITY_API_KEY` / `PPLX_API_KEY` -> anonymous ask-endpoint fallback. `isAvailable()` gates the auto chain on credentials, but `isExplicitlyAvailable()` is always true, so explicit selection works unauthenticated. diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 6f32dc885..b45ed3284 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added `providers.webSearchOrder` to prioritize web-search fallbacks while preserving the built-in order for unlisted providers; a failing preferred provider now continues through that fallback chain. + ## [17.0.5] - 2026-07-18 ### Added diff --git a/packages/coding-agent/src/config/provider-globals.ts b/packages/coding-agent/src/config/provider-globals.ts index 9fdd5d4a3..3d2bfe01c 100644 --- a/packages/coding-agent/src/config/provider-globals.ts +++ b/packages/coding-agent/src/config/provider-globals.ts @@ -2,6 +2,7 @@ import * as imageGen from "../tools/image-gen"; import * as webSearch from "../web/search"; interface ProviderGlobalSettings { + get(path: "providers.webSearchOrder"): unknown; get(path: "providers.webSearchExclude"): unknown; get(path: "providers.webSearch"): unknown; get(path: "providers.image"): unknown; @@ -13,6 +14,11 @@ export function applyProviderGlobalsFromSettings(settings: ProviderGlobalSetting webSearch.setExcludedSearchProviders(excludedWebSearchProviders.filter(webSearch.isSearchProviderId)); } + const orderedWebSearchProviders = settings.get("providers.webSearchOrder"); + if (Array.isArray(orderedWebSearchProviders)) { + webSearch.setSearchProviderOrder(orderedWebSearchProviders.filter(webSearch.isSearchProviderId)); + } + const webSearchProvider = settings.get("providers.webSearch"); if (typeof webSearchProvider === "string" && webSearch.isSearchProviderPreference(webSearchProvider)) { webSearch.setPreferredSearchProvider(webSearchProvider); diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index 23b0e6800..2d19e749c 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -4444,6 +4444,16 @@ export const SETTINGS_SCHEMA = { options: SEARCH_PROVIDER_OPTIONS, }, }, + "providers.webSearchOrder": { + type: "array", + default: [] as SearchProviderId[], + ui: { + tab: "providers", + group: "Services", + label: "Web Search Provider Order", + description: "Prioritized providers; unlisted providers retain their default order afterward", + }, + }, "providers.webSearchExclude": { type: "array", default: [] as SearchProviderId[], diff --git a/packages/coding-agent/src/modes/controllers/selector-controller.ts b/packages/coding-agent/src/modes/controllers/selector-controller.ts index 371f06ee4..d1144a3eb 100644 --- a/packages/coding-agent/src/modes/controllers/selector-controller.ts +++ b/packages/coding-agent/src/modes/controllers/selector-controller.ts @@ -63,6 +63,7 @@ import { setExcludedSearchProviders, setPreferredImageProvider, setPreferredSearchProvider, + setSearchProviderOrder, } from "../../tools"; import { shortenPath } from "../../tools/render-utils"; import { copyToClipboard } from "../../utils/clipboard"; @@ -611,6 +612,11 @@ export class SelectorController { setPreferredSearchProvider(value); } break; + case "providers.webSearchOrder": + if (Array.isArray(value)) { + setSearchProviderOrder(value.filter(isSearchProviderId)); + } + break; case "providers.webSearchExclude": if (Array.isArray(value)) { setExcludedSearchProviders(value.filter(isSearchProviderId)); diff --git a/packages/coding-agent/src/sdk.ts b/packages/coding-agent/src/sdk.ts index af123be59..92b629a03 100644 --- a/packages/coding-agent/src/sdk.ts +++ b/packages/coding-agent/src/sdk.ts @@ -182,6 +182,7 @@ import { setExcludedSearchProviders, setPreferredImageProvider, setPreferredSearchProvider, + setSearchProviderOrder, type Tool, type ToolSession, WebSearchTool, @@ -1294,6 +1295,11 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} setExcludedSearchProviders(excludedWebSearchProviders.filter(isSearchProviderId)); } + const orderedWebSearchProviders = settings.get("providers.webSearchOrder"); + if (Array.isArray(orderedWebSearchProviders)) { + setSearchProviderOrder(orderedWebSearchProviders.filter(isSearchProviderId)); + } + const webSearchProvider = settings.get("providers.webSearch"); if (typeof webSearchProvider === "string" && isSearchProviderPreference(webSearchProvider)) { setPreferredSearchProvider(webSearchProvider); diff --git a/packages/coding-agent/src/web/search/index.ts b/packages/coding-agent/src/web/search/index.ts index 20b82d4a6..cba6b330c 100644 --- a/packages/coding-agent/src/web/search/index.ts +++ b/packages/coding-agent/src/web/search/index.ts @@ -216,7 +216,6 @@ async function executeSearch( // summary error), masking the cancellation. throwIfAborted(signal); failures.push({ provider: provider ?? providerMeta, error }); - if (candidate.explicit) break; } } @@ -351,6 +350,11 @@ export function getSearchTools(): CustomTool[] { return [webSearchCustomTool]; } -export { getSearchProvider, setExcludedSearchProviders, setPreferredSearchProvider } from "./provider"; +export { + getSearchProvider, + setExcludedSearchProviders, + setPreferredSearchProvider, + setSearchProviderOrder, +} from "./provider"; export type { SearchProviderId as SearchProvider, SearchResponse } from "./types"; export { isSearchProviderId, isSearchProviderPreference } from "./types"; diff --git a/packages/coding-agent/src/web/search/provider.ts b/packages/coding-agent/src/web/search/provider.ts index d141378c9..e290c0ce4 100644 --- a/packages/coding-agent/src/web/search/provider.ts +++ b/packages/coding-agent/src/web/search/provider.ts @@ -197,6 +197,21 @@ export function setPreferredSearchProvider(provider: SearchProviderId | "auto"): preferredProvId = provider; } +/** Provider fallback order set via settings (default: built-in order). */ +let orderedProvIds: readonly SearchProviderId[] = SEARCH_PROVIDER_ORDER; + +/** + * Prioritize configured providers while retaining every unlisted provider in + * its built-in relative order. Invalid IDs are ignored defensively. + */ +export function setSearchProviderOrder(providers: readonly SearchProviderId[]): void { + const prioritized = new Set(providers.filter(id => SEARCH_PROVIDER_ORDER.includes(id))); + orderedProvIds = + prioritized.size === 0 + ? SEARCH_PROVIDER_ORDER + : [...prioritized, ...SEARCH_PROVIDER_ORDER.filter(id => !prioritized.has(id))]; +} + /** Providers excluded from web search resolution via settings. */ let excludedProvIds = new Set(); @@ -225,7 +240,7 @@ export function resolveProviderCandidates( candidates.push({ id: preferredProvider, explicit: true }); } - for (const id of SEARCH_PROVIDER_ORDER) { + for (const id of orderedProvIds) { if (id === preferredProvider || isSearchProviderExcluded(id)) continue; candidates.push({ id, explicit: false }); } diff --git a/packages/coding-agent/test/config/provider-globals.test.ts b/packages/coding-agent/test/config/provider-globals.test.ts index 81e4d6dcc..d91370830 100644 --- a/packages/coding-agent/test/config/provider-globals.test.ts +++ b/packages/coding-agent/test/config/provider-globals.test.ts @@ -10,12 +10,16 @@ describe("applyProviderGlobalsFromSettings", () => { it("reapplies valid web and image provider globals from cwd-scoped settings", () => { const excludeSpy = vi.spyOn(webSearch, "setExcludedSearchProviders").mockImplementation(() => {}); + const orderSpy = vi.spyOn(webSearch, "setSearchProviderOrder").mockImplementation(() => {}); const webSpy = vi.spyOn(webSearch, "setPreferredSearchProvider").mockImplementation(() => {}); const imageSpy = vi.spyOn(imageGen, "setPreferredImageProvider").mockImplementation(() => {}); applyProviderGlobalsFromSettings({ - get(path: "providers.webSearchExclude" | "providers.webSearch" | "providers.image"): unknown { + get( + path: "providers.webSearchOrder" | "providers.webSearchExclude" | "providers.webSearch" | "providers.image", + ): unknown { const values: Record = { + "providers.webSearchOrder": ["perplexity", "not-a-provider", "exa"], "providers.webSearchExclude": ["exa", "not-a-provider", "gemini"], "providers.webSearch": "perplexity", "providers.image": "xai", @@ -24,6 +28,7 @@ describe("applyProviderGlobalsFromSettings", () => { }, }); + expect(orderSpy).toHaveBeenCalledWith(["perplexity", "exa"]); expect(excludeSpy).toHaveBeenCalledWith(["exa", "gemini"]); expect(webSpy).toHaveBeenCalledWith("perplexity"); expect(imageSpy).toHaveBeenCalledWith("xai"); diff --git a/packages/coding-agent/test/web/search/abort-and-timeout.test.ts b/packages/coding-agent/test/web/search/abort-and-timeout.test.ts index a625b751b..641a2592c 100644 --- a/packages/coding-agent/test/web/search/abort-and-timeout.test.ts +++ b/packages/coding-agent/test/web/search/abort-and-timeout.test.ts @@ -16,7 +16,7 @@ import type { AuthStorage, FetchImpl } from "@oh-my-pi/pi-ai"; import type { AgentStorage } from "@oh-my-pi/pi-coding-agent/session/agent-storage"; import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; import { ToolAbortError } from "@oh-my-pi/pi-coding-agent/tools/tool-errors"; -import { WebSearchTool } from "@oh-my-pi/pi-coding-agent/web/search"; +import { runSearchQuery, WebSearchTool } from "@oh-my-pi/pi-coding-agent/web/search"; import * as provider from "@oh-my-pi/pi-coding-agent/web/search/provider"; import { searchAnthropic } from "@oh-my-pi/pi-coding-agent/web/search/providers/anthropic"; import type { SearchParams } from "@oh-my-pi/pi-coding-agent/web/search/providers/base"; @@ -272,6 +272,31 @@ describe("executeSearch abort propagation", () => { expect(fallbackSearch).not.toHaveBeenCalled(); }); + it("falls through after the preferred provider fails", async () => { + const fallbackSearch = vi.fn( + async (): Promise => ({ + provider: "brave", + sources: [{ title: "Fallback result", url: "https://example.com/fallback" }], + }), + ); + const getProvider = mockProviderChain( + [ + fakeProvider("exa", async () => { + throw new SearchProviderError("exa", "Preferred provider failed.", 500); + }), + fakeProvider("brave", fallbackSearch), + ], + { explicitFirst: true }, + ); + + const tool = new WebSearchTool(FAKE_SESSION); + const result = await tool.execute("test-id", { query: "anything" }); + + expect(result.details?.response.provider).toBe("brave"); + expect(getProvider).toHaveBeenCalledTimes(2); + expect(fallbackSearch).toHaveBeenCalledTimes(1); + }); + it("does not fall through after an explicitly selected provider fails", async () => { const fallbackSearch = vi.fn( async (): Promise => ({ @@ -289,8 +314,7 @@ describe("executeSearch abort propagation", () => { { explicitFirst: true }, ); - const tool = new WebSearchTool(FAKE_SESSION); - const result = await tool.execute("test-id", { query: "anything" }); + const result = await runSearchQuery({ query: "anything", provider: "codex" }, { authStorage: {} as AuthStorage }); expect(result.details?.error).toContain("Configured Codex endpoint does not support web_search."); expect(result.details?.response.provider).toBe("codex"); diff --git a/packages/coding-agent/test/web/search/provider-chain.test.ts b/packages/coding-agent/test/web/search/provider-chain.test.ts index bae5bf195..aaf998398 100644 --- a/packages/coding-agent/test/web/search/provider-chain.test.ts +++ b/packages/coding-agent/test/web/search/provider-chain.test.ts @@ -6,6 +6,7 @@ import { resolveProviderChain, setExcludedSearchProviders, setPreferredSearchProvider, + setSearchProviderOrder, } from "@oh-my-pi/pi-coding-agent/web/search/provider"; import { SEARCH_PROVIDER_ORDER } from "@oh-my-pi/pi-coding-agent/web/search/types"; @@ -35,17 +36,22 @@ function restoreEnv(): void { afterEach(() => { setPreferredSearchProvider("auto"); setExcludedSearchProviders([]); + setSearchProviderOrder([]); restoreEnv(); }); describe("resolveProviderCandidates", () => { - it("orders the preferred provider before unloaded fallbacks", () => { - const candidates = resolveProviderCandidates("exa"); + it("orders the preferred provider before configured and built-in fallbacks", () => { + setSearchProviderOrder(["gemini", "exa"]); - expect(candidates[0]).toEqual({ id: "exa", explicit: true }); - expect(candidates.slice(1).map(candidate => candidate.id)).toEqual( - SEARCH_PROVIDER_ORDER.filter(id => id !== "exa"), - ); + const candidates = resolveProviderCandidates("perplexity"); + + expect(candidates[0]).toEqual({ id: "perplexity", explicit: true }); + expect(candidates.slice(1).map(candidate => candidate.id)).toEqual([ + "gemini", + "exa", + ...SEARCH_PROVIDER_ORDER.filter(id => id !== "perplexity" && id !== "gemini" && id !== "exa"), + ]); }); it("omits excluded providers without resolving them", () => { @@ -56,6 +62,16 @@ describe("resolveProviderCandidates", () => { expect(candidates.map(candidate => candidate.id)).not.toContain("duckduckgo"); expect(candidates.map(candidate => candidate.id)).not.toContain("google"); }); + + it("applies live settings edits, filtering invalid and duplicate provider IDs", () => { + const controller = new SelectorController({} as unknown as ConstructorParameters[0]); + + controller.handleSettingChange("providers.webSearchOrder", ["exa", "not-a-provider", "exa", "gemini"]); + + const candidates = resolveProviderCandidates("auto"); + expect(candidates.slice(0, 2).map(candidate => candidate.id)).toEqual(["exa", "gemini"]); + expect(candidates).toHaveLength(SEARCH_PROVIDER_ORDER.length); + }); }); describe("resolveProviderChain", () => { From e51b848043e7d55595385aace3739b04cffa5e24 Mon Sep 17 00:00:00 2001 From: Alexander Kirilin Date: Sat, 18 Jul 2026 21:10:45 -0400 Subject: [PATCH 2/2] docs(omp): document webSearch fallback cascade in changelog --- packages/coding-agent/CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index b45ed3284..6bcae9237 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -6,6 +6,10 @@ - Added `providers.webSearchOrder` to prioritize web-search fallbacks while preserving the built-in order for unlisted providers; a failing preferred provider now continues through that fallback chain. +### Changed + +- Changed `providers.webSearch` preferred provider failure handling to fall back and cascade through other configured/default search providers rather than stopping immediately. + ## [17.0.5] - 2026-07-18 ### Added