diff --git a/docs/environment-variables.md b/docs/environment-variables.md index f7de20936..fef5df3a9 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -191,6 +191,7 @@ OAuth host chain: `KIMI_CODE_OAUTH_HOST` → `KIMI_OAUTH_HOST` → `https://auth | `EXA_API_KEY` | Exa search provider and Exa MCP tools | | `BRAVE_API_KEY` | Brave search provider | | `PERPLEXITY_API_KEY` | Perplexity search provider API-key mode | +| `TAVILY_API_KEY` | Tavily search provider | | `ZAI_API_KEY` | z.ai search provider (also checks stored OAuth in `agent.db`) | | `OPENAI_API_KEY` / Codex OAuth in DB | Codex search provider availability/auth | diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 0d24cb7ce..c81af609e 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -1,6 +1,9 @@ # Changelog ## [Unreleased] +### Added + +- Added Tavily web search provider support with API key authentication ### Fixed @@ -1717,4 +1720,4 @@ _Dedicated to Peter's shoulder ([@steipete](https://twitter.com/steipete))_ ## [0.9.4] - 2025-11-26 -Initial release with multi-provider LLM support. +Initial release with multi-provider LLM support. \ No newline at end of file diff --git a/packages/ai/src/auth-storage.ts b/packages/ai/src/auth-storage.ts index 68ad4ab98..5c0b1c297 100644 --- a/packages/ai/src/auth-storage.ts +++ b/packages/ai/src/auth-storage.ts @@ -57,6 +57,7 @@ import { loginPerplexity } from "./utils/oauth/perplexity"; import { loginQianfan } from "./utils/oauth/qianfan"; import { loginQwenPortal } from "./utils/oauth/qwen-portal"; import { loginSynthetic } from "./utils/oauth/synthetic"; +import { loginTavily } from "./utils/oauth/tavily"; import { loginTogether } from "./utils/oauth/together"; import type { OAuthController, OAuthCredentials, OAuthProvider, OAuthProviderId } from "./utils/oauth/types"; import { loginVenice } from "./utils/oauth/venice"; @@ -818,6 +819,11 @@ export class AuthStorage { await saveApiKeyCredential(apiKey); return; } + case "tavily": { + const apiKey = await loginTavily(ctrl); + await saveApiKeyCredential(apiKey); + return; + } case "venice": { const apiKey = await loginVenice(ctrl); await saveApiKeyCredential(apiKey); diff --git a/packages/ai/src/cli.ts b/packages/ai/src/cli.ts index 98438eee7..180d0178f 100755 --- a/packages/ai/src/cli.ts +++ b/packages/ai/src/cli.ts @@ -13,6 +13,7 @@ import { loginKimi } from "./utils/oauth/kimi"; import { loginMiniMaxCode, loginMiniMaxCodeCn } from "./utils/oauth/minimax-code"; import { loginNanoGPT } from "./utils/oauth/nanogpt"; import { loginOpenAICodex } from "./utils/oauth/openai-codex"; +import { loginTavily } from "./utils/oauth/tavily"; import type { OAuthCredentials, OAuthProvider } from "./utils/oauth/types"; import { loginZai } from "./utils/oauth/zai"; import { loginZenMux } from "./utils/oauth/zenmux"; @@ -175,6 +176,22 @@ async function login(provider: OAuthProvider): Promise { console.log(`\nAPI key saved to ~/.omp/agent/agent.db`); return; } + case "tavily": { + const apiKey = await loginTavily({ + onAuth(info) { + const { url, instructions } = info; + console.log(`\nOpen this URL in your browser:\n${url}`); + if (instructions) console.log(instructions); + console.log(); + }, + onPrompt(p) { + return promptFn(`${p.message}${p.placeholder ? ` (${p.placeholder})` : ""}:`); + }, + }); + storage.saveApiKey(provider, apiKey); + console.log(`\nAPI key saved to ~/.omp/agent/agent.db`); + return; + } case "cursor": credentials = await loginCursor( @@ -306,6 +323,7 @@ Providers: kimi-code Kimi Code kilo Kilo Gateway kagi Kagi + tavily Tavily zai Z.AI (GLM Coding Plan) nanogpt NanoGPT minimax-code MiniMax Coding Plan (International) diff --git a/packages/ai/src/stream.ts b/packages/ai/src/stream.ts index a7cc7096b..789fd937f 100644 --- a/packages/ai/src/stream.ts +++ b/packages/ai/src/stream.ts @@ -83,6 +83,7 @@ const serviceProviderMap: Record = { jina: "JINA_API_KEY", brave: "BRAVE_API_KEY", perplexity: "PERPLEXITY_API_KEY", + tavily: "TAVILY_API_KEY", kagi: "KAGI_API_KEY", // GitHub Copilot uses GitHub personal access token "github-copilot": () => $pickenv("COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"), diff --git a/packages/ai/src/utils/oauth/index.ts b/packages/ai/src/utils/oauth/index.ts index a71722f30..fbe32e189 100644 --- a/packages/ai/src/utils/oauth/index.ts +++ b/packages/ai/src/utils/oauth/index.ts @@ -104,6 +104,8 @@ export { loginQianfan } from "./qianfan"; export { loginQwenPortal } from "./qwen-portal"; // Synthetic (API key) export { loginSynthetic } from "./synthetic"; +// Tavily (API key) +export { loginTavily } from "./tavily"; // Together (API key) export { loginTogether } from "./together"; export * from "./types"; @@ -204,6 +206,11 @@ const builtInOAuthProviders: OAuthProviderInfo[] = [ name: "Synthetic", available: true, }, + { + id: "tavily", + name: "Tavily", + available: true, + }, { id: "together", name: "Together", diff --git a/packages/ai/src/utils/oauth/tavily.ts b/packages/ai/src/utils/oauth/tavily.ts new file mode 100644 index 000000000..cc319e641 --- /dev/null +++ b/packages/ai/src/utils/oauth/tavily.ts @@ -0,0 +1,46 @@ +/** + * Tavily login flow. + * + * Tavily web search uses an API key from the account settings page. + * This is an API key flow: + * 1. Open browser to Tavily settings + * 2. User copies API key + * 3. User pastes key into CLI + */ + +import type { OAuthController } from "./types"; + +const AUTH_URL = "https://app.tavily.com/home"; + +/** + * Login to Tavily. + * + * Opens browser to API keys page and prompts user to paste their API key. + * Returns the API key directly (not OAuthCredentials - this isn't OAuth). + */ +export async function loginTavily(options: OAuthController): Promise { + if (!options.onPrompt) { + throw new Error("Tavily login requires onPrompt callback"); + } + + options.onAuth?.({ + url: AUTH_URL, + instructions: "Copy your Tavily API key from the API Keys page.", + }); + + const apiKey = await options.onPrompt({ + message: "Paste your Tavily API key", + placeholder: "tvly-...", + }); + + if (options.signal?.aborted) { + throw new Error("Login cancelled"); + } + + const trimmed = apiKey.trim(); + if (!trimmed) { + throw new Error("API key is required"); + } + + return trimmed; +} diff --git a/packages/ai/src/utils/oauth/types.ts b/packages/ai/src/utils/oauth/types.ts index b739a6067..6404ea335 100644 --- a/packages/ai/src/utils/oauth/types.ts +++ b/packages/ai/src/utils/oauth/types.ts @@ -37,6 +37,7 @@ export type OAuthProvider = | "qianfan" | "qwen-portal" | "synthetic" + | "tavily" | "together" | "venice" | "vllm" diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index c921f50ee..9ed48972b 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,10 +1,11 @@ # Changelog ## [Unreleased] - ### Added +- Added Tavily as a supported web search provider with `TAVILY_API_KEY` credential discovery and provider fallback support - Added `#`-triggered prompt action suggestions in the editor, with keybinding hints for line navigation and prompt copy actions +- Added Tavily as a supported web search provider with `TAVILY_API_KEY` credential discovery and provider fallback support ([#313](https://github.com/can1357/oh-my-pi/issues/313)) ### Removed diff --git a/packages/coding-agent/DEVELOPMENT.md b/packages/coding-agent/DEVELOPMENT.md index 2b03be0b3..afbf93df1 100644 --- a/packages/coding-agent/DEVELOPMENT.md +++ b/packages/coding-agent/DEVELOPMENT.md @@ -1049,7 +1049,7 @@ This tool is intentionally distinct from `fetch`: it executes page interactions Provider registry (`SEARCH_PROVIDERS`) and fallback order (`SEARCH_PROVIDER_ORDER`) are defined in `provider.ts`: -`perplexity → exa → brave → jina → kimi → anthropic → gemini → codex → zai → synthetic` +`perplexity → brave → jina → kimi → anthropic → gemini → codex → zai → exa → tavily → kagi → synthetic` `resolveProviderChain(preferredProvider)` behavior: diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index b7ef8cf4e..edae2bf1a 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -225,6 +225,7 @@ export function getExtraHelpText(): string { BRAVE_API_KEY - Brave web search PERPLEXITY_API_KEY - Perplexity web search (API) PERPLEXITY_COOKIES - Perplexity web search (session cookie) + TAVILY_API_KEY - Tavily web search ANTHROPIC_SEARCH_API_KEY - Anthropic search provider ${chalk.dim("# Configuration")} diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index 6cf4399d4..6015116db 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -811,6 +811,7 @@ export const SETTINGS_SCHEMA = { "anthropic", "gemini", "codex", + "tavily", "kagi", "synthetic", ] as const, diff --git a/packages/coding-agent/src/modes/components/settings-defs.ts b/packages/coding-agent/src/modes/components/settings-defs.ts index cd40b15bd..0deeeb5f6 100644 --- a/packages/coding-agent/src/modes/components/settings-defs.ts +++ b/packages/coding-agent/src/modes/components/settings-defs.ts @@ -224,8 +224,7 @@ const OPTION_PROVIDERS: Partial> = { { value: "auto", label: "Auto", - description: - "Priority: Perplexity > Exa > Brave > Jina > Kimi > Anthropic > Gemini > Codex > Z.AI > Synthetic", + description: "Preferred web-search provider", }, { value: "exa", label: "Exa", description: "Requires EXA_API_KEY" }, { value: "brave", label: "Brave", description: "Requires BRAVE_API_KEY" }, @@ -234,6 +233,7 @@ const OPTION_PROVIDERS: Partial> = { { value: "perplexity", label: "Perplexity", description: "Requires PERPLEXITY_COOKIES or PERPLEXITY_API_KEY" }, { value: "anthropic", label: "Anthropic", description: "Uses Anthropic web search" }, { 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 and Kagi Search API beta access" }, { value: "synthetic", label: "Synthetic", description: "Requires SYNTHETIC_API_KEY" }, ], diff --git a/packages/coding-agent/src/web/search/index.ts b/packages/coding-agent/src/web/search/index.ts index d47a36443..f2ca89cac 100644 --- a/packages/coding-agent/src/web/search/index.ts +++ b/packages/coding-agent/src/web/search/index.ts @@ -1,7 +1,7 @@ /** * Unified Web Search Tool * - * Single tool supporting Anthropic, Perplexity, Exa, Brave, Jina, Kimi, Gemini, Codex, Z.AI, and Synthetic + * Single tool supporting Anthropic, Perplexity, Exa, Brave, Jina, Kimi, Gemini, Codex, Tavily, Kagi, Z.AI, and Synthetic * providers with provider-specific parameters exposed conditionally. * * When EXA_API_KEY is available, additional specialized tools are exposed: @@ -45,6 +45,7 @@ export const webSearchSchema = Type.Object({ "perplexity", "gemini", "codex", + "tavily", "kagi", "synthetic", ], @@ -77,6 +78,7 @@ export type SearchParams = { | "perplexity" | "gemini" | "codex" + | "tavily" | "kagi" | "synthetic"; recency?: "day" | "week" | "month" | "year"; diff --git a/packages/coding-agent/src/web/search/provider.ts b/packages/coding-agent/src/web/search/provider.ts index 3f510a270..3f7304899 100644 --- a/packages/coding-agent/src/web/search/provider.ts +++ b/packages/coding-agent/src/web/search/provider.ts @@ -9,6 +9,7 @@ import { KagiProvider } from "./providers/kagi"; import { KimiProvider } from "./providers/kimi"; import { PerplexityProvider } from "./providers/perplexity"; import { SyntheticProvider } from "./providers/synthetic"; +import { TavilyProvider } from "./providers/tavily"; import { ZaiProvider } from "./providers/zai"; import type { SearchProviderId } from "./types"; @@ -25,11 +26,13 @@ const SEARCH_PROVIDERS: Record = { anthropic: new AnthropicProvider(), gemini: new GeminiProvider(), codex: new CodexProvider(), + tavily: new TavilyProvider(), kagi: new KagiProvider(), synthetic: new SyntheticProvider(), } as const; export const SEARCH_PROVIDER_ORDER: SearchProviderId[] = [ + "tavily", "perplexity", "brave", "jina", @@ -55,7 +58,7 @@ export function setPreferredSearchProvider(provider: SearchProviderId | "auto"): preferredProvId = provider; } -/** Determine which providers are configured (priority: Perplexity → Brave → Jina → Kimi → Anthropic → Gemini → Codex → Z.AI → Exa → Synthetic) */ +/** Determine which providers are configured (priority: Perplexity → Brave → Jina → Kimi → Anthropic → Gemini → Codex → Z.AI → Exa → Tavily → Kagi → Synthetic) */ export async function resolveProviderChain( preferredProvider: SearchProviderId | "auto" = preferredProvId, ): Promise { diff --git a/packages/coding-agent/src/web/search/providers/tavily.ts b/packages/coding-agent/src/web/search/providers/tavily.ts new file mode 100644 index 000000000..a079651f5 --- /dev/null +++ b/packages/coding-agent/src/web/search/providers/tavily.ts @@ -0,0 +1,162 @@ +/** + * Tavily Web Search Provider + * + * Uses Tavily's agent-focused search API to return structured results with an + * optional synthesized answer. + */ +import { getEnvApiKey } from "@oh-my-pi/pi-ai"; +import type { SearchResponse, SearchSource } from "../../../web/search/types"; +import { SearchProviderError } from "../../../web/search/types"; +import { clampNumResults, dateToAgeSeconds } from "../utils"; +import type { SearchParams } from "./base"; +import { SearchProvider } from "./base"; +import { findCredential } from "./utils"; + +const TAVILY_SEARCH_URL = "https://api.tavily.com/search"; +const DEFAULT_NUM_RESULTS = 5; +const MAX_NUM_RESULTS = 20; + +export interface TavilySearchParams { + query: string; + num_results?: number; + recency?: "day" | "week" | "month" | "year"; + signal?: AbortSignal; +} + +interface TavilySearchResult { + title?: string | null; + url?: string | null; + content?: string | null; + published_date?: string | null; +} + +interface TavilySearchResponse { + answer?: string | null; + results?: TavilySearchResult[]; + request_id?: string | null; +} + +function asRecord(value: unknown): Record | null { + if (typeof value !== "object" || value === null) return null; + return value as Record; +} + +function getErrorMessage(value: unknown): string | null { + if (typeof value === "string") { + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; + } + + const record = asRecord(value); + if (!record) return null; + + for (const key of ["detail", "error", "message"]) { + const message = getErrorMessage(record[key]); + if (message) return message; + } + + return null; +} + +/** Find Tavily API key from environment or agent.db credentials. */ +export async function findApiKey(): Promise { + return findCredential(getEnvApiKey("tavily"), "tavily"); +} + +function buildRequestBody(params: TavilySearchParams): Record { + const numResults = clampNumResults(params.num_results, DEFAULT_NUM_RESULTS, MAX_NUM_RESULTS); + return { + query: params.query, + search_depth: "basic", + topic: params.recency ? "news" : "general", + time_range: params.recency, + max_results: numResults, + include_answer: "advanced", + include_raw_content: false, + }; +} + +async function callTavilySearch(apiKey: string, params: TavilySearchParams): Promise { + const response = await fetch(TAVILY_SEARCH_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify(buildRequestBody(params)), + signal: params.signal, + }); + + if (!response.ok) { + const errorText = await response.text(); + let message = errorText.trim(); + if (message.length === 0) { + message = response.statusText; + } else { + try { + message = getErrorMessage(JSON.parse(errorText)) ?? message; + } catch { + // Keep raw text fallback. + } + } + throw new SearchProviderError("tavily", `Tavily API error (${response.status}): ${message}`, response.status); + } + + return (await response.json()) as TavilySearchResponse; +} + +/** Execute Tavily web search. */ +export async function searchTavily(params: TavilySearchParams): Promise { + const apiKey = await findApiKey(); + if (!apiKey) { + throw new Error( + 'Tavily credentials not found. Set TAVILY_API_KEY or store an API key for provider "tavily" in agent.db.', + ); + } + + const numResults = clampNumResults(params.num_results, DEFAULT_NUM_RESULTS, MAX_NUM_RESULTS); + const response = await callTavilySearch(apiKey, params); + const sources: SearchSource[] = []; + + for (const result of response.results ?? []) { + if (!result.url) continue; + sources.push({ + title: result.title ?? result.url, + url: result.url, + snippet: result.content ?? undefined, + publishedDate: result.published_date ?? undefined, + ageSeconds: dateToAgeSeconds(result.published_date ?? undefined), + }); + } + + return { + provider: "tavily", + answer: response.answer?.trim() || undefined, + sources: sources.slice(0, numResults), + requestId: response.request_id ?? undefined, + authMode: "api_key", + }; +} + +/** Search provider for Tavily web search. */ +export class TavilyProvider extends SearchProvider { + readonly id = "tavily"; + readonly label = "Tavily"; + + async isAvailable(): Promise { + try { + return !!(await findApiKey()); + } catch { + return false; + } + } + + search(params: SearchParams): Promise { + return searchTavily({ + query: params.query, + num_results: params.numSearchResults ?? params.limit, + recency: params.recency, + signal: params.signal, + }); + } +} diff --git a/packages/coding-agent/src/web/search/types.ts b/packages/coding-agent/src/web/search/types.ts index 051963a7d..ed22e7602 100644 --- a/packages/coding-agent/src/web/search/types.ts +++ b/packages/coding-agent/src/web/search/types.ts @@ -15,6 +15,7 @@ export type SearchProviderId = | "perplexity" | "gemini" | "codex" + | "tavily" | "kagi" | "synthetic"; diff --git a/packages/coding-agent/test/tools/web-search-tavily.test.ts b/packages/coding-agent/test/tools/web-search-tavily.test.ts new file mode 100644 index 000000000..88db59e94 --- /dev/null +++ b/packages/coding-agent/test/tools/web-search-tavily.test.ts @@ -0,0 +1,105 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; +import { hookFetch } from "@oh-my-pi/pi-utils"; +import { getSearchProvider, resolveProviderChain, SEARCH_PROVIDER_ORDER } from "../../src/web/search/provider"; +import { searchTavily } from "../../src/web/search/providers/tavily"; +import type { SearchProviderError } from "../../src/web/search/types"; + +describe("Tavily web search provider", () => { + beforeEach(() => { + process.env.TAVILY_API_KEY = "test-tavily-key"; + }); + + afterEach(() => { + vi.restoreAllMocks(); + delete process.env.TAVILY_API_KEY; + }); + + it("registers tavily in the provider registry and fallback order", async () => { + expect(SEARCH_PROVIDER_ORDER).toContain("tavily"); + expect(getSearchProvider("tavily").label).toBe("Tavily"); + const providers = await resolveProviderChain("tavily"); + expect(providers[0]?.id).toBe("tavily"); + }); + + it("maps Tavily responses into SearchResponse and forwards recency filters", async () => { + let requestBody: Record | null = null; + + using _hook = hookFetch(async (_input, init) => { + requestBody = JSON.parse(String(init?.body ?? "null")) as Record; + return new Response( + JSON.stringify({ + answer: "Synthesized Tavily answer", + request_id: "req-tavily-123", + results: [ + { + title: "Result One", + url: "https://example.com/one", + content: "First snippet", + published_date: "2026-03-01T00:00:00Z", + }, + { + url: "https://example.com/two", + content: "Second snippet", + }, + ], + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + }); + + const response = await searchTavily({ query: "latest ai news", num_results: 2, recency: "week" }); + expect(requestBody).toMatchObject({ + query: "latest ai news", + max_results: 2, + time_range: "week", + topic: "news", + include_answer: "advanced", + include_raw_content: false, + }); + expect(response).toMatchObject({ + provider: "tavily", + answer: "Synthesized Tavily answer", + requestId: "req-tavily-123", + authMode: "api_key", + sources: [ + { + title: "Result One", + url: "https://example.com/one", + snippet: "First snippet", + publishedDate: "2026-03-01T00:00:00Z", + }, + { + title: "https://example.com/two", + url: "https://example.com/two", + snippet: "Second snippet", + }, + ], + }); + expect(response.sources[0]?.ageSeconds).toBeTypeOf("number"); + }); + + it("surfaces structured API errors", async () => { + using _hook = hookFetch( + () => + new Response(JSON.stringify({ detail: { error: "invalid api key" } }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }), + ); + + await expect(searchTavily({ query: "bad auth" })).rejects.toEqual( + expect.objectContaining({ + provider: "tavily", + status: 401, + message: "Tavily API error (401): invalid api key", + }) satisfies Partial, + ); + }); + + it("throws a clear error when Tavily credentials are missing", async () => { + delete process.env.TAVILY_API_KEY; + await expect(searchTavily({ query: "missing creds" })).rejects.toThrow( + 'Tavily credentials not found. Set TAVILY_API_KEY or store an API key for provider "tavily" in agent.db.', + ); + }); +});