diff --git a/README.md b/README.md index b6a6d11..a406efc 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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 @@ -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 @@ -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`: diff --git a/packages/pi/extensions/askweb.ts b/packages/pi/extensions/askweb.ts index 4a41871..731f39c 100644 --- a/packages/pi/extensions/askweb.ts +++ b/packages/pi/extensions/askweb.ts @@ -52,7 +52,7 @@ function loadAskweb(): Promise { 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." @@ -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.", ], diff --git a/src/ai.ts b/src/ai.ts index c1b40a4..a9e358b 100644 --- a/src/ai.ts +++ b/src/ai.ts @@ -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.'), diff --git a/src/core/providers.ts b/src/core/providers.ts index 013c442..4c3d56b 100644 --- a/src/core/providers.ts +++ b/src/core/providers.ts @@ -4,6 +4,7 @@ export const builtinProviders = [ 'jina', 'searxng', 'serpapi', + 'serpbase', 'tavily', ] as const diff --git a/src/core/resolve.ts b/src/core/resolve.ts index c6df4b0..fd5be61 100644 --- a/src/core/resolve.ts +++ b/src/core/resolve.ts @@ -8,6 +8,7 @@ const envKeys: Record = { JINA_API_KEY: 'jina', TAVILY_API_KEY: 'tavily', SERPAPI_API_KEY: 'serpapi', + SERPBASE_API_KEY: 'serpbase', } function envVarFor(name: WebSearchProviderName): string | null { diff --git a/src/opencode.ts b/src/opencode.ts index 0b64e85..dbb22c8 100644 --- a/src/opencode.ts +++ b/src/opencode.ts @@ -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.'), diff --git a/src/providers/index.ts b/src/providers/index.ts index ef367b2..d108bee 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -3,4 +3,5 @@ import './brave.ts' import './jina.ts' import './tavily.ts' import './serpapi.ts' +import './serpbase.ts' import './searxng.ts' diff --git a/src/providers/serpbase.ts b/src/providers/serpbase.ts new file mode 100644 index 0000000..868eaab --- /dev/null +++ b/src/providers/serpbase.ts @@ -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 { + 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(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) diff --git a/test/index.test.ts b/test/index.test.ts index e0c5a2a..c647c36 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -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', () => { diff --git a/test/unit/ai-tool.test.ts b/test/unit/ai-tool.test.ts index 7936e11..7facd6e 100644 --- a/test/unit/ai-tool.test.ts +++ b/test/unit/ai-tool.test.ts @@ -62,7 +62,7 @@ const searxngResponse = { } const savedEnv: Record = {} -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(() => { diff --git a/test/unit/all.test.ts b/test/unit/all.test.ts index 1131340..217062f 100644 --- a/test/unit/all.test.ts +++ b/test/unit/all.test.ts @@ -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 () => { @@ -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 () => { diff --git a/test/unit/providers-command.test.ts b/test/unit/providers-command.test.ts index d764b8f..3ebf273 100644 --- a/test/unit/providers-command.test.ts +++ b/test/unit/providers-command.test.ts @@ -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 = {} diff --git a/test/unit/resolve-async.test.ts b/test/unit/resolve-async.test.ts index 1951bac..624194a 100644 --- a/test/unit/resolve-async.test.ts +++ b/test/unit/resolve-async.test.ts @@ -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 = {} diff --git a/test/unit/resolve.test.ts b/test/unit/resolve.test.ts index 912e589..89399ea 100644 --- a/test/unit/resolve.test.ts +++ b/test/unit/resolve.test.ts @@ -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 = {} @@ -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') }) }) @@ -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') }) diff --git a/test/unit/serpbase.test.ts b/test/unit/serpbase.test.ts new file mode 100644 index 0000000..18c4600 --- /dev/null +++ b/test/unit/serpbase.test.ts @@ -0,0 +1,200 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const mockPostJSON = vi.fn() + +vi.mock('../../src/core/client.ts', () => ({ + Client: vi.fn(), + defaultClient: vi.fn(() => ({ + getJSON: vi.fn(), + postJSON: mockPostJSON, + maxRetries: 5, + baseDelay: 50, + timeout: 30000, + userAgent: 'askweb/0.0.1', + })), +})) + +import { create, has } from '../../src/core/registry.ts' +import { AskwebError, AuthError, RateLimitError } from '../../src/core/errors.ts' +import type { SearchResult } from '../../src/core/types.ts' + +// Triggers self-registration of serpbase provider +import '../../src/providers/index.ts' + +const serpBaseResponse = { + status: 0, + request_id: 'req-123', + elapsed_ms: 1071, + credits_charged: 1, + search_type: 'search', + query: 'test query', + organic: [{ + rank: 1, + position: 1, + title: 'Test Result', + link: 'https://example.com', + url: 'https://example.com', + display_url: 'example.com', + display_link: 'example.com', + source_url: 'https://www.google.com/url?q=https://example.com', + snippet: 'A test snippet from SerpBase', + date: '2 days ago', + icon: 'https://example.com/favicon.ico', + }], +} + +describe('serpbase provider', () => { + beforeEach(() => { + mockPostJSON.mockReset() + mockPostJSON.mockResolvedValue(serpBaseResponse) + delete process.env.SERPBASE_API_KEY + }) + + describe('self-registration', () => { + it('registers itself on import', () => { + expect(has('serpbase')).toBe(true) + }) + }) + + describe('create', () => { + it('creates provider with apiKey', () => { + expect(() => create('serpbase', { apiKey: 'test-key' })).not.toThrow() + }) + + it('creates provider with env api key', () => { + process.env.SERPBASE_API_KEY = 'env-key' + expect(() => create('serpbase')).not.toThrow() + }) + + it('throws AuthError without apiKey and without env var', () => { + expect(() => create('serpbase', {})).toThrow(AuthError) + }) + }) + + describe('name()', () => { + it('returns serpbase', () => { + const provider = create('serpbase', { apiKey: 'test-key' }) + expect(provider.name()).toBe('serpbase') + }) + }) + + describe('search()', () => { + it('calls postJSON with Google search endpoint, body, and X-API-Key header', async () => { + const provider = create('serpbase', { apiKey: 'test-key' }) + await provider.search('test query') + + expect(mockPostJSON).toHaveBeenCalledOnce() + const [url, body, headers] = mockPostJSON.mock.calls[0] + + expect(url).toBe('https://api.serpbase.dev/google/search') + expect(body).toEqual({ q: 'test query', hl: 'en', gl: 'us', page: 1 }) + expect(headers).toEqual({ 'X-API-Key': 'test-key' }) + }) + + it('maps organic result fields correctly', async () => { + const provider = create('serpbase', { apiKey: 'test-key' }) + const results: SearchResult[] = await provider.search('test query') + + expect(results).toHaveLength(1) + const result = results[0] + expect(result.url).toBe('https://example.com') + expect(result.title).toBe('Test Result') + expect(result.snippet).toBe('A test snippet from SerpBase') + expect(result.favicon).toBe('https://example.com/favicon.ico') + expect(result.publishedDate).toBe('2 days ago') + expect(result.metadata?.position).toBe(1) + expect(result.metadata?.requestId).toBe('req-123') + expect(result.metadata?.creditsCharged).toBe(1) + }) + + it('maps maxResults option by slicing returned results', async () => { + mockPostJSON.mockResolvedValueOnce({ + ...serpBaseResponse, + organic: [ + serpBaseResponse.organic[0], + { ...serpBaseResponse.organic[0], rank: 2, position: 2, title: 'Second Result', link: 'https://second.example.com' }, + ], + }) + + const provider = create('serpbase', { apiKey: 'test-key' }) + const results = await provider.search('test query', { maxResults: 1 }) + + expect(results).toHaveLength(1) + expect(results[0].title).toBe('Test Result') + }) + + it('uses images endpoint and maps image fields for images category', async () => { + mockPostJSON.mockResolvedValueOnce({ + status: 0, + request_id: 'req-img', + elapsed_ms: 1200, + credits_charged: 2, + search_type: 'images', + images: [{ + rank: 1, + title: 'Image Result', + link: 'https://example.com/page', + image_url: 'https://example.com/image.png', + thumbnail_url: 'https://example.com/thumb.png', + source: 'Example', + domain: 'example.com', + }], + }) + + const provider = create('serpbase', { apiKey: 'test-key' }) + const results = await provider.search('image query', { category: 'images' }) + + const [url] = mockPostJSON.mock.calls[0] + expect(url).toBe('https://api.serpbase.dev/google/images') + expect(results).toHaveLength(1) + expect(results[0].url).toBe('https://example.com/page') + expect(results[0].image).toBe('https://example.com/image.png') + expect(results[0].metadata?.searchType).toBe('images') + }) + + it('throws AuthError for SerpBase unauthorized business status', async () => { + mockPostJSON.mockResolvedValueOnce({ + status: 1001, + error: 'unauthorized', + request_id: 'req-auth', + elapsed_ms: 0, + credits_charged: 0, + search_type: 'search', + }) + + const provider = create('serpbase', { apiKey: 'bad-key' }) + + await expect(provider.search('test query')).rejects.toThrow(AuthError) + }) + + it('throws RateLimitError for SerpBase rate limit business status', async () => { + mockPostJSON.mockResolvedValueOnce({ + status: 1029, + error: 'rate limited', + request_id: 'req-rate', + elapsed_ms: 0, + credits_charged: 0, + search_type: 'search', + }) + + const provider = create('serpbase', { apiKey: 'test-key' }) + + await expect(provider.search('test query')).rejects.toThrow(RateLimitError) + }) + + it('throws AskwebError for insufficient credits business status', async () => { + mockPostJSON.mockResolvedValueOnce({ + status: 1020, + error: 'insufficient credits', + request_id: 'req-credits', + elapsed_ms: 0, + credits_charged: 0, + search_type: 'search', + }) + + const provider = create('serpbase', { apiKey: 'test-key' }) + + await expect(provider.search('test query')).rejects.toThrow(AskwebError) + }) + }) +})