feat(coding-agent): added Tavily web search provider with OAuth auth

- Added Tavily web search provider with API key authentication and credential discovery from environment or database.
- Integrated Tavily as highest-priority search provider in fallback chain with structured response mapping and error handling.
- Added Tavily OAuth login flow in CLI and auth-storage with manual API key input and validation.
- Added comprehensive test suite for Tavily provider covering registration, response mapping, error handling, and credential validation.

Fixes #313
This commit is contained in:
can1357
2026-03-09 16:11:32 +01:00
parent 46be698745
commit 68ae4b7bee
18 changed files with 366 additions and 7 deletions
+1
View File
@@ -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 |
+4 -1
View File
@@ -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.
+6
View File
@@ -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);
+18
View File
@@ -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<void> {
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)
+1
View File
@@ -83,6 +83,7 @@ const serviceProviderMap: Record<string, KeyResolver> = {
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"),
+7
View File
@@ -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",
+46
View File
@@ -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<string> {
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;
}
+1
View File
@@ -37,6 +37,7 @@ export type OAuthProvider =
| "qianfan"
| "qwen-portal"
| "synthetic"
| "tavily"
| "together"
| "venice"
| "vllm"
+2 -1
View File
@@ -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
+1 -1
View File
@@ -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:
+1
View File
@@ -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")}
@@ -811,6 +811,7 @@ export const SETTINGS_SCHEMA = {
"anthropic",
"gemini",
"codex",
"tavily",
"kagi",
"synthetic",
] as const,
@@ -224,8 +224,7 @@ const OPTION_PROVIDERS: Partial<Record<SettingPath, OptionProvider>> = {
{
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<Record<SettingPath, OptionProvider>> = {
{ 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" },
],
@@ -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";
@@ -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<SearchProviderId, SearchProvider> = {
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<SearchProvider[]> {
@@ -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<string, unknown> | null {
if (typeof value !== "object" || value === null) return null;
return value as Record<string, unknown>;
}
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<string | null> {
return findCredential(getEnvApiKey("tavily"), "tavily");
}
function buildRequestBody(params: TavilySearchParams): Record<string, unknown> {
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<TavilySearchResponse> {
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<SearchResponse> {
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<boolean> {
try {
return !!(await findApiKey());
} catch {
return false;
}
}
search(params: SearchParams): Promise<SearchResponse> {
return searchTavily({
query: params.query,
num_results: params.numSearchResults ?? params.limit,
recency: params.recency,
signal: params.signal,
});
}
}
@@ -15,6 +15,7 @@ export type SearchProviderId =
| "perplexity"
| "gemini"
| "codex"
| "tavily"
| "kagi"
| "synthetic";
@@ -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<string, unknown> | null = null;
using _hook = hookFetch(async (_input, init) => {
requestBody = JSON.parse(String(init?.body ?? "null")) as Record<string, unknown>;
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<SearchProviderError>,
);
});
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.',
);
});
});