Merge PR #6029: feat(omp): configure web search provider order (@riverpilot)

# Conflicts:
#	packages/coding-agent/src/modes/controllers/selector-controller.ts
This commit is contained in:
can1357
2026-07-23 20:18:08 +02:00
12 changed files with 117 additions and 17 deletions
+2
View File
@@ -648,6 +648,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
@@ -675,6 +676,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`. |
+5 -4
View File
@@ -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.
+5
View File
@@ -289,6 +289,11 @@
- Fixed the Cursor-backed advisor losing entire turns when it selected server-native tools (`bash`, `grep`, etc.) outside its grant: exec-resolved native blocks are already rejected in-band by the advisor-scoped bridge, so they no longer trip the unavailable-tool quarantine and discard the `advise` emitted in the same turn ([#5900](https://github.com/can1357/oh-my-pi/issues/5900)).
- Fixed custom `anthropic-messages` OAuth providers being unable to opt into configured Claude Code fingerprint header overrides. ([#5888](https://github.com/can1357/oh-my-pi/issues/5888))
- Fixed authoritative providers (e.g. `openai-codex`) keeping unsupported bundled models selectable when a fresh model cache and an expired OAuth token coincided: built-in discovery now forces the OAuth refresh so the provider's model manager is constructed and prunes stale bundled entries (e.g. `gpt-5.4-nano`) instead of waiting out the cache TTL. ([#5364](https://github.com/can1357/oh-my-pi/issues/5364))
- 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
@@ -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);
@@ -4567,6 +4567,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[],
@@ -63,6 +63,7 @@ import {
setExcludedSearchProviders,
setPreferredImageProvider,
setPreferredSearchProvider,
setSearchProviderOrder,
type ToolSession,
} from "../../tools";
import { AskTool, type AskToolDetails, type AskToolInput } from "../../tools/ask";
@@ -614,6 +615,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));
+6
View File
@@ -184,6 +184,7 @@ import {
setExcludedSearchProviders,
setPreferredImageProvider,
setPreferredSearchProvider,
setSearchProviderOrder,
type Tool,
type ToolSession,
WebSearchTool,
@@ -1260,6 +1261,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);
@@ -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<any, any>[] {
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";
@@ -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<SearchProviderId>();
@@ -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 });
}
@@ -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<string, unknown> = {
"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");
@@ -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<SearchResponse> => ({
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<SearchResponse> => ({
@@ -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");
@@ -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<typeof SelectorController>[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", () => {