diff --git a/.changeset/parallel-search.md b/.changeset/parallel-search.md new file mode 100644 index 000000000..c1506cfeb --- /dev/null +++ b/.changeset/parallel-search.md @@ -0,0 +1,5 @@ +--- +'@tanstack/ai-parallel': minor +--- + +Add a first-class Parallel Search client and server tool for source-grounded TanStack AI agents. diff --git a/docs/adapters/parallel.md b/docs/adapters/parallel.md new file mode 100644 index 000000000..fe5ee5739 --- /dev/null +++ b/docs/adapters/parallel.md @@ -0,0 +1,99 @@ +--- +title: Parallel Search +id: parallel-adapter +order: 11 +description: "Add live web sources and citations to TanStack AI agents with Parallel Search." +keywords: + - tanstack ai + - parallel + - parallel search + - web search + - citations +--- + +An AI agent needs current web sources to answer questions beyond its training data. Parallel Search gives any function-calling TanStack AI model ranked sources and relevant excerpts. + +## Set up the workspace + +`@tanstack/ai-parallel` is available from the TanStack AI workspace. + +1. Install dependencies from the repository root: + + ```bash + pnpm install + ``` + +2. Set your Parallel API key: + + ```bash + export PARALLEL_API_KEY=your-api-key + ``` + +## Add web search to an agent + +Add `parallelSearchTool()` to the `tools` array for a function-calling adapter: + +```ts +import { chat } from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' +import { parallelSearchTool } from '@tanstack/ai-parallel' + +const stream = chat({ + adapter: openaiText('gpt-5.6'), + tools: [ + parallelSearchTool({ + mode: 'fast', + defaultMaxResults: 5, + }), + ], + messages: [ + { + role: 'user', + content: 'Find recent research on reliable AI agents and cite your sources.', + }, + ], +}) +``` + +The tool runs on the server and returns each source URL, relevant excerpts, title, and publication date. Set `sessionId` explicitly when searches should share a Parallel session. + +## Control search behavior + +Configure source restrictions when you create the tool: + +```ts +import { parallelSearchTool } from '@tanstack/ai-parallel' + +const search = parallelSearchTool({ + mode: 'basic', + defaultMaxResults: 3, + sourcePolicy: { + include_domains: ['arxiv.org'], + after_date: '2026-08-01', + }, +}) +``` + +The model can provide a query, an optional search objective, and an optional result limit. Application-owned source rules apply to every request. + +## Use the search client directly + +Call Parallel Search outside an agent loop with `ParallelSearchClient`: + +```ts +import { ParallelSearchClient } from '@tanstack/ai-parallel' + +const client = new ParallelSearchClient() +const response = await client.search({ + search_queries: ['reliable AI agent research'], + objective: 'Find recent peer-reviewed research.', + mode: 'basic', + advanced_settings: { max_results: 3 }, +}) + +for (const result of response.results) { + console.log(result.url, result.excerpts) +} +``` + +The client calls `POST https://api.parallel.ai/v1/search` with your `PARALLEL_API_KEY`. See the [Parallel Search API reference](https://docs.parallel.ai/api-reference/search/search) for request details. diff --git a/docs/config.json b/docs/config.json index dfe4cd277..12d222663 100644 --- a/docs/config.json +++ b/docs/config.json @@ -977,6 +977,11 @@ "to": "adapters/perplexity", "addedAt": "2026-08-13" }, + { + "label": "Parallel Search", + "to": "adapters/parallel", + "addedAt": "2026-08-23" + }, { "label": "Vercel AI Gateway", "to": "adapters/vercel-gateway", diff --git a/packages/ai-parallel/README.md b/packages/ai-parallel/README.md new file mode 100644 index 000000000..e1626f8e0 --- /dev/null +++ b/packages/ai-parallel/README.md @@ -0,0 +1,76 @@ +# @tanstack/ai-parallel + +Give a TanStack AI agent current web sources and relevant excerpts through the [Parallel Search API](https://docs.parallel.ai/api-reference/search/search). + +This package is available from the TanStack AI workspace. + +## Set up + +1. Install dependencies from the repository root: + + ```bash + pnpm install + ``` + +2. Set your Parallel API key: + + ```bash + export PARALLEL_API_KEY=your-api-key + ``` + +3. Add the search tool to a function-calling model: + + ```ts + import { chat } from '@tanstack/ai' + import { openaiText } from '@tanstack/ai-openai' + import { parallelSearchTool } from '@tanstack/ai-parallel' + + const stream = chat({ + adapter: openaiText('gpt-5.6'), + tools: [ + parallelSearchTool({ + mode: 'fast', + defaultMaxResults: 5, + }), + ], + messages: [ + { + role: 'user', + content: + 'Find recent research on reliable AI agents and cite your sources.', + }, + ], + }) + ``` + +The tool returns source URLs, relevant excerpts, titles, and publication dates. Set `sessionId` explicitly when searches should share a Parallel session. + +## Restrict sources + +Set application-owned source rules when you create the tool: + +```ts +const search = parallelSearchTool({ + mode: 'basic', + sourcePolicy: { + include_domains: ['arxiv.org'], + after_date: '2026-08-01', + }, +}) +``` + +## Call the Search API directly + +```ts +import { ParallelSearchClient } from '@tanstack/ai-parallel' + +const client = new ParallelSearchClient() +const { results } = await client.search({ + search_queries: ['reliable AI agent research'], + objective: 'Find recent peer-reviewed research.', + mode: 'basic', + advanced_settings: { max_results: 3 }, +}) +``` + +The client sends requests to `POST https://api.parallel.ai/v1/search`. Pass `apiKey`, `baseURL`, or `fetch` to configure the client. diff --git a/packages/ai-parallel/package.json b/packages/ai-parallel/package.json new file mode 100644 index 000000000..c84290b52 --- /dev/null +++ b/packages/ai-parallel/package.json @@ -0,0 +1,64 @@ +{ + "name": "@tanstack/ai-parallel", + "version": "0.1.0", + "description": "Parallel Search API client and server tool for TanStack AI", + "author": "Tanner Linsley", + "license": "MIT", + "homepage": "https://tanstack.com/ai", + "repository": { + "type": "git", + "url": "git+https://github.com/TanStack/ai.git", + "directory": "packages/ai-parallel" + }, + "bugs": { + "url": "https://github.com/TanStack/ai/issues" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "type": "module", + "module": "./dist/esm/index.js", + "types": "./dist/esm/index.d.ts", + "exports": { + ".": { + "types": "./dist/esm/index.d.ts", + "import": "./dist/esm/index.js" + } + }, + "files": [ + "dist", + "src" + ], + "scripts": { + "build": "vite build", + "clean": "premove ./build ./dist", + "lint:fix": "oxlint src --type-aware --fix", + "test:build": "publint --strict", + "test:oxlint": "oxlint src --type-aware", + "test:lib": "vitest run", + "test:lib:dev": "pnpm test:lib --watch", + "test:types": "tsc" + }, + "keywords": [ + "ai", + "ai-sdk", + "typescript", + "tanstack", + "parallel", + "search", + "web-search" + ], + "dependencies": { + "parallel-web": "^1.3.0" + }, + "devDependencies": { + "@tanstack/ai": "workspace:*", + "vite": "^8.2.1", + "zod": "^4.2.0" + }, + "peerDependencies": { + "@tanstack/ai": "workspace:^", + "zod": "^4.0.0" + } +} diff --git a/packages/ai-parallel/src/client.test.ts b/packages/ai-parallel/src/client.test.ts new file mode 100644 index 000000000..b4d33fa4e --- /dev/null +++ b/packages/ai-parallel/src/client.test.ts @@ -0,0 +1,310 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ParallelSearchClient } from './client' +import { fetchCall, mockFetch, searchResponse } from '../tests/test-utils' + +afterEach(() => { + vi.unstubAllEnvs() + vi.restoreAllMocks() +}) + +describe('ParallelSearchClient', () => { + it('calls the GA endpoint with API-key authentication and required queries', async () => { + const fetchMock = mockFetch( + searchResponse([ + { + url: 'https://example.com/article', + title: 'Example article', + excerpts: ['The cited evidence.'], + publish_date: '2026-08-20', + }, + ]), + ) + const client = new ParallelSearchClient({ + apiKey: 'test-key', + fetch: fetchMock, + }) + const searchQueries = [' recent research '] as const + + const response = await client.search({ + search_queries: searchQueries, + objective: 'Find current research.', + mode: 'fast', + advanced_settings: { + max_results: 3, + source_policy: { include_domains: ['example.com'] }, + }, + }) + + expect(fetchCall(fetchMock).url).toBe('https://api.parallel.ai/v1/search') + const headers = new Headers(fetchCall(fetchMock).init.headers) + expect(headers.get('x-api-key')).toBe('test-key') + expect(headers.get('Content-Type')).toBe('application/json') + expect(headers.get('Accept')).toBe('application/json') + expect(searchQueries).toEqual([' recent research ']) + expect(fetchCall(fetchMock).body).toEqual({ + search_queries: ['recent research'], + objective: 'Find current research.', + mode: 'fast', + advanced_settings: { + max_results: 3, + source_policy: { include_domains: ['example.com'] }, + }, + }) + expect(response).toEqual( + searchResponse([ + { + url: 'https://example.com/article', + title: 'Example article', + excerpts: ['The cited evidence.'], + publish_date: '2026-08-20', + }, + ]), + ) + }) + + it('falls back to the trimmed environment key for blank explicit credentials', async () => { + vi.stubEnv('PARALLEL_API_KEY', ' environment-key ') + const fetchMock = mockFetch(searchResponse()) + const client = new ParallelSearchClient({ apiKey: ' ', fetch: fetchMock }) + + await client.search({ search_queries: ['news'] }) + + expect( + new Headers(fetchCall(fetchMock).init.headers).get('x-api-key'), + ).toBe('environment-key') + }) + + it('rejects missing API keys without issuing a request', () => { + vi.stubEnv('PARALLEL_API_KEY', '') + const fetchMock = mockFetch(searchResponse()) + + expect(() => new ParallelSearchClient({ fetch: fetchMock })).toThrow( + /PARALLEL_API_KEY/, + ) + expect( + () => new ParallelSearchClient({ apiKey: ' ', fetch: fetchMock }), + ).toThrow(/PARALLEL_API_KEY/) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('rejects missing and blank search queries before issuing a request', async () => { + const fetchMock = mockFetch(searchResponse()) + const client = new ParallelSearchClient({ + apiKey: 'test-key', + fetch: fetchMock, + }) + + await expect(client.search({ search_queries: [] })).rejects.toThrow( + /non-empty search query/, + ) + await expect(client.search({ search_queries: [' '] })).rejects.toThrow( + /non-empty search query/, + ) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('rejects invalid result limits before issuing a request', async () => { + const fetchMock = mockFetch(searchResponse()) + const client = new ParallelSearchClient({ + apiKey: 'test-key', + fetch: fetchMock, + }) + + await expect( + client.search({ + search_queries: ['news'], + advanced_settings: { max_results: 0 }, + }), + ).rejects.toThrow(/positive integer/) + await expect( + client.search({ + search_queries: ['news'], + advanced_settings: { max_results: 1.5 }, + }), + ).rejects.toThrow(/positive integer/) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('rejects more than 200 source-policy domains', async () => { + const fetchMock = mockFetch(searchResponse()) + const client = new ParallelSearchClient({ + apiKey: 'test-key', + fetch: fetchMock, + }) + + await expect( + client.search({ + search_queries: ['news'], + advanced_settings: { + source_policy: { + include_domains: Array.from( + { length: 200 }, + (_, index) => `source-${index}.test`, + ), + exclude_domains: ['excluded.test'], + }, + }, + }), + ).rejects.toThrow(/at most 200 domains/) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('surfaces HTTP failures without dropping the response details', async () => { + const fetchMock = mockFetch( + { error: { message: 'Rate limited.' } }, + 429, + 'Too Many Requests', + ) + const client = new ParallelSearchClient({ + apiKey: 'test-key', + fetch: fetchMock, + }) + + await expect(client.search({ search_queries: ['news'] })).rejects.toThrow( + /429.*Rate limited/, + ) + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it('rejects malformed response payloads', async () => { + const fetchMock = mockFetch({ + ...searchResponse(), + results: [{ url: 'https://example.com', excerpts: 'not-an-array' }], + }) + const client = new ParallelSearchClient({ + apiKey: 'test-key', + fetch: fetchMock, + }) + + await expect(client.search({ search_queries: ['news'] })).rejects.toThrow( + /invalid response/, + ) + }) + + it('omits nullable optional citation fields', async () => { + const fetchMock = mockFetch( + searchResponse([ + { + url: 'https://example.com', + excerpts: ['Evidence'], + title: null, + publish_date: null, + }, + ]), + ) + const client = new ParallelSearchClient({ + apiKey: 'test-key', + fetch: fetchMock, + }) + + const response = await client.search({ search_queries: ['news'] }) + + expect(response.results).toEqual([ + { url: 'https://example.com', excerpts: ['Evidence'] }, + ]) + }) + + it('preserves SDK usage and warning metadata', async () => { + const response = { + ...searchResponse(), + usage: [{ count: 1, name: 'search' }], + warnings: [ + { + type: 'input_validation_warning', + message: 'One source was excluded.', + }, + ], + } + const fetchMock = mockFetch(response) + const client = new ParallelSearchClient({ + apiKey: 'test-key', + fetch: fetchMock, + }) + + await expect(client.search({ search_queries: ['news'] })).resolves.toEqual( + response, + ) + }) + + it('rejects an already cancelled search before issuing a request', async () => { + const controller = new AbortController() + const reason = new Error('Search was cancelled.') + const fetchMock = mockFetch(searchResponse()) + const client = new ParallelSearchClient({ + apiKey: 'test-key', + fetch: fetchMock, + }) + + controller.abort(reason) + + await expect( + client.search( + { search_queries: ['news'] }, + { signal: controller.signal }, + ), + ).rejects.toBe(reason) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('rejects when cancellation interrupts an unfinished response body', async () => { + const controller = new AbortController() + const reason = new Error('Search was cancelled.') + const fetchMock = vi.fn(async () => { + const body = new ReadableStream({ + start(stream) { + stream.enqueue( + new TextEncoder().encode( + '{"search_id":"search_test","session_id":"session_test","results":[', + ), + ) + }, + }) + + return new Response(body, { + headers: { 'Content-Type': 'application/json' }, + }) + }) + const client = new ParallelSearchClient({ + apiKey: 'test-key', + fetch: fetchMock, + }) + const search = client.search( + { search_queries: ['news'] }, + { signal: controller.signal }, + ) + + await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledOnce()) + controller.abort(reason) + + const outcome = await Promise.race([ + search.then( + () => 'fulfilled', + (error: unknown) => error, + ), + new Promise((resolve) => setTimeout(() => resolve('still pending'), 0)), + ]) + + expect(outcome).toBe(reason) + }) + + it('forwards cancellation and honors custom base URLs', async () => { + const controller = new AbortController() + const fetchMock = mockFetch(searchResponse()) + const client = new ParallelSearchClient({ + apiKey: 'test-key', + baseURL: 'https://proxy.example.com/', + fetch: fetchMock, + }) + + await client.search( + { search_queries: ['news'] }, + { signal: controller.signal }, + ) + + expect(fetchCall(fetchMock).url).toBe('https://proxy.example.com/v1/search') + const forwardedSignal = fetchCall(fetchMock).init.signal + expect(forwardedSignal).toBeInstanceOf(AbortSignal) + controller.abort() + expect(forwardedSignal?.aborted).toBe(true) + }) +}) diff --git a/packages/ai-parallel/src/client.ts b/packages/ai-parallel/src/client.ts new file mode 100644 index 000000000..9635235ae --- /dev/null +++ b/packages/ai-parallel/src/client.ts @@ -0,0 +1,160 @@ +import Parallel from 'parallel-web' + +const searchModes = ['turbo', 'fast', 'basic', 'advanced'] as const + +export type ParallelSearchMode = NonNullable + +export type ParallelSearchSourcePolicy = Parallel.SourcePolicy + +export type ParallelSearchAdvancedSettings = Parallel.AdvancedSearchSettings + +export type ParallelSearchRequest = Omit< + Parallel.SearchParams, + 'search_queries' +> & { + search_queries: ReadonlyArray +} + +export type ParallelSearchResult = Omit< + Parallel.WebSearchResult, + 'title' | 'publish_date' +> & { + title?: string + publish_date?: string +} + +export type ParallelSearchResponse = Omit & { + results: Array +} + +export interface ParallelSearchClientConfig { + /** API key. Defaults to the PARALLEL_API_KEY environment variable. */ + apiKey?: string + /** API base URL. Defaults to https://api.parallel.ai. */ + baseURL?: string + /** Fetch implementation for custom transports and tests. */ + fetch?: typeof fetch +} + +/** A small client for the generally available Parallel Search API. */ +export class ParallelSearchClient { + private readonly client: Parallel + + constructor(config: ParallelSearchClientConfig = {}) { + const environmentKey = + typeof process === 'undefined' ? undefined : process.env.PARALLEL_API_KEY + const apiKey = config.apiKey?.trim() || environmentKey?.trim() + + if (!apiKey) { + throw new Error( + 'PARALLEL_API_KEY is required. Set it in your environment or pass an explicit apiKey.', + ) + } + + this.client = new Parallel({ + apiKey, + baseURL: config.baseURL, + fetch: config.fetch, + maxRetries: 0, + }) + } + + async search( + request: ParallelSearchRequest, + options: { signal?: AbortSignal } = {}, + ): Promise { + const searchQueries = request.search_queries.map((query) => query.trim()) + + if ( + searchQueries.length === 0 || + searchQueries.some((query) => query.length === 0) + ) { + throw new Error( + 'ParallelSearchClient.search requires at least one non-empty search query.', + ) + } + + if (request.mode && !searchModes.includes(request.mode)) { + throw new Error('mode must be turbo, fast, basic, or advanced.') + } + + const maxResults = request.advanced_settings?.max_results + if ( + maxResults != null && + (!Number.isInteger(maxResults) || maxResults < 1) + ) { + throw new Error('max_results must be a positive integer.') + } + + const sourcePolicy = request.advanced_settings?.source_policy + const domainCount = + (sourcePolicy?.include_domains?.length ?? 0) + + (sourcePolicy?.exclude_domains?.length ?? 0) + if (domainCount > 200) { + throw new Error( + 'include_domains and exclude_domains can contain at most 200 domains combined.', + ) + } + + const { signal } = options + signal?.throwIfAborted() + + const search = this.client.search( + { + ...request, + search_queries: searchQueries, + }, + options, + ) + + if (!signal) return normalizeSearchResponse(await search) + + const response = await new Promise( + (resolve, reject) => { + const onAbort = () => reject(signal.reason) + signal.addEventListener('abort', onAbort, { once: true }) + search + .then(resolve, reject) + .finally(() => signal.removeEventListener('abort', onAbort)) + }, + ) + + return normalizeSearchResponse(response) + } +} + +/** Preserve TanStack's non-null citation contract without discarding SDK metadata. */ +function normalizeSearchResponse( + response: Parallel.SearchResult, +): ParallelSearchResponse { + if ( + typeof response?.search_id !== 'string' || + typeof response.session_id !== 'string' || + !Array.isArray(response.results) + ) { + throw new Error('Parallel Search API returned an invalid response.') + } + + return { + ...response, + results: response.results.map((result) => { + if ( + typeof result?.url !== 'string' || + !Array.isArray(result.excerpts) || + result.excerpts.some((excerpt) => typeof excerpt !== 'string') || + (result.title != null && typeof result.title !== 'string') || + (result.publish_date != null && typeof result.publish_date !== 'string') + ) { + throw new Error('Parallel Search API returned an invalid response.') + } + + const { title, publish_date, ...citation } = result + + return { + ...citation, + ...(title ? { title } : {}), + ...(publish_date ? { publish_date } : {}), + } + }), + } +} diff --git a/packages/ai-parallel/src/index.ts b/packages/ai-parallel/src/index.ts new file mode 100644 index 000000000..080d42098 --- /dev/null +++ b/packages/ai-parallel/src/index.ts @@ -0,0 +1,12 @@ +export { + ParallelSearchClient, + type ParallelSearchAdvancedSettings, + type ParallelSearchClientConfig, + type ParallelSearchMode, + type ParallelSearchRequest, + type ParallelSearchResponse, + type ParallelSearchResult, + type ParallelSearchSourcePolicy, +} from './client' + +export { parallelSearchTool, type ParallelSearchToolConfig } from './tool' diff --git a/packages/ai-parallel/src/tool.test.ts b/packages/ai-parallel/src/tool.test.ts new file mode 100644 index 000000000..264475d4e --- /dev/null +++ b/packages/ai-parallel/src/tool.test.ts @@ -0,0 +1,163 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { parallelSearchTool } from './tool' +import { fetchCall, mockFetch, searchResponse } from '../tests/test-utils' + +const context = { + emitCustomEvent: () => {}, +} + +afterEach(() => { + vi.unstubAllEnvs() + vi.restoreAllMocks() +}) + +describe('parallelSearchTool', () => { + it('creates a native server tool without resolving credentials eagerly', () => { + vi.stubEnv('PARALLEL_API_KEY', '') + + const tool = parallelSearchTool() + + expect(tool.__toolSide).toBe('server') + expect(tool.name).toBe('parallel_search') + expect(tool.description).toMatch(/Parallel/) + expect(tool.inputSchema).toBeDefined() + expect(tool.outputSchema).toBeDefined() + expect(typeof tool.execute).toBe('function') + }) + + it('returns citation-rich search results and nests GA advanced settings', async () => { + const fetchMock = mockFetch( + searchResponse([ + { + url: 'https://example.com/research', + title: 'Research', + excerpts: ['A source-grounded result.'], + publish_date: '2026-08-20', + }, + ]), + ) + const tool = parallelSearchTool({ + apiKey: 'test-key', + fetch: fetchMock, + mode: 'basic', + defaultMaxResults: 5, + sourcePolicy: { + include_domains: ['example.com'], + after_date: '2026-08-01', + }, + }) + + const results = await tool.execute?.( + { + query: 'recent AI research', + objective: 'Find recent primary sources.', + }, + context, + ) + + expect(results).toEqual({ + results: [ + { + url: 'https://example.com/research', + title: 'Research', + excerpts: ['A source-grounded result.'], + publish_date: '2026-08-20', + }, + ], + }) + expect(fetchCall(fetchMock).body).toEqual({ + search_queries: ['recent AI research'], + objective: 'Find recent primary sources.', + mode: 'basic', + advanced_settings: { + max_results: 5, + source_policy: { + include_domains: ['example.com'], + after_date: '2026-08-01', + }, + }, + }) + }) + + it('allows a model-provided result limit to override the application default', async () => { + const fetchMock = mockFetch(searchResponse()) + const tool = parallelSearchTool({ + apiKey: 'test-key', + fetch: fetchMock, + defaultMaxResults: 5, + }) + + await tool.execute?.({ query: 'news', max_results: 2 }, context) + + expect(fetchCall(fetchMock).body.advanced_settings).toEqual({ + max_results: 2, + }) + }) + + it('does not leak returned sessions between unrelated tool calls', async () => { + const fetchMock = mockFetch(searchResponse([], 'session_returned')) + const tool = parallelSearchTool({ + apiKey: 'test-key', + fetch: fetchMock, + }) + + await tool.execute?.({ query: 'first search' }, context) + await tool.execute?.({ query: 'second search' }, context) + + expect(fetchCall(fetchMock, 0).body).not.toHaveProperty('session_id') + expect(fetchCall(fetchMock, 1).body).not.toHaveProperty('session_id') + }) + + it('uses only the explicitly configured search session', async () => { + const fetchMock = mockFetch(searchResponse([], 'session_returned')) + const tool = parallelSearchTool({ + apiKey: 'test-key', + fetch: fetchMock, + sessionId: 'session_existing', + }) + + await tool.execute?.({ query: 'first search' }, context) + await tool.execute?.({ query: 'second search' }, context) + + expect(fetchCall(fetchMock, 0).body.session_id).toBe('session_existing') + expect(fetchCall(fetchMock, 1).body.session_id).toBe('session_existing') + }) + + it('forwards the chat cancellation signal to Parallel', async () => { + const controller = new AbortController() + const fetchMock = mockFetch(searchResponse()) + const tool = parallelSearchTool({ + apiKey: 'test-key', + fetch: fetchMock, + }) + + await tool.execute?.( + { query: 'news' }, + { ...context, abortSignal: controller.signal }, + ) + + const forwardedSignal = fetchCall(fetchMock).init.signal + expect(forwardedSignal).toBeInstanceOf(AbortSignal) + controller.abort() + expect(forwardedSignal?.aborted).toBe(true) + }) + + it('rejects invalid application result limits', () => { + expect(() => parallelSearchTool({ defaultMaxResults: 0 })).toThrow( + /positive integer/, + ) + expect(() => parallelSearchTool({ defaultMaxResults: 1.5 })).toThrow( + /positive integer/, + ) + }) + + it('supports custom tool names and descriptions', () => { + const tool = parallelSearchTool({ + name: 'web_search', + description: 'Find current evidence.', + }) + + expect(tool.name).toBe('web_search') + expect(tool.description).toBe('Find current evidence.') + }) +}) diff --git a/packages/ai-parallel/src/tool.ts b/packages/ai-parallel/src/tool.ts new file mode 100644 index 000000000..4a5be9036 --- /dev/null +++ b/packages/ai-parallel/src/tool.ts @@ -0,0 +1,100 @@ +import { toolDefinition } from '@tanstack/ai' +import { z } from 'zod' +import { ParallelSearchClient } from './client' +import type { + ParallelSearchClientConfig, + ParallelSearchMode, + ParallelSearchSourcePolicy, +} from './client' + +const inputSchema = z.object({ + query: z.string().trim().min(1).describe('The web search query.'), + objective: z + .string() + .trim() + .min(1) + .optional() + .describe('The question or goal that focuses the search results.'), + max_results: z + .number() + .int() + .positive() + .optional() + .describe('Maximum number of web results to return.'), +}) + +const outputSchema = z.object({ + results: z.array( + z.object({ + url: z.string(), + excerpts: z.array(z.string()), + title: z.string().optional(), + publish_date: z.string().optional(), + }), + ), +}) + +export interface ParallelSearchToolConfig extends ParallelSearchClientConfig { + /** Tool name presented to the model. Defaults to parallel_search. */ + name?: string + /** Tool description presented to the model. */ + description?: string + /** Search mode applied to every request from this tool. */ + mode?: ParallelSearchMode + /** Result limit applied when the model does not provide one. */ + defaultMaxResults?: number + /** Application-controlled source restrictions for every search. */ + sourcePolicy?: ParallelSearchSourcePolicy + /** Explicit search session to use for every request from this tool. */ + sessionId?: string +} + +/** Build a server-side TanStack AI tool backed by Parallel Search. */ +export function parallelSearchTool(config: ParallelSearchToolConfig = {}) { + const { + name, + description, + mode, + defaultMaxResults, + sourcePolicy, + sessionId, + ...clientConfig + } = config + + if ( + defaultMaxResults !== undefined && + (!Number.isInteger(defaultMaxResults) || defaultMaxResults < 1) + ) { + throw new Error('defaultMaxResults must be a positive integer.') + } + + let client: ParallelSearchClient | undefined + + return toolDefinition({ + name: name ?? 'parallel_search', + description: + description ?? + 'Search the live web with Parallel and return ranked sources, URLs, publication dates, and relevant excerpts.', + inputSchema, + outputSchema, + }).server(async ({ query, objective, max_results }, context) => { + client ??= new ParallelSearchClient(clientConfig) + const maxResults = max_results ?? defaultMaxResults + + const response = await client.search( + { + search_queries: [query], + objective, + mode, + session_id: sessionId, + advanced_settings: + maxResults !== undefined || sourcePolicy + ? { max_results: maxResults, source_policy: sourcePolicy } + : undefined, + }, + { signal: context?.abortSignal }, + ) + + return { results: response.results } + }) +} diff --git a/packages/ai-parallel/tests/chat.test.ts b/packages/ai-parallel/tests/chat.test.ts new file mode 100644 index 000000000..edf6feee6 --- /dev/null +++ b/packages/ai-parallel/tests/chat.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from 'vitest' +import { chat } from '@tanstack/ai' +import type { AnyTextAdapter } from '@tanstack/ai' +import { parallelSearchTool } from '../src/tool' +import { fetchCall, mockFetch, searchResponse } from './test-utils' + +describe('Parallel Search inside chat()', () => { + it('executes a real native server tool and returns its sources to the model', async () => { + const fetchMock = mockFetch( + searchResponse([ + { + url: 'https://example.com/evidence', + title: 'Current evidence', + excerpts: ['The cited answer.'], + }, + ]), + ) + const search = parallelSearchTool({ + apiKey: 'test-key', + fetch: fetchMock, + mode: 'fast', + }) + const calls: Array<{ + messages: Array<{ role: string; content?: unknown }> + }> = [] + let iteration = 0 + const adapter = { + kind: 'text', + name: 'mock', + model: 'test-model', + '~types': { + providerOptions: {}, + inputModalities: ['text'], + messageMetadataByModality: { + text: undefined, + image: undefined, + audio: undefined, + video: undefined, + document: undefined, + }, + toolCapabilities: [], + toolCallMetadata: undefined, + systemPromptMetadata: undefined, + }, + chatStream: (options: { + messages: Array<{ role: string; content?: unknown }> + }) => { + calls.push(options) + const currentIteration = iteration++ + return (async function* () { + const timestamp = Date.now() + yield { + type: 'RUN_STARTED', + runId: 'run_test', + threadId: 'thread_test', + timestamp, + } + + if (currentIteration === 0) { + yield { + type: 'TOOL_CALL_START', + toolCallId: 'call_search', + toolCallName: 'parallel_search', + timestamp, + } + yield { + type: 'TOOL_CALL_ARGS', + toolCallId: 'call_search', + delta: '{"query":"latest AI research"}', + timestamp, + } + yield { + type: 'RUN_FINISHED', + runId: 'run_test', + threadId: 'thread_test', + metadata: { tanstack: { finishReason: 'tool_calls' } }, + timestamp, + } + return + } + + yield { + type: 'TEXT_MESSAGE_START', + messageId: 'message_test', + role: 'assistant', + timestamp, + } + yield { + type: 'TEXT_MESSAGE_CONTENT', + messageId: 'message_test', + delta: 'The source confirms the answer.', + timestamp, + } + yield { + type: 'TEXT_MESSAGE_END', + messageId: 'message_test', + timestamp, + } + yield { + type: 'RUN_FINISHED', + runId: 'run_test', + threadId: 'thread_test', + metadata: { tanstack: { finishReason: 'stop' } }, + timestamp, + } + })() + }, + structuredOutput: async () => ({ data: {}, rawText: '{}' }), + } as unknown as AnyTextAdapter + + const result = await chat({ + adapter, + tools: [search], + messages: [{ role: 'user', content: 'Find the latest research.' }], + stream: false, + }) + + expect(result).toBe('The source confirms the answer.') + expect(fetchCall(fetchMock).body).toEqual({ + search_queries: ['latest AI research'], + mode: 'fast', + }) + expect(calls).toHaveLength(2) + const toolResult = calls[1]?.messages.find( + (message) => message.role === 'tool', + ) + expect(JSON.stringify(toolResult)).toContain('https://example.com/evidence') + }) +}) diff --git a/packages/ai-parallel/tests/test-utils.ts b/packages/ai-parallel/tests/test-utils.ts new file mode 100644 index 000000000..5719eed77 --- /dev/null +++ b/packages/ai-parallel/tests/test-utils.ts @@ -0,0 +1,39 @@ +import { vi } from 'vitest' + +export function mockFetch(payload: unknown, status = 200, statusText = '') { + return vi.fn( + async (_input: string | URL | Request, _init?: RequestInit) => + new Response(JSON.stringify(payload), { + status, + statusText, + headers: { 'Content-Type': 'application/json' }, + }), + ) +} + +export function searchResponse( + results: Array> = [], + sessionId = 'session_test', +) { + return { + search_id: 'search_test', + session_id: sessionId, + results, + } +} + +export function fetchCall(fetchMock: ReturnType, index = 0) { + const call = fetchMock.mock.calls[index] + const input = call?.[0] + const init = call?.[1] + + if (input === undefined || init === undefined) { + throw new Error('Expected fetch to receive a URL and request options.') + } + + return { + url: input instanceof Request ? input.url : String(input), + init, + body: JSON.parse(String(init.body)) as Record, + } +} diff --git a/packages/ai-parallel/tsconfig.json b/packages/ai-parallel/tsconfig.json new file mode 100644 index 000000000..c38689f4e --- /dev/null +++ b/packages/ai-parallel/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": ["src", "tests"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/ai-parallel/vite.config.ts b/packages/ai-parallel/vite.config.ts new file mode 100644 index 000000000..898935638 --- /dev/null +++ b/packages/ai-parallel/vite.config.ts @@ -0,0 +1,21 @@ +import { defineConfig, mergeConfig } from 'vitest/config' +import { tanstackViteConfig } from '@tanstack/vite-config' +import packageJson from './package.json' with { type: 'json' } + +export default mergeConfig( + defineConfig({ + test: { + name: packageJson.name, + dir: './', + watch: false, + globals: true, + environment: 'node', + include: ['src/**/*.test.ts', 'tests/**/*.test.ts'], + }, + }), + tanstackViteConfig({ + entry: ['./src/index.ts'], + srcDir: './src', + cjs: false, + }), +) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 827b40724..91ab4d7e1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2312,6 +2312,22 @@ importers: specifier: ^4.2.0 version: 4.3.6 + packages/ai-parallel: + dependencies: + parallel-web: + specifier: ^1.3.0 + version: 1.3.0 + devDependencies: + '@tanstack/ai': + specifier: workspace:* + version: link:../ai + vite: + specifier: ^8.2.1 + version: 8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0) + zod: + specifier: ^4.2.0 + version: 4.3.6 + packages/ai-perplexity: devDependencies: '@tanstack/ai': @@ -14370,6 +14386,9 @@ packages: package-manager-detector@1.6.0: resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + parallel-web@1.3.0: + resolution: {integrity: sha512-uCJxbjAU0ZMvXi0XHNSBY5B5r7SBAuTfYtkbiW+tQqRxAmq/FHZ/h57Q4zclnnX6CgTW+PQDk2NPH9Q7ULYbMw==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -30819,6 +30838,8 @@ snapshots: package-manager-detector@1.6.0: {} + parallel-web@1.3.0: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 diff --git a/scripts/lovable-gateway.models.json b/scripts/lovable-gateway.models.json index 8a193376e..79c6babbc 100644 --- a/scripts/lovable-gateway.models.json +++ b/scripts/lovable-gateway.models.json @@ -12,15 +12,8 @@ "max_tokens": 65536, "knowledge": "2025-01", "modalities": { - "input": [ - "text", - "image", - "audio", - "video" - ], - "output": [ - "text" - ] + "input": ["text", "image", "audio", "video"], + "output": ["text"] }, "pricing": { "input": { @@ -85,15 +78,8 @@ "max_tokens": 65536, "knowledge": "2025-01", "modalities": { - "input": [ - "text", - "image", - "video" - ], - "output": [ - "text", - "image" - ] + "input": ["text", "image", "video"], + "output": ["text", "image"] }, "pricing": { "input": { @@ -150,15 +136,8 @@ "max_tokens": 65536, "knowledge": "2025-01", "modalities": { - "input": [ - "text", - "image", - "audio", - "video" - ], - "output": [ - "text" - ] + "input": ["text", "image", "audio", "video"], + "output": ["text"] }, "pricing": { "input": { @@ -222,12 +201,8 @@ "context_window": 8192, "max_tokens": 16384, "modalities": { - "input": [ - "text" - ], - "output": [ - "audio" - ] + "input": ["text"], + "output": ["audio"] }, "pricing": { "input": { @@ -265,12 +240,8 @@ "max_tokens": 16384, "knowledge": "2025-01", "modalities": { - "input": [ - "text" - ], - "output": [ - "audio" - ] + "input": ["text"], + "output": ["audio"] }, "pricing": { "input": { @@ -308,15 +279,8 @@ "max_tokens": 65536, "knowledge": "2025-01", "modalities": { - "input": [ - "text", - "image", - "audio", - "video" - ], - "output": [ - "text" - ] + "input": ["text", "image", "audio", "video"], + "output": ["text"] }, "pricing": { "input": { @@ -406,12 +370,8 @@ "max_tokens": 16384, "knowledge": "2025-01", "modalities": { - "input": [ - "text" - ], - "output": [ - "audio" - ] + "input": ["text"], + "output": ["audio"] }, "pricing": { "input": { @@ -449,15 +409,8 @@ "max_tokens": 65000, "knowledge": "2025-01", "modalities": { - "input": [ - "text", - "image", - "audio", - "video" - ], - "output": [ - "text" - ] + "input": ["text", "image", "audio", "video"], + "output": ["text"] }, "pricing": { "input": { @@ -522,15 +475,8 @@ "max_tokens": 32768, "knowledge": "2025-01", "modalities": { - "input": [ - "text", - "image", - "video" - ], - "output": [ - "text", - "image" - ] + "input": ["text", "image", "video"], + "output": ["text", "image"] }, "pricing": { "input": { @@ -595,15 +541,8 @@ "max_tokens": 32768, "knowledge": "2025-01", "modalities": { - "input": [ - "text", - "image", - "video" - ], - "output": [ - "text", - "image" - ] + "input": ["text", "image", "video"], + "output": ["text", "image"] }, "pricing": { "input": { @@ -668,15 +607,8 @@ "max_tokens": 65000, "knowledge": "2025-01", "modalities": { - "input": [ - "text", - "image", - "audio", - "video" - ], - "output": [ - "text" - ] + "input": ["text", "image", "audio", "video"], + "output": ["text"] }, "pricing": { "input": { @@ -740,15 +672,8 @@ "context_window": 65536, "max_tokens": 4096, "modalities": { - "input": [ - "text", - "image", - "video" - ], - "output": [ - "text", - "image" - ] + "input": ["text", "image", "video"], + "output": ["text", "image"] }, "pricing": { "input": { @@ -813,12 +738,8 @@ "max_tokens": 16384, "knowledge": "2025-01", "modalities": { - "input": [ - "text" - ], - "output": [ - "audio" - ] + "input": ["text"], + "output": ["audio"] }, "pricing": { "input": { @@ -856,15 +777,8 @@ "max_tokens": 64000, "knowledge": "2025-01", "modalities": { - "input": [ - "text", - "image", - "audio", - "video" - ], - "output": [ - "text" - ] + "input": ["text", "image", "audio", "video"], + "output": ["text"] }, "pricing": { "input": { @@ -954,15 +868,8 @@ "max_tokens": 64000, "knowledge": "2025-01", "modalities": { - "input": [ - "text", - "image", - "audio", - "video" - ], - "output": [ - "text" - ] + "input": ["text", "image", "audio", "video"], + "output": ["text"] }, "pricing": { "input": { @@ -1027,15 +934,8 @@ "max_tokens": 64000, "knowledge": "2026-03", "modalities": { - "input": [ - "text", - "image", - "audio", - "video" - ], - "output": [ - "text" - ] + "input": ["text", "image", "audio", "video"], + "output": ["text"] }, "pricing": { "input": { @@ -1100,12 +1000,8 @@ "max_tokens": 0, "knowledge": "2025-05", "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] + "input": ["text"], + "output": ["text"] }, "pricing": { "input": { @@ -1133,15 +1029,8 @@ "max_tokens": 0, "knowledge": "2025-11", "modalities": { - "input": [ - "text", - "image", - "audio", - "video" - ], - "output": [ - "text" - ] + "input": ["text", "image", "audio", "video"], + "output": ["text"] }, "pricing": { "input": { @@ -1192,13 +1081,8 @@ "context_window": 0, "max_tokens": 0, "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "video" - ] + "input": ["text", "image"], + "output": ["video"] }, "pricing": { "video_duration": { @@ -1226,13 +1110,8 @@ "context_window": 0, "max_tokens": 0, "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "video" - ] + "input": ["text", "image"], + "output": ["video"] }, "pricing": { "video_duration": { @@ -1260,13 +1139,8 @@ "context_window": 0, "max_tokens": 0, "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "video" - ] + "input": ["text", "image"], + "output": ["video"] }, "pricing": { "video_duration": { @@ -1291,13 +1165,8 @@ "context_window": 16000, "max_tokens": 2000, "modalities": { - "input": [ - "text", - "audio" - ], - "output": [ - "text" - ] + "input": ["text", "audio"], + "output": ["text"] }, "pricing": { "input": { @@ -1342,12 +1211,8 @@ "context_window": 2000, "max_tokens": 0, "modalities": { - "input": [ - "text" - ], - "output": [ - "audio" - ] + "input": ["text"], + "output": ["audio"] }, "pricing": { "input": { @@ -1384,13 +1249,8 @@ "context_window": 16000, "max_tokens": 2000, "modalities": { - "input": [ - "text", - "audio" - ], - "output": [ - "text" - ] + "input": ["text", "audio"], + "output": ["text"] }, "pricing": { "input": { @@ -1436,13 +1296,8 @@ "max_tokens": 128000, "knowledge": "2024-09-30", "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] + "input": ["text", "image"], + "output": ["text"] }, "pricing": { "input": { @@ -1526,13 +1381,8 @@ "max_tokens": 128000, "knowledge": "2024-05-30", "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] + "input": ["text", "image"], + "output": ["text"] }, "pricing": { "input": { @@ -1616,13 +1466,8 @@ "max_tokens": 128000, "knowledge": "2024-05-30", "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] + "input": ["text", "image"], + "output": ["text"] }, "pricing": { "input": { @@ -1671,13 +1516,8 @@ "max_tokens": 128000, "knowledge": "2024-10", "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] + "input": ["text", "image"], + "output": ["text"] }, "pricing": { "input": { @@ -1761,13 +1601,8 @@ "max_tokens": 128000, "knowledge": "2025-08-31", "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] + "input": ["text", "image"], + "output": ["text"] }, "pricing": { "input": { @@ -1866,13 +1701,8 @@ "max_tokens": 128000, "knowledge": "2025-08-31", "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] + "input": ["text", "image"], + "output": ["text"] }, "pricing": { "input": { @@ -1956,13 +1786,8 @@ "max_tokens": 128000, "knowledge": "2025-08-31", "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] + "input": ["text", "image"], + "output": ["text"] }, "pricing": { "input": { @@ -2011,13 +1836,8 @@ "max_tokens": 128000, "knowledge": "2025-08-31", "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] + "input": ["text", "image"], + "output": ["text"] }, "pricing": { "input": { @@ -2081,13 +1901,8 @@ "max_tokens": 128000, "knowledge": "2025-12-01", "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] + "input": ["text", "image"], + "output": ["text"] }, "pricing": { "input": { @@ -2186,13 +2001,8 @@ "max_tokens": 128000, "knowledge": "2025-12-01", "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] + "input": ["text", "image"], + "output": ["text"] }, "pricing": { "input": { @@ -2256,13 +2066,8 @@ "max_tokens": 128000, "knowledge": "2026-02-16", "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] + "input": ["text", "image"], + "output": ["text"] }, "pricing": { "input": { @@ -2361,13 +2166,8 @@ "max_tokens": 128000, "knowledge": "2026-02-16", "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] + "input": ["text", "image"], + "output": ["text"] }, "pricing": { "input": { @@ -2466,13 +2266,8 @@ "max_tokens": 128000, "knowledge": "2026-02-16", "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] + "input": ["text", "image"], + "output": ["text"] }, "pricing": { "input": { @@ -2570,13 +2365,8 @@ "context_window": 0, "max_tokens": 0, "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "image" - ] + "input": ["text", "image"], + "output": ["image"] }, "pricing": { "input": { @@ -2621,13 +2411,8 @@ "context_window": 0, "max_tokens": 0, "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "image" - ] + "input": ["text", "image"], + "output": ["image"] }, "pricing": { "input": { @@ -2672,12 +2457,8 @@ "context_window": 8191, "max_tokens": 0, "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] + "input": ["text"], + "output": ["text"] }, "pricing": { "input": { @@ -2704,12 +2485,8 @@ "context_window": 8191, "max_tokens": 0, "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] + "input": ["text"], + "output": ["text"] }, "pricing": { "input": {