Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
[![license](https://img.shields.io/github/license/oritwoen/askweb?style=flat&colorA=130f40&colorB=474787)](https://github.com/oritwoen/askweb/blob/main/LICENSE)
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/oritwoen/askweb)

One API for Brave, Exa, Jina, Tavily, SerpAPI, and SearXNG. Write your search logic once, swap the provider string, done.
One API for Brave, Exa, Jina, Tavily, SerpAPI, SerpBase, and SearXNG. Write your search logic once, swap the provider string, done.

If you're building an AI agent or a CLI tool that needs web search, you don't want to hardcode a single provider's API. They all return roughly the same thing, a list of URLs with titles and snippets, but the auth, endpoints, and response shapes are all different. Exa uses POST with `x-api-key`, Brave uses GET with `X-Subscription-Token`, Jina uses Bearer auth, Tavily puts the key in the request body. And so on.

Expand Down Expand Up @@ -33,7 +33,7 @@ Provided slash commands:
- `/web [query]` - quick search from the TUI; results are shown as a selector and the chosen URL is pasted into the editor
- `/web-providers` - show provider configuration and reachability status

The extension reuses the same env vars as the library (`EXA_API_KEY`, `BRAVE_API_KEY`, `JINA_API_KEY`, `TAVILY_API_KEY`, `SERPAPI_API_KEY`, or a self-hosted SearXNG). Pi bundles `@earendil-works/pi-coding-agent`, `@earendil-works/pi-tui`, and `typebox`, so no extra installs are needed.
The extension reuses the same env vars as the library (`EXA_API_KEY`, `BRAVE_API_KEY`, `JINA_API_KEY`, `TAVILY_API_KEY`, `SERPAPI_API_KEY`, `SERPBASE_API_KEY`, or a self-hosted SearXNG). Pi bundles `@earendil-works/pi-coding-agent`, `@earendil-works/pi-tui`, and `typebox`, so no extra installs are needed.

## Install

Expand Down Expand Up @@ -182,6 +182,7 @@ askweb providers
| Jina | `JINA_API_KEY` | Bearer header | Required for search; optional for read |
| SearXNG | - | None | Self-hosted |
| SerpAPI | `SERPAPI_API_KEY` | Query param | 100 queries/mo |
| SerpBase | `SERPBASE_API_KEY` | `X-API-Key` header | 100 searches to start |
| Tavily | `TAVILY_API_KEY` | Body | 1k queries/mo |

### Result shape
Expand All @@ -195,9 +196,12 @@ All search providers always return `{ url, title, snippet }`. Optional fields de
| Tavily | `text` (raw_content, full HTML/markdown), `score`, `publishedDate` |
| Brave | `text` (joined `extra_snippets`), `favicon` |
| SerpAPI | `image` (thumbnail), `publishedDate`, `favicon`, `metadata.{position, source, displayedLink}` |
| SerpBase | `image` (SERP thumbnail/image), `publishedDate`, `favicon`, `metadata.{position, rank, searchType, requestId, elapsedMs, creditsCharged}` |
| SearXNG | `image`, `score`, `publishedDate`, `metadata.{engine, engines, category}` |

Pick the provider that fits the shape you want. Exa is closest to "AI search" (summary + highlights + full text on request). Jina uses Jina Search Foundation and can return result content plus metadata. Tavily is best when you want the raw page content. Brave/SerpAPI/SearXNG are classic SERP-style metadata.
Pick the provider that fits the shape you want. Exa is closest to "AI search" (summary + highlights + full text on request). Jina uses Jina Search Foundation and can return result content plus metadata. Tavily is best when you want the raw page content. Brave/SerpAPI/SerpBase/SearXNG are classic SERP-style metadata.

SerpBase uses Google SERP endpoints. `category: "images"`, `"news"`, or `"videos"` selects the matching SerpBase endpoint; `maxResults` is applied client-side to the returned page.

SearXNG requires no API key. It's a self-hosted metasearch engine. By default askweb connects to `http://localhost:8080`. Override with `baseURL`:

Expand Down
6 changes: 3 additions & 3 deletions packages/pi/extensions/askweb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ function loadAskweb(): Promise<AskwebModule> {
return askwebModulePromise
}

const PROVIDERS = ["auto", "all", "brave", "exa", "jina", "searxng", "serpapi", "tavily"] as const
const PROVIDERS = ["auto", "all", "brave", "exa", "jina", "searxng", "serpapi", "serpbase", "tavily"] as const
const PROVIDER_HINT = `Provider to use. One of: ${PROVIDERS.join(", ")}. "auto" (or omit) picks the first available provider from env. Use "all" to query every configured provider in parallel.`
const READ_PROVIDER_HINT = "Read provider to use. Defaults to Jina and is validated against askweb.readProviderNames at execution time."

Expand Down Expand Up @@ -132,13 +132,13 @@ export default function askwebExtension(pi: ExtensionAPI) {
name: "askweb",
label: "Askweb Search",
description:
"Read-only/open-world network search: query one configured provider (Brave, Exa, Jina, Tavily, SerpAPI, SearXNG) or fan out to every available provider with provider=all. Always returns {url, title, snippet}; optional fields vary by provider: Exa adds summary/highlights/full text + score/author/image, Jina adds content/text + published date/image/metadata, Tavily adds full raw_content + score, Brave adds extra_snippets, SerpAPI adds thumbnail + position metadata, SearXNG adds engine metadata. Pick provider for the shape you need.",
"Read-only/open-world network search: query one configured provider (Brave, Exa, Jina, Tavily, SerpAPI, SerpBase, SearXNG) or fan out to every available provider with provider=all. Always returns {url, title, snippet}; optional fields vary by provider: Exa adds summary/highlights/full text + score/author/image, Jina adds content/text + published date/image/metadata, Tavily adds full raw_content + score, Brave adds extra_snippets, SerpAPI adds thumbnail + position metadata, SerpBase adds Google SERP rank/request metadata, SearXNG adds engine metadata. Pick provider for the shape you need.",
promptSnippet:
"Search the web with askweb. Use provider=all to query every configured provider in parallel.",
promptGuidelines: [
"Use askweb when the user explicitly asks for fresh web information, news, references, or links.",
"Prefer a single provider when the user names one; use provider=all when freshness or coverage matters and at least two providers are configured.",
"For AI-style summaries/highlights/full page text prefer Exa; for Jina Search Foundation results use Jina; for raw full page content prefer Tavily; for classic SERP metadata Brave/SerpAPI/SearXNG are fine.",
"For AI-style summaries/highlights/full page text prefer Exa; for Jina Search Foundation results use Jina; for raw full page content prefer Tavily; for classic SERP metadata Brave/SerpAPI/SerpBase/SearXNG are fine.",
"Pass maxResults conservatively (5-10) unless the user asks for more.",
"Forward includeDomains/excludeDomains/startPublishedDate/endPublishedDate when the user gives concrete filters.",
],
Expand Down
2 changes: 1 addition & 1 deletion src/ai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import './providers/index.ts'
const providerNames = [...builtinProviders, 'all'] as const

export const searchTool = tool({
description: 'Search the web using multiple search engines (Brave, Exa, Jina, Tavily, SerpAPI, SearXNG). Returns relevant web pages with titles, URLs, snippets, and optional metadata. Use provider "all" to query all available providers in parallel and get deduplicated results.',
description: 'Search the web using multiple search engines (Brave, Exa, Jina, Tavily, SerpAPI, SerpBase, SearXNG). Returns relevant web pages with titles, URLs, snippets, and optional metadata. Use provider "all" to query all available providers in parallel and get deduplicated results.',
inputSchema: z.object({
query: z.string().describe('Search query'),
provider: z.enum(providerNames).optional().describe('Provider to use. Defaults to first available from env. Use "all" for parallel search.'),
Expand Down
1 change: 1 addition & 0 deletions src/core/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export const builtinProviders = [
'jina',
'searxng',
'serpapi',
'serpbase',
'tavily',
] as const

Expand Down
1 change: 1 addition & 0 deletions src/core/resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const envKeys: Record<string, WebSearchProviderName> = {
JINA_API_KEY: 'jina',
TAVILY_API_KEY: 'tavily',
SERPAPI_API_KEY: 'serpapi',
SERPBASE_API_KEY: 'serpbase',
}

function envVarFor(name: WebSearchProviderName): string | null {
Expand Down
2 changes: 1 addition & 1 deletion src/opencode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ const providerNames = [...builtinProviders, 'all'] as const
const AskwebPlugin: Plugin = async () => ({
tool: {
askweb: tool({
description: 'Search the web using multiple search engines (Brave, Exa, Jina, Tavily, SerpAPI, SearXNG). Returns relevant web pages with titles, URLs, snippets, and optional metadata. Use provider "all" to query all available providers in parallel and get deduplicated results.',
description: 'Search the web using multiple search engines (Brave, Exa, Jina, Tavily, SerpAPI, SerpBase, SearXNG). Returns relevant web pages with titles, URLs, snippets, and optional metadata. Use provider "all" to query all available providers in parallel and get deduplicated results.',
args: {
query: z.string().describe('Search query'),
provider: z.enum(providerNames).optional().describe('Provider to use. Defaults to first available from env. Use "all" for parallel search.'),
Expand Down
1 change: 1 addition & 0 deletions src/providers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ import './brave.ts'
import './jina.ts'
import './tavily.ts'
import './serpapi.ts'
import './serpbase.ts'
import './searxng.ts'
171 changes: 171 additions & 0 deletions src/providers/serpbase.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import type { SearchResult, SearchOptions, SearchProvider, ProviderConfig, ProviderFactory } from '../core/types.ts'
import { defaultClient } from '../core/client.ts'
import type { Client } from '../core/client.ts'
import { AskwebError, AuthError, RateLimitError, normalizeError } from '../core/errors.ts'
import { register } from '../core/registry.ts'

interface SerpBaseSearchRequest {
q: string
hl?: string
gl?: string
page?: number
}

interface SerpBaseResult {
rank?: number
position?: number
title?: string
link?: string
url?: string
source_url?: string
display_url?: string
display_link?: string
snippet?: string
date?: string
published_at?: string
icon?: string
image_url?: string
thumbnail_url?: string
thumbnail?: string
source?: string
domain?: string
time?: string
duration?: string
}

interface SerpBaseSearchResponse {
status: number
error?: string
request_id: string
elapsed_ms: number
credits_charged: number
search_type: string
query?: string
organic?: SerpBaseResult[]
images?: SerpBaseResult[]
news?: SerpBaseResult[]
videos?: SerpBaseResult[]
}

const SERPBASE_MAX_RESULTS = 20

class SerpBaseProvider implements SearchProvider {
private readonly client: Client
private readonly baseURL: string
private readonly apiKey: string

constructor(config: ProviderConfig) {
if (!config.apiKey) {
throw new AuthError('Missing API key for SerpBase. Set SERPBASE_API_KEY', 'serpbase')
}

this.client = defaultClient()
this.baseURL = config.baseURL ?? 'https://api.serpbase.dev'
this.apiKey = config.apiKey
}

name(): string {
return 'serpbase'
}

async search(query: string, options?: SearchOptions): Promise<SearchResult[]> {
const endpoint = endpointForCategory(options?.category)
const body = {
q: query,
hl: 'en',
gl: 'us',
page: 1,
} satisfies SerpBaseSearchRequest

try {
const url = `${this.baseURL}${endpoint}`
const headers = { 'X-API-Key': this.apiKey }
const response = await this.client.postJSON<SerpBaseSearchResponse>(url, body, headers)
assertSerpBaseSuccess(response)
return resultsForResponse(response)
.slice(0, clampMaxResults(options?.maxResults ?? 10))
.map(result => mapResult(result, response))
}
catch (error) {
throw normalizeError(error, 'serpbase')
}
}
}

function endpointForCategory(category: string | undefined): '/google/search' | '/google/images' | '/google/news' | '/google/videos' {
switch (category) {
case 'images':
case 'image':
return '/google/images'
case 'news':
return '/google/news'
case 'videos':
case 'video':
return '/google/videos'
default:
return '/google/search'
}
}

function clampMaxResults(maxResults: number): number {
return Math.min(Math.max(maxResults, 1), SERPBASE_MAX_RESULTS)
}

function assertSerpBaseSuccess(response: SerpBaseSearchResponse): void {
if (response.status === 0) return

const message = response.error ?? `SerpBase API error: status=${response.status}`
switch (response.status) {
case 1001:
throw new AuthError(`Authentication failed: ${message}`, 'serpbase')
case 1029:
throw new RateLimitError(60)
case 1020:
throw new AskwebError(`SerpBase insufficient credits: ${message}`)
default:
throw new AskwebError(`SerpBase API error ${response.status}: ${message}`)
}
}

function resultsForResponse(response: SerpBaseSearchResponse): SerpBaseResult[] {
switch (response.search_type) {
case 'images':
return response.images ?? []
case 'news':
return response.news ?? []
case 'videos':
return response.videos ?? []
default:
return response.organic ?? []
}
}

function mapResult(result: SerpBaseResult, response: SerpBaseSearchResponse): SearchResult {
const url = result.url ?? result.link ?? result.source_url ?? result.image_url ?? ''
return {
url,
title: result.title ?? result.source ?? result.domain ?? '',
snippet: result.snippet ?? '',
publishedDate: result.published_at ?? result.date ?? result.time,
image: result.image_url ?? result.thumbnail_url ?? result.thumbnail,
favicon: result.icon,
metadata: {
position: result.position ?? result.rank,
rank: result.rank,
displayUrl: result.display_url,
displayLink: result.display_link,
sourceUrl: result.source_url,
source: result.source,
domain: result.domain,
duration: result.duration,
searchType: response.search_type,
requestId: response.request_id,
elapsedMs: response.elapsed_ms,
creditsCharged: response.credits_charged,
},
}
}

const factory: ProviderFactory = (config) => new SerpBaseProvider(config)

register('serpbase', 'https://api.serpbase.dev', factory)
2 changes: 1 addition & 1 deletion test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ describe('askweb', () => {
})

it('should list all built-in provider names', () => {
expect(builtinProviders).toEqual(['brave', 'exa', 'jina', 'searxng', 'serpapi', 'tavily'])
expect(builtinProviders).toEqual(['brave', 'exa', 'jina', 'searxng', 'serpapi', 'serpbase', 'tavily'])
})

it('should register built-in providers from main entrypoint', () => {
Expand Down
2 changes: 1 addition & 1 deletion test/unit/ai-tool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ const searxngResponse = {
}

const savedEnv: Record<string, string | undefined> = {}
const envKeys = ['EXA_API_KEY', 'BRAVE_API_KEY', 'JINA_API_KEY', 'TAVILY_API_KEY', 'SERPAPI_API_KEY']
const envKeys = ['EXA_API_KEY', 'BRAVE_API_KEY', 'JINA_API_KEY', 'TAVILY_API_KEY', 'SERPAPI_API_KEY', 'SERPBASE_API_KEY']

describe('searchTool', () => {
beforeEach(() => {
Expand Down
2 changes: 2 additions & 0 deletions test/unit/all.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ describe('searchAll', () => {
delete process.env.JINA_API_KEY
delete process.env.TAVILY_API_KEY
delete process.env.SERPAPI_API_KEY
delete process.env.SERPBASE_API_KEY
})

it('queries multiple providers and merges results', async () => {
Expand Down Expand Up @@ -377,6 +378,7 @@ describe('searchAllDetailed', () => {
delete process.env.JINA_API_KEY
delete process.env.TAVILY_API_KEY
delete process.env.SERPAPI_API_KEY
delete process.env.SERPBASE_API_KEY
})

it('returns results and empty errors when all providers succeed', async () => {
Expand Down
2 changes: 1 addition & 1 deletion test/unit/providers-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ vi.mock('consola', () => ({
import providersCommand from '../../src/commands/providers.ts'
import { builtinProviders } from '../../src/index.ts'

const envKeys = ['EXA_API_KEY', 'BRAVE_API_KEY', 'JINA_API_KEY', 'TAVILY_API_KEY', 'SERPAPI_API_KEY']
const envKeys = ['EXA_API_KEY', 'BRAVE_API_KEY', 'JINA_API_KEY', 'TAVILY_API_KEY', 'SERPAPI_API_KEY', 'SERPBASE_API_KEY']

describe('providers command', () => {
const savedEnv: Record<string, string | undefined> = {}
Expand Down
2 changes: 1 addition & 1 deletion test/unit/resolve-async.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { searchAllDetailed } from '../../src/core/all.ts'
import { NoProviderAvailableError } from '../../src/core/errors.ts'
import '../../src/providers/index.ts'

const envKeys = ['EXA_API_KEY', 'BRAVE_API_KEY', 'JINA_API_KEY', 'TAVILY_API_KEY', 'SERPAPI_API_KEY'] as const
const envKeys = ['EXA_API_KEY', 'BRAVE_API_KEY', 'JINA_API_KEY', 'TAVILY_API_KEY', 'SERPAPI_API_KEY', 'SERPBASE_API_KEY'] as const

describe('resolve async', () => {
const savedEnv: Record<string, string | undefined> = {}
Expand Down
4 changes: 3 additions & 1 deletion test/unit/resolve.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { detectAvailableProviders, resolveDefaultProvider, listProviders } from '../../src/core/resolve.ts'
import '../../src/providers/index.ts'

const envKeys = ['EXA_API_KEY', 'BRAVE_API_KEY', 'JINA_API_KEY', 'TAVILY_API_KEY', 'SERPAPI_API_KEY'] as const
const envKeys = ['EXA_API_KEY', 'BRAVE_API_KEY', 'JINA_API_KEY', 'TAVILY_API_KEY', 'SERPAPI_API_KEY', 'SERPBASE_API_KEY'] as const

describe('resolve', () => {
const savedEnv: Record<string, string | undefined> = {}
Expand Down Expand Up @@ -53,6 +53,7 @@ describe('resolve', () => {
expect(available).not.toContain('jina')
expect(available).not.toContain('tavily')
expect(available).not.toContain('serpapi')
expect(available).not.toContain('serpbase')
})
})

Expand Down Expand Up @@ -82,6 +83,7 @@ describe('resolve', () => {
expect(names).toContain('jina')
expect(names).toContain('searxng')
expect(names).toContain('serpapi')
expect(names).toContain('serpbase')
expect(names).toContain('tavily')
})

Expand Down
Loading
Loading