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:
@@ -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 |
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -37,6 +37,7 @@ export type OAuthProvider =
|
||||
| "qianfan"
|
||||
| "qwen-portal"
|
||||
| "synthetic"
|
||||
| "tavily"
|
||||
| "together"
|
||||
| "venice"
|
||||
| "vllm"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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.',
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user