From bea2d77627a1287da5e3571f8887fc0b4c8172ca Mon Sep 17 00:00:00 2001 From: George Pickett Date: Sat, 22 Aug 2026 16:59:28 -0700 Subject: [PATCH 1/5] feat(ai-parallel): add Parallel Search integration --- .changeset/parallel-search.md | 5 + docs/adapters/parallel.md | 97 ++++++++++ docs/config.json | 5 + packages/ai-parallel/README.md | 76 ++++++++ packages/ai-parallel/package.json | 62 ++++++ packages/ai-parallel/src/client.ts | 191 +++++++++++++++++++ packages/ai-parallel/src/index.ts | 12 ++ packages/ai-parallel/src/tool.ts | 109 +++++++++++ packages/ai-parallel/tests/chat.test.ts | 129 +++++++++++++ packages/ai-parallel/tests/client.test.ts | 219 ++++++++++++++++++++++ packages/ai-parallel/tests/test-utils.ts | 39 ++++ packages/ai-parallel/tests/tool.test.ts | 146 +++++++++++++++ packages/ai-parallel/tsconfig.json | 8 + packages/ai-parallel/vite.config.ts | 21 +++ pnpm-lock.yaml | 15 ++ 15 files changed, 1134 insertions(+) create mode 100644 .changeset/parallel-search.md create mode 100644 docs/adapters/parallel.md create mode 100644 packages/ai-parallel/README.md create mode 100644 packages/ai-parallel/package.json create mode 100644 packages/ai-parallel/src/client.ts create mode 100644 packages/ai-parallel/src/index.ts create mode 100644 packages/ai-parallel/src/tool.ts create mode 100644 packages/ai-parallel/tests/chat.test.ts create mode 100644 packages/ai-parallel/tests/client.test.ts create mode 100644 packages/ai-parallel/tests/test-utils.ts create mode 100644 packages/ai-parallel/tests/tool.test.ts create mode 100644 packages/ai-parallel/tsconfig.json create mode 100644 packages/ai-parallel/vite.config.ts diff --git a/.changeset/parallel-search.md b/.changeset/parallel-search.md new file mode 100644 index 0000000000..c1506cfeb3 --- /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 0000000000..1065679960 --- /dev/null +++ b/docs/adapters/parallel.md @@ -0,0 +1,97 @@ +--- +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. Later searches reuse the current Parallel session. + +## Control search behavior + +Configure source restrictions when you create the tool: + +```ts +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 dfe4cd2779..c208f04f42 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-22" + }, { "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 0000000000..5771aad486 --- /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. Consecutive searches reuse the same 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 0000000000..4cbdd025d1 --- /dev/null +++ b/packages/ai-parallel/package.json @@ -0,0 +1,62 @@ +{ + "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" + ], + "devDependencies": { + "@tanstack/ai": "workspace:*", + "@vitest/coverage-v8": "4.1.10", + "vite": "^8.2.1", + "zod": "^4.2.0" + }, + "peerDependencies": { + "@tanstack/ai": "workspace:^", + "zod": "^4.0.0" + } +} diff --git a/packages/ai-parallel/src/client.ts b/packages/ai-parallel/src/client.ts new file mode 100644 index 0000000000..e00e6eee36 --- /dev/null +++ b/packages/ai-parallel/src/client.ts @@ -0,0 +1,191 @@ +export type ParallelSearchMode = 'turbo' | 'fast' | 'basic' | 'advanced' + +export interface ParallelSearchSourcePolicy { + after_date?: string + include_domains?: Array + exclude_domains?: Array +} + +export interface ParallelSearchAdvancedSettings { + max_results?: number + source_policy?: ParallelSearchSourcePolicy + location?: string +} + +export interface ParallelSearchRequest { + search_queries: ReadonlyArray + objective?: string + mode?: ParallelSearchMode + advanced_settings?: ParallelSearchAdvancedSettings + max_chars_total?: number + session_id?: string +} + +export interface ParallelSearchResult { + url: string + excerpts: Array + title?: string + publish_date?: string +} + +export interface ParallelSearchResponse { + search_id: string + session_id: string + 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 +} + +const searchModes = new Set([ + 'turbo', + 'fast', + 'basic', + 'advanced', +]) + +/** A small client for the generally available Parallel Search API. */ +export class ParallelSearchClient { + private readonly apiKey: string + private readonly baseURL: string + private readonly fetchImpl: typeof fetch + + 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.apiKey = apiKey + this.baseURL = (config.baseURL ?? 'https://api.parallel.ai').replace( + /\/+$/, + '', + ) + this.fetchImpl = config.fetch ?? globalThis.fetch.bind(globalThis) + } + + 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.has(request.mode)) { + throw new Error('mode must be turbo, fast, basic, or advanced.') + } + + const maxResults = request.advanced_settings?.max_results + if ( + maxResults !== undefined && + (!Number.isInteger(maxResults) || maxResults < 1) + ) { + throw new Error('max_results must be a positive integer.') + } + + const sourcePolicy = request.advanced_settings?.source_policy + const domains = [ + ...(sourcePolicy?.include_domains ?? []), + ...(sourcePolicy?.exclude_domains ?? []), + ] + if (domains.length > 200) { + throw new Error( + 'include_domains and exclude_domains can contain at most 200 domains combined.', + ) + } + + const response = await this.fetchImpl(`${this.baseURL}/v1/search`, { + method: 'POST', + headers: { + 'x-api-key': this.apiKey, + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify({ + ...request, + search_queries: searchQueries, + }), + signal: options.signal, + }) + + if (!response.ok) { + let details = '' + try { + details = await response.text() + } catch { + // Preserve the HTTP status when the response body cannot be read. + } + throw new Error( + `Parallel Search API request failed: ${response.status} ${response.statusText}${ + details ? `: ${details}` : '' + }`, + ) + } + + return parseSearchResponse(await response.json()) + } +} + +function isSearchResult(value: unknown): value is ParallelSearchResult { + return ( + typeof value === 'object' && + value !== null && + 'url' in value && + typeof value.url === 'string' && + 'excerpts' in value && + Array.isArray(value.excerpts) && + value.excerpts.every((excerpt) => typeof excerpt === 'string') && + (!('title' in value) || + value.title === null || + typeof value.title === 'string') && + (!('publish_date' in value) || + value.publish_date === null || + typeof value.publish_date === 'string') + ) +} + +function parseSearchResponse(value: unknown): ParallelSearchResponse { + if ( + typeof value !== 'object' || + value === null || + !('search_id' in value) || + typeof value.search_id !== 'string' || + !('session_id' in value) || + typeof value.session_id !== 'string' || + !('results' in value) || + !Array.isArray(value.results) || + !value.results.every(isSearchResult) + ) { + throw new Error('Parallel Search API returned an invalid response.') + } + + return { + search_id: value.search_id, + session_id: value.session_id, + results: value.results.map((result) => ({ + url: result.url, + excerpts: result.excerpts, + ...(result.title ? { title: result.title } : {}), + ...(result.publish_date ? { publish_date: result.publish_date } : {}), + })), + } +} diff --git a/packages/ai-parallel/src/index.ts b/packages/ai-parallel/src/index.ts new file mode 100644 index 0000000000..080d42098b --- /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.ts b/packages/ai-parallel/src/tool.ts new file mode 100644 index 0000000000..ed4060f7c2 --- /dev/null +++ b/packages/ai-parallel/src/tool.ts @@ -0,0 +1,109 @@ +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 + /** Existing search session to continue. Later searches reuse returned sessions. */ + 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: configuredSessionId, + ...clientConfig + } = config + + if ( + defaultMaxResults !== undefined && + (!Number.isInteger(defaultMaxResults) || defaultMaxResults < 1) + ) { + throw new Error('defaultMaxResults must be a positive integer.') + } + + let client: ParallelSearchClient | undefined + let sessionId = configuredSessionId + + 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 ? { objective } : {}), + ...(mode ? { mode } : {}), + ...(sessionId ? { session_id: sessionId } : {}), + ...(maxResults !== undefined || sourcePolicy + ? { + advanced_settings: { + ...(maxResults !== undefined + ? { max_results: maxResults } + : {}), + ...(sourcePolicy ? { source_policy: sourcePolicy } : {}), + }, + } + : {}), + }, + { signal: context?.abortSignal }, + ) + + sessionId = response.session_id + + 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 0000000000..edf6feee67 --- /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/client.test.ts b/packages/ai-parallel/tests/client.test.ts new file mode 100644 index 0000000000..a7cf1c437e --- /dev/null +++ b/packages/ai-parallel/tests/client.test.ts @@ -0,0 +1,219 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ParallelSearchClient } from '../src/client' +import { fetchCall, mockFetch, searchResponse } from './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 response = await client.search({ + search_queries: [' recent research '], + 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') + expect(fetchCall(fetchMock).init.headers).toEqual({ + 'x-api-key': 'test-key', + 'Content-Type': 'application/json', + Accept: 'application/json', + }) + 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('reads and trims PARALLEL_API_KEY when no explicit key is provided', async () => { + vi.stubEnv('PARALLEL_API_KEY', ' environment-key ') + const fetchMock = mockFetch(searchResponse()) + const client = new ParallelSearchClient({ fetch: fetchMock }) + + await client.search({ search_queries: ['news'] }) + + expect(fetchCall(fetchMock).init.headers).toMatchObject({ + 'x-api-key': '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(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.*Too Many Requests.*Rate limited/, + ) + }) + + 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('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') + expect(fetchCall(fetchMock).init.signal).toBe(controller.signal) + }) +}) diff --git a/packages/ai-parallel/tests/test-utils.ts b/packages/ai-parallel/tests/test-utils.ts new file mode 100644 index 0000000000..20c0db8c94 --- /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: typeof input === 'string' ? input : String(input), + init, + body: JSON.parse(String(init.body)) as Record, + } +} diff --git a/packages/ai-parallel/tests/tool.test.ts b/packages/ai-parallel/tests/tool.test.ts new file mode 100644 index 0000000000..f26167554b --- /dev/null +++ b/packages/ai-parallel/tests/tool.test.ts @@ -0,0 +1,146 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { parallelSearchTool } from '../src/tool' +import { fetchCall, mockFetch, searchResponse } from './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('reuses the returned session across later tool calls', 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_returned') + }) + + 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 }, + ) + + expect(fetchCall(fetchMock).init.signal).toBe(controller.signal) + }) + + 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/tsconfig.json b/packages/ai-parallel/tsconfig.json new file mode 100644 index 0000000000..c38689f4ea --- /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 0000000000..7ab9a04380 --- /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: ['tests/**/*.test.ts'], + }, + }), + tanstackViteConfig({ + entry: ['./src/index.ts'], + srcDir: './src', + cjs: false, + }), +) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 827b407241..c151d95ef9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2312,6 +2312,21 @@ importers: specifier: ^4.2.0 version: 4.3.6 + packages/ai-parallel: + devDependencies: + '@tanstack/ai': + specifier: workspace:* + version: link:../ai + '@vitest/coverage-v8': + specifier: 4.1.10 + version: 4.1.10(vitest@4.1.10) + 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': From f3842b6f51f205c74bd59f8d156a2fcffa999f59 Mon Sep 17 00:00:00 2001 From: George Pickett Date: Sat, 22 Aug 2026 19:53:58 -0700 Subject: [PATCH 2/5] fix(ai-parallel): keep search sessions explicitly scoped --- docs/adapters/parallel.md | 2 +- packages/ai-parallel/README.md | 2 +- packages/ai-parallel/package.json | 1 - packages/ai-parallel/src/client.ts | 22 ++++++++------------ packages/ai-parallel/src/tool.ts | 27 +++++++++---------------- packages/ai-parallel/tests/tool.test.ts | 18 +++++++++++++++-- pnpm-lock.yaml | 3 --- 7 files changed, 35 insertions(+), 40 deletions(-) diff --git a/docs/adapters/parallel.md b/docs/adapters/parallel.md index 1065679960..78b1bc29a1 100644 --- a/docs/adapters/parallel.md +++ b/docs/adapters/parallel.md @@ -55,7 +55,7 @@ const stream = chat({ }) ``` -The tool runs on the server and returns each source URL, relevant excerpts, title, and publication date. Later searches reuse the current Parallel session. +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 diff --git a/packages/ai-parallel/README.md b/packages/ai-parallel/README.md index 5771aad486..e1626f8e03 100644 --- a/packages/ai-parallel/README.md +++ b/packages/ai-parallel/README.md @@ -43,7 +43,7 @@ This package is available from the TanStack AI workspace. }) ``` -The tool returns source URLs, relevant excerpts, titles, and publication dates. Consecutive searches reuse the same Parallel session. +The tool returns source URLs, relevant excerpts, titles, and publication dates. Set `sessionId` explicitly when searches should share a Parallel session. ## Restrict sources diff --git a/packages/ai-parallel/package.json b/packages/ai-parallel/package.json index 4cbdd025d1..123f75e4b5 100644 --- a/packages/ai-parallel/package.json +++ b/packages/ai-parallel/package.json @@ -51,7 +51,6 @@ ], "devDependencies": { "@tanstack/ai": "workspace:*", - "@vitest/coverage-v8": "4.1.10", "vite": "^8.2.1", "zod": "^4.2.0" }, diff --git a/packages/ai-parallel/src/client.ts b/packages/ai-parallel/src/client.ts index e00e6eee36..cd8a8d2796 100644 --- a/packages/ai-parallel/src/client.ts +++ b/packages/ai-parallel/src/client.ts @@ -1,4 +1,6 @@ -export type ParallelSearchMode = 'turbo' | 'fast' | 'basic' | 'advanced' +const searchModes = ['turbo', 'fast', 'basic', 'advanced'] as const + +export type ParallelSearchMode = (typeof searchModes)[number] export interface ParallelSearchSourcePolicy { after_date?: string @@ -43,13 +45,6 @@ export interface ParallelSearchClientConfig { fetch?: typeof fetch } -const searchModes = new Set([ - 'turbo', - 'fast', - 'basic', - 'advanced', -]) - /** A small client for the generally available Parallel Search API. */ export class ParallelSearchClient { private readonly apiKey: string @@ -90,7 +85,7 @@ export class ParallelSearchClient { ) } - if (request.mode && !searchModes.has(request.mode)) { + if (request.mode && !searchModes.includes(request.mode)) { throw new Error('mode must be turbo, fast, basic, or advanced.') } @@ -103,11 +98,10 @@ export class ParallelSearchClient { } const sourcePolicy = request.advanced_settings?.source_policy - const domains = [ - ...(sourcePolicy?.include_domains ?? []), - ...(sourcePolicy?.exclude_domains ?? []), - ] - if (domains.length > 200) { + 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.', ) diff --git a/packages/ai-parallel/src/tool.ts b/packages/ai-parallel/src/tool.ts index ed4060f7c2..4a5be90366 100644 --- a/packages/ai-parallel/src/tool.ts +++ b/packages/ai-parallel/src/tool.ts @@ -45,7 +45,7 @@ export interface ParallelSearchToolConfig extends ParallelSearchClientConfig { defaultMaxResults?: number /** Application-controlled source restrictions for every search. */ sourcePolicy?: ParallelSearchSourcePolicy - /** Existing search session to continue. Later searches reuse returned sessions. */ + /** Explicit search session to use for every request from this tool. */ sessionId?: string } @@ -57,7 +57,7 @@ export function parallelSearchTool(config: ParallelSearchToolConfig = {}) { mode, defaultMaxResults, sourcePolicy, - sessionId: configuredSessionId, + sessionId, ...clientConfig } = config @@ -69,7 +69,6 @@ export function parallelSearchTool(config: ParallelSearchToolConfig = {}) { } let client: ParallelSearchClient | undefined - let sessionId = configuredSessionId return toolDefinition({ name: name ?? 'parallel_search', @@ -85,25 +84,17 @@ export function parallelSearchTool(config: ParallelSearchToolConfig = {}) { const response = await client.search( { search_queries: [query], - ...(objective ? { objective } : {}), - ...(mode ? { mode } : {}), - ...(sessionId ? { session_id: sessionId } : {}), - ...(maxResults !== undefined || sourcePolicy - ? { - advanced_settings: { - ...(maxResults !== undefined - ? { max_results: maxResults } - : {}), - ...(sourcePolicy ? { source_policy: sourcePolicy } : {}), - }, - } - : {}), + objective, + mode, + session_id: sessionId, + advanced_settings: + maxResults !== undefined || sourcePolicy + ? { max_results: maxResults, source_policy: sourcePolicy } + : undefined, }, { signal: context?.abortSignal }, ) - sessionId = response.session_id - return { results: response.results } }) } diff --git a/packages/ai-parallel/tests/tool.test.ts b/packages/ai-parallel/tests/tool.test.ts index f26167554b..ea3b4d0435 100644 --- a/packages/ai-parallel/tests/tool.test.ts +++ b/packages/ai-parallel/tests/tool.test.ts @@ -94,7 +94,21 @@ describe('parallelSearchTool', () => { }) }) - it('reuses the returned session across later tool calls', async () => { + 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', @@ -106,7 +120,7 @@ describe('parallelSearchTool', () => { 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_returned') + expect(fetchCall(fetchMock, 1).body.session_id).toBe('session_existing') }) it('forwards the chat cancellation signal to Parallel', async () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c151d95ef9..25e6e118e7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2317,9 +2317,6 @@ importers: '@tanstack/ai': specifier: workspace:* version: link:../ai - '@vitest/coverage-v8': - specifier: 4.1.10 - version: 4.1.10(vitest@4.1.10) 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) From 1e0835e1a4af4aba3cdfd9a449c8ad2d4cb2b8fd Mon Sep 17 00:00:00 2001 From: George Pickett Date: Sun, 23 Aug 2026 20:00:56 -0700 Subject: [PATCH 3/5] fix(ai-parallel): adopt the official Parallel SDK --- docs/config.json | 2 +- packages/ai-parallel/package.json | 3 + .../ai-parallel/{tests => src}/client.test.ts | 60 +++++-- packages/ai-parallel/src/client.ts | 156 +++++++----------- .../ai-parallel/{tests => src}/tool.test.ts | 23 +-- packages/ai-parallel/tests/test-utils.ts | 2 +- packages/ai-parallel/vite.config.ts | 2 +- pnpm-lock.yaml | 9 + 8 files changed, 131 insertions(+), 126 deletions(-) rename packages/ai-parallel/{tests => src}/client.test.ts (76%) rename packages/ai-parallel/{tests => src}/tool.test.ts (85%) diff --git a/docs/config.json b/docs/config.json index c208f04f42..12d222663f 100644 --- a/docs/config.json +++ b/docs/config.json @@ -980,7 +980,7 @@ { "label": "Parallel Search", "to": "adapters/parallel", - "addedAt": "2026-08-22" + "addedAt": "2026-08-23" }, { "label": "Vercel AI Gateway", diff --git a/packages/ai-parallel/package.json b/packages/ai-parallel/package.json index 123f75e4b5..c84290b525 100644 --- a/packages/ai-parallel/package.json +++ b/packages/ai-parallel/package.json @@ -49,6 +49,9 @@ "search", "web-search" ], + "dependencies": { + "parallel-web": "^1.3.0" + }, "devDependencies": { "@tanstack/ai": "workspace:*", "vite": "^8.2.1", diff --git a/packages/ai-parallel/tests/client.test.ts b/packages/ai-parallel/src/client.test.ts similarity index 76% rename from packages/ai-parallel/tests/client.test.ts rename to packages/ai-parallel/src/client.test.ts index a7cf1c437e..95d4eef2aa 100644 --- a/packages/ai-parallel/tests/client.test.ts +++ b/packages/ai-parallel/src/client.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { ParallelSearchClient } from '../src/client' -import { fetchCall, mockFetch, searchResponse } from './test-utils' +import { ParallelSearchClient } from './client' +import { fetchCall, mockFetch, searchResponse } from '../tests/test-utils' afterEach(() => { vi.unstubAllEnvs() @@ -23,9 +23,10 @@ describe('ParallelSearchClient', () => { apiKey: 'test-key', fetch: fetchMock, }) + const searchQueries = [' recent research '] as const const response = await client.search({ - search_queries: [' recent research '], + search_queries: searchQueries, objective: 'Find current research.', mode: 'fast', advanced_settings: { @@ -35,11 +36,11 @@ describe('ParallelSearchClient', () => { }) expect(fetchCall(fetchMock).url).toBe('https://api.parallel.ai/v1/search') - expect(fetchCall(fetchMock).init.headers).toEqual({ - 'x-api-key': 'test-key', - 'Content-Type': 'application/json', - Accept: 'application/json', - }) + 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.', @@ -61,16 +62,16 @@ describe('ParallelSearchClient', () => { ) }) - it('reads and trims PARALLEL_API_KEY when no explicit key is provided', async () => { + 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({ fetch: fetchMock }) + const client = new ParallelSearchClient({ apiKey: ' ', fetch: fetchMock }) await client.search({ search_queries: ['news'] }) - expect(fetchCall(fetchMock).init.headers).toMatchObject({ - 'x-api-key': 'environment-key', - }) + expect( + new Headers(fetchCall(fetchMock).init.headers).get('x-api-key'), + ).toBe('environment-key') }) it('rejects missing API keys without issuing a request', () => { @@ -80,6 +81,9 @@ describe('ParallelSearchClient', () => { expect(() => new ParallelSearchClient({ fetch: fetchMock })).toThrow( /PARALLEL_API_KEY/, ) + expect( + () => new ParallelSearchClient({ apiKey: ' ', fetch: fetchMock }), + ).toThrow(/PARALLEL_API_KEY/) expect(fetchMock).not.toHaveBeenCalled() }) @@ -157,8 +161,9 @@ describe('ParallelSearchClient', () => { }) await expect(client.search({ search_queries: ['news'] })).rejects.toThrow( - /429.*Too Many Requests.*Rate limited/, + /429.*Rate limited/, ) + expect(fetchMock).toHaveBeenCalledTimes(1) }) it('rejects malformed response payloads', async () => { @@ -199,6 +204,28 @@ describe('ParallelSearchClient', () => { ]) }) + 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('forwards cancellation and honors custom base URLs', async () => { const controller = new AbortController() const fetchMock = mockFetch(searchResponse()) @@ -214,6 +241,9 @@ describe('ParallelSearchClient', () => { ) expect(fetchCall(fetchMock).url).toBe('https://proxy.example.com/v1/search') - expect(fetchCall(fetchMock).init.signal).toBe(controller.signal) + 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 index cd8a8d2796..87e675c77d 100644 --- a/packages/ai-parallel/src/client.ts +++ b/packages/ai-parallel/src/client.ts @@ -1,38 +1,29 @@ +import Parallel from 'parallel-web' + const searchModes = ['turbo', 'fast', 'basic', 'advanced'] as const -export type ParallelSearchMode = (typeof searchModes)[number] +export type ParallelSearchMode = NonNullable -export interface ParallelSearchSourcePolicy { - after_date?: string - include_domains?: Array - exclude_domains?: Array -} +export type ParallelSearchSourcePolicy = Parallel.SourcePolicy -export interface ParallelSearchAdvancedSettings { - max_results?: number - source_policy?: ParallelSearchSourcePolicy - location?: string -} +export type ParallelSearchAdvancedSettings = Parallel.AdvancedSearchSettings -export interface ParallelSearchRequest { +export type ParallelSearchRequest = Omit< + Parallel.SearchParams, + 'search_queries' +> & { search_queries: ReadonlyArray - objective?: string - mode?: ParallelSearchMode - advanced_settings?: ParallelSearchAdvancedSettings - max_chars_total?: number - session_id?: string } -export interface ParallelSearchResult { - url: string - excerpts: Array +export type ParallelSearchResult = Omit< + Parallel.WebSearchResult, + 'title' | 'publish_date' +> & { title?: string publish_date?: string } -export interface ParallelSearchResponse { - search_id: string - session_id: string +export type ParallelSearchResponse = Omit & { results: Array } @@ -47,9 +38,7 @@ export interface ParallelSearchClientConfig { /** A small client for the generally available Parallel Search API. */ export class ParallelSearchClient { - private readonly apiKey: string - private readonly baseURL: string - private readonly fetchImpl: typeof fetch + private readonly client: Parallel constructor(config: ParallelSearchClientConfig = {}) { const environmentKey = @@ -62,12 +51,12 @@ export class ParallelSearchClient { ) } - this.apiKey = apiKey - this.baseURL = (config.baseURL ?? 'https://api.parallel.ai').replace( - /\/+$/, - '', - ) - this.fetchImpl = config.fetch ?? globalThis.fetch.bind(globalThis) + this.client = new Parallel({ + apiKey, + baseURL: config.baseURL, + fetch: config.fetch, + maxRetries: 0, + }) } async search( @@ -91,7 +80,7 @@ export class ParallelSearchClient { const maxResults = request.advanced_settings?.max_results if ( - maxResults !== undefined && + maxResults != null && (!Number.isInteger(maxResults) || maxResults < 1) ) { throw new Error('max_results must be a positive integer.') @@ -107,79 +96,50 @@ export class ParallelSearchClient { ) } - const response = await this.fetchImpl(`${this.baseURL}/v1/search`, { - method: 'POST', - headers: { - 'x-api-key': this.apiKey, - 'Content-Type': 'application/json', - Accept: 'application/json', - }, - body: JSON.stringify({ - ...request, - search_queries: searchQueries, - }), - signal: options.signal, - }) - - if (!response.ok) { - let details = '' - try { - details = await response.text() - } catch { - // Preserve the HTTP status when the response body cannot be read. - } - throw new Error( - `Parallel Search API request failed: ${response.status} ${response.statusText}${ - details ? `: ${details}` : '' - }`, - ) - } - - return parseSearchResponse(await response.json()) + return normalizeSearchResponse( + await this.client.search( + { + ...request, + search_queries: searchQueries, + }, + options, + ), + ) } } -function isSearchResult(value: unknown): value is ParallelSearchResult { - return ( - typeof value === 'object' && - value !== null && - 'url' in value && - typeof value.url === 'string' && - 'excerpts' in value && - Array.isArray(value.excerpts) && - value.excerpts.every((excerpt) => typeof excerpt === 'string') && - (!('title' in value) || - value.title === null || - typeof value.title === 'string') && - (!('publish_date' in value) || - value.publish_date === null || - typeof value.publish_date === 'string') - ) -} - -function parseSearchResponse(value: unknown): ParallelSearchResponse { +/** Preserve TanStack's non-null citation contract without discarding SDK metadata. */ +function normalizeSearchResponse( + response: Parallel.SearchResult, +): ParallelSearchResponse { if ( - typeof value !== 'object' || - value === null || - !('search_id' in value) || - typeof value.search_id !== 'string' || - !('session_id' in value) || - typeof value.session_id !== 'string' || - !('results' in value) || - !Array.isArray(value.results) || - !value.results.every(isSearchResult) + 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 { - search_id: value.search_id, - session_id: value.session_id, - results: value.results.map((result) => ({ - url: result.url, - excerpts: result.excerpts, - ...(result.title ? { title: result.title } : {}), - ...(result.publish_date ? { publish_date: result.publish_date } : {}), - })), + ...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/tests/tool.test.ts b/packages/ai-parallel/src/tool.test.ts similarity index 85% rename from packages/ai-parallel/tests/tool.test.ts rename to packages/ai-parallel/src/tool.test.ts index ea3b4d0435..264475d4e5 100644 --- a/packages/ai-parallel/tests/tool.test.ts +++ b/packages/ai-parallel/src/tool.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { parallelSearchTool } from '../src/tool' -import { fetchCall, mockFetch, searchResponse } from './test-utils' +import { parallelSearchTool } from './tool' +import { fetchCall, mockFetch, searchResponse } from '../tests/test-utils' const context = { emitCustomEvent: () => {}, @@ -47,7 +47,7 @@ describe('parallelSearchTool', () => { }, }) - const results = await tool.execute!( + const results = await tool.execute?.( { query: 'recent AI research', objective: 'Find recent primary sources.', @@ -87,7 +87,7 @@ describe('parallelSearchTool', () => { defaultMaxResults: 5, }) - await tool.execute!({ query: 'news', max_results: 2 }, context) + await tool.execute?.({ query: 'news', max_results: 2 }, context) expect(fetchCall(fetchMock).body.advanced_settings).toEqual({ max_results: 2, @@ -101,8 +101,8 @@ describe('parallelSearchTool', () => { fetch: fetchMock, }) - await tool.execute!({ query: 'first search' }, context) - await tool.execute!({ query: 'second search' }, context) + 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') @@ -116,8 +116,8 @@ describe('parallelSearchTool', () => { sessionId: 'session_existing', }) - await tool.execute!({ query: 'first search' }, context) - await tool.execute!({ query: 'second search' }, context) + 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') @@ -131,12 +131,15 @@ describe('parallelSearchTool', () => { fetch: fetchMock, }) - await tool.execute!( + await tool.execute?.( { query: 'news' }, { ...context, abortSignal: controller.signal }, ) - expect(fetchCall(fetchMock).init.signal).toBe(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', () => { diff --git a/packages/ai-parallel/tests/test-utils.ts b/packages/ai-parallel/tests/test-utils.ts index 20c0db8c94..5719eed77c 100644 --- a/packages/ai-parallel/tests/test-utils.ts +++ b/packages/ai-parallel/tests/test-utils.ts @@ -32,7 +32,7 @@ export function fetchCall(fetchMock: ReturnType, index = 0) { } return { - url: typeof input === 'string' ? input : String(input), + url: input instanceof Request ? input.url : String(input), init, body: JSON.parse(String(init.body)) as Record, } diff --git a/packages/ai-parallel/vite.config.ts b/packages/ai-parallel/vite.config.ts index 7ab9a04380..8989356380 100644 --- a/packages/ai-parallel/vite.config.ts +++ b/packages/ai-parallel/vite.config.ts @@ -10,7 +10,7 @@ export default mergeConfig( watch: false, globals: true, environment: 'node', - include: ['tests/**/*.test.ts'], + include: ['src/**/*.test.ts', 'tests/**/*.test.ts'], }, }), tanstackViteConfig({ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 25e6e118e7..91ab4d7e19 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2313,6 +2313,10 @@ importers: version: 4.3.6 packages/ai-parallel: + dependencies: + parallel-web: + specifier: ^1.3.0 + version: 1.3.0 devDependencies: '@tanstack/ai': specifier: workspace:* @@ -14382,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'} @@ -30831,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 From d9704b37b343531781f3f4347f4a37befb22e900 Mon Sep 17 00:00:00 2001 From: George Pickett Date: Mon, 24 Aug 2026 10:51:46 -0700 Subject: [PATCH 4/5] fix(ai-parallel): honor cancellation while parsing search responses --- docs/adapters/parallel.md | 2 + packages/ai-parallel/src/client.test.ts | 61 +++++++++++++++++++++++++ packages/ai-parallel/src/client.ts | 31 +++++++++---- 3 files changed, 86 insertions(+), 8 deletions(-) diff --git a/docs/adapters/parallel.md b/docs/adapters/parallel.md index 78b1bc29a1..fe5ee57394 100644 --- a/docs/adapters/parallel.md +++ b/docs/adapters/parallel.md @@ -62,6 +62,8 @@ The tool runs on the server and returns each source URL, relevant excerpts, titl Configure source restrictions when you create the tool: ```ts +import { parallelSearchTool } from '@tanstack/ai-parallel' + const search = parallelSearchTool({ mode: 'basic', defaultMaxResults: 3, diff --git a/packages/ai-parallel/src/client.test.ts b/packages/ai-parallel/src/client.test.ts index 95d4eef2aa..b4d33fa4e4 100644 --- a/packages/ai-parallel/src/client.test.ts +++ b/packages/ai-parallel/src/client.test.ts @@ -226,6 +226,67 @@ describe('ParallelSearchClient', () => { ) }) + 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()) diff --git a/packages/ai-parallel/src/client.ts b/packages/ai-parallel/src/client.ts index 87e675c77d..9635235ae8 100644 --- a/packages/ai-parallel/src/client.ts +++ b/packages/ai-parallel/src/client.ts @@ -96,15 +96,30 @@ export class ParallelSearchClient { ) } - return normalizeSearchResponse( - await this.client.search( - { - ...request, - search_queries: searchQueries, - }, - options, - ), + 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) } } From 978afc69a743eaca98059935169c4ecb9c52f2f4 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 26 Aug 2026 21:26:35 +0000 Subject: [PATCH 5/5] ci: apply automated fixes --- scripts/lovable-gateway.models.json | 387 ++++++---------------------- 1 file changed, 82 insertions(+), 305 deletions(-) diff --git a/scripts/lovable-gateway.models.json b/scripts/lovable-gateway.models.json index 8a193376e4..79c6babbc9 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": {