From 035845ca97406ad514165838163a1f164ae4f15c Mon Sep 17 00:00:00 2001 From: Vu Anh Nguyen Date: Tue, 2 Jun 2026 12:04:57 +0700 Subject: [PATCH] Fix web search provider TUI options --- docs/tools/web_search.md | 8 +- packages/coding-agent/CHANGELOG.md | 1 + .../src/config/settings-schema.ts | 57 +--------- packages/coding-agent/src/web/search/index.ts | 2 +- .../coding-agent/src/web/search/provider.ts | 52 +++------ packages/coding-agent/src/web/search/types.ts | 105 ++++++++++++------ .../coding-agent/test/setup-wizard.test.ts | 31 ++++++ 7 files changed, 131 insertions(+), 125 deletions(-) diff --git a/docs/tools/web_search.md b/docs/tools/web_search.md index 35266124a..9099280cb 100644 --- a/docs/tools/web_search.md +++ b/docs/tools/web_search.md @@ -90,7 +90,7 @@ Streaming: none. `WebSearchTool.execute()` forwards its `AbortSignal` into `exec - **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 `resolveProviderChain()`. `packages/coding-agent/src/sdk.ts` and `packages/coding-agent/src/modes/controllers/selector-controller.ts` wire this from settings. - - **Auto chain order**: `tavily`, `perplexity`, `brave`, `jina`, `kimi`, `anthropic`, `gemini`, `codex`, `zai`, `exa`, `parallel`, `kagi`, `synthetic`, `searxng` (`SEARCH_PROVIDER_ORDER` in `packages/coding-agent/src/web/search/provider.ts`). + - **Auto chain order**: `tavily`, `perplexity`, `brave`, `jina`, `kimi`, `anthropic`, `gemini`, `codex`, `zai`, `exa`, `parallel`, `kagi`, `synthetic`, `searxng` (`SEARCH_PROVIDER_ORDER` in `packages/coding-agent/src/web/search/types.ts`). - **Provider adapters** - **Tavily** — `packages/coding-agent/src/web/search/providers/tavily.ts` - Availability: API key from env or `agent.db` via `findCredential()`. @@ -192,7 +192,7 @@ Streaming: none. `WebSearchTool.execute()` forwards its `AbortSignal` into `exec - Many provider adapters accept `AbortSignal`; `WebSearchTool.execute()` passes the tool call signal into `executeSearch()`, which forwards it as `params.signal` to providers and rethrows cancellation during fallback. ## Limits & Caps -- Provider auto-order length: 14 providers (`SEARCH_PROVIDER_ORDER` in `packages/coding-agent/src/web/search/provider.ts`). +- Provider auto-order length: 14 providers (`SEARCH_PROVIDER_ORDER` in `packages/coding-agent/src/web/search/types.ts`). - `formatForLLM()` truncates source snippets and citation text to 240 chars (`packages/coding-agent/src/web/search/index.ts`). - `formatForLLM()` emits at most 3 search queries, each truncated to 120 chars (`packages/coding-agent/src/web/search/index.ts`). - Brave result count: default `10`, max `20` (`DEFAULT_NUM_RESULTS`, `MAX_NUM_RESULTS` in `packages/coding-agent/src/web/search/providers/brave.ts`). @@ -224,5 +224,5 @@ Streaming: none. `WebSearchTool.execute()` forwards its `AbortSignal` into `exec - Most providers treat `limit` and `num_search_results` as the same number because adapters pass `params.numSearchResults ?? params.limit`. Perplexity is the only implementation that preserves both concepts. - The prompt says `recency` is for Brave and Perplexity, but code also implements it for Tavily and SearXNG. - The year rewrite in `executeSearch()` is blunt: any `2020`-`2029` substring is replaced with the current year. -- `packages/coding-agent/src/config/settings-schema.ts` exposes provider preferences for `auto`, `exa`, `brave`, `jina`, `kimi`, `perplexity`, `anthropic`, `zai`, `tavily`, `kagi`, `synthetic`, `parallel`, and `searxng`. Gemini and Codex are in the registry and auto chain but not in that settings enum. -- Exa availability is optimistic. Unless settings disable it, the provider stays in the chain even without an API key because it can fall back to MCP. +- `packages/coding-agent/src/config/settings-schema.ts` uses the shared `SEARCH_PROVIDER_PREFERENCES` / `SEARCH_PROVIDER_OPTIONS` metadata, so the settings selector and setup wizard expose `auto` plus every provider in the auto chain. +- Exa requires an API key from the environment or credential store; it no longer falls back to unauthenticated MCP search. diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 7041710cd..764e8ebd2 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -20,6 +20,7 @@ - Migrated the Kagi web search provider to Kagi's V1 Search API (`POST /api/v1/search`), replacing the sunset V0 endpoint while keeping the `kagi` provider id, `KAGI_API_KEY` credential, and `/login kagi` flow unchanged ([#1272](https://github.com/can1357/oh-my-pi/pull/1272) by [@thismat](https://github.com/thismat)) ### Fixed +- Fixed the web-search provider selectors in TUI settings/setup to derive from the shared provider metadata, so newly added providers cannot be omitted from the preference list. - Fixed `read`, `search`, `find`, `ast_grep`, and `ast_edit` recovering when a model flattens multiple existing paths into one comma-, semicolon-, or space-delimited string while preserving real paths that contain delimiters. - Fixed Exa web search reporting available without Exa credentials, which could route searches into the unauthenticated public MCP fallback and stall before trying the next provider. Availability and `searchExa()` now resolve through the standard `AuthStorage` cascade (`EXA_API_KEY` env or stored credential) ([#1695](https://github.com/can1357/oh-my-pi/issues/1695)). diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index e03c9c8c2..2d4693014 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -23,6 +23,7 @@ import { TINY_TITLE_MODEL_VALUES, } from "../tiny/models"; import { EDIT_MODES } from "../utils/edit-mode"; +import { SEARCH_PROVIDER_OPTIONS, SEARCH_PROVIDER_PREFERENCES } from "../web/search/types"; /** Unified settings schema - single source of truth for all settings. * Unified settings schema - single source of truth for all settings. @@ -2866,65 +2867,13 @@ export const SETTINGS_SCHEMA = { // Provider selection "providers.webSearch": { type: "enum", - values: [ - "auto", - "exa", - "brave", - "jina", - "kimi", - "zai", - "perplexity", - "anthropic", - "gemini", - "codex", - "tavily", - "kagi", - "synthetic", - "parallel", - "searxng", - ] as const, + values: SEARCH_PROVIDER_PREFERENCES, default: "auto", ui: { tab: "providers", label: "Web Search Provider", description: "Provider for web search tool", - options: [ - { - value: "auto", - label: "Auto", - description: "Preferred web-search provider", - }, - { value: "exa", label: "Exa", description: "Requires EXA_API_KEY" }, - { value: "brave", label: "Brave", description: "Requires BRAVE_API_KEY" }, - { value: "jina", label: "Jina", description: "Requires JINA_API_KEY" }, - { value: "kimi", label: "Kimi", description: "Requires MOONSHOT_SEARCH_API_KEY or MOONSHOT_API_KEY" }, - { - value: "perplexity", - label: "Perplexity", - description: "Requires PERPLEXITY_COOKIES or PERPLEXITY_API_KEY", - }, - { - value: "anthropic", - label: "Anthropic", - description: "Claude's native web_search tool (uses Anthropic OAuth or ANTHROPIC_API_KEY)", - }, - { - value: "codex", - label: "OpenAI", - description: "OpenAI's native web_search (uses ChatGPT OAuth via /login openai-codex)", - }, - { - value: "gemini", - label: "Gemini", - description: "Google Search grounding via Gemini (uses google-gemini-cli or google-antigravity OAuth)", - }, - { value: "zai", label: "Z.AI", description: "Calls Z.AI webSearchPrime MCP" }, - { value: "tavily", label: "Tavily", description: "Requires TAVILY_API_KEY" }, - { value: "kagi", label: "Kagi", description: "Requires KAGI_API_KEY (Kagi V1 Search API)" }, - { value: "synthetic", label: "Synthetic", description: "Requires SYNTHETIC_API_KEY" }, - { value: "parallel", label: "Parallel", description: "Requires PARALLEL_API_KEY" }, - { value: "searxng", label: "SearXNG", description: "Requires SEARXNG_ENDPOINT or searxng.endpoint" }, - ], + options: SEARCH_PROVIDER_OPTIONS, }, }, "providers.image": { diff --git a/packages/coding-agent/src/web/search/index.ts b/packages/coding-agent/src/web/search/index.ts index 5234a66e2..f6fd599ce 100644 --- a/packages/coding-agent/src/web/search/index.ts +++ b/packages/coding-agent/src/web/search/index.ts @@ -220,7 +220,7 @@ export async function runSearchQuery( /** * Web search tool implementation. * - * Supports Anthropic, Perplexity, Exa, Brave, Jina, Kimi, Gemini, Codex, Z.AI, SearXNG, and Synthetic providers with automatic fallback. + * Supports the configured web-search provider chain with automatic fallback. */ export class WebSearchTool implements AgentTool { readonly name = "web_search"; diff --git a/packages/coding-agent/src/web/search/provider.ts b/packages/coding-agent/src/web/search/provider.ts index cae471361..3218a4206 100644 --- a/packages/coding-agent/src/web/search/provider.ts +++ b/packages/coding-agent/src/web/search/provider.ts @@ -5,15 +5,16 @@ // fetch/parse/format helpers) and only one — at most — is needed per session, // so eager construction was wasted work at startup. // -// The `label`/`id` metadata is kept inline so callers needing a display name -// (error formatting, UI listings) do not force a load. +// Provider modules are loaded lazily; display metadata lives in types.ts so UI +// listings can share it without importing provider implementations. import type { AuthStorage } from "@oh-my-pi/pi-ai"; import type { SearchProvider } from "./providers/base"; -import type { SearchProviderId } from "./types"; +import { SEARCH_PROVIDER_LABELS, SEARCH_PROVIDER_ORDER, type SearchProviderId } from "./types"; export type { SearchParams } from "./providers/base"; export { SearchProvider } from "./providers/base"; +export { SEARCH_PROVIDER_ORDER } from "./types"; interface ProviderMeta { id: SearchProviderId; @@ -25,72 +26,72 @@ interface ProviderMeta { const PROVIDER_META: Record = { exa: { id: "exa", - label: "Exa", + label: SEARCH_PROVIDER_LABELS.exa, load: async () => new (await import("./providers/exa")).ExaProvider(), }, brave: { id: "brave", - label: "Brave", + label: SEARCH_PROVIDER_LABELS.brave, load: async () => new (await import("./providers/brave")).BraveProvider(), }, jina: { id: "jina", - label: "Jina", + label: SEARCH_PROVIDER_LABELS.jina, load: async () => new (await import("./providers/jina")).JinaProvider(), }, perplexity: { id: "perplexity", - label: "Perplexity", + label: SEARCH_PROVIDER_LABELS.perplexity, load: async () => new (await import("./providers/perplexity")).PerplexityProvider(), }, kimi: { id: "kimi", - label: "Kimi", + label: SEARCH_PROVIDER_LABELS.kimi, load: async () => new (await import("./providers/kimi")).KimiProvider(), }, zai: { id: "zai", - label: "Z.AI", + label: SEARCH_PROVIDER_LABELS.zai, load: async () => new (await import("./providers/zai")).ZaiProvider(), }, anthropic: { id: "anthropic", - label: "Anthropic", + label: SEARCH_PROVIDER_LABELS.anthropic, load: async () => new (await import("./providers/anthropic")).AnthropicProvider(), }, gemini: { id: "gemini", - label: "Gemini", + label: SEARCH_PROVIDER_LABELS.gemini, load: async () => new (await import("./providers/gemini")).GeminiProvider(), }, codex: { id: "codex", - label: "OpenAI", + label: SEARCH_PROVIDER_LABELS.codex, load: async () => new (await import("./providers/codex")).CodexProvider(), }, tavily: { id: "tavily", - label: "Tavily", + label: SEARCH_PROVIDER_LABELS.tavily, load: async () => new (await import("./providers/tavily")).TavilyProvider(), }, parallel: { id: "parallel", - label: "Parallel", + label: SEARCH_PROVIDER_LABELS.parallel, load: async () => new (await import("./providers/parallel")).ParallelProvider(), }, kagi: { id: "kagi", - label: "Kagi", + label: SEARCH_PROVIDER_LABELS.kagi, load: async () => new (await import("./providers/kagi")).KagiProvider(), }, synthetic: { id: "synthetic", - label: "Synthetic", + label: SEARCH_PROVIDER_LABELS.synthetic, load: async () => new (await import("./providers/synthetic")).SyntheticProvider(), }, searxng: { id: "searxng", - label: "SearXNG", + label: SEARCH_PROVIDER_LABELS.searxng, load: async () => new (await import("./providers/searxng")).SearXNGProvider(), }, }; @@ -118,23 +119,6 @@ export async function getSearchProvider(id: SearchProviderId): Promise = { + tavily: "Tavily", + perplexity: "Perplexity", + brave: "Brave", + jina: "Jina", + kimi: "Kimi", + anthropic: "Anthropic", + gemini: "Gemini", + codex: "OpenAI", + zai: "Z.AI", + exa: "Exa", + parallel: "Parallel", + kagi: "Kagi", + synthetic: "Synthetic", + searxng: "SearXNG", +}; export function isSearchProviderId(value: string): value is SearchProviderId { - return [ - "exa", - "brave", - "jina", - "kimi", - "zai", - "anthropic", - "perplexity", - "gemini", - "codex", - "tavily", - "parallel", - "kagi", - "synthetic", - "searxng", - ].includes(value); + return SEARCH_PROVIDER_ORDER.includes(value as SearchProviderId); } export function isSearchProviderPreference(value: string): value is SearchProviderId | "auto" { - return value === "auto" || isSearchProviderId(value); + return SEARCH_PROVIDER_PREFERENCES.includes(value as SearchProviderId | "auto"); } /** Source returned by search (all providers) */ diff --git a/packages/coding-agent/test/setup-wizard.test.ts b/packages/coding-agent/test/setup-wizard.test.ts index b48618473..7f01b01fe 100644 --- a/packages/coding-agent/test/setup-wizard.test.ts +++ b/packages/coding-agent/test/setup-wizard.test.ts @@ -13,6 +13,7 @@ import { import { WebSearchTab } from "../src/modes/setup-wizard/scenes/web-search"; import { initTheme, theme } from "../src/modes/theme/theme"; import type { InteractiveModeContext } from "../src/modes/types"; +import { SEARCH_PROVIDER_OPTIONS, SEARCH_PROVIDER_PREFERENCES } from "../src/web/search/types"; function fakeContextWithConfiguredModel(): InteractiveModeContext { return { @@ -174,6 +175,12 @@ describe("setup wizard glyph scene", () => { }); describe("setup wizard web search tab", () => { + it("exposes every web-search provider preference in the schema-backed TUI list", () => { + const schema = SETTINGS_SCHEMA["providers.webSearch"]; + expect(schema.values).toEqual(SEARCH_PROVIDER_PREFERENCES); + expect(schema.ui.options).toEqual(SEARCH_PROVIDER_OPTIONS); + }); + it("persists the highlighted provider as the web search preference", async () => { const settings = Settings.isolated(); const host = { @@ -196,6 +203,30 @@ describe("setup wizard web search tab", () => { expect(expected).not.toBe("auto"); expect(settings.get("providers.webSearch")).toBe(expected); }); + + it("can select the last provider in the setup TUI list", async () => { + const settings = Settings.isolated(); + const host = { + ctx: { + settings, + session: { modelRegistry: { authStorage: { hasAuth: () => false } } }, + }, + requestRender: () => {}, + finish: () => {}, + setFocus: () => {}, + restoreFocus: () => {}, + } as unknown as SetupSceneHost; + + const tab = new WebSearchTab(host); + for (let i = 1; i < SEARCH_PROVIDER_OPTIONS.length; i++) { + tab.handleInput("\x1b[B"); + } + tab.handleInput("\n"); + await Bun.sleep(20); + + const lastOption = SEARCH_PROVIDER_OPTIONS[SEARCH_PROVIDER_OPTIONS.length - 1]!; + expect(settings.get("providers.webSearch")).toBe(lastOption.value); + }); }); describe("omp setup onboarding trigger", () => {