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:
@@ -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`. |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user