diff --git a/.changeset/add-ai-utils-package.md b/.changeset/add-ai-utils-package.md new file mode 100644 index 000000000..c5cdcddee --- /dev/null +++ b/.changeset/add-ai-utils-package.md @@ -0,0 +1,5 @@ +--- +'@tanstack/ai-utils': minor +--- + +New package: shared provider-agnostic utilities for TanStack AI adapters. Includes `generateId`, `getApiKeyFromEnv`, `transformNullsToUndefined`, and `ModelMeta` types with `defineModelMeta` validation helper. Zero runtime dependencies. diff --git a/.changeset/add-openai-base-package.md b/.changeset/add-openai-base-package.md new file mode 100644 index 000000000..b549fe322 --- /dev/null +++ b/.changeset/add-openai-base-package.md @@ -0,0 +1,5 @@ +--- +'@tanstack/openai-base': minor +--- + +New package: shared base adapters and utilities for OpenAI-compatible providers. Includes Chat Completions and Responses API text adapter base classes, image/summarize/transcription/TTS/video adapter base classes, schema converter, 15 tool converters, and shared types. Providers extend these base classes to reduce duplication and ensure consistent behavior. diff --git a/.changeset/refactor-providers-to-shared-packages.md b/.changeset/refactor-providers-to-shared-packages.md new file mode 100644 index 000000000..fdbaac062 --- /dev/null +++ b/.changeset/refactor-providers-to-shared-packages.md @@ -0,0 +1,13 @@ +--- +'@tanstack/ai-openai': patch +'@tanstack/ai-grok': patch +'@tanstack/ai-groq': patch +'@tanstack/ai-openrouter': patch +'@tanstack/ai-ollama': patch +'@tanstack/ai-anthropic': patch +'@tanstack/ai-gemini': patch +'@tanstack/ai-fal': patch +'@tanstack/ai-elevenlabs': patch +--- + +Internal refactor: every provider now delegates `getApiKeyFromEnv` / `generateId` / `transformNullsToUndefined` / `ModelMeta` helpers to the new `@tanstack/ai-utils` package. `ai-openai` and `ai-grok` additionally inherit OpenAI-compatible adapter base classes (Chat Completions / Responses text, image, summarize, transcription, TTS, video) from the new `@tanstack/openai-base` package; `ai-groq` keeps its own `BaseTextAdapter`-derived text adapter (Groq uses the `groq-sdk`, not the OpenAI SDK) but consumes `@tanstack/openai-base`'s schema converter and tool converters. The remaining providers (`ai-anthropic`, `ai-gemini`, `ai-ollama`, `ai-openrouter`, `ai-fal`, `ai-elevenlabs`) only consume `@tanstack/ai-utils` because they speak provider-native protocols, not OpenAI-compatible ones. No breaking changes — all public APIs remain identical. diff --git a/examples/ts-react-chat/src/lib/model-selection.ts b/examples/ts-react-chat/src/lib/model-selection.ts index 95c122cd4..01f6a1304 100644 --- a/examples/ts-react-chat/src/lib/model-selection.ts +++ b/examples/ts-react-chat/src/lib/model-selection.ts @@ -15,37 +15,48 @@ export interface ModelOption { export const MODEL_OPTIONS: Array = [ // OpenAI + { provider: 'openai', model: 'gpt-5.2', label: 'OpenAI - GPT-5.2' }, + { provider: 'openai', model: 'gpt-5.2-pro', label: 'OpenAI - GPT-5.2 Pro' }, + { provider: 'openai', model: 'gpt-5.1', label: 'OpenAI - GPT-5.1' }, + { provider: 'openai', model: 'gpt-5', label: 'OpenAI - GPT-5' }, + { provider: 'openai', model: 'gpt-5-mini', label: 'OpenAI - GPT-5 Mini' }, + { provider: 'openai', model: 'gpt-5-nano', label: 'OpenAI - GPT-5 Nano' }, + { provider: 'openai', model: 'gpt-4.1', label: 'OpenAI - GPT-4.1' }, { provider: 'openai', model: 'gpt-4o', label: 'OpenAI - GPT-4o' }, { provider: 'openai', model: 'gpt-4o-mini', label: 'OpenAI - GPT-4o Mini' }, - { provider: 'openai', model: 'gpt-5', label: 'OpenAI - GPT-5' }, // Anthropic { provider: 'anthropic', - model: 'claude-sonnet-4-6', - label: 'Anthropic - Claude Sonnet 4.6', + model: 'claude-opus-4-7', + label: 'Anthropic - Claude Opus 4.7', }, { provider: 'anthropic', - model: 'claude-sonnet-4-5-20250929', - label: 'Anthropic - Claude Sonnet 4.5', + model: 'claude-opus-4-6', + label: 'Anthropic - Claude Opus 4.6', }, { provider: 'anthropic', - model: 'claude-opus-4-5-20251101', - label: 'Anthropic - Claude Opus 4.5', + model: 'claude-sonnet-4-6', + label: 'Anthropic - Claude Sonnet 4.6', + }, + { + provider: 'anthropic', + model: 'claude-sonnet-4-5', + label: 'Anthropic - Claude Sonnet 4.5', }, { provider: 'anthropic', - model: 'claude-haiku-4-0-20250514', - label: 'Anthropic - Claude Haiku 4.0', + model: 'claude-haiku-4-5', + label: 'Anthropic - Claude Haiku 4.5', }, // Gemini { provider: 'gemini', - model: 'gemini-2.0-flash', - label: 'Gemini - 2.0 Flash', + model: 'gemini-2.5-pro', + label: 'Gemini - 2.5 Pro', }, { provider: 'gemini', @@ -54,20 +65,60 @@ export const MODEL_OPTIONS: Array = [ }, { provider: 'gemini', - model: 'gemini-2.5-pro', - label: 'Gemini - 2.5 Pro', + model: 'gemini-2.0-flash', + label: 'Gemini - 2.0 Flash', }, - // Openrouter + // Openrouter — multi-provider via OpenRouter's unified API + { + provider: 'openrouter', + model: 'openai/gpt-5.2', + label: 'OpenRouter - OpenAI GPT-5.2', + }, + { + provider: 'openrouter', + model: 'openai/gpt-5.1', + label: 'OpenRouter - OpenAI GPT-5.1', + }, + { + provider: 'openrouter', + model: 'openai/gpt-5', + label: 'OpenRouter - OpenAI GPT-5', + }, + { + provider: 'openrouter', + model: 'openai/gpt-4o', + label: 'OpenRouter - OpenAI GPT-4o', + }, + { + provider: 'openrouter', + model: 'anthropic/claude-opus-4.7', + label: 'OpenRouter - Anthropic Claude Opus 4.7', + }, + { + provider: 'openrouter', + model: 'anthropic/claude-sonnet-4.6', + label: 'OpenRouter - Anthropic Claude Sonnet 4.6', + }, + { + provider: 'openrouter', + model: 'anthropic/claude-haiku-4.5', + label: 'OpenRouter - Anthropic Claude Haiku 4.5', + }, + { + provider: 'openrouter', + model: 'google/gemini-2.5-pro', + label: 'OpenRouter - Google Gemini 2.5 Pro', + }, { provider: 'openrouter', - model: 'openai/chatgpt-4o-latest', - label: 'Openrouter - ChatGPT 4o Latest', + model: 'x-ai/grok-4', + label: 'OpenRouter - xAI Grok 4', }, { provider: 'openrouter', - model: 'openai/chatgpt-4o-mini', - label: 'Openrouter - ChatGPT 4o Mini', + model: 'meta-llama/llama-3.3-70b-instruct', + label: 'OpenRouter - Meta Llama 3.3 70B (Groq-routed)', }, // Ollama diff --git a/packages/typescript/ai-anthropic/package.json b/packages/typescript/ai-anthropic/package.json index 546ca553a..ae254d8b3 100644 --- a/packages/typescript/ai-anthropic/package.json +++ b/packages/typescript/ai-anthropic/package.json @@ -44,7 +44,8 @@ "test:types": "tsc" }, "dependencies": { - "@anthropic-ai/sdk": "^0.71.2" + "@anthropic-ai/sdk": "^0.71.2", + "@tanstack/ai-utils": "workspace:*" }, "peerDependencies": { "@tanstack/ai": "workspace:^", diff --git a/packages/typescript/ai-anthropic/src/utils/client.ts b/packages/typescript/ai-anthropic/src/utils/client.ts index e42c1255f..d07d2b2af 100644 --- a/packages/typescript/ai-anthropic/src/utils/client.ts +++ b/packages/typescript/ai-anthropic/src/utils/client.ts @@ -1,4 +1,5 @@ import Anthropic_SDK from '@anthropic-ai/sdk' +import { generateId as _generateId, getApiKeyFromEnv } from '@tanstack/ai-utils' import type { ClientOptions } from '@anthropic-ai/sdk' export interface AnthropicClientConfig extends ClientOptions { @@ -22,26 +23,12 @@ export function createAnthropicClient( * @throws Error if ANTHROPIC_API_KEY is not found */ export function getAnthropicApiKeyFromEnv(): string { - const env = - typeof globalThis !== 'undefined' && (globalThis as any).window?.env - ? (globalThis as any).window.env - : typeof process !== 'undefined' - ? process.env - : undefined - const key = env?.ANTHROPIC_API_KEY - - if (!key) { - throw new Error( - 'ANTHROPIC_API_KEY is required. Please set it in your environment variables or use the factory function with an explicit API key.', - ) - } - - return key + return getApiKeyFromEnv('ANTHROPIC_API_KEY') } /** * Generates a unique ID with a prefix */ export function generateId(prefix: string): string { - return `${prefix}-${Date.now()}-${Math.random().toString(36).substring(7)}` + return _generateId(prefix) } diff --git a/packages/typescript/ai-elevenlabs/package.json b/packages/typescript/ai-elevenlabs/package.json index 9563f29b9..55f909f58 100644 --- a/packages/typescript/ai-elevenlabs/package.json +++ b/packages/typescript/ai-elevenlabs/package.json @@ -49,7 +49,8 @@ }, "dependencies": { "@elevenlabs/client": "^1.3.1", - "@elevenlabs/elevenlabs-js": "^2.44.0" + "@elevenlabs/elevenlabs-js": "^2.44.0", + "@tanstack/ai-utils": "workspace:*" }, "peerDependencies": { "@tanstack/ai": "workspace:^", diff --git a/packages/typescript/ai-elevenlabs/src/utils/client.ts b/packages/typescript/ai-elevenlabs/src/utils/client.ts index 252d3713e..42182c01a 100644 --- a/packages/typescript/ai-elevenlabs/src/utils/client.ts +++ b/packages/typescript/ai-elevenlabs/src/utils/client.ts @@ -1,4 +1,8 @@ import { ElevenLabsClient } from '@elevenlabs/elevenlabs-js' +import { + getApiKeyFromEnv, + generateId as sharedGenerateId, +} from '@tanstack/ai-utils' import type { ElevenLabsOutputFormat } from '../model-meta' /** @@ -39,14 +43,7 @@ function getEnvironment(): EnvObject | undefined { } export function getElevenLabsApiKeyFromEnv(): string { - const key = getEnvironment()?.ELEVENLABS_API_KEY - if (!key) { - throw new Error( - 'ELEVENLABS_API_KEY is required. Please set it in your environment ' + - 'variables or pass it explicitly to the adapter factory.', - ) - } - return key + return getApiKeyFromEnv('ELEVENLABS_API_KEY') } export function getElevenLabsAgentIdFromEnv(): string { @@ -80,9 +77,9 @@ export function createElevenLabsClient( }) } -export function generateId(prefix: string): string { - return `${prefix}-${Date.now()}-${Math.random().toString(36).substring(2)}` -} +// Re-exported from `@tanstack/ai-utils` so existing callers keep working +// while the implementation stays deduped across provider packages. +export const generateId = sharedGenerateId /** * Convert an ArrayBuffer to base64 in a cross-runtime way. diff --git a/packages/typescript/ai-fal/package.json b/packages/typescript/ai-fal/package.json index 097d3a4e0..91b977a0c 100644 --- a/packages/typescript/ai-fal/package.json +++ b/packages/typescript/ai-fal/package.json @@ -46,7 +46,8 @@ "transcription" ], "dependencies": { - "@fal-ai/client": "^1.9.4" + "@fal-ai/client": "^1.9.4", + "@tanstack/ai-utils": "workspace:*" }, "devDependencies": { "@tanstack/ai": "workspace:*", diff --git a/packages/typescript/ai-fal/src/utils/client.ts b/packages/typescript/ai-fal/src/utils/client.ts index c39a96bdd..1ac43063e 100644 --- a/packages/typescript/ai-fal/src/utils/client.ts +++ b/packages/typescript/ai-fal/src/utils/client.ts @@ -1,42 +1,13 @@ import { fal } from '@fal-ai/client' +import { generateId as _generateId, getApiKeyFromEnv } from '@tanstack/ai-utils' export interface FalClientConfig { apiKey: string proxyUrl?: string } -interface EnvObject { - FAL_KEY?: string -} - -interface WindowWithEnv { - env?: EnvObject -} - -function getEnvironment(): EnvObject | undefined { - if (typeof globalThis !== 'undefined') { - const win = (globalThis as { window?: WindowWithEnv }).window - if (win?.env) { - return win.env - } - } - if (typeof process !== 'undefined') { - return process.env as EnvObject - } - return undefined -} - export function getFalApiKeyFromEnv(): string { - const env = getEnvironment() - const key = env?.FAL_KEY - - if (!key) { - throw new Error( - 'FAL_KEY is required. Please set it in your environment variables or use the factory function with an explicit API key.', - ) - } - - return key + return getApiKeyFromEnv('FAL_KEY') } export function configureFalClient(config?: FalClientConfig): void { @@ -48,7 +19,7 @@ export function configureFalClient(config?: FalClientConfig): void { } export function generateId(prefix: string): string { - return `${prefix}-${Date.now()}-${Math.random().toString(36).substring(2)}` + return _generateId(prefix) } /** diff --git a/packages/typescript/ai-gemini/package.json b/packages/typescript/ai-gemini/package.json index 565f89104..6ea980877 100644 --- a/packages/typescript/ai-gemini/package.json +++ b/packages/typescript/ai-gemini/package.json @@ -44,7 +44,8 @@ "adapter" ], "dependencies": { - "@google/genai": "^1.43.0" + "@google/genai": "^1.43.0", + "@tanstack/ai-utils": "workspace:*" }, "peerDependencies": { "@tanstack/ai": "workspace:^" diff --git a/packages/typescript/ai-gemini/src/utils/client.ts b/packages/typescript/ai-gemini/src/utils/client.ts index bb92293d7..fb7ccb6c0 100644 --- a/packages/typescript/ai-gemini/src/utils/client.ts +++ b/packages/typescript/ai-gemini/src/utils/client.ts @@ -1,4 +1,5 @@ import { GoogleGenAI } from '@google/genai' +import { generateId as _generateId, getApiKeyFromEnv } from '@tanstack/ai-utils' import type { GoogleGenAIOptions } from '@google/genai' export interface GeminiClientConfig extends GoogleGenAIOptions { @@ -20,26 +21,22 @@ export function createGeminiClient(config: GeminiClientConfig): GoogleGenAI { * @throws Error if GOOGLE_API_KEY or GEMINI_API_KEY is not found */ export function getGeminiApiKeyFromEnv(): string { - const env = - typeof globalThis !== 'undefined' && (globalThis as any).window?.env - ? (globalThis as any).window.env - : typeof process !== 'undefined' - ? process.env - : undefined - const key = env?.GOOGLE_API_KEY || env?.GEMINI_API_KEY - - if (!key) { - throw new Error( - 'GOOGLE_API_KEY or GEMINI_API_KEY is required. Please set it in your environment variables or use the factory function with an explicit API key.', - ) + try { + return getApiKeyFromEnv('GOOGLE_API_KEY') + } catch { + try { + return getApiKeyFromEnv('GEMINI_API_KEY') + } catch { + throw new Error( + 'GOOGLE_API_KEY or GEMINI_API_KEY is not set. Please set one of these environment variables or pass the API key directly.', + ) + } } - - return key } /** * Generates a unique ID with a prefix */ export function generateId(prefix: string): string { - return `${prefix}-${Date.now()}-${Math.random().toString(36).substring(7)}` + return _generateId(prefix) } diff --git a/packages/typescript/ai-grok/package.json b/packages/typescript/ai-grok/package.json index 077b1f130..98378cac8 100644 --- a/packages/typescript/ai-grok/package.json +++ b/packages/typescript/ai-grok/package.json @@ -44,6 +44,8 @@ "adapter" ], "dependencies": { + "@tanstack/ai-utils": "workspace:*", + "@tanstack/openai-base": "workspace:*", "openai": "^6.9.1" }, "devDependencies": { diff --git a/packages/typescript/ai-grok/src/adapters/image.ts b/packages/typescript/ai-grok/src/adapters/image.ts index 21e2e0048..53ef336b9 100644 --- a/packages/typescript/ai-grok/src/adapters/image.ts +++ b/packages/typescript/ai-grok/src/adapters/image.ts @@ -1,5 +1,5 @@ -import { BaseImageAdapter } from '@tanstack/ai/adapters' -import { createGrokClient, generateId, getGrokApiKeyFromEnv } from '../utils' +import { OpenAICompatibleImageAdapter } from '@tanstack/openai-base' +import { getGrokApiKeyFromEnv, withGrokDefaults } from '../utils/client' import { validateImageSize, validateNumberOfImages, @@ -11,12 +11,6 @@ import type { GrokImageModelSizeByName, GrokImageProviderOptions, } from '../image/image-provider-options' -import type { - GeneratedImage, - ImageGenerationOptions, - ImageGenerationResult, -} from '@tanstack/ai' -import type OpenAI_SDK from 'openai' import type { GrokClientConfig } from '../utils' /** @@ -37,7 +31,7 @@ export interface GrokImageConfig extends GrokClientConfig {} */ export class GrokImageAdapter< TModel extends GrokImageModel, -> extends BaseImageAdapter< +> extends OpenAICompatibleImageAdapter< TModel, GrokImageProviderOptions, GrokImageModelProviderOptionsByName, @@ -46,92 +40,29 @@ export class GrokImageAdapter< readonly kind = 'image' as const readonly name = 'grok' as const - private client: OpenAI_SDK - constructor(config: GrokImageConfig, model: TModel) { - super(model, {}) - this.client = createGrokClient(config) + super(withGrokDefaults(config), model, 'grok') } - async generateImages( - options: ImageGenerationOptions, - ): Promise { - const { model, prompt, numberOfImages, size, logger } = options - - logger.request(`activity=generateImage provider=grok model=${this.model}`, { - provider: 'grok', - model: this.model, - }) - - try { - // Validate inputs - validatePrompt({ prompt, model }) - validateImageSize(model, size) - validateNumberOfImages(model, numberOfImages) - - // Build request based on model type - const request = this.buildRequest(options) - - const response = await this.client.images.generate({ - ...request, - stream: false, - }) - - return this.transformResponse(model, response) - } catch (error) { - logger.errors('grok.generateImage fatal', { - error, - source: 'grok.generateImage', - }) - throw error - } + protected override validatePrompt(options: { + prompt: string + model: string + }): void { + validatePrompt(options) } - private buildRequest( - options: ImageGenerationOptions, - ): OpenAI_SDK.Images.ImageGenerateParams { - const { model, prompt, numberOfImages, size, modelOptions } = options - - // Spread modelOptions FIRST so explicit args (model, prompt, n, size) win - // and user-supplied modelOptions cannot silently override them. - return { - ...modelOptions, - model, - prompt, - n: numberOfImages ?? 1, - size: size as OpenAI_SDK.Images.ImageGenerateParams['size'], - } + protected override validateImageSize( + model: string, + size: string | undefined, + ): void { + validateImageSize(model, size) } - private transformResponse( + protected override validateNumberOfImages( model: string, - response: OpenAI_SDK.Images.ImagesResponse, - ): ImageGenerationResult { - const images: Array = (response.data ?? []).flatMap( - (item): Array => { - const revisedPrompt = item.revised_prompt - if (item.b64_json) { - return [{ b64Json: item.b64_json, revisedPrompt }] - } - if (item.url) { - return [{ url: item.url, revisedPrompt }] - } - return [] - }, - ) - - return { - id: generateId(this.name), - model, - images, - usage: response.usage - ? { - inputTokens: response.usage.input_tokens, - outputTokens: response.usage.output_tokens, - totalTokens: response.usage.total_tokens, - } - : undefined, - } + numberOfImages: number | undefined, + ): void { + validateNumberOfImages(model, numberOfImages) } } diff --git a/packages/typescript/ai-grok/src/adapters/summarize.ts b/packages/typescript/ai-grok/src/adapters/summarize.ts index eadaaf9e6..f13984bac 100644 --- a/packages/typescript/ai-grok/src/adapters/summarize.ts +++ b/packages/typescript/ai-grok/src/adapters/summarize.ts @@ -1,12 +1,8 @@ -import { BaseSummarizeAdapter } from '@tanstack/ai/adapters' +import { OpenAICompatibleSummarizeAdapter } from '@tanstack/openai-base' import { getGrokApiKeyFromEnv } from '../utils' import { GrokTextAdapter } from './text' +import type { ChatStreamCapable } from '@tanstack/openai-base' import type { GROK_CHAT_MODELS } from '../model-meta' -import type { - StreamChunk, - SummarizationOptions, - SummarizationResult, -} from '@tanstack/ai' import type { GrokClientConfig } from '../utils' /** @@ -35,125 +31,24 @@ export type GrokSummarizeModel = (typeof GROK_CHAT_MODELS)[number] */ export class GrokSummarizeAdapter< TModel extends GrokSummarizeModel, -> extends BaseSummarizeAdapter { +> extends OpenAICompatibleSummarizeAdapter< + TModel, + GrokSummarizeProviderOptions +> { readonly kind = 'summarize' as const readonly name = 'grok' as const - private textAdapter: GrokTextAdapter - constructor(config: GrokSummarizeConfig, model: TModel) { - super({}, model) - this.textAdapter = new GrokTextAdapter(config, model) - } - - async summarize(options: SummarizationOptions): Promise { - const { logger } = options - const systemPrompt = this.buildSummarizationPrompt(options) - - logger.request(`activity=summarize provider=grok`, { - provider: 'grok', - model: options.model, - }) - - // Use the text adapter's streaming and collect the result - let summary = '' - const id = '' - let model = options.model - let usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 } - - try { - for await (const chunk of this.textAdapter.chatStream({ - model: options.model, - messages: [{ role: 'user', content: options.text }], - systemPrompts: [systemPrompt], - maxTokens: options.maxLength, - temperature: 0.3, - logger, - })) { - // AG-UI TEXT_MESSAGE_CONTENT event - if (chunk.type === 'TEXT_MESSAGE_CONTENT') { - if (chunk.content) { - summary = chunk.content - } else { - summary += chunk.delta - } - model = chunk.model || model - } - // AG-UI RUN_FINISHED event - if (chunk.type === 'RUN_FINISHED') { - if (chunk.usage) { - usage = chunk.usage - } - } - } - } catch (error) { - logger.errors('grok.summarize fatal', { - error, - source: 'grok.summarize', - }) - throw error - } - - return { id, model, summary, usage } - } - - async *summarizeStream( - options: SummarizationOptions, - ): AsyncIterable { - const { logger } = options - const systemPrompt = this.buildSummarizationPrompt(options) - - logger.request(`activity=summarize provider=grok`, { - provider: 'grok', - model: options.model, - stream: true, - }) - - try { - // Delegate directly to the text adapter's streaming - yield* this.textAdapter.chatStream({ - model: options.model, - messages: [{ role: 'user', content: options.text }], - systemPrompts: [systemPrompt], - maxTokens: options.maxLength, - temperature: 0.3, - logger, - }) - } catch (error) { - logger.errors('grok.summarize fatal', { - error, - source: 'grok.summarize', - }) - throw error - } - } - - private buildSummarizationPrompt(options: SummarizationOptions): string { - let prompt = 'You are a professional summarizer. ' - - switch (options.style) { - case 'bullet-points': - prompt += 'Provide a summary in bullet point format. ' - break - case 'paragraph': - prompt += 'Provide a summary in paragraph format. ' - break - case 'concise': - prompt += 'Provide a very concise summary in 1-2 sentences. ' - break - default: - prompt += 'Provide a clear and concise summary. ' - } - - if (options.focus && options.focus.length > 0) { - prompt += `Focus on the following aspects: ${options.focus.join(', ')}. ` - } - - if (options.maxLength) { - prompt += `Keep the summary under ${options.maxLength} tokens. ` - } - - return prompt + // The text adapter accepts richer provider options than the summarize adapter needs, + // but we only pass basic options (model, messages, systemPrompts, etc.) at call time. + super( + new GrokTextAdapter( + config, + model, + ) as unknown as ChatStreamCapable, + model, + 'grok', + ) } } diff --git a/packages/typescript/ai-grok/src/adapters/text.ts b/packages/typescript/ai-grok/src/adapters/text.ts index e185c5ecf..c0c22e3c4 100644 --- a/packages/typescript/ai-grok/src/adapters/text.ts +++ b/packages/typescript/ai-grok/src/adapters/text.ts @@ -1,52 +1,23 @@ -import { BaseTextAdapter } from '@tanstack/ai/adapters' -import { validateTextProviderOptions } from '../text/text-provider-options' -import { convertToolsToProviderFormat } from '../tools' -import { - createGrokClient, - generateId, - getGrokApiKeyFromEnv, - makeGrokStructuredOutputCompatible, - transformNullsToUndefined, -} from '../utils' +import { OpenAICompatibleChatCompletionsTextAdapter } from '@tanstack/openai-base' +import { getGrokApiKeyFromEnv, withGrokDefaults } from '../utils/client' import type { GROK_CHAT_MODELS, GrokChatModelToolCapabilitiesByName, ResolveInputModalities, ResolveProviderOptions, } from '../model-meta' -import type { - StructuredOutputOptions, - StructuredOutputResult, -} from '@tanstack/ai/adapters' -import type { InternalLogger } from '@tanstack/ai/adapter-internals' -import type OpenAI_SDK from 'openai' -import type { - ContentPart, - Modality, - ModelMessage, - StreamChunk, - TextOptions, -} from '@tanstack/ai' -import type { - ExternalTextProviderOptions as GrokTextProviderOptions, - InternalTextProviderOptions, -} from '../text/text-provider-options' -import type { - GrokImageMetadata, - GrokMessageMetadataByModality, -} from '../message-types' +import type { Modality } from '@tanstack/ai' +import type { GrokMessageMetadataByModality } from '../message-types' import type { GrokClientConfig } from '../utils' +/** + * Resolve tool capabilities for a specific Grok model. + */ type ResolveToolCapabilities = TModel extends keyof GrokChatModelToolCapabilitiesByName ? NonNullable : readonly [] -/** Cast an event object to StreamChunk. Adapters construct events with string - * literal types which are structurally compatible with the EventType enum. */ -const asChunk = (chunk: Record) => - chunk as unknown as StreamChunk - /** * Configuration for Grok text adapter */ @@ -62,6 +33,10 @@ export type { ExternalTextProviderOptions as GrokTextProviderOptions } from '../ * * Tree-shakeable adapter for Grok chat/text completion functionality. * Uses OpenAI-compatible Chat Completions API (not Responses API). + * + * Delegates implementation to {@link OpenAICompatibleChatCompletionsTextAdapter} + * from `@tanstack/openai-base` and threads Grok-specific tool-capability typing + * through the 5th generic of the base class. */ export class GrokTextAdapter< TModel extends (typeof GROK_CHAT_MODELS)[number], @@ -70,7 +45,7 @@ export class GrokTextAdapter< ResolveInputModalities, TToolCapabilities extends ReadonlyArray = ResolveToolCapabilities, -> extends BaseTextAdapter< +> extends OpenAICompatibleChatCompletionsTextAdapter< TModel, TProviderOptions, TInputModalities, @@ -80,532 +55,8 @@ export class GrokTextAdapter< readonly kind = 'text' as const readonly name = 'grok' as const - private client: OpenAI_SDK - constructor(config: GrokTextConfig, model: TModel) { - super({}, model) - this.client = createGrokClient(config) - } - - async *chatStream( - options: TextOptions, - ): AsyncIterable { - const requestParams = this.mapTextOptionsToGrok(options) - const timestamp = Date.now() - const { logger } = options - - // AG-UI lifecycle tracking (mutable state object for ESLint compatibility) - const aguiState = { - runId: options.runId ?? generateId(this.name), - threadId: options.threadId ?? generateId(this.name), - messageId: generateId(this.name), - timestamp, - hasEmittedRunStarted: false, - } - - try { - logger.request( - `activity=chat provider=grok model=${this.model} messages=${options.messages.length} tools=${options.tools?.length ?? 0} stream=true`, - { provider: 'grok', model: this.model }, - ) - const stream = await this.client.chat.completions.create({ - ...requestParams, - stream: true, - }) - - yield* this.processGrokStreamChunks(stream, options, aguiState, logger) - } catch (error: unknown) { - const err = error as Error & { code?: string } - - // Emit RUN_STARTED if not yet emitted - if (!aguiState.hasEmittedRunStarted) { - aguiState.hasEmittedRunStarted = true - yield asChunk({ - type: 'RUN_STARTED', - runId: aguiState.runId, - threadId: aguiState.threadId, - model: options.model, - timestamp, - }) - } - - // Emit AG-UI RUN_ERROR - yield asChunk({ - type: 'RUN_ERROR', - runId: aguiState.runId, - model: options.model, - timestamp, - message: err.message || 'Unknown error', - code: err.code, - error: { - message: err.message || 'Unknown error', - code: err.code, - }, - }) - - logger.errors('grok.chatStream fatal', { - error, - source: 'grok.chatStream', - }) - } - } - - /** - * Generate structured output using Grok's JSON Schema response format. - * Uses stream: false to get the complete response in one call. - * - * Grok has strict requirements for structured output (via OpenAI-compatible API): - * - All properties must be in the `required` array - * - Optional fields should have null added to their type union - * - additionalProperties must be false for all objects - * - * The outputSchema is already JSON Schema (converted in the ai layer). - * We apply Grok-specific transformations for structured output compatibility. - */ - async structuredOutput( - options: StructuredOutputOptions, - ): Promise> { - const { chatOptions, outputSchema } = options - const requestParams = this.mapTextOptionsToGrok(chatOptions) - const { logger } = chatOptions - - // Apply Grok-specific transformations for structured output compatibility - const jsonSchema = makeGrokStructuredOutputCompatible( - outputSchema, - outputSchema.required || [], - ) - - try { - logger.request( - `activity=chat provider=grok model=${this.model} messages=${chatOptions.messages.length} tools=${chatOptions.tools?.length ?? 0} stream=false`, - { provider: 'grok', model: this.model }, - ) - const response = await this.client.chat.completions.create({ - ...requestParams, - stream: false, - response_format: { - type: 'json_schema', - json_schema: { - name: 'structured_output', - schema: jsonSchema, - strict: true, - }, - }, - }) - - // Extract text content from the response - const rawText = response.choices[0]?.message.content || '' - - // Parse the JSON response - let parsed: unknown - try { - parsed = JSON.parse(rawText) - } catch { - throw new Error( - `Failed to parse structured output as JSON. Content: ${rawText.slice(0, 200)}${rawText.length > 200 ? '...' : ''}`, - ) - } - - // Transform null values to undefined to match original Zod schema expectations - // Grok returns null for optional fields we made nullable in the schema - const transformed = transformNullsToUndefined(parsed) - - return { - data: transformed, - rawText, - } - } catch (error: unknown) { - logger.errors('grok.structuredOutput fatal', { - error, - source: 'grok.structuredOutput', - }) - throw error - } - } - - private async *processGrokStreamChunks( - stream: AsyncIterable, - options: TextOptions, - aguiState: { - runId: string - threadId: string - messageId: string - timestamp: number - hasEmittedRunStarted: boolean - }, - logger: InternalLogger, - ): AsyncIterable { - let accumulatedContent = '' - const timestamp = aguiState.timestamp - let hasEmittedTextMessageStart = false - - // Track tool calls being streamed (arguments come in chunks) - const toolCallsInProgress = new Map< - number, - { - id: string - name: string - arguments: string - started: boolean // Track if TOOL_CALL_START has been emitted - } - >() - - try { - for await (const chunk of stream) { - logger.provider(`provider=grok`, { chunk }) - const choice = chunk.choices[0] - - if (!choice) continue - - // Emit RUN_STARTED on first chunk - if (!aguiState.hasEmittedRunStarted) { - aguiState.hasEmittedRunStarted = true - yield asChunk({ - type: 'RUN_STARTED', - runId: aguiState.runId, - threadId: aguiState.threadId, - model: chunk.model || options.model, - timestamp, - }) - } - - const delta = choice.delta - const deltaContent = delta.content - const deltaToolCalls = delta.tool_calls - - // Handle content delta - if (deltaContent) { - // Emit TEXT_MESSAGE_START on first text content - if (!hasEmittedTextMessageStart) { - hasEmittedTextMessageStart = true - yield asChunk({ - type: 'TEXT_MESSAGE_START', - messageId: aguiState.messageId, - model: chunk.model || options.model, - timestamp, - role: 'assistant', - }) - } - - accumulatedContent += deltaContent - - // Emit AG-UI TEXT_MESSAGE_CONTENT - yield asChunk({ - type: 'TEXT_MESSAGE_CONTENT', - messageId: aguiState.messageId, - model: chunk.model || options.model, - timestamp, - delta: deltaContent, - content: accumulatedContent, - }) - } - - // Handle tool calls - they come in as deltas - if (deltaToolCalls) { - for (const toolCallDelta of deltaToolCalls) { - const index = toolCallDelta.index - - // Initialize or update the tool call in progress - if (!toolCallsInProgress.has(index)) { - toolCallsInProgress.set(index, { - id: toolCallDelta.id || '', - name: toolCallDelta.function?.name || '', - arguments: '', - started: false, - }) - } - - const toolCall = toolCallsInProgress.get(index)! - - // Update with any new data from the delta - if (toolCallDelta.id) { - toolCall.id = toolCallDelta.id - } - if (toolCallDelta.function?.name) { - toolCall.name = toolCallDelta.function.name - } - if (toolCallDelta.function?.arguments) { - toolCall.arguments += toolCallDelta.function.arguments - } - - // Emit TOOL_CALL_START when we have id and name - if (toolCall.id && toolCall.name && !toolCall.started) { - toolCall.started = true - yield asChunk({ - type: 'TOOL_CALL_START', - toolCallId: toolCall.id, - toolCallName: toolCall.name, - toolName: toolCall.name, - model: chunk.model || options.model, - timestamp, - index, - }) - } - - // Emit TOOL_CALL_ARGS for argument deltas - if (toolCallDelta.function?.arguments && toolCall.started) { - yield asChunk({ - type: 'TOOL_CALL_ARGS', - toolCallId: toolCall.id, - model: chunk.model || options.model, - timestamp, - delta: toolCallDelta.function.arguments, - }) - } - } - } - - // Handle finish reason - if (choice.finish_reason) { - // Emit all completed tool calls - if ( - choice.finish_reason === 'tool_calls' || - toolCallsInProgress.size > 0 - ) { - for (const [, toolCall] of toolCallsInProgress) { - // Parse arguments for TOOL_CALL_END - let parsedInput: unknown = {} - try { - parsedInput = toolCall.arguments - ? JSON.parse(toolCall.arguments) - : {} - } catch { - parsedInput = {} - } - - // Emit AG-UI TOOL_CALL_END - yield asChunk({ - type: 'TOOL_CALL_END', - toolCallId: toolCall.id, - toolCallName: toolCall.name, - toolName: toolCall.name, - model: chunk.model || options.model, - timestamp, - input: parsedInput, - }) - } - } - - const computedFinishReason = - choice.finish_reason === 'tool_calls' || - toolCallsInProgress.size > 0 - ? 'tool_calls' - : 'stop' - - // Emit TEXT_MESSAGE_END if we had text content - if (hasEmittedTextMessageStart) { - yield asChunk({ - type: 'TEXT_MESSAGE_END', - messageId: aguiState.messageId, - model: chunk.model || options.model, - timestamp, - }) - } - - // Emit AG-UI RUN_FINISHED - yield asChunk({ - type: 'RUN_FINISHED', - runId: aguiState.runId, - threadId: aguiState.threadId, - model: chunk.model || options.model, - timestamp, - usage: chunk.usage - ? { - promptTokens: chunk.usage.prompt_tokens || 0, - completionTokens: chunk.usage.completion_tokens || 0, - totalTokens: chunk.usage.total_tokens || 0, - } - : undefined, - finishReason: computedFinishReason, - }) - } - } - } catch (error: unknown) { - const err = error as Error & { code?: string } - logger.errors('grok stream ended with error', { - error, - source: 'grok.processGrokStreamChunks', - }) - - // Emit AG-UI RUN_ERROR - yield asChunk({ - type: 'RUN_ERROR', - runId: aguiState.runId, - model: options.model, - timestamp, - message: err.message || 'Unknown error occurred', - code: err.code, - error: { - message: err.message || 'Unknown error occurred', - code: err.code, - }, - }) - } - } - - /** - * Maps common options to Grok-specific Chat Completions format - */ - private mapTextOptionsToGrok( - options: TextOptions, - ): OpenAI_SDK.Chat.Completions.ChatCompletionCreateParamsStreaming { - const modelOptions = options.modelOptions as - | Omit< - InternalTextProviderOptions, - 'max_tokens' | 'tools' | 'temperature' | 'input' | 'top_p' - > - | undefined - - if (modelOptions) { - validateTextProviderOptions({ - ...modelOptions, - model: options.model, - }) - } - - const tools = options.tools - ? convertToolsToProviderFormat(options.tools) - : undefined - - // Build messages array with system prompts - const messages: Array = - [] - - // Add system prompts first - if (options.systemPrompts && options.systemPrompts.length > 0) { - messages.push({ - role: 'system', - content: options.systemPrompts.join('\n'), - }) - } - - // Convert messages - for (const message of options.messages) { - messages.push(this.convertMessageToGrok(message)) - } - - return { - model: options.model, - messages, - temperature: options.temperature, - max_tokens: options.maxTokens, - top_p: options.topP, - tools: tools as Array, - stream: true, - stream_options: { include_usage: true }, - } - } - - private convertMessageToGrok( - message: ModelMessage, - ): OpenAI_SDK.Chat.Completions.ChatCompletionMessageParam { - // Handle tool messages - if (message.role === 'tool') { - return { - role: 'tool', - tool_call_id: message.toolCallId || '', - content: - typeof message.content === 'string' - ? message.content - : JSON.stringify(message.content), - } - } - - // Handle assistant messages - if (message.role === 'assistant') { - const toolCalls = message.toolCalls?.map((tc) => ({ - id: tc.id, - type: 'function' as const, - function: { - name: tc.function.name, - arguments: - typeof tc.function.arguments === 'string' - ? tc.function.arguments - : JSON.stringify(tc.function.arguments), - }, - })) - - return { - role: 'assistant', - content: this.extractTextContent(message.content), - ...(toolCalls && toolCalls.length > 0 ? { tool_calls: toolCalls } : {}), - } - } - - // Handle user messages - support multimodal content - const contentParts = this.normalizeContent(message.content) - - // If only text, use simple string format - if (contentParts.length === 1 && contentParts[0]?.type === 'text') { - return { - role: 'user', - content: contentParts[0].content, - } - } - - // Otherwise, use array format for multimodal - const parts: Array = - [] - for (const part of contentParts) { - if (part.type === 'text') { - parts.push({ type: 'text', text: part.content }) - } else if (part.type === 'image') { - const imageMetadata = part.metadata as GrokImageMetadata | undefined - // For base64 data, construct a data URI using the mimeType from source - const imageValue = part.source.value - const imageUrl = - part.source.type === 'data' && !imageValue.startsWith('data:') - ? `data:${part.source.mimeType};base64,${imageValue}` - : imageValue - parts.push({ - type: 'image_url', - image_url: { - url: imageUrl, - detail: imageMetadata?.detail || 'auto', - }, - }) - } - } - - return { - role: 'user', - content: parts.length > 0 ? parts : '', - } - } - - /** - * Normalizes message content to an array of ContentPart. - * Handles backward compatibility with string content. - */ - private normalizeContent( - content: string | null | Array, - ): Array { - if (content === null) { - return [] - } - if (typeof content === 'string') { - return [{ type: 'text', content: content }] - } - return content - } - - /** - * Extracts text content from a content value that may be string, null, or ContentPart array. - */ - private extractTextContent( - content: string | null | Array, - ): string { - if (content === null) { - return '' - } - if (typeof content === 'string') { - return content - } - // It's an array of ContentPart - return content - .filter((p) => p.type === 'text') - .map((p) => p.content) - .join('') + super(withGrokDefaults(config), model, 'grok') } } diff --git a/packages/typescript/ai-grok/src/text/text-provider-options.ts b/packages/typescript/ai-grok/src/text/text-provider-options.ts index a05222ff1..c0e7480f7 100644 --- a/packages/typescript/ai-grok/src/text/text-provider-options.ts +++ b/packages/typescript/ai-grok/src/text/text-provider-options.ts @@ -1,5 +1,3 @@ -import type { FunctionTool } from '../tools/function-tool' - /** * Grok Text Provider Options * @@ -51,27 +49,7 @@ export interface GrokTextProviderOptions extends GrokBaseOptions { stop?: string | Array } -/** - * Internal options interface for validation - * Used internally by the adapter - */ -export interface InternalTextProviderOptions extends GrokTextProviderOptions { - model: string - stream?: boolean - tools?: Array -} - /** * External provider options (what users pass in) */ export type ExternalTextProviderOptions = GrokTextProviderOptions - -/** - * Validates text provider options - */ -export function validateTextProviderOptions( - _options: InternalTextProviderOptions, -): void { - // Basic validation can be added here if needed - // For now, Grok API will handle validation -} diff --git a/packages/typescript/ai-grok/src/tools/function-tool.ts b/packages/typescript/ai-grok/src/tools/function-tool.ts deleted file mode 100644 index 646fb8953..000000000 --- a/packages/typescript/ai-grok/src/tools/function-tool.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { makeGrokStructuredOutputCompatible } from '../utils/schema-converter' -import type { JSONSchema, Tool } from '@tanstack/ai' -import type OpenAI from 'openai' - -// Use Chat Completions API tool format (not Responses API) -export type FunctionTool = OpenAI.Chat.Completions.ChatCompletionTool - -/** - * Converts a standard Tool to Grok ChatCompletionTool format. - * - * Tool schemas are already converted to JSON Schema in the ai layer. - * We apply Grok-specific transformations for strict mode: - * - All properties in required array - * - Optional fields made nullable - * - additionalProperties: false - * - * This enables strict mode for all tools automatically. - */ -export function convertFunctionToolToAdapterFormat(tool: Tool): FunctionTool { - // Tool schemas are already converted to JSON Schema in the ai layer - // Apply Grok-specific transformations for strict mode - const inputSchema = (tool.inputSchema ?? { - type: 'object', - properties: {}, - required: [], - }) as JSONSchema - - const jsonSchema = makeGrokStructuredOutputCompatible( - inputSchema, - inputSchema.required || [], - ) - - // Ensure additionalProperties is false for strict mode - jsonSchema.additionalProperties = false - - return { - type: 'function', - function: { - name: tool.name, - description: tool.description, - parameters: jsonSchema, - strict: true, // Always use strict mode since our schema converter handles the requirements - }, - } satisfies FunctionTool -} diff --git a/packages/typescript/ai-grok/src/tools/index.ts b/packages/typescript/ai-grok/src/tools/index.ts index c90334153..95a570117 100644 --- a/packages/typescript/ai-grok/src/tools/index.ts +++ b/packages/typescript/ai-grok/src/tools/index.ts @@ -1,5 +1,5 @@ export { - convertFunctionToolToAdapterFormat, - type FunctionTool, -} from './function-tool' -export { convertToolsToProviderFormat } from './tool-converter' + type ChatCompletionFunctionTool as FunctionTool, + convertFunctionToolToChatCompletionsFormat as convertFunctionToolToAdapterFormat, + convertToolsToChatCompletionsFormat as convertToolsToProviderFormat, +} from '@tanstack/openai-base' diff --git a/packages/typescript/ai-grok/src/tools/tool-converter.ts b/packages/typescript/ai-grok/src/tools/tool-converter.ts deleted file mode 100644 index 969fdb72d..000000000 --- a/packages/typescript/ai-grok/src/tools/tool-converter.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { convertFunctionToolToAdapterFormat } from './function-tool' -import type { FunctionTool } from './function-tool' -import type { Tool } from '@tanstack/ai' - -/** - * Converts an array of standard Tools to Grok-specific format - * Grok uses OpenAI-compatible API, so we primarily support function tools - */ -export function convertToolsToProviderFormat( - tools: Array, -): Array { - return tools.map((tool) => { - // For Grok, all tools are converted as function tools - // Grok uses OpenAI-compatible API which primarily supports function tools - return convertFunctionToolToAdapterFormat(tool) - }) -} diff --git a/packages/typescript/ai-grok/src/utils/client.ts b/packages/typescript/ai-grok/src/utils/client.ts index 54f70eafe..890224592 100644 --- a/packages/typescript/ai-grok/src/utils/client.ts +++ b/packages/typescript/ai-grok/src/utils/client.ts @@ -1,46 +1,31 @@ -import OpenAI_SDK from 'openai' -import type { ClientOptions } from 'openai' +import { getApiKeyFromEnv } from '@tanstack/ai-utils' +import type { OpenAICompatibleClientConfig } from '@tanstack/openai-base' -export interface GrokClientConfig extends ClientOptions { - apiKey: string -} - -/** - * Creates a Grok SDK client instance using OpenAI SDK with xAI's base URL - */ -export function createGrokClient(config: GrokClientConfig): OpenAI_SDK { - return new OpenAI_SDK({ - ...config, - apiKey: config.apiKey, - baseURL: config.baseURL || 'https://api.x.ai/v1', - }) -} +export interface GrokClientConfig extends OpenAICompatibleClientConfig {} /** * Gets Grok API key from environment variables * @throws Error if XAI_API_KEY is not found */ export function getGrokApiKeyFromEnv(): string { - const env = - typeof globalThis !== 'undefined' && (globalThis as any).window?.env - ? (globalThis as any).window.env - : typeof process !== 'undefined' - ? process.env - : undefined - const key = env?.XAI_API_KEY - - if (!key) { + try { + return getApiKeyFromEnv('XAI_API_KEY') + } catch { throw new Error( 'XAI_API_KEY is required. Please set it in your environment variables or use the factory function with an explicit API key.', ) } - - return key } /** - * Generates a unique ID with a prefix + * Returns a Grok client config with the default xAI base URL applied + * when not already set. */ -export function generateId(prefix: string): string { - return `${prefix}-${Date.now()}-${Math.random().toString(36).substring(7)}` +export function withGrokDefaults( + config: GrokClientConfig, +): OpenAICompatibleClientConfig { + return { + ...config, + baseURL: config.baseURL || 'https://api.x.ai/v1', + } } diff --git a/packages/typescript/ai-grok/src/utils/index.ts b/packages/typescript/ai-grok/src/utils/index.ts index 21c06112c..525cfc726 100644 --- a/packages/typescript/ai-grok/src/utils/index.ts +++ b/packages/typescript/ai-grok/src/utils/index.ts @@ -1,7 +1,7 @@ +export { generateId } from '@tanstack/ai-utils' export { - createGrokClient, getGrokApiKeyFromEnv, - generateId, + withGrokDefaults, type GrokClientConfig, } from './client' export { diff --git a/packages/typescript/ai-grok/src/utils/schema-converter.ts b/packages/typescript/ai-grok/src/utils/schema-converter.ts index 38c345e22..20c2d36d3 100644 --- a/packages/typescript/ai-grok/src/utils/schema-converter.ts +++ b/packages/typescript/ai-grok/src/utils/schema-converter.ts @@ -1,110 +1,2 @@ -/** - * Recursively transform null values to undefined in an object. - * - * This is needed because Grok's structured output (via OpenAI-compatible API) requires all fields to be - * in the `required` array, with optional fields made nullable (type: ["string", "null"]). - * When Grok returns null for optional fields, we need to convert them back to - * undefined to match the original Zod schema expectations. - * - * @param obj - Object to transform - * @returns Object with nulls converted to undefined - */ -export function transformNullsToUndefined(obj: T): T { - if (obj === null) { - return undefined as unknown as T - } - - if (Array.isArray(obj)) { - return obj.map((item) => transformNullsToUndefined(item)) as unknown as T - } - - if (typeof obj === 'object') { - const result: Record = {} - for (const [key, value] of Object.entries(obj as Record)) { - const transformed = transformNullsToUndefined(value) - // Only include the key if the value is not undefined - // This makes { notes: null } become {} (field absent) instead of { notes: undefined } - if (transformed !== undefined) { - result[key] = transformed - } - } - return result as T - } - - return obj -} - -/** - * Transform a JSON schema to be compatible with Grok's structured output requirements (OpenAI-compatible). - * Grok requires: - * - All properties must be in the `required` array - * - Optional fields should have null added to their type union - * - additionalProperties must be false for objects - * - * @param schema - JSON schema to transform - * @param originalRequired - Original required array (to know which fields were optional) - * @returns Transformed schema compatible with Grok structured output - */ -export function makeGrokStructuredOutputCompatible( - schema: Record, - originalRequired: Array = [], -): Record { - const result = { ...schema } - - // Handle object types - if (result.type === 'object' && result.properties) { - const properties = { ...result.properties } - const allPropertyNames = Object.keys(properties) - - // Transform each property - for (const propName of allPropertyNames) { - const prop = properties[propName] - const wasOptional = !originalRequired.includes(propName) - - // Recursively transform nested objects/arrays - if (prop.type === 'object' && prop.properties) { - properties[propName] = makeGrokStructuredOutputCompatible( - prop, - prop.required || [], - ) - } else if (prop.type === 'array' && prop.items) { - properties[propName] = { - ...prop, - items: makeGrokStructuredOutputCompatible( - prop.items, - prop.items.required || [], - ), - } - } else if (wasOptional) { - // Make optional fields nullable by adding null to the type - if (prop.type && !Array.isArray(prop.type)) { - properties[propName] = { - ...prop, - type: [prop.type, 'null'], - } - } else if (Array.isArray(prop.type) && !prop.type.includes('null')) { - properties[propName] = { - ...prop, - type: [...prop.type, 'null'], - } - } - } - } - - result.properties = properties - // ALL properties must be required for Grok structured output - result.required = allPropertyNames - // additionalProperties must be false - result.additionalProperties = false - } - - // Handle array types with object items - if (result.type === 'array' && result.items) { - result.items = makeGrokStructuredOutputCompatible( - result.items, - result.items.required || [], - ) - } - - return result -} +export { transformNullsToUndefined } from '@tanstack/ai-utils' +export { makeStructuredOutputCompatible as makeGrokStructuredOutputCompatible } from '@tanstack/openai-base' diff --git a/packages/typescript/ai-grok/tests/grok-adapter.test.ts b/packages/typescript/ai-grok/tests/grok-adapter.test.ts index f992cfadb..2f6f2741c 100644 --- a/packages/typescript/ai-grok/tests/grok-adapter.test.ts +++ b/packages/typescript/ai-grok/tests/grok-adapter.test.ts @@ -8,16 +8,15 @@ import type { StreamChunk, Tool } from '@tanstack/ai' // Test helper: a silent logger for test chatStream calls. const testLogger = resolveDebugOption(false) -// Declare mockCreate at module level -let mockCreate: ReturnType - -// Mock the OpenAI SDK +// Mock the OpenAI SDK to avoid constructing a real client during adapter +// instantiation. Tests that need to inspect calls inject their own mock client +// via `injectMockClient`. vi.mock('openai', () => { return { default: class { chat = { completions: { - create: (...args: Array) => mockCreate(...args), + create: vi.fn(), }, } }, @@ -41,17 +40,26 @@ function createAsyncIterable(chunks: Array): AsyncIterable { } } -// Helper to setup the mock SDK client for streaming responses -function setupMockSdkClient( +// Helper to create a mock OpenAI client and inject it into an adapter +function injectMockClient( + adapter: object, streamChunks: Array>, nonStreamResponse?: Record, -) { - mockCreate = vi.fn().mockImplementation((params) => { +): ReturnType { + const mockCreate = vi.fn().mockImplementation((params) => { if (params.stream) { return Promise.resolve(createAsyncIterable(streamChunks)) } return Promise.resolve(nonStreamResponse) }) + ;(adapter as any).client = { + chat: { + completions: { + create: mockCreate, + }, + }, + } + return mockCreate } const weatherTool: Tool = { @@ -192,8 +200,8 @@ describe('Grok AG-UI event emission', () => { }, ] - setupMockSdkClient(streamChunks) const adapter = createGrokText('grok-3', 'test-api-key') + injectMockClient(adapter, streamChunks) const chunks: Array = [] for await (const chunk of adapter.chatStream({ @@ -240,8 +248,8 @@ describe('Grok AG-UI event emission', () => { }, ] - setupMockSdkClient(streamChunks) const adapter = createGrokText('grok-3', 'test-api-key') + injectMockClient(adapter, streamChunks) const chunks: Array = [] for await (const chunk of adapter.chatStream({ @@ -299,8 +307,8 @@ describe('Grok AG-UI event emission', () => { }, ] - setupMockSdkClient(streamChunks) const adapter = createGrokText('grok-3', 'test-api-key') + injectMockClient(adapter, streamChunks) const chunks: Array = [] for await (const chunk of adapter.chatStream({ @@ -390,8 +398,8 @@ describe('Grok AG-UI event emission', () => { }, ] - setupMockSdkClient(streamChunks) const adapter = createGrokText('grok-3', 'test-api-key') + injectMockClient(adapter, streamChunks) const chunks: Array = [] for await (const chunk of adapter.chatStream({ @@ -458,9 +466,16 @@ describe('Grok AG-UI event emission', () => { }, } - mockCreate = vi.fn().mockResolvedValue(errorIterable) - const adapter = createGrokText('grok-3', 'test-api-key') + const mockCreate = vi.fn().mockResolvedValue(errorIterable) + ;(adapter as any).client = { + chat: { + completions: { + create: mockCreate, + }, + }, + } + const chunks: Array = [] for await (const chunk of adapter.chatStream({ @@ -508,8 +523,8 @@ describe('Grok AG-UI event emission', () => { }, ] - setupMockSdkClient(streamChunks) const adapter = createGrokText('grok-3', 'test-api-key') + injectMockClient(adapter, streamChunks) const chunks: Array = [] for await (const chunk of adapter.chatStream({ @@ -585,8 +600,8 @@ describe('Grok AG-UI event emission', () => { }, ] - setupMockSdkClient(streamChunks) const adapter = createGrokText('grok-3', 'test-api-key') + injectMockClient(adapter, streamChunks) const chunks: Array = [] for await (const chunk of adapter.chatStream({ diff --git a/packages/typescript/ai-groq/package.json b/packages/typescript/ai-groq/package.json index 54ac5a374..35a7b3d3a 100644 --- a/packages/typescript/ai-groq/package.json +++ b/packages/typescript/ai-groq/package.json @@ -51,6 +51,8 @@ "zod": "^4.0.0" }, "dependencies": { + "@tanstack/ai-utils": "workspace:*", + "@tanstack/openai-base": "workspace:*", "groq-sdk": "^0.37.0" } } diff --git a/packages/typescript/ai-groq/src/utils/client.ts b/packages/typescript/ai-groq/src/utils/client.ts index f143193d2..4e4f64580 100644 --- a/packages/typescript/ai-groq/src/utils/client.ts +++ b/packages/typescript/ai-groq/src/utils/client.ts @@ -1,3 +1,4 @@ +import { generateId as _generateId, getApiKeyFromEnv } from '@tanstack/ai-utils' import Groq_SDK from 'groq-sdk' import type { ClientOptions } from 'groq-sdk' @@ -17,26 +18,12 @@ export function createGroqClient(config: GroqClientConfig): Groq_SDK { * @throws Error if GROQ_API_KEY is not found */ export function getGroqApiKeyFromEnv(): string { - const env = - typeof globalThis !== 'undefined' && (globalThis as any).window?.env - ? (globalThis as any).window.env - : typeof process !== 'undefined' - ? process.env - : undefined - const key = env?.GROQ_API_KEY - - if (!key) { - throw new Error( - 'GROQ_API_KEY is required. Please set it in your environment variables or use the factory function with an explicit API key.', - ) - } - - return key + return getApiKeyFromEnv('GROQ_API_KEY') } /** * Generates a unique ID with a prefix */ export function generateId(prefix: string): string { - return `${prefix}-${Date.now()}-${Math.random().toString(36).substring(7)}` + return _generateId(prefix) } diff --git a/packages/typescript/ai-groq/src/utils/schema-converter.ts b/packages/typescript/ai-groq/src/utils/schema-converter.ts index d0a57cf44..6db95a620 100644 --- a/packages/typescript/ai-groq/src/utils/schema-converter.ts +++ b/packages/typescript/ai-groq/src/utils/schema-converter.ts @@ -1,35 +1,123 @@ +import { makeStructuredOutputCompatible } from '@tanstack/openai-base' +import { transformNullsToUndefined } from '@tanstack/ai-utils' + +export { transformNullsToUndefined } + /** - * Recursively transform null values to undefined in an object. - * - * This is needed because Groq's structured output requires all fields to be - * in the `required` array, with optional fields made nullable (type: ["string", "null"]). - * When Groq returns null for optional fields, we need to convert them back to - * undefined to match the original Zod schema expectations. - * - * @param obj - Object to transform - * @returns Object with nulls converted to undefined + * Recursively removes `required: []` from a schema object. + * Groq rejects `required` when it is an empty array, even though + * OpenAI-compatible schemas allow it. */ -export function transformNullsToUndefined(obj: T): T { - if (obj === null) { - return undefined as unknown as T +function removeEmptyRequired(schema: Record): Record { + const result = { ...schema } + + if (Array.isArray(result.required) && result.required.length === 0) { + delete result.required } - if (Array.isArray(obj)) { - return obj.map((item) => transformNullsToUndefined(item)) as unknown as T + if (result.properties && typeof result.properties === 'object') { + const properties: Record = {} + for (const [key, value] of Object.entries( + result.properties as Record, + )) { + properties[key] = + typeof value === 'object' && value !== null && !Array.isArray(value) + ? removeEmptyRequired(value) + : value + } + result.properties = properties + } + + if ( + result.items && + typeof result.items === 'object' && + !Array.isArray(result.items) + ) { + result.items = removeEmptyRequired(result.items) } - if (typeof obj === 'object') { - const result: Record = {} - for (const [key, value] of Object.entries(obj as Record)) { - const transformed = transformNullsToUndefined(value) - if (transformed !== undefined) { - result[key] = transformed - } + // Recurse into combinator arrays (anyOf, oneOf, allOf) + for (const keyword of ['anyOf', 'oneOf', 'allOf'] as const) { + if (Array.isArray(result[keyword])) { + result[keyword] = result[keyword].map((entry: Record) => + removeEmptyRequired(entry), + ) } - return result as T } - return obj + // Recurse into additionalProperties if it's a schema object + if ( + result.additionalProperties && + typeof result.additionalProperties === 'object' && + !Array.isArray(result.additionalProperties) + ) { + result.additionalProperties = removeEmptyRequired( + result.additionalProperties, + ) + } + + return result +} + +/** + * Recursively normalise object schemas so any `{ type: 'object' }` node + * without `properties` gets an empty `properties: {}` object. The + * openai-base transformer only descends into objects that already have + * `properties` set, so a Zod `z.object({})` nested inside `properties`, + * `items`, `additionalProperties`, or a combinator branch would otherwise + * skip the strict-mode rewrite and fail Groq validation. + */ +function normalizeObjectSchemas( + schema: Record, +): Record { + const result: Record = + schema.type === 'object' && !schema.properties + ? { ...schema, properties: {} } + : { ...schema } + + if (result.properties && typeof result.properties === 'object') { + result.properties = Object.fromEntries( + Object.entries(result.properties as Record).map( + ([key, value]) => [ + key, + typeof value === 'object' && value !== null && !Array.isArray(value) + ? normalizeObjectSchemas(value) + : value, + ], + ), + ) + } + + if ( + result.items && + typeof result.items === 'object' && + !Array.isArray(result.items) + ) { + result.items = normalizeObjectSchemas(result.items) + } + + for (const keyword of ['anyOf', 'oneOf', 'allOf'] as const) { + const branch = result[keyword] + if (Array.isArray(branch)) { + result[keyword] = branch.map((entry) => + typeof entry === 'object' && entry !== null + ? normalizeObjectSchemas(entry as Record) + : entry, + ) + } + } + + if ( + result.additionalProperties && + typeof result.additionalProperties === 'object' && + !Array.isArray(result.additionalProperties) + ) { + result.additionalProperties = normalizeObjectSchemas( + result.additionalProperties as Record, + ) + } + + return result } /** @@ -39,6 +127,10 @@ export function transformNullsToUndefined(obj: T): T { * - All properties must be in the `required` array * - Optional fields should have null added to their type union * - additionalProperties must be false for objects + * - `required` must be omitted (not empty array) when there are no properties + * + * Delegates to the shared OpenAI-compatible transformer and applies the + * Groq-specific quirk of removing empty `required` arrays. * * @param schema - JSON schema to transform * @param originalRequired - Original required array (to know which fields were optional) @@ -48,63 +140,12 @@ export function makeGroqStructuredOutputCompatible( schema: Record, originalRequired: Array = [], ): Record { - const result = { ...schema } + // Recursively patch every `{ type: 'object' }` node so the openai-base + // transformer descends into nested empty objects too. + const normalised = normalizeObjectSchemas(schema) - if (result.type === 'object') { - if (!result.properties) { - result.properties = {} - } - const properties = { ...result.properties } - const allPropertyNames = Object.keys(properties) - - for (const propName of allPropertyNames) { - const prop = properties[propName] - const wasOptional = !originalRequired.includes(propName) - - if (prop.type === 'object' && prop.properties) { - properties[propName] = makeGroqStructuredOutputCompatible( - prop, - prop.required || [], - ) - } else if (prop.type === 'array' && prop.items) { - properties[propName] = { - ...prop, - items: makeGroqStructuredOutputCompatible( - prop.items, - prop.items.required || [], - ), - } - } else if (wasOptional) { - if (prop.type && !Array.isArray(prop.type)) { - properties[propName] = { - ...prop, - type: [prop.type, 'null'], - } - } else if (Array.isArray(prop.type) && !prop.type.includes('null')) { - properties[propName] = { - ...prop, - type: [...prop.type, 'null'], - } - } - } - } + const result = makeStructuredOutputCompatible(normalised, originalRequired) - result.properties = properties - // Groq rejects `required` when there are no properties, even if it's an empty array - if (allPropertyNames.length > 0) { - result.required = allPropertyNames - } else { - delete result.required - } - result.additionalProperties = false - } - - if (result.type === 'array' && result.items) { - result.items = makeGroqStructuredOutputCompatible( - result.items, - result.items.required || [], - ) - } - - return result + // Groq rejects `required` when it is an empty array + return removeEmptyRequired(result) } diff --git a/packages/typescript/ai-groq/tests/groq-adapter.test.ts b/packages/typescript/ai-groq/tests/groq-adapter.test.ts index da421a8b5..a053aeea8 100644 --- a/packages/typescript/ai-groq/tests/groq-adapter.test.ts +++ b/packages/typescript/ai-groq/tests/groq-adapter.test.ts @@ -93,9 +93,7 @@ describe('Groq adapters', () => { it('throws if GROQ_API_KEY is not set when using groqText', () => { vi.stubEnv('GROQ_API_KEY', '') - expect(() => groqText('llama-3.3-70b-versatile')).toThrow( - 'GROQ_API_KEY is required', - ) + expect(() => groqText('llama-3.3-70b-versatile')).toThrow('GROQ_API_KEY') }) it('allows custom baseURL override', () => { diff --git a/packages/typescript/ai-groq/tests/schema-converter.test.ts b/packages/typescript/ai-groq/tests/schema-converter.test.ts new file mode 100644 index 000000000..b6daa00e0 --- /dev/null +++ b/packages/typescript/ai-groq/tests/schema-converter.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it } from 'vitest' +import { makeGroqStructuredOutputCompatible } from '../src/utils/schema-converter' + +describe('makeGroqStructuredOutputCompatible', () => { + it('should remove empty required arrays inside anyOf variants', () => { + const schema = { + type: 'object', + properties: { + value: { + anyOf: [ + { + type: 'object', + properties: {}, + required: [], + }, + { type: 'null' }, + ], + }, + }, + required: ['value'], + } + + const result = makeGroqStructuredOutputCompatible(schema, ['value']) + + // Empty required inside anyOf variant should be removed + const objectVariant = result.properties.value.anyOf.find( + (v: any) => v.type === 'object', + ) + expect(objectVariant.required).toBeUndefined() + }) + + it('should not have any empty required arrays in nested structures', () => { + const schema = { + type: 'object', + properties: { + data: { + type: 'object', + properties: { + inner: { type: 'string' }, + }, + required: ['inner'], + }, + }, + required: ['data'], + } + + // First create a schema that would produce empty required after processing + const result = makeGroqStructuredOutputCompatible(schema, ['data']) + + // Should not have empty required arrays anywhere + const checkNoEmptyRequired = (obj: any): void => { + if (obj && typeof obj === 'object') { + if (Array.isArray(obj.required)) { + expect(obj.required.length).toBeGreaterThan(0) + } + for (const value of Object.values(obj)) { + if (typeof value === 'object' && value !== null) { + checkNoEmptyRequired(value) + } + } + } + } + checkNoEmptyRequired(result) + }) + + it('should normalise nested empty-object schemas in properties', () => { + // Reproduces the bug where a nested `{ type: 'object' }` without + // `properties` slipped past the openai-base transformer because the + // ai-groq layer only normalised the top-level node. + const schema = { + type: 'object', + properties: { + child: { type: 'object' }, + }, + required: ['child'], + } + + const result = makeGroqStructuredOutputCompatible(schema, ['child']) + + expect(result.properties.child.type).toBe('object') + expect(result.properties.child.properties).toEqual({}) + // openai-base sets additionalProperties: false on every rewritten object + expect(result.properties.child.additionalProperties).toBe(false) + }) + + it('should normalise nested empty-object schemas in array items', () => { + const schema = { + type: 'object', + properties: { + items: { + type: 'array', + items: { type: 'object' }, + }, + }, + required: ['items'], + } + + const result = makeGroqStructuredOutputCompatible(schema, ['items']) + + expect(result.properties.items.items.type).toBe('object') + expect(result.properties.items.items.properties).toEqual({}) + expect(result.properties.items.items.additionalProperties).toBe(false) + }) + + it('should normalise nested empty-object schemas inside anyOf', () => { + const schema = { + type: 'object', + properties: { + value: { + anyOf: [{ type: 'object' }, { type: 'string' }], + }, + }, + required: ['value'], + } + + const result = makeGroqStructuredOutputCompatible(schema, ['value']) + + const objectVariant = result.properties.value.anyOf.find( + (v: any) => v.type === 'object', + ) + expect(objectVariant.properties).toEqual({}) + expect(objectVariant.additionalProperties).toBe(false) + }) + + it('should remove empty required in additionalProperties', () => { + const schema = { + type: 'object', + properties: { + meta: { + type: 'object', + properties: { + name: { type: 'string' }, + }, + required: ['name'], + additionalProperties: { + type: 'object', + properties: {}, + required: [], + }, + }, + }, + required: ['meta'], + } + + const result = makeGroqStructuredOutputCompatible(schema, ['meta']) + + // meta should have required with allPropertyNames + expect(result.properties.meta.required).toEqual(['name']) + // additionalProperties' empty required should be removed + if ( + result.properties.meta.additionalProperties && + typeof result.properties.meta.additionalProperties === 'object' + ) { + expect( + result.properties.meta.additionalProperties.required, + ).toBeUndefined() + } + }) +}) diff --git a/packages/typescript/ai-ollama/package.json b/packages/typescript/ai-ollama/package.json index 3c36bc510..94f2974bd 100644 --- a/packages/typescript/ai-ollama/package.json +++ b/packages/typescript/ai-ollama/package.json @@ -41,6 +41,7 @@ "adapter" ], "dependencies": { + "@tanstack/ai-utils": "workspace:*", "ollama": "^0.6.3" }, "peerDependencies": { diff --git a/packages/typescript/ai-ollama/src/utils/client.ts b/packages/typescript/ai-ollama/src/utils/client.ts index 7c4cb8caa..2245b9a0b 100644 --- a/packages/typescript/ai-ollama/src/utils/client.ts +++ b/packages/typescript/ai-ollama/src/utils/client.ts @@ -1,4 +1,5 @@ import { Ollama } from 'ollama' +import { generateId as _generateId } from '@tanstack/ai-utils' export interface OllamaClientConfig { host?: string @@ -39,7 +40,7 @@ export function getOllamaHostFromEnv(): string { * Generates a unique ID with a prefix */ export function generateId(prefix: string = 'msg'): string { - return `${prefix}-${Date.now()}-${Math.random().toString(36).substring(7)}` + return _generateId(prefix) } /** diff --git a/packages/typescript/ai-openai/package.json b/packages/typescript/ai-openai/package.json index 26a8f792d..bd318d0c8 100644 --- a/packages/typescript/ai-openai/package.json +++ b/packages/typescript/ai-openai/package.json @@ -44,6 +44,8 @@ "adapter" ], "dependencies": { + "@tanstack/ai-utils": "workspace:*", + "@tanstack/openai-base": "workspace:*", "openai": "^6.9.1" }, "peerDependencies": { diff --git a/packages/typescript/ai-openai/src/adapters/image.ts b/packages/typescript/ai-openai/src/adapters/image.ts index 5274717ec..e1220738f 100644 --- a/packages/typescript/ai-openai/src/adapters/image.ts +++ b/packages/typescript/ai-openai/src/adapters/image.ts @@ -1,9 +1,5 @@ -import { BaseImageAdapter } from '@tanstack/ai/adapters' -import { - createOpenAIClient, - generateId, - getOpenAIApiKeyFromEnv, -} from '../utils/client' +import { OpenAICompatibleImageAdapter } from '@tanstack/openai-base' +import { getOpenAIApiKeyFromEnv } from '../utils/client' import { validateImageSize, validateNumberOfImages, @@ -15,12 +11,6 @@ import type { OpenAIImageModelSizeByName, OpenAIImageProviderOptions, } from '../image/image-provider-options' -import type { - GeneratedImage, - ImageGenerationOptions, - ImageGenerationResult, -} from '@tanstack/ai' -import type OpenAI_SDK from 'openai' import type { OpenAIClientConfig } from '../utils/client' /** @@ -41,7 +31,7 @@ export interface OpenAIImageConfig extends OpenAIClientConfig {} */ export class OpenAIImageAdapter< TModel extends OpenAIImageModel, -> extends BaseImageAdapter< +> extends OpenAICompatibleImageAdapter< TModel, OpenAIImageProviderOptions, OpenAIImageModelProviderOptionsByName, @@ -50,95 +40,29 @@ export class OpenAIImageAdapter< readonly kind = 'image' as const readonly name = 'openai' as const - private client: OpenAI_SDK - constructor(config: OpenAIImageConfig, model: TModel) { - super(model, {}) - this.client = createOpenAIClient(config) + super(config, model, 'openai') } - async generateImages( - options: ImageGenerationOptions, - ): Promise { - const { model, prompt, numberOfImages, size, logger } = options - - logger.request( - `activity=generateImage provider=openai model=${this.model}`, - { - provider: 'openai', - model: this.model, - }, - ) - - try { - // Validate inputs - validatePrompt({ prompt, model }) - validateImageSize(model, size) - validateNumberOfImages(model, numberOfImages) - - // Build request based on model type - const request = this.buildRequest(options) - - const response = await this.client.images.generate({ - ...request, - stream: false, - }) - - return this.transformResponse(model, response) - } catch (error) { - logger.errors('openai.generateImage fatal', { - error, - source: 'openai.generateImage', - }) - throw error - } + protected override validatePrompt(options: { + prompt: string + model: string + }): void { + validatePrompt(options) } - private buildRequest( - options: ImageGenerationOptions, - ): OpenAI_SDK.Images.ImageGenerateParams { - const { model, prompt, numberOfImages, size, modelOptions } = options - - // Spread modelOptions FIRST so explicit args (model, prompt, n, size) win - // and user-supplied modelOptions cannot silently override them. - return { - ...modelOptions, - model, - prompt, - n: numberOfImages ?? 1, - size: size as OpenAI_SDK.Images.ImageGenerateParams['size'], - } + protected override validateImageSize( + model: string, + size: string | undefined, + ): void { + validateImageSize(model, size) } - private transformResponse( + protected override validateNumberOfImages( model: string, - response: OpenAI_SDK.Images.ImagesResponse, - ): ImageGenerationResult { - const images: Array = (response.data ?? []).flatMap( - (item): Array => { - const revisedPrompt = item.revised_prompt - if (item.b64_json) { - return [{ b64Json: item.b64_json, revisedPrompt }] - } - if (item.url) { - return [{ url: item.url, revisedPrompt }] - } - return [] - }, - ) - - return { - id: generateId(this.name), - model, - images, - usage: response.usage - ? { - inputTokens: response.usage.input_tokens, - outputTokens: response.usage.output_tokens, - totalTokens: response.usage.total_tokens, - } - : undefined, - } + numberOfImages: number | undefined, + ): void { + validateNumberOfImages(model, numberOfImages) } } diff --git a/packages/typescript/ai-openai/src/adapters/summarize.ts b/packages/typescript/ai-openai/src/adapters/summarize.ts index 25fcc17af..6e143bfab 100644 --- a/packages/typescript/ai-openai/src/adapters/summarize.ts +++ b/packages/typescript/ai-openai/src/adapters/summarize.ts @@ -1,12 +1,7 @@ -import { BaseSummarizeAdapter } from '@tanstack/ai/adapters' +import { OpenAICompatibleSummarizeAdapter } from '@tanstack/openai-base' import { getOpenAIApiKeyFromEnv } from '../utils/client' import { OpenAITextAdapter } from './text' import type { OpenAIChatModel } from '../model-meta' -import type { - StreamChunk, - SummarizationOptions, - SummarizationResult, -} from '@tanstack/ai' import type { OpenAIClientConfig } from '../utils/client' /** @@ -32,125 +27,15 @@ export interface OpenAISummarizeProviderOptions { */ export class OpenAISummarizeAdapter< TModel extends OpenAIChatModel, -> extends BaseSummarizeAdapter { +> extends OpenAICompatibleSummarizeAdapter< + TModel, + OpenAISummarizeProviderOptions +> { readonly kind = 'summarize' as const readonly name = 'openai' as const - private textAdapter: OpenAITextAdapter - constructor(config: OpenAISummarizeConfig, model: TModel) { - super({}, model) - this.textAdapter = new OpenAITextAdapter(config, model) - } - - async summarize(options: SummarizationOptions): Promise { - const { logger } = options - const systemPrompt = this.buildSummarizationPrompt(options) - - logger.request(`activity=summarize provider=openai`, { - provider: 'openai', - model: options.model, - }) - - // Use the text adapter's streaming and collect the result - let summary = '' - const id = '' - let model = options.model - let usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 } - - try { - for await (const chunk of this.textAdapter.chatStream({ - model: options.model, - messages: [{ role: 'user', content: options.text }], - systemPrompts: [systemPrompt], - maxTokens: options.maxLength, - temperature: 0.3, - logger, - })) { - // AG-UI TEXT_MESSAGE_CONTENT event - if (chunk.type === 'TEXT_MESSAGE_CONTENT') { - if (chunk.content) { - summary = chunk.content - } else { - summary += chunk.delta - } - model = chunk.model || model - } - // AG-UI RUN_FINISHED event - if (chunk.type === 'RUN_FINISHED') { - if (chunk.usage) { - usage = chunk.usage - } - } - } - } catch (error) { - logger.errors('openai.summarize fatal', { - error, - source: 'openai.summarize', - }) - throw error - } - - return { id, model, summary, usage } - } - - async *summarizeStream( - options: SummarizationOptions, - ): AsyncIterable { - const { logger } = options - const systemPrompt = this.buildSummarizationPrompt(options) - - logger.request(`activity=summarize provider=openai`, { - provider: 'openai', - model: options.model, - stream: true, - }) - - try { - // Delegate directly to the text adapter's streaming - yield* this.textAdapter.chatStream({ - model: options.model, - messages: [{ role: 'user', content: options.text }], - systemPrompts: [systemPrompt], - maxTokens: options.maxLength, - temperature: 0.3, - logger, - }) - } catch (error) { - logger.errors('openai.summarize fatal', { - error, - source: 'openai.summarize', - }) - throw error - } - } - - private buildSummarizationPrompt(options: SummarizationOptions): string { - let prompt = 'You are a professional summarizer. ' - - switch (options.style) { - case 'bullet-points': - prompt += 'Provide a summary in bullet point format. ' - break - case 'paragraph': - prompt += 'Provide a summary in paragraph format. ' - break - case 'concise': - prompt += 'Provide a very concise summary in 1-2 sentences. ' - break - default: - prompt += 'Provide a clear and concise summary. ' - } - - if (options.focus && options.focus.length > 0) { - prompt += `Focus on the following aspects: ${options.focus.join(', ')}. ` - } - - if (options.maxLength) { - prompt += `Keep the summary under ${options.maxLength} tokens. ` - } - - return prompt + super(new OpenAITextAdapter(config, model), model, 'openai') } } diff --git a/packages/typescript/ai-openai/src/adapters/text.ts b/packages/typescript/ai-openai/src/adapters/text.ts index 139629869..2e9f91e9c 100644 --- a/packages/typescript/ai-openai/src/adapters/text.ts +++ b/packages/typescript/ai-openai/src/adapters/text.ts @@ -1,16 +1,7 @@ -import { BaseTextAdapter } from '@tanstack/ai/adapters' -import { toRunErrorPayload } from '@tanstack/ai/adapter-internals' +import { OpenAICompatibleResponsesTextAdapter } from '@tanstack/openai-base' import { validateTextProviderOptions } from '../text/text-provider-options' import { convertToolsToProviderFormat } from '../tools' -import { - createOpenAIClient, - generateId, - getOpenAIApiKeyFromEnv, -} from '../utils/client' -import { - makeOpenAIStructuredOutputCompatible, - transformNullsToUndefined, -} from '../utils/schema-converter' +import { getOpenAIApiKeyFromEnv } from '../utils/client' import type { OPENAI_CHAT_MODELS, OpenAIChatModel, @@ -18,36 +9,15 @@ import type { OpenAIChatModelToolCapabilitiesByName, OpenAIModelInputModalitiesByName, } from '../model-meta' -import type { - StructuredOutputOptions, - StructuredOutputResult, -} from '@tanstack/ai/adapters' -import type { InternalLogger } from '@tanstack/ai/adapter-internals' import type OpenAI_SDK from 'openai' -import type { Responses } from 'openai/resources' -import type { - ContentPart, - Modality, - ModelMessage, - StreamChunk, - TextOptions, -} from '@tanstack/ai' +import type { Modality, TextOptions } from '@tanstack/ai' import type { ExternalTextProviderOptions, InternalTextProviderOptions, } from '../text/text-provider-options' -import type { - OpenAIAudioMetadata, - OpenAIImageMetadata, - OpenAIMessageMetadataByModality, -} from '../message-types' +import type { OpenAIMessageMetadataByModality } from '../message-types' import type { OpenAIClientConfig } from '../utils/client' -/** Cast an event object to StreamChunk. Adapters construct events with string - * literal types which are structurally compatible with the EventType enum. */ -const asChunk = (chunk: Record) => - chunk as unknown as StreamChunk - /** * Configuration for OpenAI text adapter */ @@ -97,7 +67,9 @@ type ResolveToolCapabilities = * OpenAI Text (Chat) Adapter * * Tree-shakeable adapter for OpenAI chat/text completion functionality. - * Import only what you need for smaller bundle sizes. + * Delegates implementation to {@link OpenAICompatibleResponsesTextAdapter} from + * `@tanstack/openai-base` and threads OpenAI-specific tool-capability typing + * through the 5th generic of the base class. */ export class OpenAITextAdapter< TModel extends OpenAIChatModel, @@ -106,7 +78,7 @@ export class OpenAITextAdapter< ResolveInputModalities, TToolCapabilities extends ReadonlyArray = ResolveToolCapabilities, -> extends BaseTextAdapter< +> extends OpenAICompatibleResponsesTextAdapter< TModel, TProviderOptions, TInputModalities, @@ -116,748 +88,24 @@ export class OpenAITextAdapter< readonly kind = 'text' as const readonly name = 'openai' as const - private client: OpenAI_SDK - constructor(config: OpenAITextConfig, model: TModel) { - super({}, model) - this.client = createOpenAIClient(config) - } - - async *chatStream( - options: TextOptions, - ): AsyncIterable { - // Track tool call metadata by unique ID - // OpenAI streams tool calls with deltas - first chunk has ID/name, subsequent chunks only have args - // We assign our own indices as we encounter unique tool call IDs - const toolCallMetadata = new Map< - string, - { index: number; name: string; started: boolean } - >() - const requestArguments = this.mapTextOptionsToOpenAI(options) - const { logger } = options - - try { - logger.request( - `activity=chat provider=openai model=${this.model} messages=${options.messages.length} tools=${options.tools?.length ?? 0} stream=true`, - { provider: 'openai', model: this.model }, - ) - const response = await this.client.responses.create( - { - ...requestArguments, - stream: true, - }, - { - headers: options.request?.headers, - signal: options.request?.signal, - }, - ) - - // Chat Completions API uses SSE format - iterate directly - yield* this.processOpenAIStreamChunks( - response, - toolCallMetadata, - options, - () => generateId(this.name), - logger, - ) - } catch (error: unknown) { - // Narrow before logging: raw SDK errors can carry request metadata - // (including auth headers) which we must never surface to user loggers. - logger.errors('openai.chatStream fatal', { - error: toRunErrorPayload(error, 'openai.chatStream failed'), - source: 'openai.chatStream', - }) - throw error - } + super(config, model, 'openai') } /** - * Generate structured output using OpenAI's native JSON Schema response format. - * Uses stream: false to get the complete response in one call. - * - * OpenAI has strict requirements for structured output: - * - All properties must be in the `required` array - * - Optional fields should have null added to their type union - * - additionalProperties must be false for all objects - * - * The outputSchema is already JSON Schema (converted in the ai layer). - * We apply OpenAI-specific transformations for structured output compatibility. + * Maps common options to OpenAI-specific format. + * Overrides the base class to use OpenAI's full tool converter + * (supporting special tool types like file_search, web_search, etc.) + * and to apply OpenAI-specific provider option validation. */ - async structuredOutput( - options: StructuredOutputOptions, - ): Promise> { - const { chatOptions, outputSchema } = options - const requestArguments = this.mapTextOptionsToOpenAI(chatOptions) - const { logger } = chatOptions - - // Apply OpenAI-specific transformations for structured output compatibility - const jsonSchema = makeOpenAIStructuredOutputCompatible( - outputSchema, - outputSchema.required || [], - ) - - try { - logger.request( - `activity=chat provider=openai model=${this.model} messages=${chatOptions.messages.length} tools=${chatOptions.tools?.length ?? 0} stream=false`, - { provider: 'openai', model: this.model }, - ) - const response = await this.client.responses.create( - { - ...requestArguments, - stream: false, - // Configure structured output via text.format - text: { - format: { - type: 'json_schema', - name: 'structured_output', - schema: jsonSchema, - strict: true, - }, - }, - }, - { - headers: chatOptions.request?.headers, - signal: chatOptions.request?.signal, - }, - ) - - // Extract text content from the response - const rawText = this.extractTextFromResponse(response) - - // Parse the JSON response - let parsed: unknown - try { - parsed = JSON.parse(rawText) - } catch { - throw new Error( - `Failed to parse structured output as JSON. Content: ${rawText.slice(0, 200)}${rawText.length > 200 ? '...' : ''}`, - ) - } - - // Transform null values to undefined to match original Zod schema expectations - // OpenAI returns null for optional fields we made nullable in the schema - const transformed = transformNullsToUndefined(parsed) - - return { - data: transformed, - rawText, - } - } catch (error: unknown) { - logger.errors('openai.structuredOutput fatal', { - error, - source: 'openai.structuredOutput', - }) - throw error - } - } - - /** - * Extract text content from a non-streaming response - */ - private extractTextFromResponse( - response: OpenAI_SDK.Responses.Response, - ): string { - let textContent = '' - - for (const item of response.output) { - if (item.type === 'message') { - for (const part of item.content) { - if (part.type === 'output_text') { - textContent += part.text - } - } - } - } - - return textContent - } - - private async *processOpenAIStreamChunks( - stream: AsyncIterable, - toolCallMetadata: Map< - string, - { index: number; name: string; started: boolean } - >, - options: TextOptions, - genId: () => string, - logger: InternalLogger, - ): AsyncIterable { - let accumulatedContent = '' - let accumulatedReasoning = '' - const timestamp = Date.now() - let chunkCount = 0 - - // Track if we've been streaming deltas to avoid duplicating content from done events - let hasStreamedContentDeltas = false - let hasStreamedReasoningDeltas = false - - // Preserve response metadata across events - let model: string = options.model - - // AG-UI lifecycle tracking - const runId = options.runId ?? genId() - const threadId = options.threadId ?? genId() - const messageId = genId() - let stepId: string | null = null - let reasoningMessageId: string | null = null - let hasClosedReasoning = false - let hasEmittedRunStarted = false - let hasEmittedTextMessageStart = false - let hasEmittedStepStarted = false - - try { - for await (const chunk of stream) { - chunkCount++ - logger.provider(`provider=openai type=${chunk.type}`, { - chunk, - }) - - // Emit RUN_STARTED on first chunk - if (!hasEmittedRunStarted) { - hasEmittedRunStarted = true - yield asChunk({ - type: 'RUN_STARTED', - runId, - threadId, - model: model || options.model, - timestamp, - }) - } - - const handleContentPart = ( - contentPart: - | OpenAI_SDK.Responses.ResponseOutputText - | OpenAI_SDK.Responses.ResponseOutputRefusal - | OpenAI_SDK.Responses.ResponseContentPartAddedEvent.ReasoningText, - ): StreamChunk => { - if (contentPart.type === 'output_text') { - accumulatedContent += contentPart.text - return asChunk({ - type: 'TEXT_MESSAGE_CONTENT', - messageId, - model: model || options.model, - timestamp, - delta: contentPart.text, - content: accumulatedContent, - }) - } - - if (contentPart.type === 'reasoning_text') { - accumulatedReasoning += contentPart.text - const currentStepId = stepId || genId() - return asChunk({ - type: 'STEP_FINISHED', - stepName: currentStepId, - stepId: currentStepId, - model: model || options.model, - timestamp, - delta: contentPart.text, - content: accumulatedReasoning, - }) - } - return asChunk({ - type: 'RUN_ERROR', - runId, - message: contentPart.refusal, - model: model || options.model, - timestamp, - error: { - message: contentPart.refusal, - }, - }) - } - // handle general response events - if ( - chunk.type === 'response.created' || - chunk.type === 'response.incomplete' || - chunk.type === 'response.failed' - ) { - model = chunk.response.model - // Reset streaming flags for new response - hasStreamedContentDeltas = false - hasStreamedReasoningDeltas = false - hasEmittedTextMessageStart = false - hasEmittedStepStarted = false - reasoningMessageId = null - hasClosedReasoning = false - accumulatedContent = '' - accumulatedReasoning = '' - if (chunk.response.error) { - yield asChunk({ - type: 'RUN_ERROR', - runId, - message: chunk.response.error.message, - code: chunk.response.error.code, - model: chunk.response.model, - timestamp, - error: chunk.response.error, - }) - } - if (chunk.response.incomplete_details) { - const incompleteMessage = - chunk.response.incomplete_details.reason ?? '' - yield asChunk({ - type: 'RUN_ERROR', - runId, - message: incompleteMessage, - model: chunk.response.model, - timestamp, - error: { - message: incompleteMessage, - }, - }) - } - } - // Handle output text deltas (token-by-token streaming) - // response.output_text.delta provides incremental text updates - if (chunk.type === 'response.output_text.delta' && chunk.delta) { - // Delta can be an array of strings or a single string - const textDelta = Array.isArray(chunk.delta) - ? chunk.delta.join('') - : typeof chunk.delta === 'string' - ? chunk.delta - : '' - - if (textDelta) { - // Close reasoning events before text starts - if (reasoningMessageId && !hasClosedReasoning) { - hasClosedReasoning = true - yield asChunk({ - type: 'REASONING_MESSAGE_END', - messageId: reasoningMessageId, - model: model || options.model, - timestamp, - }) - yield asChunk({ - type: 'REASONING_END', - messageId: reasoningMessageId, - model: model || options.model, - timestamp, - }) - } - - // Emit TEXT_MESSAGE_START on first text content - if (!hasEmittedTextMessageStart) { - hasEmittedTextMessageStart = true - yield asChunk({ - type: 'TEXT_MESSAGE_START', - messageId, - model: model || options.model, - timestamp, - role: 'assistant', - }) - } - - accumulatedContent += textDelta - hasStreamedContentDeltas = true - yield asChunk({ - type: 'TEXT_MESSAGE_CONTENT', - messageId, - model: model || options.model, - timestamp, - delta: textDelta, - content: accumulatedContent, - }) - } - } - - // Handle reasoning deltas (token-by-token thinking/reasoning streaming) - // response.reasoning_text.delta provides incremental reasoning updates - if (chunk.type === 'response.reasoning_text.delta' && chunk.delta) { - // Delta can be an array of strings or a single string - const reasoningDelta = Array.isArray(chunk.delta) - ? chunk.delta.join('') - : typeof chunk.delta === 'string' - ? chunk.delta - : '' - - if (reasoningDelta) { - // Emit STEP_STARTED and REASONING_START on first reasoning content - if (!hasEmittedStepStarted) { - hasEmittedStepStarted = true - stepId = genId() - reasoningMessageId = genId() - - // Spec REASONING events - yield asChunk({ - type: 'REASONING_START', - messageId: reasoningMessageId, - model: model || options.model, - timestamp, - }) - yield asChunk({ - type: 'REASONING_MESSAGE_START', - messageId: reasoningMessageId, - role: 'reasoning' as const, - model: model || options.model, - timestamp, - }) - - // Legacy STEP events (kept during transition) - yield asChunk({ - type: 'STEP_STARTED', - stepName: stepId, - stepId, - model: model || options.model, - timestamp, - stepType: 'thinking', - }) - } - - accumulatedReasoning += reasoningDelta - hasStreamedReasoningDeltas = true - - // Spec REASONING content event - yield asChunk({ - type: 'REASONING_MESSAGE_CONTENT', - messageId: reasoningMessageId!, - delta: reasoningDelta, - model: model || options.model, - timestamp, - }) - - // Legacy STEP event - yield asChunk({ - type: 'STEP_FINISHED', - stepName: stepId || genId(), - stepId: stepId || genId(), - model: model || options.model, - timestamp, - delta: reasoningDelta, - content: accumulatedReasoning, - }) - } - } - - // Handle reasoning summary deltas (when using reasoning.summary option) - // response.reasoning_summary_text.delta provides incremental summary updates - if ( - chunk.type === 'response.reasoning_summary_text.delta' && - chunk.delta - ) { - const summaryDelta = - typeof chunk.delta === 'string' ? chunk.delta : '' - - if (summaryDelta) { - // Emit STEP_STARTED and REASONING_START on first reasoning content - if (!hasEmittedStepStarted) { - hasEmittedStepStarted = true - stepId = genId() - reasoningMessageId = genId() - - // Spec REASONING events - yield asChunk({ - type: 'REASONING_START', - messageId: reasoningMessageId, - model: model || options.model, - timestamp, - }) - yield asChunk({ - type: 'REASONING_MESSAGE_START', - messageId: reasoningMessageId, - role: 'reasoning' as const, - model: model || options.model, - timestamp, - }) - - // Legacy STEP events (kept during transition) - yield asChunk({ - type: 'STEP_STARTED', - stepName: stepId, - stepId, - model: model || options.model, - timestamp, - stepType: 'thinking', - }) - } - - accumulatedReasoning += summaryDelta - hasStreamedReasoningDeltas = true - - // Spec REASONING content event - yield asChunk({ - type: 'REASONING_MESSAGE_CONTENT', - messageId: reasoningMessageId!, - delta: summaryDelta, - model: model || options.model, - timestamp, - }) - - // Legacy STEP event - yield asChunk({ - type: 'STEP_FINISHED', - stepName: stepId || genId(), - stepId: stepId || genId(), - model: model || options.model, - timestamp, - delta: summaryDelta, - content: accumulatedReasoning, - }) - } - } - - // handle content_part added events for text, reasoning and refusals - if (chunk.type === 'response.content_part.added') { - const contentPart = chunk.part - // Close reasoning before text starts - if (contentPart.type === 'output_text') { - if (reasoningMessageId && !hasClosedReasoning) { - hasClosedReasoning = true - yield asChunk({ - type: 'REASONING_MESSAGE_END', - messageId: reasoningMessageId, - model: model || options.model, - timestamp, - }) - yield asChunk({ - type: 'REASONING_END', - messageId: reasoningMessageId, - model: model || options.model, - timestamp, - }) - } - } - - // Emit TEXT_MESSAGE_START if this is text content - if ( - contentPart.type === 'output_text' && - !hasEmittedTextMessageStart - ) { - hasEmittedTextMessageStart = true - yield asChunk({ - type: 'TEXT_MESSAGE_START', - messageId, - model: model || options.model, - timestamp, - role: 'assistant', - }) - } - // Emit STEP_STARTED and REASONING events if this is reasoning content - if (contentPart.type === 'reasoning_text' && !hasEmittedStepStarted) { - hasEmittedStepStarted = true - stepId = genId() - reasoningMessageId = genId() - - // Spec REASONING events - yield asChunk({ - type: 'REASONING_START', - messageId: reasoningMessageId, - model: model || options.model, - timestamp, - }) - yield asChunk({ - type: 'REASONING_MESSAGE_START', - messageId: reasoningMessageId, - role: 'reasoning' as const, - model: model || options.model, - timestamp, - }) - - // Legacy STEP events (kept during transition) - yield asChunk({ - type: 'STEP_STARTED', - stepName: stepId, - stepId, - model: model || options.model, - timestamp, - stepType: 'thinking', - }) - } - yield handleContentPart(contentPart) - } - - if (chunk.type === 'response.content_part.done') { - const contentPart = chunk.part - - // Skip emitting chunks for content parts that we've already streamed via deltas - // The done event is just a completion marker, not new content - if (contentPart.type === 'output_text' && hasStreamedContentDeltas) { - // Content already accumulated from deltas, skip - continue - } - if ( - contentPart.type === 'reasoning_text' && - hasStreamedReasoningDeltas - ) { - // Reasoning already accumulated from deltas, skip - continue - } - - // Only emit if we haven't been streaming deltas (e.g., for non-streaming responses) - yield handleContentPart(contentPart) - } - - // handle output_item.added to capture function call metadata (name) - if (chunk.type === 'response.output_item.added') { - const item = chunk.item - if (item.type === 'function_call' && item.id) { - // Store the function name for later use - if (!toolCallMetadata.has(item.id)) { - toolCallMetadata.set(item.id, { - index: chunk.output_index, - name: item.name || '', - started: false, - }) - } - // Emit TOOL_CALL_START - yield asChunk({ - type: 'TOOL_CALL_START', - toolCallId: item.id, - toolCallName: item.name || '', - toolName: item.name || '', - model: model || options.model, - timestamp, - index: chunk.output_index, - }) - toolCallMetadata.get(item.id)!.started = true - } - } - - // Handle function call arguments delta (streaming) - if ( - chunk.type === 'response.function_call_arguments.delta' && - chunk.delta - ) { - const metadata = toolCallMetadata.get(chunk.item_id) - yield asChunk({ - type: 'TOOL_CALL_ARGS', - toolCallId: chunk.item_id, - model: model || options.model, - timestamp, - delta: chunk.delta, - args: metadata ? undefined : chunk.delta, // We don't accumulate here, let caller handle it - }) - } - - if (chunk.type === 'response.function_call_arguments.done') { - const { item_id } = chunk - - // Get the function name from metadata (captured in output_item.added) - const metadata = toolCallMetadata.get(item_id) - const name = metadata?.name || '' - - // Parse arguments - let parsedInput: unknown = {} - try { - const parsed = chunk.arguments ? JSON.parse(chunk.arguments) : {} - parsedInput = parsed && typeof parsed === 'object' ? parsed : {} - } catch { - parsedInput = {} - } - - yield asChunk({ - type: 'TOOL_CALL_END', - toolCallId: item_id, - toolCallName: name, - toolName: name, - model: model || options.model, - timestamp, - input: parsedInput, - }) - } - - if (chunk.type === 'response.completed') { - // Close reasoning events if still open - if (reasoningMessageId && !hasClosedReasoning) { - hasClosedReasoning = true - yield asChunk({ - type: 'REASONING_MESSAGE_END', - messageId: reasoningMessageId, - model: model || options.model, - timestamp, - }) - yield asChunk({ - type: 'REASONING_END', - messageId: reasoningMessageId, - model: model || options.model, - timestamp, - }) - } - - // Emit TEXT_MESSAGE_END if we had text content - if (hasEmittedTextMessageStart) { - yield asChunk({ - type: 'TEXT_MESSAGE_END', - messageId, - model: model || options.model, - timestamp, - }) - } - - // Determine finish reason based on output - // If there are function_call items in the output, it's a tool_calls finish - const hasFunctionCalls = chunk.response.output.some( - (item: unknown) => - (item as { type: string }).type === 'function_call', - ) - - yield asChunk({ - type: 'RUN_FINISHED', - runId, - threadId, - model: model || options.model, - timestamp, - usage: { - promptTokens: chunk.response.usage?.input_tokens || 0, - completionTokens: chunk.response.usage?.output_tokens || 0, - totalTokens: chunk.response.usage?.total_tokens || 0, - }, - finishReason: hasFunctionCalls ? 'tool_calls' : 'stop', - }) - } - - if (chunk.type === 'error') { - yield asChunk({ - type: 'RUN_ERROR', - runId, - message: chunk.message, - code: chunk.code ?? undefined, - model: model || options.model, - timestamp, - error: { - message: chunk.message, - code: chunk.code ?? undefined, - }, - }) - } - } - } catch (error: unknown) { - const err = error as Error & { code?: string } - logger.errors('openai stream ended with error', { - error, - source: 'openai.processOpenAIStreamChunks', - totalChunks: chunkCount, - }) - yield asChunk({ - type: 'RUN_ERROR', - runId, - message: err.message || 'Unknown error occurred', - code: err.code, - model: options.model, - timestamp, - error: { - message: err.message || 'Unknown error occurred', - code: err.code, - }, - }) - } - } - - /** - * Maps common options to OpenAI-specific format - * Handles translation of normalized options to OpenAI's API format - */ - private mapTextOptionsToOpenAI(options: TextOptions) { + protected override mapOptionsToRequest( + options: TextOptions, + ): Omit { + // The structural type the validator expects is broader than what + // `TProviderOptions` is bound to per-model, so narrow via the internal + // shape rather than re-exposing it on the public override signature. const modelOptions = options.modelOptions as - | Omit< - InternalTextProviderOptions, - | 'max_output_tokens' - | 'tools' - | 'metadata' - | 'temperature' - | 'input' - | 'top_p' - > + | InternalTextProviderOptions | undefined const input = this.convertMessagesToInput(options.messages) if (modelOptions) { @@ -872,207 +120,35 @@ export class OpenAITextAdapter< ? convertToolsToProviderFormat(options.tools) : undefined + // Mirror the base adapter's precedence: spread `modelOptions` first, then + // conditionally add explicit top-level options only when defined. The + // previous override spread `...modelOptions` LAST and wrote + // `temperature: options.temperature` unconditionally — re-introducing the + // exact regression the base class's nullish-aware merge fixes. const requestParams: Omit< OpenAI_SDK.Responses.ResponseCreateParams, 'stream' > = { - model: options.model, - temperature: options.temperature, - max_output_tokens: options.maxTokens, - top_p: options.topP, - metadata: options.metadata, - instructions: options.systemPrompts?.join('\n'), ...modelOptions, + model: options.model, + ...(options.temperature !== undefined && { + temperature: options.temperature, + }), + ...(options.maxTokens !== undefined && { + max_output_tokens: options.maxTokens, + }), + ...(options.topP !== undefined && { top_p: options.topP }), + ...(options.metadata !== undefined && { metadata: options.metadata }), + ...(options.systemPrompts && + options.systemPrompts.length > 0 && { + instructions: options.systemPrompts.join('\n'), + }), input, - tools, + ...(tools && tools.length > 0 && { tools }), } return requestParams } - - private convertMessagesToInput( - messages: Array, - ): Responses.ResponseInput { - const result: Responses.ResponseInput = [] - - for (const message of messages) { - // Handle tool messages - convert to FunctionToolCallOutput - if (message.role === 'tool') { - result.push({ - type: 'function_call_output', - call_id: message.toolCallId || '', - output: - typeof message.content === 'string' - ? message.content - : JSON.stringify(message.content), - }) - continue - } - - // Handle assistant messages - if (message.role === 'assistant') { - // If the assistant message has tool calls, add them as FunctionToolCall objects - // OpenAI Responses API expects arguments as a string (JSON string) - if (message.toolCalls && message.toolCalls.length > 0) { - for (const toolCall of message.toolCalls) { - // Keep arguments as string for Responses API - // Our internal format stores arguments as a JSON string, which is what API expects - const argumentsString = - typeof toolCall.function.arguments === 'string' - ? toolCall.function.arguments - : JSON.stringify(toolCall.function.arguments) - - result.push({ - type: 'function_call', - call_id: toolCall.id, - name: toolCall.function.name, - arguments: argumentsString, - }) - } - } - - // Add the assistant's text message if there is content - if (message.content) { - // Assistant messages are typically text-only - const contentStr = this.extractTextContent(message.content) - if (contentStr) { - result.push({ - type: 'message', - role: 'assistant', - content: contentStr, - }) - } - } - - continue - } - - // Handle user messages (default case) - support multimodal content - const contentParts = this.normalizeContent(message.content) - const openAIContent: Array = [] - - for (const part of contentParts) { - openAIContent.push( - this.convertContentPartToOpenAI( - part as ContentPart< - unknown, - OpenAIImageMetadata, - OpenAIAudioMetadata, - unknown, - unknown - >, - ), - ) - } - - // If no content parts, add empty text - if (openAIContent.length === 0) { - openAIContent.push({ type: 'input_text', text: '' }) - } - - result.push({ - type: 'message', - role: 'user', - content: openAIContent, - }) - } - - return result - } - - /** - * Converts a ContentPart to OpenAI input content item. - * Handles text, image, and audio content parts. - */ - private convertContentPartToOpenAI( - part: ContentPart< - unknown, - OpenAIImageMetadata, - OpenAIAudioMetadata, - unknown, - unknown - >, - ): Responses.ResponseInputContent { - switch (part.type) { - case 'text': - return { - type: 'input_text', - text: part.content, - } - case 'image': { - const imageMetadata = part.metadata - if (part.source.type === 'url') { - return { - type: 'input_image', - image_url: part.source.value, - detail: imageMetadata?.detail || 'auto', - } - } - // For base64 data, construct a data URI using the mimeType from source - const imageValue = part.source.value - const imageUrl = imageValue.startsWith('data:') - ? imageValue - : `data:${part.source.mimeType};base64,${imageValue}` - return { - type: 'input_image', - image_url: imageUrl, - detail: imageMetadata?.detail || 'auto', - } - } - case 'audio': { - if (part.source.type === 'url') { - // OpenAI may support audio URLs in the future - // For now, treat as data URI - return { - type: 'input_file', - file_url: part.source.value, - } - } - return { - type: 'input_file', - file_data: part.source.value, - } - } - - default: - throw new Error(`Unsupported content part type: ${part.type}`) - } - } - - /** - * Normalizes message content to an array of ContentPart. - * Handles backward compatibility with string content. - */ - private normalizeContent( - content: string | null | Array, - ): Array { - if (content === null) { - return [] - } - if (typeof content === 'string') { - return [{ type: 'text', content: content }] - } - return content - } - - /** - * Extracts text content from a content value that may be string, null, or ContentPart array. - */ - private extractTextContent( - content: string | null | Array, - ): string { - if (content === null) { - return '' - } - if (typeof content === 'string') { - return content - } - // It's an array of ContentPart - return content - .filter((p) => p.type === 'text') - .map((p) => p.content) - .join('') - } } /** diff --git a/packages/typescript/ai-openai/src/adapters/transcription.ts b/packages/typescript/ai-openai/src/adapters/transcription.ts index 34139a3fd..5a7742298 100644 --- a/packages/typescript/ai-openai/src/adapters/transcription.ts +++ b/packages/typescript/ai-openai/src/adapters/transcription.ts @@ -1,17 +1,7 @@ -import { BaseTranscriptionAdapter } from '@tanstack/ai/adapters' -import { - createOpenAIClient, - generateId, - getOpenAIApiKeyFromEnv, -} from '../utils/client' +import { OpenAICompatibleTranscriptionAdapter } from '@tanstack/openai-base' +import { getOpenAIApiKeyFromEnv } from '../utils/client' import type { OpenAITranscriptionModel } from '../model-meta' import type { OpenAITranscriptionProviderOptions } from '../audio/transcription-provider-options' -import type { - TranscriptionOptions, - TranscriptionResult, - TranscriptionSegment, -} from '@tanstack/ai' -import type OpenAI_SDK from 'openai' import type { OpenAIClientConfig } from '../utils/client' /** @@ -34,148 +24,21 @@ export interface OpenAITranscriptionConfig extends OpenAIClientConfig {} */ export class OpenAITranscriptionAdapter< TModel extends OpenAITranscriptionModel, -> extends BaseTranscriptionAdapter { +> extends OpenAICompatibleTranscriptionAdapter< + TModel, + OpenAITranscriptionProviderOptions +> { readonly name = 'openai' as const - private client: OpenAI_SDK - constructor(config: OpenAITranscriptionConfig, model: TModel) { - super(model, config) - this.client = createOpenAIClient(config) - } - - async transcribe( - options: TranscriptionOptions, - ): Promise { - const { logger } = options - const { model, audio, language, prompt, responseFormat, modelOptions } = - options - - logger.request( - `activity=generateTranscription provider=openai model=${model}`, - { provider: 'openai', model }, - ) - - try { - // Convert audio input to File object - const file = this.prepareAudioFile(audio) - - // Build request - const request: OpenAI_SDK.Audio.TranscriptionCreateParams = { - model, - file, - language, - prompt, - response_format: this.mapResponseFormat(responseFormat), - ...modelOptions, - } - - // Call OpenAI API - use verbose_json to get timestamps when available - const useVerbose = - responseFormat === 'verbose_json' || - (!responseFormat && model !== 'whisper-1') - - if (useVerbose) { - const response = await this.client.audio.transcriptions.create({ - ...request, - response_format: 'verbose_json', - }) - - return { - id: generateId(this.name), - model, - text: response.text, - language: response.language, - duration: response.duration, - segments: response.segments?.map( - (seg): TranscriptionSegment => ({ - id: seg.id, - start: seg.start, - end: seg.end, - text: seg.text, - confidence: seg.avg_logprob - ? Math.exp(seg.avg_logprob) - : undefined, - }), - ), - words: response.words?.map((w) => ({ - word: w.word, - start: w.start, - end: w.end, - })), - } - } else { - const response = await this.client.audio.transcriptions.create(request) - - return { - id: generateId(this.name), - model, - text: typeof response === 'string' ? response : response.text, - language, - } - } - } catch (error) { - logger.errors('openai.transcribe fatal', { - error, - source: 'openai.transcribe', - }) - throw error - } - } - - private prepareAudioFile(audio: string | File | Blob | ArrayBuffer): File { - // If already a File, return it - if (typeof File !== 'undefined' && audio instanceof File) { - return audio - } - - // If Blob, convert to File - if (typeof Blob !== 'undefined' && audio instanceof Blob) { - return new File([audio], 'audio.mp3', { - type: audio.type || 'audio/mpeg', - }) - } - - // If ArrayBuffer, convert to File - if (audio instanceof ArrayBuffer) { - return new File([audio], 'audio.mp3', { type: 'audio/mpeg' }) - } - - // If base64 string, decode and convert to File - if (typeof audio === 'string') { - // Check if it's a data URL - if (audio.startsWith('data:')) { - const parts = audio.split(',') - const header = parts[0] - const base64Data = parts[1] || '' - const mimeMatch = header?.match(/data:([^;]+)/) - const mimeType = mimeMatch?.[1] || 'audio/mpeg' - const binaryStr = atob(base64Data) - const bytes = new Uint8Array(binaryStr.length) - for (let i = 0; i < binaryStr.length; i++) { - bytes[i] = binaryStr.charCodeAt(i) - } - const extension = mimeType.split('/')[1] || 'mp3' - return new File([bytes], `audio.${extension}`, { type: mimeType }) - } - - // Assume raw base64 - const binaryStr = atob(audio) - const bytes = new Uint8Array(binaryStr.length) - for (let i = 0; i < binaryStr.length; i++) { - bytes[i] = binaryStr.charCodeAt(i) - } - return new File([bytes], 'audio.mp3', { type: 'audio/mpeg' }) - } - - throw new Error('Invalid audio input type') + super(config, model, 'openai') } - private mapResponseFormat( - format?: 'json' | 'text' | 'srt' | 'verbose_json' | 'vtt', - ): OpenAI_SDK.Audio.TranscriptionCreateParams['response_format'] { - if (!format) return 'json' - return format as OpenAI_SDK.Audio.TranscriptionCreateParams['response_format'] + protected override shouldDefaultToVerbose(model: string): boolean { + // Only Whisper supports `verbose_json`. The gpt-4o-* transcribe models + // accept only `json` and `text` and reject `verbose_json` with HTTP 400, + // so they must NOT default to verbose. The previous logic was inverted. + return model === 'whisper-1' } } diff --git a/packages/typescript/ai-openai/src/adapters/tts.ts b/packages/typescript/ai-openai/src/adapters/tts.ts index a50f4abe0..59d302970 100644 --- a/packages/typescript/ai-openai/src/adapters/tts.ts +++ b/packages/typescript/ai-openai/src/adapters/tts.ts @@ -1,22 +1,12 @@ -import { BaseTTSAdapter } from '@tanstack/ai/adapters' -import { - createOpenAIClient, - generateId, - getOpenAIApiKeyFromEnv, -} from '../utils/client' +import { OpenAICompatibleTTSAdapter } from '@tanstack/openai-base' +import { getOpenAIApiKeyFromEnv } from '../utils/client' import { validateAudioInput, validateInstructions, validateSpeed, } from '../audio/audio-provider-options' import type { OpenAITTSModel } from '../model-meta' -import type { - OpenAITTSFormat, - OpenAITTSProviderOptions, - OpenAITTSVoice, -} from '../audio/tts-provider-options' -import type { TTSOptions, TTSResult } from '@tanstack/ai' -import type OpenAI_SDK from 'openai' +import type { OpenAITTSProviderOptions } from '../audio/tts-provider-options' import type { OpenAIClientConfig } from '../utils/client' /** @@ -37,88 +27,36 @@ export interface OpenAITTSConfig extends OpenAIClientConfig {} */ export class OpenAITTSAdapter< TModel extends OpenAITTSModel, -> extends BaseTTSAdapter { +> extends OpenAICompatibleTTSAdapter { readonly name = 'openai' as const - private client: OpenAI_SDK - constructor(config: OpenAITTSConfig, model: TModel) { - super(model, config) - this.client = createOpenAIClient(config) + super(config, model, 'openai') } - async generateSpeech( - options: TTSOptions, - ): Promise { - const { logger } = options - const { model, text, voice, format, speed, modelOptions } = options - - logger.request(`activity=generateSpeech provider=openai model=${model}`, { - provider: 'openai', - model, - }) - - // Validate inputs using existing validators - const audioOptions = { - input: text, - model, - voice: voice as OpenAITTSVoice, - speed, - response_format: format as OpenAITTSFormat, - ...modelOptions, - } - - validateAudioInput(audioOptions) - validateSpeed(audioOptions) - validateInstructions(audioOptions) + protected override validateAudioInput(text: string): void { + // Delegate to OpenAI-specific validation that also validates model/voice/format + validateAudioInput({ input: text, model: this.model, voice: 'alloy' }) + } - // Build request - const request: OpenAI_SDK.Audio.SpeechCreateParams = { - model, - input: text, - voice: voice || 'alloy', - response_format: format, - speed, - ...modelOptions, + protected override validateSpeed(speed?: number): void { + if (speed !== undefined) { + validateSpeed({ speed, model: this.model, input: '', voice: 'alloy' }) } + } - try { - // Call OpenAI API - const response = await this.client.audio.speech.create(request) - - // Convert response to base64 - const arrayBuffer = await response.arrayBuffer() - const base64 = Buffer.from(arrayBuffer).toString('base64') - - const outputFormat = format || 'mp3' - const contentType = this.getContentType(outputFormat) - - return { - id: generateId(this.name), + protected override validateInstructions( + model: string, + modelOptions?: OpenAITTSProviderOptions, + ): void { + if (modelOptions) { + validateInstructions({ + ...modelOptions, model, - audio: base64, - format: outputFormat, - contentType, - } - } catch (error) { - logger.errors('openai.generateSpeech fatal', { - error, - source: 'openai.generateSpeech', + input: '', + voice: 'alloy', }) - throw error - } - } - - private getContentType(format: string): string { - const contentTypes: Record = { - mp3: 'audio/mpeg', - opus: 'audio/opus', - aac: 'audio/aac', - flac: 'audio/flac', - wav: 'audio/wav', - pcm: 'audio/pcm', } - return contentTypes[format] || 'audio/mpeg' } } diff --git a/packages/typescript/ai-openai/src/adapters/video.ts b/packages/typescript/ai-openai/src/adapters/video.ts index 68366a811..e5bc9aee1 100644 --- a/packages/typescript/ai-openai/src/adapters/video.ts +++ b/packages/typescript/ai-openai/src/adapters/video.ts @@ -1,5 +1,5 @@ -import { BaseVideoAdapter } from '@tanstack/ai/adapters' -import { createOpenAIClient, getOpenAIApiKeyFromEnv } from '../utils/client' +import { OpenAICompatibleVideoAdapter } from '@tanstack/openai-base' +import { getOpenAIApiKeyFromEnv } from '../utils/client' import { toApiSeconds, validateVideoSeconds, @@ -12,12 +12,7 @@ import type { OpenAIVideoModelSizeByName, OpenAIVideoProviderOptions, } from '../video/video-provider-options' -import type { - VideoGenerationOptions, - VideoJobResult, - VideoStatusResult, - VideoUrlResult, -} from '@tanstack/ai' +import type { VideoGenerationOptions } from '@tanstack/ai' import type OpenAI_SDK from 'openai' import type { OpenAIClientConfig } from '../utils/client' @@ -44,7 +39,7 @@ export interface OpenAIVideoConfig extends OpenAIClientConfig {} */ export class OpenAIVideoAdapter< TModel extends OpenAIVideoModel, -> extends BaseVideoAdapter< +> extends OpenAICompatibleVideoAdapter< TModel, OpenAIVideoProviderOptions, OpenAIVideoModelProviderOptionsByName, @@ -52,241 +47,22 @@ export class OpenAIVideoAdapter< > { readonly name = 'openai' as const - private client: OpenAI_SDK - constructor(config: OpenAIVideoConfig, model: TModel) { - super(config, model) - this.client = createOpenAIClient(config) - } - - /** - * Create a new video generation job. - * - * API: POST /v1/videos - * Docs: https://platform.openai.com/docs/api-reference/videos/create - * - * @experimental Video generation is an experimental feature and may change. - * - * @example - * ```ts - * const { jobId } = await adapter.createVideoJob({ - * model: 'sora-2', - * prompt: 'A cat chasing a dog in a sunny park', - * size: '1280x720', - * duration: 8 // seconds: 4, 8, or 12 - * }) - * ``` - */ - async createVideoJob( - options: VideoGenerationOptions, - ): Promise { - const { model, size, duration, modelOptions, logger } = options - - logger.request( - `activity=generateVideo provider=openai model=${this.model}`, - { - provider: 'openai', - model: this.model, - }, - ) - - try { - // Validate inputs - validateVideoSize(model, size) - // Duration maps to 'seconds' in the API - const seconds = duration ?? modelOptions?.seconds - validateVideoSeconds(model, seconds) - - // Build request - const request = this.buildRequest(options) - - // POST /v1/videos - // Cast to any because the videos API may not be in SDK types yet - const client = this.client - const response = await client.videos.create(request) - - return { - jobId: response.id, - model, - } - } catch (error: any) { - logger.errors('openai.createVideoJob fatal', { - error, - source: 'openai.createVideoJob', - }) - // Fallback for when the videos API is not available - if (error?.message?.includes('videos') || error?.code === 'invalid_api') { - throw new Error( - `Video generation API is not available. The Sora API may require special access. ` + - `Original error: ${error.message}`, - ) - } - throw error - } + super(config, model, 'openai') } - /** - * Get the current status of a video generation job. - * - * API: GET /v1/videos/{video_id} - * Docs: https://platform.openai.com/docs/api-reference/videos/get - * - * @experimental Video generation is an experimental feature and may change. - * - * @example - * ```ts - * const status = await adapter.getVideoStatus(jobId) - * if (status.status === 'completed') { - * console.log('Video is ready!') - * } else if (status.status === 'processing') { - * console.log(`Progress: ${status.progress}%`) - * } - * ``` - */ - async getVideoStatus(jobId: string): Promise { - try { - // GET /v1/videos/{video_id} - const client = this.client - const response = await client.videos.retrieve(jobId) - - return { - jobId, - status: this.mapStatus(response.status), - progress: response.progress, - error: response.error?.message, - } - } catch (error: any) { - if (error.status === 404) { - return { - jobId, - status: 'failed', - error: 'Job not found', - } - } - throw error - } + protected override validateVideoSize(model: string, size?: string): void { + validateVideoSize(model, size) } - /** - * Get the URL to download/view the generated video. - * - * API: GET /v1/videos/{video_id}/content - * Docs: https://platform.openai.com/docs/api-reference/videos/content - * - * @experimental Video generation is an experimental feature and may change. - * - * @example - * ```ts - * const { url, expiresAt } = await adapter.getVideoUrl(jobId) - * console.log('Video URL:', url) - * console.log('Expires at:', expiresAt) - * ``` - */ - async getVideoUrl(jobId: string): Promise { - try { - // GET /v1/videos/{video_id}/content - // The SDK may not have a .content() method, so we try multiple approaches - const client = this.client as any - - let response: any - - // Try different possible method names - if (typeof client.videos?.content === 'function') { - response = await client.videos.content(jobId) - } else if (typeof client.videos?.getContent === 'function') { - response = await client.videos.getContent(jobId) - } else if (typeof client.videos?.download === 'function') { - response = await client.videos.download(jobId) - } else { - // Fallback: check if retrieve returns the URL directly - const videoInfo = await client.videos.retrieve(jobId) - if (videoInfo.url) { - return { - jobId, - url: videoInfo.url, - expiresAt: videoInfo.expires_at - ? new Date(videoInfo.expires_at) - : undefined, - } - } - - // Last resort: The /content endpoint returns raw binary video data, not JSON. - // We need to construct a URL that the client can use to fetch the video. - // The URL needs to include auth, so we'll create a signed URL or return - // a proxy endpoint. - - // For now, return a URL that goes through our API to proxy the request - // since the raw endpoint requires auth headers that browsers can't send. - // The video element can't add Authorization headers, so we need a workaround. - - // Option 1: Return the direct URL (only works if OpenAI supports query param auth) - // Option 2: Return a blob URL after fetching (memory intensive) - // Option 3: Return a proxy URL through our server - - // Let's try fetching and returning a data URL for now - const baseUrl = this.config.baseUrl || 'https://api.openai.com/v1' - const apiKey = this.config.apiKey - - const contentResponse = await fetch( - `${baseUrl}/videos/${jobId}/content`, - { - method: 'GET', - headers: { - Authorization: `Bearer ${apiKey}`, - }, - }, - ) - - if (!contentResponse.ok) { - // Try to parse error as JSON, but it might be binary - const contentType = contentResponse.headers.get('content-type') - if (contentType?.includes('application/json')) { - const errorData = await contentResponse.json().catch(() => ({})) - throw new Error( - errorData.error?.message || - `Failed to get video content: ${contentResponse.status}`, - ) - } - throw new Error( - `Failed to get video content: ${contentResponse.status}`, - ) - } - - // The response is the raw video file - convert to base64 data URL - const videoBlob = await contentResponse.blob() - const buffer = await videoBlob.arrayBuffer() - const base64 = Buffer.from(buffer).toString('base64') - const mimeType = - contentResponse.headers.get('content-type') || 'video/mp4' - - return { - jobId, - url: `data:${mimeType};base64,${base64}`, - expiresAt: undefined, // Data URLs don't expire - } - } - - return { - jobId, - url: response.url, - expiresAt: response.expires_at - ? new Date(response.expires_at) - : undefined, - } - } catch (error: any) { - if (error.status === 404) { - throw new Error(`Video job not found: ${jobId}`) - } - if (error.status === 400) { - throw new Error( - `Video is not ready for download. Check status first. Job ID: ${jobId}`, - ) - } - throw error - } + protected override validateVideoSeconds( + model: string, + seconds?: number | string, + ): void { + validateVideoSeconds(model, seconds) } - private buildRequest( + protected override buildRequest( options: VideoGenerationOptions, ): OpenAI_SDK.Videos.VideoCreateParams { const { model, prompt, size, duration, modelOptions } = options @@ -313,28 +89,6 @@ export class OpenAIVideoAdapter< return request } - - private mapStatus( - apiStatus: string, - ): 'pending' | 'processing' | 'completed' | 'failed' { - switch (apiStatus) { - case 'queued': - case 'pending': - return 'pending' - case 'processing': - case 'in_progress': - return 'processing' - case 'completed': - case 'succeeded': - return 'completed' - case 'failed': - case 'error': - case 'cancelled': - return 'failed' - default: - return 'processing' - } - } } /** diff --git a/packages/typescript/ai-openai/src/tools/apply-patch-tool.ts b/packages/typescript/ai-openai/src/tools/apply-patch-tool.ts index 486841f75..ab4ed63df 100644 --- a/packages/typescript/ai-openai/src/tools/apply-patch-tool.ts +++ b/packages/typescript/ai-openai/src/tools/apply-patch-tool.ts @@ -1,32 +1,18 @@ -import type OpenAI from 'openai' -import type { ProviderTool, Tool } from '@tanstack/ai' +import { applyPatchTool as baseApplyPatchTool } from '@tanstack/openai-base' +import type { ProviderTool } from '@tanstack/ai' -export type ApplyPatchToolConfig = OpenAI.Responses.ApplyPatchTool - -/** @deprecated Renamed to `ApplyPatchToolConfig`. Will be removed in a future release. */ -export type ApplyPatchTool = ApplyPatchToolConfig +export { + type ApplyPatchToolConfig, + type ApplyPatchTool, + convertApplyPatchToolToAdapterFormat, +} from '@tanstack/openai-base' export type OpenAIApplyPatchTool = ProviderTool<'openai', 'apply_patch'> /** - * Converts a standard Tool to OpenAI ApplyPatchTool format - */ -export function convertApplyPatchToolToAdapterFormat( - _tool: Tool, -): ApplyPatchToolConfig { - return { - type: 'apply_patch', - } -} - -/** - * Creates a standard Tool from ApplyPatchTool parameters + * Creates a standard Tool from ApplyPatchTool parameters, branded as an + * OpenAI provider tool. */ export function applyPatchTool(): OpenAIApplyPatchTool { - // Phantom-brand cast: '~provider'/'~toolKind' are type-only and never assigned at runtime. - return { - name: 'apply_patch', - description: 'Apply a patch to modify files', - metadata: {}, - } as unknown as OpenAIApplyPatchTool + return baseApplyPatchTool() as OpenAIApplyPatchTool } diff --git a/packages/typescript/ai-openai/src/tools/code-interpreter-tool.ts b/packages/typescript/ai-openai/src/tools/code-interpreter-tool.ts index 357b47c64..52c43d89f 100644 --- a/packages/typescript/ai-openai/src/tools/code-interpreter-tool.ts +++ b/packages/typescript/ai-openai/src/tools/code-interpreter-tool.ts @@ -1,10 +1,12 @@ -import type { ProviderTool, Tool } from '@tanstack/ai' -import type OpenAI from 'openai' +import { codeInterpreterTool as baseCodeInterpreterTool } from '@tanstack/openai-base' +import type { ProviderTool } from '@tanstack/ai' +import type { CodeInterpreterToolConfig } from '@tanstack/openai-base' -export type CodeInterpreterToolConfig = OpenAI.Responses.Tool.CodeInterpreter - -/** @deprecated Renamed to `CodeInterpreterToolConfig`. Will be removed in a future release. */ -export type CodeInterpreterTool = CodeInterpreterToolConfig +export { + type CodeInterpreterToolConfig, + type CodeInterpreterTool, + convertCodeInterpreterToolToAdapterFormat, +} from '@tanstack/openai-base' export type OpenAICodeInterpreterTool = ProviderTool< 'openai', @@ -12,31 +14,12 @@ export type OpenAICodeInterpreterTool = ProviderTool< > /** - * Converts a standard Tool to OpenAI CodeInterpreterTool format - */ -export function convertCodeInterpreterToolToAdapterFormat( - tool: Tool, -): CodeInterpreterToolConfig { - const metadata = tool.metadata as CodeInterpreterToolConfig - return { - type: 'code_interpreter', - container: metadata.container, - } -} - -/** - * Creates a standard Tool from CodeInterpreterTool parameters + * Creates a standard Tool from CodeInterpreterTool parameters, branded as an + * OpenAI provider tool. Delegates construction to the base factory and brands + * the result via a phantom-typed `ProviderTool` cast. */ export function codeInterpreterTool( container: CodeInterpreterToolConfig, ): OpenAICodeInterpreterTool { - // Phantom-brand cast: '~provider'/'~toolKind' are type-only and never assigned at runtime. - return { - name: 'code_interpreter', - description: 'Execute code in a sandboxed environment', - metadata: { - type: 'code_interpreter', - container, - }, - } as unknown as OpenAICodeInterpreterTool + return baseCodeInterpreterTool(container) as OpenAICodeInterpreterTool } diff --git a/packages/typescript/ai-openai/src/tools/computer-use-tool.ts b/packages/typescript/ai-openai/src/tools/computer-use-tool.ts index 72e3a9399..8226c7acd 100644 --- a/packages/typescript/ai-openai/src/tools/computer-use-tool.ts +++ b/packages/typescript/ai-openai/src/tools/computer-use-tool.ts @@ -1,40 +1,27 @@ -import type OpenAI from 'openai' -import type { ProviderTool, Tool } from '@tanstack/ai' +import { computerUseTool as baseComputerUseTool } from '@tanstack/openai-base' +import type { ProviderTool } from '@tanstack/ai' +import type { ComputerUseToolConfig } from '@tanstack/openai-base' -export type ComputerUseToolConfig = OpenAI.Responses.ComputerTool - -/** @deprecated Renamed to `ComputerUseToolConfig`. Will be removed in a future release. */ -export type ComputerUseTool = ComputerUseToolConfig +export { + type ComputerUseToolConfig, + type ComputerUseTool, + convertComputerUseToolToAdapterFormat, +} from '@tanstack/openai-base' +// The brand discriminator (`computer_use`) intentionally differs from the +// runtime tool name (`computer_use_preview`). The brand matches the model-meta +// tool-capability union (`tools: ['computer_use', ...]`) used to gate which +// models can construct this tool at compile time, while the runtime name +// matches the OpenAI SDK's literal `'computer_use_preview'` that the +// special-tool dispatcher in `convertToolsToProviderFormat` switches on. export type OpenAIComputerUseTool = ProviderTool<'openai', 'computer_use'> /** - * Converts a standard Tool to OpenAI ComputerUseTool format - */ -export function convertComputerUseToolToAdapterFormat( - tool: Tool, -): ComputerUseToolConfig { - const metadata = tool.metadata as ComputerUseToolConfig - return { - type: 'computer_use_preview', - display_height: metadata.display_height, - display_width: metadata.display_width, - environment: metadata.environment, - } -} - -/** - * Creates a standard Tool from ComputerUseTool parameters + * Creates a standard Tool from ComputerUseTool parameters, branded as an + * OpenAI provider tool. */ export function computerUseTool( toolData: ComputerUseToolConfig, ): OpenAIComputerUseTool { - // Phantom-brand cast: '~provider'/'~toolKind' are type-only and never assigned at runtime. - return { - name: 'computer_use_preview', - description: 'Control a virtual computer', - metadata: { - ...toolData, - }, - } as unknown as OpenAIComputerUseTool + return baseComputerUseTool(toolData) as OpenAIComputerUseTool } diff --git a/packages/typescript/ai-openai/src/tools/custom-tool.ts b/packages/typescript/ai-openai/src/tools/custom-tool.ts index 1bbd543b7..9d898a897 100644 --- a/packages/typescript/ai-openai/src/tools/custom-tool.ts +++ b/packages/typescript/ai-openai/src/tools/custom-tool.ts @@ -1,33 +1,6 @@ -import type OpenAI from 'openai' -import type { Tool } from '@tanstack/ai' - -export type CustomToolConfig = OpenAI.Responses.CustomTool - -/** @deprecated Renamed to `CustomToolConfig`. Will be removed in a future release. */ -export type CustomTool = CustomToolConfig - -/** - * Converts a standard Tool to OpenAI CustomTool format - */ -export function convertCustomToolToAdapterFormat(tool: Tool): CustomToolConfig { - const metadata = tool.metadata as CustomToolConfig - return { - type: 'custom', - name: metadata.name, - description: metadata.description, - format: metadata.format, - } -} - -/** - * Creates a standard Tool from CustomTool parameters - */ -export function customTool(toolData: CustomToolConfig): Tool { - return { - name: 'custom', - description: toolData.description || 'A custom tool', - metadata: { - ...toolData, - }, - } -} +export { + type CustomToolConfig, + type CustomTool, + convertCustomToolToAdapterFormat, + customTool, +} from '@tanstack/openai-base' diff --git a/packages/typescript/ai-openai/src/tools/file-search-tool.ts b/packages/typescript/ai-openai/src/tools/file-search-tool.ts index ee109e10f..c90af1011 100644 --- a/packages/typescript/ai-openai/src/tools/file-search-tool.ts +++ b/packages/typescript/ai-openai/src/tools/file-search-tool.ts @@ -1,48 +1,21 @@ -import type OpenAI from 'openai' -import type { ProviderTool, Tool } from '@tanstack/ai' +import { fileSearchTool as baseFileSearchTool } from '@tanstack/openai-base' +import type { ProviderTool } from '@tanstack/ai' +import type { FileSearchToolConfig } from '@tanstack/openai-base' -const validateMaxNumResults = (maxNumResults: number | undefined) => { - if (maxNumResults && (maxNumResults < 1 || maxNumResults > 50)) { - throw new Error('max_num_results must be between 1 and 50.') - } -} - -export type FileSearchToolConfig = OpenAI.Responses.FileSearchTool - -/** @deprecated Renamed to `FileSearchToolConfig`. Will be removed in a future release. */ -export type FileSearchTool = FileSearchToolConfig +export { + type FileSearchToolConfig, + type FileSearchTool, + convertFileSearchToolToAdapterFormat, +} from '@tanstack/openai-base' export type OpenAIFileSearchTool = ProviderTool<'openai', 'file_search'> /** - * Converts a standard Tool to OpenAI FileSearchTool format - */ -export function convertFileSearchToolToAdapterFormat( - tool: Tool, -): OpenAI.Responses.FileSearchTool { - const metadata = tool.metadata as OpenAI.Responses.FileSearchTool - return { - type: 'file_search', - vector_store_ids: metadata.vector_store_ids, - max_num_results: metadata.max_num_results, - ranking_options: metadata.ranking_options, - filters: metadata.filters, - } -} - -/** - * Creates a standard Tool from FileSearchTool parameters + * Creates a standard Tool from FileSearchTool parameters, branded as an + * OpenAI provider tool. */ export function fileSearchTool( - toolData: OpenAI.Responses.FileSearchTool, + toolData: FileSearchToolConfig, ): OpenAIFileSearchTool { - validateMaxNumResults(toolData.max_num_results) - // Phantom-brand cast: '~provider'/'~toolKind' are type-only and never assigned at runtime. - return { - name: 'file_search', - description: 'Search files in vector stores', - metadata: { - ...toolData, - }, - } as unknown as OpenAIFileSearchTool + return baseFileSearchTool(toolData) as OpenAIFileSearchTool } diff --git a/packages/typescript/ai-openai/src/tools/function-tool.ts b/packages/typescript/ai-openai/src/tools/function-tool.ts index 6b4630f3f..fefd46433 100644 --- a/packages/typescript/ai-openai/src/tools/function-tool.ts +++ b/packages/typescript/ai-openai/src/tools/function-tool.ts @@ -1,47 +1,5 @@ -import { makeOpenAIStructuredOutputCompatible } from '../utils/schema-converter' -import type { JSONSchema, Tool } from '@tanstack/ai' -import type OpenAI from 'openai' - -export type FunctionToolConfig = OpenAI.Responses.FunctionTool - -/** @deprecated Renamed to `FunctionToolConfig`. Will be removed in a future release. */ -export type FunctionTool = FunctionToolConfig - -/** - * Converts a standard Tool to OpenAI FunctionTool format. - * - * Tool schemas are already converted to JSON Schema in the ai layer. - * We apply OpenAI-specific transformations for strict mode: - * - All properties in required array - * - Optional fields made nullable - * - additionalProperties: false - * - * This enables strict mode for all tools automatically. - */ -export function convertFunctionToolToAdapterFormat( - tool: Tool, -): FunctionToolConfig { - // Tool schemas are already converted to JSON Schema in the ai layer - // Apply OpenAI-specific transformations for strict mode - const inputSchema = (tool.inputSchema ?? { - type: 'object', - properties: {}, - required: [], - }) as JSONSchema - - const jsonSchema = makeOpenAIStructuredOutputCompatible( - inputSchema, - inputSchema.required || [], - ) - - // Ensure additionalProperties is false for strict mode - jsonSchema.additionalProperties = false - - return { - type: 'function', - name: tool.name, - description: tool.description, - parameters: jsonSchema, - strict: true, // Always use strict mode since our schema converter handles the requirements - } satisfies FunctionToolConfig -} +export { + type FunctionToolConfig, + type FunctionTool, + convertFunctionToolToAdapterFormat, +} from '@tanstack/openai-base' diff --git a/packages/typescript/ai-openai/src/tools/image-generation-tool.ts b/packages/typescript/ai-openai/src/tools/image-generation-tool.ts index 9b3abb395..d621889f9 100644 --- a/packages/typescript/ai-openai/src/tools/image-generation-tool.ts +++ b/packages/typescript/ai-openai/src/tools/image-generation-tool.ts @@ -1,48 +1,24 @@ -import type OpenAI from 'openai' -import type { ProviderTool, Tool } from '@tanstack/ai' +import { imageGenerationTool as baseImageGenerationTool } from '@tanstack/openai-base' +import type { ProviderTool } from '@tanstack/ai' +import type { ImageGenerationToolConfig } from '@tanstack/openai-base' -export type ImageGenerationToolConfig = OpenAI.Responses.Tool.ImageGeneration - -/** @deprecated Renamed to `ImageGenerationToolConfig`. Will be removed in a future release. */ -export type ImageGenerationTool = ImageGenerationToolConfig +export { + type ImageGenerationToolConfig, + type ImageGenerationTool, + convertImageGenerationToolToAdapterFormat, +} from '@tanstack/openai-base' export type OpenAIImageGenerationTool = ProviderTool< 'openai', 'image_generation' > -const validatePartialImages = (value: number | undefined) => { - if (value !== undefined && (value < 0 || value > 3)) { - throw new Error('partial_images must be between 0 and 3') - } -} - -/** - * Converts a standard Tool to OpenAI ImageGenerationTool format - */ -export function convertImageGenerationToolToAdapterFormat( - tool: Tool, -): ImageGenerationToolConfig { - const metadata = tool.metadata as Omit - return { - type: 'image_generation', - ...metadata, - } -} - /** - * Creates a standard Tool from ImageGenerationTool parameters + * Creates a standard Tool from ImageGenerationTool parameters, branded as an + * OpenAI provider tool. */ export function imageGenerationTool( toolData: Omit, ): OpenAIImageGenerationTool { - validatePartialImages(toolData.partial_images) - // Phantom-brand cast: '~provider'/'~toolKind' are type-only and never assigned at runtime. - return { - name: 'image_generation', - description: 'Generate images based on text descriptions', - metadata: { - ...toolData, - }, - } as unknown as OpenAIImageGenerationTool + return baseImageGenerationTool(toolData) as OpenAIImageGenerationTool } diff --git a/packages/typescript/ai-openai/src/tools/index.ts b/packages/typescript/ai-openai/src/tools/index.ts index 918f222e6..7eff9fc69 100644 --- a/packages/typescript/ai-openai/src/tools/index.ts +++ b/packages/typescript/ai-openai/src/tools/index.ts @@ -1,32 +1,4 @@ -// Keep the existing discriminated union defined inline. -// Built from the deprecated config-type aliases — matches the SDK shape that -// `convertToolsToProviderFormat` emits. -import type { ApplyPatchTool } from './apply-patch-tool' -import type { CodeInterpreterTool } from './code-interpreter-tool' -import type { ComputerUseTool } from './computer-use-tool' -import type { CustomTool } from './custom-tool' -import type { FileSearchTool } from './file-search-tool' -import type { FunctionTool } from './function-tool' -import type { ImageGenerationTool } from './image-generation-tool' -import type { LocalShellTool } from './local-shell-tool' -import type { MCPTool } from './mcp-tool' -import type { ShellTool } from './shell-tool' -import type { WebSearchPreviewTool } from './web-search-preview-tool' -import type { WebSearchTool } from './web-search-tool' - -export type OpenAITool = - | ApplyPatchTool - | CodeInterpreterTool - | ComputerUseTool - | CustomTool - | FileSearchTool - | FunctionTool - | ImageGenerationTool - | LocalShellTool - | MCPTool - | ShellTool - | WebSearchPreviewTool - | WebSearchTool +export { type OpenAITool } from '@tanstack/openai-base' export { applyPatchTool, diff --git a/packages/typescript/ai-openai/src/tools/local-shell-tool.ts b/packages/typescript/ai-openai/src/tools/local-shell-tool.ts index ce388ca3b..f49850b84 100644 --- a/packages/typescript/ai-openai/src/tools/local-shell-tool.ts +++ b/packages/typescript/ai-openai/src/tools/local-shell-tool.ts @@ -1,32 +1,18 @@ -import type OpenAI from 'openai' -import type { ProviderTool, Tool } from '@tanstack/ai' +import { localShellTool as baseLocalShellTool } from '@tanstack/openai-base' +import type { ProviderTool } from '@tanstack/ai' -export type LocalShellToolConfig = OpenAI.Responses.Tool.LocalShell - -/** @deprecated Renamed to `LocalShellToolConfig`. Will be removed in a future release. */ -export type LocalShellTool = LocalShellToolConfig +export { + type LocalShellToolConfig, + type LocalShellTool, + convertLocalShellToolToAdapterFormat, +} from '@tanstack/openai-base' export type OpenAILocalShellTool = ProviderTool<'openai', 'local_shell'> /** - * Converts a standard Tool to OpenAI LocalShellTool format - */ -export function convertLocalShellToolToAdapterFormat( - _tool: Tool, -): LocalShellToolConfig { - return { - type: 'local_shell', - } -} - -/** - * Creates a standard Tool from LocalShellTool parameters + * Creates a standard Tool from LocalShellTool parameters, branded as an + * OpenAI provider tool. */ export function localShellTool(): OpenAILocalShellTool { - // Phantom-brand cast: '~provider'/'~toolKind' are type-only and never assigned at runtime. - return { - name: 'local_shell', - description: 'Execute local shell commands', - metadata: {}, - } as unknown as OpenAILocalShellTool + return baseLocalShellTool() as OpenAILocalShellTool } diff --git a/packages/typescript/ai-openai/src/tools/mcp-tool.ts b/packages/typescript/ai-openai/src/tools/mcp-tool.ts index 4f224c108..73c6b95b7 100644 --- a/packages/typescript/ai-openai/src/tools/mcp-tool.ts +++ b/packages/typescript/ai-openai/src/tools/mcp-tool.ts @@ -1,47 +1,19 @@ -import type OpenAI from 'openai' -import type { ProviderTool, Tool } from '@tanstack/ai' +import { mcpTool as baseMcpTool } from '@tanstack/openai-base' +import type { ProviderTool } from '@tanstack/ai' +import type { MCPToolConfig } from '@tanstack/openai-base' -export type MCPToolConfig = OpenAI.Responses.Tool.Mcp - -/** @deprecated Renamed to `MCPToolConfig`. Will be removed in a future release. */ -export type MCPTool = MCPToolConfig +export { + type MCPToolConfig, + type MCPTool, + validateMCPtool, + convertMCPToolToAdapterFormat, +} from '@tanstack/openai-base' export type OpenAIMCPTool = ProviderTool<'openai', 'mcp'> -export function validateMCPtool(tool: MCPToolConfig) { - if (!tool.server_url && !tool.connector_id) { - throw new Error('Either server_url or connector_id must be provided.') - } - if (tool.connector_id && tool.server_url) { - throw new Error('Only one of server_url or connector_id can be provided.') - } -} - -/** - * Converts a standard Tool to OpenAI MCPTool format - */ -export function convertMCPToolToAdapterFormat(tool: Tool): MCPToolConfig { - const metadata = tool.metadata as Omit - - const mcpTool: MCPToolConfig = { - type: 'mcp', - ...metadata, - } - - validateMCPtool(mcpTool) - return mcpTool -} - /** - * Creates a standard Tool from MCPTool parameters + * Creates a standard Tool from MCPTool parameters, branded as an OpenAI provider tool. */ export function mcpTool(toolData: Omit): OpenAIMCPTool { - validateMCPtool({ ...toolData, type: 'mcp' }) - - // Phantom-brand cast: '~provider'/'~toolKind' are type-only and never assigned at runtime. - return { - name: 'mcp', - description: toolData.server_description || '', - metadata: toolData, - } as unknown as OpenAIMCPTool + return baseMcpTool(toolData) as OpenAIMCPTool } diff --git a/packages/typescript/ai-openai/src/tools/shell-tool.ts b/packages/typescript/ai-openai/src/tools/shell-tool.ts index 5fc4bdf65..9f48503a4 100644 --- a/packages/typescript/ai-openai/src/tools/shell-tool.ts +++ b/packages/typescript/ai-openai/src/tools/shell-tool.ts @@ -1,30 +1,17 @@ -import type OpenAI from 'openai' -import type { ProviderTool, Tool } from '@tanstack/ai' +import { shellTool as baseShellTool } from '@tanstack/openai-base' +import type { ProviderTool } from '@tanstack/ai' -export type ShellToolConfig = OpenAI.Responses.FunctionShellTool - -/** @deprecated Renamed to `ShellToolConfig`. Will be removed in a future release. */ -export type ShellTool = ShellToolConfig +export { + type ShellToolConfig, + type ShellTool, + convertShellToolToAdapterFormat, +} from '@tanstack/openai-base' export type OpenAIShellTool = ProviderTool<'openai', 'shell'> /** - * Converts a standard Tool to OpenAI ShellTool format - */ -export function convertShellToolToAdapterFormat(_tool: Tool): ShellToolConfig { - return { - type: 'shell', - } -} - -/** - * Creates a standard Tool from ShellTool parameters + * Creates a standard Tool from ShellTool parameters, branded as an OpenAI provider tool. */ export function shellTool(): OpenAIShellTool { - // Phantom-brand cast: '~provider'/'~toolKind' are type-only and never assigned at runtime. - return { - name: 'shell', - description: 'Execute shell commands', - metadata: {}, - } as unknown as OpenAIShellTool + return baseShellTool() as OpenAIShellTool } diff --git a/packages/typescript/ai-openai/src/tools/tool-choice.ts b/packages/typescript/ai-openai/src/tools/tool-choice.ts index db6e0b148..99df1824f 100644 --- a/packages/typescript/ai-openai/src/tools/tool-choice.ts +++ b/packages/typescript/ai-openai/src/tools/tool-choice.ts @@ -1,31 +1 @@ -interface MCPToolChoice { - type: 'mcp' - server_label: 'deepwiki' -} - -interface FunctionToolChoice { - type: 'function' - name: string -} - -interface CustomToolChoice { - type: 'custom' - name: string -} - -interface HostedToolChoice { - type: - | 'file_search' - | 'web_search_preview' - | 'computer_use_preview' - | 'code_interpreter' - | 'image_generation' - | 'shell' - | 'apply_patch' -} - -export type ToolChoice = - | MCPToolChoice - | FunctionToolChoice - | CustomToolChoice - | HostedToolChoice +export { type ToolChoice } from '@tanstack/openai-base' diff --git a/packages/typescript/ai-openai/src/tools/tool-converter.ts b/packages/typescript/ai-openai/src/tools/tool-converter.ts index c4ac5909a..3d78a1b18 100644 --- a/packages/typescript/ai-openai/src/tools/tool-converter.ts +++ b/packages/typescript/ai-openai/src/tools/tool-converter.ts @@ -1,72 +1 @@ -import { convertApplyPatchToolToAdapterFormat } from './apply-patch-tool' -import { convertCodeInterpreterToolToAdapterFormat } from './code-interpreter-tool' -import { convertComputerUseToolToAdapterFormat } from './computer-use-tool' -import { convertCustomToolToAdapterFormat } from './custom-tool' -import { convertFileSearchToolToAdapterFormat } from './file-search-tool' -import { convertFunctionToolToAdapterFormat } from './function-tool' -import { convertImageGenerationToolToAdapterFormat } from './image-generation-tool' -import { convertLocalShellToolToAdapterFormat } from './local-shell-tool' -import { convertMCPToolToAdapterFormat } from './mcp-tool' -import { convertShellToolToAdapterFormat } from './shell-tool' -import { convertWebSearchPreviewToolToAdapterFormat } from './web-search-preview-tool' -import { convertWebSearchToolToAdapterFormat } from './web-search-tool' -import type { OpenAITool } from './index' -import type { Tool } from '@tanstack/ai' - -/** - * Converts an array of standard Tools to OpenAI-specific format - */ -export function convertToolsToProviderFormat( - tools: Array, -): Array { - return tools.map((tool) => { - // Special tool names that map to specific OpenAI tool types - const specialToolNames = new Set([ - 'apply_patch', - 'code_interpreter', - 'computer_use_preview', - 'file_search', - 'image_generation', - 'local_shell', - 'mcp', - 'shell', - 'web_search_preview', - 'web_search', - 'custom', - ]) - - const toolName = tool.name - - // If it's a special tool name, route to the appropriate converter - if (specialToolNames.has(toolName)) { - switch (toolName) { - case 'apply_patch': - return convertApplyPatchToolToAdapterFormat(tool) - case 'code_interpreter': - return convertCodeInterpreterToolToAdapterFormat(tool) - case 'computer_use_preview': - return convertComputerUseToolToAdapterFormat(tool) - case 'file_search': - return convertFileSearchToolToAdapterFormat(tool) - case 'image_generation': - return convertImageGenerationToolToAdapterFormat(tool) - case 'local_shell': - return convertLocalShellToolToAdapterFormat(tool) - case 'mcp': - return convertMCPToolToAdapterFormat(tool) - case 'shell': - return convertShellToolToAdapterFormat(tool) - case 'web_search_preview': - return convertWebSearchPreviewToolToAdapterFormat(tool) - case 'web_search': - return convertWebSearchToolToAdapterFormat(tool) - case 'custom': - return convertCustomToolToAdapterFormat(tool) - } - } - - // For regular function tools (not special names), convert as function tool - // This handles tools like "getGuitars", "recommendGuitar", etc. - return convertFunctionToolToAdapterFormat(tool) - }) -} +export { convertToolsToProviderFormat } from '@tanstack/openai-base' diff --git a/packages/typescript/ai-openai/src/tools/web-search-preview-tool.ts b/packages/typescript/ai-openai/src/tools/web-search-preview-tool.ts index fb5163b5e..b822bafbf 100644 --- a/packages/typescript/ai-openai/src/tools/web-search-preview-tool.ts +++ b/packages/typescript/ai-openai/src/tools/web-search-preview-tool.ts @@ -1,10 +1,12 @@ -import type OpenAI from 'openai' -import type { ProviderTool, Tool } from '@tanstack/ai' +import { webSearchPreviewTool as baseWebSearchPreviewTool } from '@tanstack/openai-base' +import type { ProviderTool } from '@tanstack/ai' +import type { WebSearchPreviewToolConfig } from '@tanstack/openai-base' -export type WebSearchPreviewToolConfig = OpenAI.Responses.WebSearchPreviewTool - -/** @deprecated Renamed to `WebSearchPreviewToolConfig`. Will be removed in a future release. */ -export type WebSearchPreviewTool = WebSearchPreviewToolConfig +export { + type WebSearchPreviewToolConfig, + type WebSearchPreviewTool, + convertWebSearchPreviewToolToAdapterFormat, +} from '@tanstack/openai-base' export type OpenAIWebSearchPreviewTool = ProviderTool< 'openai', @@ -12,29 +14,11 @@ export type OpenAIWebSearchPreviewTool = ProviderTool< > /** - * Converts a standard Tool to OpenAI WebSearchPreviewTool format - */ -export function convertWebSearchPreviewToolToAdapterFormat( - tool: Tool, -): WebSearchPreviewToolConfig { - const metadata = tool.metadata as WebSearchPreviewToolConfig - return { - type: metadata.type, - search_context_size: metadata.search_context_size, - user_location: metadata.user_location, - } -} - -/** - * Creates a standard Tool from WebSearchPreviewTool parameters + * Creates a standard Tool from WebSearchPreviewTool parameters, branded as an + * OpenAI provider tool. */ export function webSearchPreviewTool( toolData: WebSearchPreviewToolConfig, ): OpenAIWebSearchPreviewTool { - // Phantom-brand cast: '~provider'/'~toolKind' are type-only and never assigned at runtime. - return { - name: 'web_search_preview', - description: 'Search the web (preview version)', - metadata: toolData, - } as unknown as OpenAIWebSearchPreviewTool + return baseWebSearchPreviewTool(toolData) as OpenAIWebSearchPreviewTool } diff --git a/packages/typescript/ai-openai/src/tools/web-search-tool.ts b/packages/typescript/ai-openai/src/tools/web-search-tool.ts index 83991e9d3..bdb39c944 100644 --- a/packages/typescript/ai-openai/src/tools/web-search-tool.ts +++ b/packages/typescript/ai-openai/src/tools/web-search-tool.ts @@ -1,33 +1,21 @@ -import type OpenAI from 'openai' -import type { ProviderTool, Tool } from '@tanstack/ai' +import { webSearchTool as baseWebSearchTool } from '@tanstack/openai-base' +import type { ProviderTool } from '@tanstack/ai' +import type { WebSearchToolConfig } from '@tanstack/openai-base' -export type WebSearchToolConfig = OpenAI.Responses.WebSearchTool - -/** @deprecated Renamed to `WebSearchToolConfig`. Will be removed in a future release. */ -export type WebSearchTool = WebSearchToolConfig +export { + type WebSearchToolConfig, + type WebSearchTool, + convertWebSearchToolToAdapterFormat, +} from '@tanstack/openai-base' export type OpenAIWebSearchTool = ProviderTool<'openai', 'web_search'> /** - * Converts a standard Tool to OpenAI WebSearchTool format - */ -export function convertWebSearchToolToAdapterFormat( - tool: Tool, -): WebSearchToolConfig { - const metadata = tool.metadata as WebSearchToolConfig - return metadata -} - -/** - * Creates a standard Tool from WebSearchTool parameters + * Creates a standard Tool from WebSearchTool parameters, branded as an OpenAI + * provider tool. */ export function webSearchTool( toolData: WebSearchToolConfig, ): OpenAIWebSearchTool { - // Phantom-brand cast: '~provider'/'~toolKind' are type-only and never assigned at runtime. - return { - name: 'web_search', - description: 'Search the web', - metadata: toolData, - } as unknown as OpenAIWebSearchTool + return baseWebSearchTool(toolData) as OpenAIWebSearchTool } diff --git a/packages/typescript/ai-openai/src/utils/client.ts b/packages/typescript/ai-openai/src/utils/client.ts index 3915e2ea1..97f1efe50 100644 --- a/packages/typescript/ai-openai/src/utils/client.ts +++ b/packages/typescript/ai-openai/src/utils/client.ts @@ -1,42 +1,12 @@ -import OpenAI_SDK from 'openai' -import type { ClientOptions } from 'openai' +import { getApiKeyFromEnv } from '@tanstack/ai-utils' +import type { OpenAICompatibleClientConfig } from '@tanstack/openai-base' -export interface OpenAIClientConfig extends ClientOptions { - apiKey: string -} - -/** - * Creates an OpenAI SDK client instance - */ -export function createOpenAIClient(config: OpenAIClientConfig): OpenAI_SDK { - return new OpenAI_SDK(config) -} +export interface OpenAIClientConfig extends OpenAICompatibleClientConfig {} /** * Gets OpenAI API key from environment variables * @throws Error if OPENAI_API_KEY is not found */ export function getOpenAIApiKeyFromEnv(): string { - const env = - typeof globalThis !== 'undefined' && (globalThis as any).window?.env - ? (globalThis as any).window.env - : typeof process !== 'undefined' - ? process.env - : undefined - const key = env?.OPENAI_API_KEY - - if (!key) { - throw new Error( - 'OPENAI_API_KEY is required. Please set it in your environment variables or use the factory function with an explicit API key.', - ) - } - - return key -} - -/** - * Generates a unique ID with a prefix - */ -export function generateId(prefix: string): string { - return `${prefix}-${Date.now()}-${Math.random().toString(36).substring(7)}` + return getApiKeyFromEnv('OPENAI_API_KEY') } diff --git a/packages/typescript/ai-openai/src/utils/schema-converter.ts b/packages/typescript/ai-openai/src/utils/schema-converter.ts index d431bfe77..fb9ee165e 100644 --- a/packages/typescript/ai-openai/src/utils/schema-converter.ts +++ b/packages/typescript/ai-openai/src/utils/schema-converter.ts @@ -1,38 +1,7 @@ -/** - * Recursively transform null values to undefined in an object. - * - * This is needed because OpenAI's structured output requires all fields to be - * in the `required` array, with optional fields made nullable (type: ["string", "null"]). - * When OpenAI returns null for optional fields, we need to convert them back to - * undefined to match the original Zod schema expectations. - * - * @param obj - Object to transform - * @returns Object with nulls converted to undefined - */ -export function transformNullsToUndefined(obj: T): T { - if (obj === null) { - return undefined as unknown as T - } +import { transformNullsToUndefined } from '@tanstack/ai-utils' +import { makeStructuredOutputCompatible } from '@tanstack/openai-base' - if (Array.isArray(obj)) { - return obj.map((item) => transformNullsToUndefined(item)) as unknown as T - } - - if (typeof obj === 'object') { - const result: Record = {} - for (const [key, value] of Object.entries(obj as Record)) { - const transformed = transformNullsToUndefined(value) - // Only include the key if the value is not undefined - // This makes { notes: null } become {} (field absent) instead of { notes: undefined } - if (transformed !== undefined) { - result[key] = transformed - } - } - return result as T - } - - return obj -} +export { transformNullsToUndefined } /** * Transform a JSON schema to be compatible with OpenAI's structured output requirements. @@ -49,86 +18,5 @@ export function makeOpenAIStructuredOutputCompatible( schema: Record, originalRequired: Array = [], ): Record { - const result = { ...schema } - - // Handle object types - if (result.type === 'object' && result.properties) { - const properties = { ...result.properties } - const allPropertyNames = Object.keys(properties) - - // Transform each property - for (const propName of allPropertyNames) { - const prop = properties[propName] - const wasOptional = !originalRequired.includes(propName) - - // Recursively transform nested objects/arrays/unions - if (prop.type === 'object' && prop.properties) { - properties[propName] = makeOpenAIStructuredOutputCompatible( - prop, - prop.required || [], - ) - } else if (prop.type === 'array' && prop.items) { - properties[propName] = { - ...prop, - items: makeOpenAIStructuredOutputCompatible( - prop.items, - prop.items.required || [], - ), - } - } else if (prop.anyOf) { - // Handle anyOf at property level (union types) - properties[propName] = makeOpenAIStructuredOutputCompatible( - prop, - prop.required || [], - ) - } else if (prop.oneOf) { - // oneOf is not supported by OpenAI - throw early - throw new Error( - 'oneOf is not supported in OpenAI structured output schemas. Check the supported outputs here: https://platform.openai.com/docs/guides/structured-outputs#supported-types', - ) - } else if (wasOptional) { - // Make optional fields nullable by adding null to the type - if (prop.type && !Array.isArray(prop.type)) { - properties[propName] = { - ...prop, - type: [prop.type, 'null'], - } - } else if (Array.isArray(prop.type) && !prop.type.includes('null')) { - properties[propName] = { - ...prop, - type: [...prop.type, 'null'], - } - } - } - } - - result.properties = properties - // ALL properties must be required for OpenAI structured output - result.required = allPropertyNames - // additionalProperties must be false - result.additionalProperties = false - } - - // Handle array types with object items - if (result.type === 'array' && result.items) { - result.items = makeOpenAIStructuredOutputCompatible( - result.items, - result.items.required || [], - ) - } - - // Handle anyOf (union types) - each variant needs to be transformed - if (result.anyOf && Array.isArray(result.anyOf)) { - result.anyOf = result.anyOf.map((variant) => - makeOpenAIStructuredOutputCompatible(variant, variant.required || []), - ) - } - - if (result.oneOf) { - throw new Error( - 'oneOf is not supported in OpenAI structured output schemas. Check the supported outputs here: https://platform.openai.com/docs/guides/structured-outputs#supported-types', - ) - } - - return result + return makeStructuredOutputCompatible(schema, originalRequired) } diff --git a/packages/typescript/ai-openrouter/package.json b/packages/typescript/ai-openrouter/package.json index 3b1fe270a..2b1eda98a 100644 --- a/packages/typescript/ai-openrouter/package.json +++ b/packages/typescript/ai-openrouter/package.json @@ -44,14 +44,15 @@ ], "dependencies": { "@openrouter/sdk": "0.12.14", - "@tanstack/ai": "workspace:*" + "@tanstack/ai-utils": "workspace:*" }, "devDependencies": { + "@tanstack/ai": "workspace:*", "@vitest/coverage-v8": "4.0.14", "vite": "^7.2.7", "zod": "^4.2.0" }, "peerDependencies": { - "@tanstack/ai": "workspace:*" + "@tanstack/ai": "workspace:^" } } diff --git a/packages/typescript/ai-openrouter/src/utils/client.ts b/packages/typescript/ai-openrouter/src/utils/client.ts index 758416993..04522c5f9 100644 --- a/packages/typescript/ai-openrouter/src/utils/client.ts +++ b/packages/typescript/ai-openrouter/src/utils/client.ts @@ -1,3 +1,5 @@ +import { generateId as _generateId, getApiKeyFromEnv } from '@tanstack/ai-utils' + export interface OpenRouterClientConfig { apiKey: string baseURL?: string @@ -5,42 +7,12 @@ export interface OpenRouterClientConfig { xTitle?: string } -interface EnvObject { - OPENROUTER_API_KEY?: string -} - -interface WindowWithEnv { - env?: EnvObject -} - -function getEnvironment(): EnvObject | undefined { - if (typeof globalThis !== 'undefined') { - const win = (globalThis as { window?: WindowWithEnv }).window - if (win?.env) { - return win.env - } - } - if (typeof process !== 'undefined') { - return process.env as EnvObject - } - return undefined -} - export function getOpenRouterApiKeyFromEnv(): string { - const env = getEnvironment() - const key = env?.OPENROUTER_API_KEY - - if (!key) { - throw new Error( - 'OPENROUTER_API_KEY is required. Please set it in your environment variables or use the factory function with an explicit API key.', - ) - } - - return key + return getApiKeyFromEnv('OPENROUTER_API_KEY') } export function generateId(prefix: string): string { - return `${prefix}-${Date.now()}-${Math.random().toString(36).substring(7)}` + return _generateId(prefix) } export function buildHeaders( diff --git a/packages/typescript/ai-utils/package.json b/packages/typescript/ai-utils/package.json new file mode 100644 index 000000000..6ccd77a7e --- /dev/null +++ b/packages/typescript/ai-utils/package.json @@ -0,0 +1,45 @@ +{ + "name": "@tanstack/ai-utils", + "version": "0.1.0", + "description": "Shared utilities for TanStack AI adapter packages", + "author": "", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/TanStack/ai.git", + "directory": "packages/typescript/ai-utils" + }, + "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": "eslint ./src --fix", + "test:build": "publint --strict", + "test:eslint": "eslint ./src", + "test:lib": "vitest run", + "test:lib:dev": "pnpm test:lib --watch", + "test:types": "tsc" + }, + "keywords": [ + "ai", + "utils", + "tanstack" + ], + "devDependencies": { + "@types/node": "^24.10.1", + "@vitest/coverage-v8": "4.0.14", + "vite": "^7.2.7" + } +} diff --git a/packages/typescript/ai-utils/src/base64.ts b/packages/typescript/ai-utils/src/base64.ts new file mode 100644 index 000000000..5da36e236 --- /dev/null +++ b/packages/typescript/ai-utils/src/base64.ts @@ -0,0 +1,81 @@ +/** + * Cross-runtime base64 helpers. + * + * Both `arrayBufferToBase64` and `base64ToArrayBuffer` prefer the new native + * `Uint8Array.toBase64()` / `Uint8Array.fromBase64()` methods (TC39 base64 + * proposal, Stage 3) when available — they are significantly faster and more + * memory-efficient than the byte-walking fallback. The fallbacks use Node's + * `Buffer` when present, then `atob`/`btoa` for browser / edge runtimes. + */ + +interface Uint8ArrayWithBase64 { + fromBase64?: (input: string) => Uint8Array +} + +interface Uint8ArrayInstanceWithBase64 { + toBase64?: () => string +} + +/** + * Encode an `ArrayBuffer` as a base64 string. + * + * Note: callers should be cautious about feeding large buffers (more than a + * few megabytes) on serverless / Workers runtimes — converting big media to + * base64 multiplies its memory footprint by ~1.33× and frequently OOMs the + * isolate. + */ +export function arrayBufferToBase64(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer) + + const fast = (bytes as Uint8ArrayInstanceWithBase64).toBase64 + if (typeof fast === 'function') { + return fast.call(bytes) + } + + if (typeof Buffer !== 'undefined' && typeof Buffer.from === 'function') { + return Buffer.from(buffer).toString('base64') + } + + if (typeof btoa === 'function') { + let binary = '' + // 32KB chunks keep us well under V8's argument-count limits for + // String.fromCharCode.apply on large buffers. + const chunkSize = 0x8000 + for (let i = 0; i < bytes.length; i += chunkSize) { + const chunk = bytes.subarray(i, i + chunkSize) + binary += String.fromCharCode.apply( + null, + chunk as unknown as Array, + ) + } + return btoa(binary) + } + + throw new Error('No base64 encoder available in this environment.') +} + +/** + * Decode a base64 string into an `ArrayBuffer`. + */ +export function base64ToArrayBuffer(base64: string): ArrayBuffer { + const fast = (Uint8Array as unknown as Uint8ArrayWithBase64).fromBase64 + if (typeof fast === 'function') { + return fast(base64).buffer as ArrayBuffer + } + + if (typeof atob === 'function') { + const binary = atob(base64) + const bytes = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i) + } + return bytes.buffer + } + + if (typeof Buffer !== 'undefined' && typeof Buffer.from === 'function') { + const buf = Buffer.from(base64, 'base64') + return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) + } + + throw new Error('No base64 decoder available in this environment.') +} diff --git a/packages/typescript/ai-utils/src/env.ts b/packages/typescript/ai-utils/src/env.ts new file mode 100644 index 000000000..57af69c52 --- /dev/null +++ b/packages/typescript/ai-utils/src/env.ts @@ -0,0 +1,18 @@ +export function getApiKeyFromEnv(envVarName: string): string { + const env = + typeof globalThis !== 'undefined' && (globalThis as any).window?.env + ? (globalThis as any).window.env + : typeof process !== 'undefined' + ? process.env + : undefined + + const apiKey = env?.[envVarName] + + if (!apiKey) { + throw new Error( + `${envVarName} is not set. Please set the ${envVarName} environment variable or pass the API key directly.`, + ) + } + + return apiKey +} diff --git a/packages/typescript/ai-utils/src/id.ts b/packages/typescript/ai-utils/src/id.ts new file mode 100644 index 000000000..3105f43ac --- /dev/null +++ b/packages/typescript/ai-utils/src/id.ts @@ -0,0 +1,9 @@ +export function generateId(prefix: string): string { + const timestamp = Date.now() + // Drop the "0." prefix from the base36 float (2 chars), keeping the full + // random portion (~9+ chars of entropy). Previously used `.substring(7)` + // which left only ~4 random chars — see the regression test in ai-fal's + // utils. + const randomPart = Math.random().toString(36).substring(2) + return `${prefix}-${timestamp}-${randomPart}` +} diff --git a/packages/typescript/ai-utils/src/index.ts b/packages/typescript/ai-utils/src/index.ts new file mode 100644 index 000000000..d6b79c9b8 --- /dev/null +++ b/packages/typescript/ai-utils/src/index.ts @@ -0,0 +1,4 @@ +export { generateId } from './id' +export { getApiKeyFromEnv } from './env' +export { transformNullsToUndefined } from './transforms' +export { arrayBufferToBase64, base64ToArrayBuffer } from './base64' diff --git a/packages/typescript/ai-utils/src/transforms.ts b/packages/typescript/ai-utils/src/transforms.ts new file mode 100644 index 000000000..362a0f81d --- /dev/null +++ b/packages/typescript/ai-utils/src/transforms.ts @@ -0,0 +1,46 @@ +/** + * Recursively strip `null` values from a JSON-shaped value so optional fields + * present as `null` in OpenAI-compatible structured output round-trip cleanly + * through Zod schemas that expect `undefined` (or absence) instead of `null`. + * + * Behaviour: + * - Top-level `null` becomes `undefined`. + * - Object properties whose value is `null` are removed entirely (so + * `'key' in result` is `false`). Zod's `.optional()` treats absent keys + * the same as `undefined`, which is the round-trip we want; setting the + * key to `undefined` would still register the property in `Object.keys` + * and break some `.strict()`/`Object.keys`-based callers. + * - Array elements recurse via this same function; a `null` element therefore + * becomes `undefined` (top-level rule), preserving array length so + * positional indices stay stable. Don't rely on element-`null` round-trip. + * + * Scope: designed for `JSON.parse` output (plain objects, arrays, strings, + * numbers, booleans, null). Class instances, `Date`, `Map`, `Set`, etc. are + * NOT preserved — they're walked via `Object.entries`, which sees only own + * enumerable string-keyed properties. Native built-ins like `Date`/`Map`/`Set` + * therefore become `{}`; arbitrary class instances become a plain-object + * snapshot of just their own enumerable string properties. Don't pass + * non-JSON values. + */ +export function transformNullsToUndefined(obj: T): T { + if (obj === null) { + return undefined as unknown as T + } + + if (typeof obj !== 'object') { + return obj + } + + if (Array.isArray(obj)) { + return obj.map((item) => transformNullsToUndefined(item)) as unknown as T + } + + const result: Record = {} + for (const [key, value] of Object.entries(obj as Record)) { + if (value === null) { + continue + } + result[key] = transformNullsToUndefined(value) + } + return result as T +} diff --git a/packages/typescript/ai-utils/tests/base64.test.ts b/packages/typescript/ai-utils/tests/base64.test.ts new file mode 100644 index 000000000..53e86af6e --- /dev/null +++ b/packages/typescript/ai-utils/tests/base64.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from 'vitest' +import { arrayBufferToBase64, base64ToArrayBuffer } from '../src/base64' + +describe('base64 helpers', () => { + it('round-trips bytes through arrayBufferToBase64 and base64ToArrayBuffer', () => { + const bytes = new Uint8Array([0, 1, 2, 3, 254, 255, 128, 64, 32]) + const base64 = arrayBufferToBase64(bytes.buffer) + const decoded = new Uint8Array(base64ToArrayBuffer(base64)) + expect(Array.from(decoded)).toEqual(Array.from(bytes)) + }) + + it('encodes a known string to the expected base64', () => { + const bytes = new TextEncoder().encode('hello world') + expect(arrayBufferToBase64(bytes.buffer)).toBe('aGVsbG8gd29ybGQ=') + }) + + it('decodes a known base64 string to the expected bytes', () => { + const decoded = new Uint8Array(base64ToArrayBuffer('aGVsbG8gd29ybGQ=')) + expect(new TextDecoder().decode(decoded)).toBe('hello world') + }) + + it('handles a multi-megabyte buffer without overflowing the call stack', () => { + // 1.5 MiB of pseudo-random bytes — exercises the chunked btoa fallback + // when the fast Uint8Array.toBase64 path is unavailable. + const size = 1.5 * 1024 * 1024 + const bytes = new Uint8Array(size) + for (let i = 0; i < size; i++) bytes[i] = i & 0xff + + const base64 = arrayBufferToBase64(bytes.buffer) + const decoded = new Uint8Array(base64ToArrayBuffer(base64)) + expect(decoded.length).toBe(size) + // Spot-check a few entries rather than comparing the full buffer. + expect(decoded[0]).toBe(0) + expect(decoded[255]).toBe(255) + expect(decoded[size - 1]).toBe((size - 1) & 0xff) + }) +}) diff --git a/packages/typescript/ai-utils/tests/env.test.ts b/packages/typescript/ai-utils/tests/env.test.ts new file mode 100644 index 000000000..0fea3ea60 --- /dev/null +++ b/packages/typescript/ai-utils/tests/env.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect, vi, afterEach } from 'vitest' +import { getApiKeyFromEnv } from '../src/env' + +describe('getApiKeyFromEnv', () => { + afterEach(() => { + vi.unstubAllEnvs() + }) + + it('should return the API key from process.env', () => { + vi.stubEnv('TEST_API_KEY', 'sk-test-123') + expect(getApiKeyFromEnv('TEST_API_KEY')).toBe('sk-test-123') + }) + + it('should throw if the env var is not set', () => { + const missingKey = `__AI_UTILS_TEST_MISSING_${Date.now()}__` + expect(() => getApiKeyFromEnv(missingKey)).toThrow(missingKey) + }) + + it('should throw if the env var is empty string', () => { + vi.stubEnv('EMPTY_KEY', '') + expect(() => getApiKeyFromEnv('EMPTY_KEY')).toThrow('EMPTY_KEY') + }) + + it('should include the env var name in the error message', () => { + const providerKey = `__AI_UTILS_TEST_PROVIDER_${Date.now()}__` + expect(() => getApiKeyFromEnv(providerKey)).toThrow(providerKey) + }) +}) diff --git a/packages/typescript/ai-utils/tests/id.test.ts b/packages/typescript/ai-utils/tests/id.test.ts new file mode 100644 index 000000000..74fe0d198 --- /dev/null +++ b/packages/typescript/ai-utils/tests/id.test.ts @@ -0,0 +1,20 @@ +import { describe, it, expect } from 'vitest' +import { generateId } from '../src/id' + +describe('generateId', () => { + it('should generate an id with the given prefix', () => { + const id = generateId('run') + expect(id).toMatch(/^run-\d+-[a-z0-9]+$/) + }) + + it('should generate unique ids', () => { + const id1 = generateId('msg') + const id2 = generateId('msg') + expect(id1).not.toBe(id2) + }) + + it('should use the prefix exactly as given', () => { + const id = generateId('tool_call') + expect(id.startsWith('tool_call-')).toBe(true) + }) +}) diff --git a/packages/typescript/ai-utils/tests/transforms.test.ts b/packages/typescript/ai-utils/tests/transforms.test.ts new file mode 100644 index 000000000..8ce65c1b6 --- /dev/null +++ b/packages/typescript/ai-utils/tests/transforms.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect } from 'vitest' +import { transformNullsToUndefined } from '../src/transforms' + +describe('transformNullsToUndefined', () => { + it('should convert null values to undefined', () => { + const result = transformNullsToUndefined({ a: null, b: 'hello' }) + expect(result).toEqual({ b: 'hello' }) + expect('a' in result).toBe(false) + }) + + it('should handle nested objects', () => { + const result = transformNullsToUndefined({ + a: { b: null, c: 'value' }, + d: null, + }) + expect(result).toEqual({ a: { c: 'value' } }) + }) + + it('should handle arrays', () => { + const result = transformNullsToUndefined({ + items: [ + { a: null, b: 1 }, + { a: 'x', b: null }, + ], + }) + expect(result).toEqual({ + items: [{ b: 1 }, { a: 'x' }], + }) + }) + + it('should return non-objects unchanged', () => { + expect(transformNullsToUndefined('hello')).toBe('hello') + expect(transformNullsToUndefined(42)).toBe(42) + expect(transformNullsToUndefined(true)).toBe(true) + }) + + it('should return null as undefined', () => { + expect(transformNullsToUndefined(null)).toBeUndefined() + }) + + it('should handle empty objects', () => { + expect(transformNullsToUndefined({})).toEqual({}) + }) + + it('should handle deeply nested nulls', () => { + const result = transformNullsToUndefined({ + a: { b: { c: { d: null, e: 'keep' } } }, + }) + expect(result).toEqual({ a: { b: { c: { e: 'keep' } } } }) + }) +}) diff --git a/packages/typescript/ai-utils/tsconfig.json b/packages/typescript/ai-utils/tsconfig.json new file mode 100644 index 000000000..ea11c1096 --- /dev/null +++ b/packages/typescript/ai-utils/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*.ts", "src/**/*.tsx"], + "exclude": ["node_modules", "dist", "**/*.config.ts"] +} diff --git a/packages/typescript/ai-utils/vite.config.ts b/packages/typescript/ai-utils/vite.config.ts new file mode 100644 index 000000000..77bcc2e60 --- /dev/null +++ b/packages/typescript/ai-utils/vite.config.ts @@ -0,0 +1,36 @@ +import { defineConfig, mergeConfig } from 'vitest/config' +import { tanstackViteConfig } from '@tanstack/vite-config' +import packageJson from './package.json' + +const config = defineConfig({ + test: { + name: packageJson.name, + dir: './', + watch: false, + globals: true, + environment: 'node', + include: ['tests/**/*.test.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html', 'lcov'], + exclude: [ + 'node_modules/', + 'dist/', + 'tests/', + '**/*.test.ts', + '**/*.config.ts', + '**/types.ts', + ], + include: ['src/**/*.ts'], + }, + }, +}) + +export default mergeConfig( + config, + tanstackViteConfig({ + entry: ['./src/index.ts'], + srcDir: './src', + cjs: false, + }), +) diff --git a/packages/typescript/openai-base/package.json b/packages/typescript/openai-base/package.json new file mode 100644 index 000000000..88950d62d --- /dev/null +++ b/packages/typescript/openai-base/package.json @@ -0,0 +1,54 @@ +{ + "name": "@tanstack/openai-base", + "version": "0.1.0", + "description": "Shared base adapters and utilities for OpenAI-compatible providers in TanStack AI", + "author": "", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/TanStack/ai.git", + "directory": "packages/typescript/openai-base" + }, + "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": "eslint ./src --fix", + "test:build": "publint --strict", + "test:eslint": "eslint ./src", + "test:lib": "vitest run", + "test:lib:dev": "pnpm test:lib --watch", + "test:types": "tsc" + }, + "keywords": [ + "ai", + "openai", + "tanstack", + "adapter", + "base" + ], + "dependencies": { + "@tanstack/ai-utils": "workspace:*", + "openai": "^6.9.1" + }, + "peerDependencies": { + "@tanstack/ai": "workspace:^" + }, + "devDependencies": { + "@tanstack/ai": "workspace:*", + "@vitest/coverage-v8": "4.0.14", + "vite": "^7.2.7" + } +} diff --git a/packages/typescript/openai-base/src/adapters/chat-completions-text.ts b/packages/typescript/openai-base/src/adapters/chat-completions-text.ts new file mode 100644 index 000000000..ac014f619 --- /dev/null +++ b/packages/typescript/openai-base/src/adapters/chat-completions-text.ts @@ -0,0 +1,817 @@ +import { BaseTextAdapter } from '@tanstack/ai/adapters' +import { toRunErrorPayload } from '@tanstack/ai/adapter-internals' +import { generateId, transformNullsToUndefined } from '@tanstack/ai-utils' +import { createOpenAICompatibleClient } from '../utils/client' +import { extractRequestOptions } from '../utils/request-options' +import { makeStructuredOutputCompatible } from '../utils/schema-converter' +import { convertToolsToChatCompletionsFormat } from './chat-completions-tool-converter' +import type { + StructuredOutputOptions, + StructuredOutputResult, +} from '@tanstack/ai/adapters' +import type OpenAI_SDK from 'openai' +import type { + ContentPart, + DefaultMessageMetadataByModality, + Modality, + ModelMessage, + StreamChunk, + TextOptions, +} from '@tanstack/ai' +import type { OpenAICompatibleClientConfig } from '../types/config' + +/** Cast an event object to StreamChunk. Adapters construct events with string + * literal types which are structurally compatible with the EventType enum. */ +const asChunk = (chunk: Record) => + chunk as unknown as StreamChunk + +/** + * OpenAI-compatible Chat Completions Text Adapter + * + * A generalized base class for providers that use the OpenAI Chat Completions API + * (`/v1/chat/completions`). Providers like Grok, Groq, OpenRouter, and others can + * extend this class and only need to: + * - Set `baseURL` in the config + * - Lock the generic type parameters to provider-specific types + * - Override specific methods for quirks + * + * All methods that build requests or process responses are `protected` so subclasses + * can override them. + */ +export class OpenAICompatibleChatCompletionsTextAdapter< + TModel extends string, + TProviderOptions extends Record = Record, + TInputModalities extends ReadonlyArray = ReadonlyArray, + TMessageMetadata extends DefaultMessageMetadataByModality = + DefaultMessageMetadataByModality, + TToolCapabilities extends ReadonlyArray = ReadonlyArray, +> extends BaseTextAdapter< + TModel, + TProviderOptions, + TInputModalities, + TMessageMetadata, + TToolCapabilities +> { + readonly kind = 'text' as const + readonly name: string + + protected client: OpenAI_SDK + + constructor( + config: OpenAICompatibleClientConfig, + model: TModel, + name: string = 'openai-compatible', + ) { + super({}, model) + this.name = name + this.client = createOpenAICompatibleClient(config) + } + + async *chatStream( + options: TextOptions, + ): AsyncIterable { + const requestParams = this.mapOptionsToRequest(options) + const timestamp = Date.now() + + // AG-UI lifecycle tracking (mutable state object for ESLint compatibility) + const aguiState = { + runId: generateId(this.name), + messageId: generateId(this.name), + timestamp, + hasEmittedRunStarted: false, + } + + try { + options.logger.request( + `activity=chat provider=${this.name} model=${this.model} messages=${options.messages.length} tools=${options.tools?.length ?? 0} stream=true`, + { provider: this.name, model: this.model }, + ) + const stream = await this.client.chat.completions.create( + { + ...requestParams, + stream: true, + stream_options: { include_usage: true }, + }, + extractRequestOptions(options.request), + ) + + yield* this.processStreamChunks(stream, options, aguiState) + } catch (error: unknown) { + // Narrow before logging: raw SDK errors can carry request metadata + // (including auth headers) which we must never surface to user loggers. + const errorPayload = toRunErrorPayload( + error, + `${this.name}.chatStream failed`, + ) + + // Emit RUN_STARTED if not yet emitted + if (!aguiState.hasEmittedRunStarted) { + aguiState.hasEmittedRunStarted = true + yield asChunk({ + type: 'RUN_STARTED', + runId: aguiState.runId, + model: options.model, + timestamp, + }) + } + + // Emit AG-UI RUN_ERROR + yield asChunk({ + type: 'RUN_ERROR', + runId: aguiState.runId, + model: options.model, + timestamp, + error: errorPayload, + }) + + options.logger.errors(`${this.name}.chatStream fatal`, { + error: errorPayload, + source: `${this.name}.chatStream`, + }) + } + } + + /** + * Generate structured output using the provider's JSON Schema response format. + * Uses stream: false to get the complete response in one call. + * + * OpenAI-compatible APIs have strict requirements for structured output: + * - All properties must be in the `required` array + * - Optional fields should have null added to their type union + * - additionalProperties must be false for all objects + * + * The outputSchema is already JSON Schema (converted in the ai layer). + * We apply provider-specific transformations for structured output compatibility. + */ + async structuredOutput( + options: StructuredOutputOptions, + ): Promise> { + const { chatOptions, outputSchema } = options + const requestParams = this.mapOptionsToRequest(chatOptions) + + const jsonSchema = this.makeStructuredOutputCompatible( + outputSchema, + outputSchema.required, + ) + + try { + // Strip stream_options which is only valid for streaming calls + const { + stream_options: _, + stream: __, + ...cleanParams + } = requestParams as any + chatOptions.logger.request( + `activity=structuredOutput provider=${this.name} model=${this.model} messages=${chatOptions.messages.length}`, + { provider: this.name, model: this.model }, + ) + const response = await this.client.chat.completions.create( + { + ...cleanParams, + stream: false, + response_format: { + type: 'json_schema', + json_schema: { + name: 'structured_output', + schema: jsonSchema, + strict: true, + }, + }, + }, + extractRequestOptions(chatOptions.request), + ) + + // Extract text content from the response + const rawText = response.choices[0]?.message.content || '' + + // Parse the JSON response + let parsed: unknown + try { + parsed = JSON.parse(rawText) + } catch { + throw new Error( + `Failed to parse structured output as JSON. Content: ${rawText.slice(0, 200)}${rawText.length > 200 ? '...' : ''}`, + ) + } + + // Transform null values to undefined to match original Zod schema expectations + // Provider returns null for optional fields we made nullable in the schema + const transformed = transformNullsToUndefined(parsed) + + return { + data: transformed, + rawText, + } + } catch (error: unknown) { + // Narrow before logging: raw SDK errors can carry request metadata + // (including auth headers) which we must never surface to user loggers. + chatOptions.logger.errors(`${this.name}.structuredOutput fatal`, { + error: toRunErrorPayload(error, `${this.name}.structuredOutput failed`), + source: `${this.name}.structuredOutput`, + }) + throw error + } + } + + /** + * Applies provider-specific transformations for structured output compatibility. + * Override this in subclasses to handle provider-specific quirks. + */ + protected makeStructuredOutputCompatible( + schema: Record, + originalRequired?: Array, + ): Record { + return makeStructuredOutputCompatible(schema, originalRequired) + } + + /** + * Processes streamed chunks from the Chat Completions API and yields AG-UI events. + * Override this in subclasses to handle provider-specific stream behavior. + */ + protected async *processStreamChunks( + stream: AsyncIterable, + options: TextOptions, + aguiState: { + runId: string + messageId: string + timestamp: number + hasEmittedRunStarted: boolean + }, + ): AsyncIterable { + let accumulatedContent = '' + const timestamp = aguiState.timestamp + let hasEmittedTextMessageStart = false + let lastModel: string | undefined + // Track usage from any chunk that carries it. With + // `stream_options: { include_usage: true }` OpenAI emits a terminal chunk + // whose `choices` is `[]` and only the `usage` field is populated; the + // earlier `finish_reason` chunk does NOT include token counts. We must + // therefore defer RUN_FINISHED until the iterator is exhausted so we can + // pick up usage from the trailing chunk regardless of arrival order. + let lastUsage: + | OpenAI_SDK.Chat.Completions.ChatCompletionChunk['usage'] + | undefined + let pendingFinishReason: + | OpenAI_SDK.Chat.Completions.ChatCompletionChunk.Choice['finish_reason'] + | undefined + + // Track tool calls being streamed (arguments come in chunks) + const toolCallsInProgress = new Map< + number, + { + id: string + name: string + arguments: string + started: boolean // Track if TOOL_CALL_START has been emitted + } + >() + // Track whether ANY tool call lifecycle was actually completed across the + // entire stream. Lets us downgrade a `tool_calls` finish_reason to `stop` + // when the upstream signalled tool calls but never produced a complete + // start/end pair — emitting RUN_FINISHED { finishReason: 'tool_calls' } + // with no matching TOOL_CALL_END would leave consumers waiting for tool + // results that never arrive. + let emittedAnyToolCallEnd = false + + try { + for await (const chunk of stream) { + const choiceForLog = chunk.choices[0] + options.logger.provider( + `provider=${this.name} finish_reason=${choiceForLog?.finish_reason ?? 'none'} hasContent=${!!choiceForLog?.delta.content} hasToolCalls=${!!choiceForLog?.delta.tool_calls} hasUsage=${!!chunk.usage}`, + { provider: this.name, model: chunk.model }, + ) + + // Capture usage from any chunk (including the terminal usage-only + // chunk emitted when `stream_options.include_usage` is on). + if (chunk.usage) { + lastUsage = chunk.usage + } + if (chunk.model) { + lastModel = chunk.model + } + + // Emit RUN_STARTED on the first chunk of any kind so callers see a + // run lifecycle even on streams that arrive entirely as usage-only + // (no choices). Without this, a usage-first stream would skip + // RUN_STARTED via `if (!choice) continue` below and the post-loop + // synthetic block would also skip RUN_FINISHED (it gates on + // `hasEmittedRunStarted`). + if (!aguiState.hasEmittedRunStarted) { + aguiState.hasEmittedRunStarted = true + yield asChunk({ + type: 'RUN_STARTED', + runId: aguiState.runId, + model: chunk.model || options.model, + timestamp, + }) + } + + const choice = chunk.choices[0] + + if (!choice) continue + + const delta = choice.delta + const deltaContent = delta.content + const deltaToolCalls = delta.tool_calls + + // Handle content delta + if (deltaContent) { + // Emit TEXT_MESSAGE_START on first text content + if (!hasEmittedTextMessageStart) { + hasEmittedTextMessageStart = true + yield asChunk({ + type: 'TEXT_MESSAGE_START', + messageId: aguiState.messageId, + model: chunk.model || options.model, + timestamp, + role: 'assistant', + }) + } + + accumulatedContent += deltaContent + + // Emit AG-UI TEXT_MESSAGE_CONTENT + yield asChunk({ + type: 'TEXT_MESSAGE_CONTENT', + messageId: aguiState.messageId, + model: chunk.model || options.model, + timestamp, + delta: deltaContent, + content: accumulatedContent, + }) + } + + // Handle tool calls - they come in as deltas + if (deltaToolCalls) { + for (const toolCallDelta of deltaToolCalls) { + const index = toolCallDelta.index + + // Initialize or update the tool call in progress + if (!toolCallsInProgress.has(index)) { + toolCallsInProgress.set(index, { + id: toolCallDelta.id || '', + name: toolCallDelta.function?.name || '', + arguments: '', + started: false, + }) + } + + const toolCall = toolCallsInProgress.get(index)! + + // Update with any new data from the delta + if (toolCallDelta.id) { + toolCall.id = toolCallDelta.id + } + if (toolCallDelta.function?.name) { + toolCall.name = toolCallDelta.function.name + } + if (toolCallDelta.function?.arguments) { + toolCall.arguments += toolCallDelta.function.arguments + } + + // Emit TOOL_CALL_START when we have id and name + if (toolCall.id && toolCall.name && !toolCall.started) { + toolCall.started = true + yield asChunk({ + type: 'TOOL_CALL_START', + toolCallId: toolCall.id, + toolCallName: toolCall.name, + toolName: toolCall.name, + model: chunk.model || options.model, + timestamp, + index, + }) + } + + // Emit TOOL_CALL_ARGS for argument deltas + if (toolCallDelta.function?.arguments && toolCall.started) { + yield asChunk({ + type: 'TOOL_CALL_ARGS', + toolCallId: toolCall.id, + model: chunk.model || options.model, + timestamp, + delta: toolCallDelta.function.arguments, + }) + } + } + } + + // Handle finish reason. We DO emit TOOL_CALL_END and TEXT_MESSAGE_END + // here because the corresponding _START events have already fired, + // and tool execution downstream wants to begin as soon as possible. + // RUN_FINISHED is deferred until the iterator is fully exhausted so + // we can capture the trailing usage chunk that arrives AFTER this + // chunk when stream_options.include_usage is on. + if (choice.finish_reason) { + if ( + choice.finish_reason === 'tool_calls' || + toolCallsInProgress.size > 0 + ) { + for (const [, toolCall] of toolCallsInProgress) { + // Skip tool calls that never emitted TOOL_CALL_START — emitting + // a stray TOOL_CALL_END here would violate AG-UI lifecycle + // (END without matching START) for partial deltas where the + // upstream never sent both id and name. + if (!toolCall.started) continue + + // Parse arguments for TOOL_CALL_END. Surface parse failures via + // the logger so a model emitting malformed JSON for tool args + // is debuggable instead of silently invoking the tool with {}. + // Non-object JSON (e.g. a bare string or number) is also coerced + // to {} so downstream tool execution doesn't receive a primitive + // input, mirroring the Responses adapter's guard. + let parsedInput: unknown = {} + if (toolCall.arguments) { + try { + const parsed: unknown = JSON.parse(toolCall.arguments) + parsedInput = + parsed && typeof parsed === 'object' ? parsed : {} + } catch (parseError) { + options.logger.errors( + `${this.name}.processStreamChunks tool-args JSON parse failed`, + { + error: toRunErrorPayload( + parseError, + `tool ${toolCall.name} (${toolCall.id}) returned malformed JSON arguments`, + ), + source: `${this.name}.processStreamChunks`, + toolCallId: toolCall.id, + toolName: toolCall.name, + rawArguments: toolCall.arguments, + }, + ) + parsedInput = {} + } + } + + // Emit AG-UI TOOL_CALL_END + yield asChunk({ + type: 'TOOL_CALL_END', + toolCallId: toolCall.id, + toolCallName: toolCall.name, + toolName: toolCall.name, + model: chunk.model || options.model, + timestamp, + input: parsedInput, + }) + emittedAnyToolCallEnd = true + } + // Clear tool-call state after emission so a subsequent + // `finish_reason: 'stop'` chunk (or the post-loop synthetic + // block) doesn't see lingering entries and misreport the finish. + toolCallsInProgress.clear() + } + + // Emit TEXT_MESSAGE_END if we had text content + if (hasEmittedTextMessageStart) { + yield asChunk({ + type: 'TEXT_MESSAGE_END', + messageId: aguiState.messageId, + model: chunk.model || options.model, + timestamp, + }) + hasEmittedTextMessageStart = false + } + + // Remember the upstream finish_reason; RUN_FINISHED is emitted at + // end-of-stream so we pick up the trailing usage-only chunk too. + pendingFinishReason = choice.finish_reason + } + } + + // Emit a single terminal RUN_FINISHED after the iterator is exhausted. + // This both delivers accurate token counts (the trailing usage chunk + // may arrive AFTER the finish_reason chunk) and gives consumers a + // guaranteed terminal event even when the upstream cuts off mid-stream + // (no finish_reason chunk ever arrives). + if (aguiState.hasEmittedRunStarted) { + // Close any started tool calls that never got finish_reason. A + // truncated stream that emitted TOOL_CALL_START but never reached + // finish_reason would otherwise leave consumers with an unbalanced + // start. Skip non-started entries (no matching START to close). + let pendingToolCount = 0 + for (const [, toolCall] of toolCallsInProgress) { + if (!toolCall.started) continue + let parsedInput: unknown = {} + if (toolCall.arguments) { + try { + const parsed: unknown = JSON.parse(toolCall.arguments) + parsedInput = parsed && typeof parsed === 'object' ? parsed : {} + } catch { + parsedInput = {} + } + } + yield asChunk({ + type: 'TOOL_CALL_END', + toolCallId: toolCall.id, + toolCallName: toolCall.name, + toolName: toolCall.name, + model: lastModel || options.model, + timestamp, + input: parsedInput, + }) + pendingToolCount += 1 + emittedAnyToolCallEnd = true + } + toolCallsInProgress.clear() + + // Make sure the text message lifecycle is closed even on early + // termination paths where finish_reason never arrives. + if (hasEmittedTextMessageStart) { + yield asChunk({ + type: 'TEXT_MESSAGE_END', + messageId: aguiState.messageId, + model: lastModel || options.model, + timestamp, + }) + } + + // Map upstream finish_reason to AG-UI's narrower vocabulary while + // preserving the upstream value when it falls outside the AG-UI set. + // Collapsing length / content_filter to 'stop' would hide why the + // run terminated — surface it instead. Use `tool_calls` only when + // a TOOL_CALL_END was actually emitted: an upstream that signalled + // `tool_calls` but never produced a started/ended pair must NOT + // surface `tool_calls` here, since downstream consumers wait for + // tool results that would never arrive. + const finishReason: string = emittedAnyToolCallEnd + ? 'tool_calls' + : pendingFinishReason === 'tool_calls' + ? 'stop' + : (pendingFinishReason ?? 'stop') + + yield asChunk({ + type: 'RUN_FINISHED', + runId: aguiState.runId, + model: lastModel || options.model, + timestamp, + usage: lastUsage + ? { + promptTokens: lastUsage.prompt_tokens || 0, + completionTokens: lastUsage.completion_tokens || 0, + totalTokens: lastUsage.total_tokens || 0, + } + : undefined, + finishReason, + }) + } + } catch (error: unknown) { + // Narrow before logging: raw SDK errors can carry request metadata + // (including auth headers) which we must never surface to user loggers. + const errorPayload = toRunErrorPayload( + error, + `${this.name}.processStreamChunks failed`, + ) + options.logger.errors(`${this.name}.processStreamChunks fatal`, { + error: errorPayload, + source: `${this.name}.processStreamChunks`, + }) + + // Emit AG-UI RUN_ERROR + yield asChunk({ + type: 'RUN_ERROR', + runId: aguiState.runId, + model: options.model, + timestamp, + error: errorPayload, + }) + } + } + + /** + * Maps common TextOptions to Chat Completions API request format. + * Override this in subclasses to add provider-specific options. + */ + protected mapOptionsToRequest( + options: TextOptions, + ): OpenAI_SDK.Chat.Completions.ChatCompletionCreateParamsStreaming { + const tools = options.tools + ? convertToolsToChatCompletionsFormat( + options.tools, + this.makeStructuredOutputCompatible.bind(this), + ) + : undefined + + // Build messages array with system prompts + const messages: Array = + [] + + // Add system prompts first + if (options.systemPrompts && options.systemPrompts.length > 0) { + messages.push({ + role: 'system', + content: options.systemPrompts.join('\n'), + }) + } + + // Convert messages + for (const message of options.messages) { + messages.push(this.convertMessage(message)) + } + + const modelOptions = options.modelOptions + + // Build the request so explicit top-level options win over modelOptions + // when set, but `undefined` top-level options do NOT clobber values the + // caller put in modelOptions. Keeping the merge nullish-aware fixes the + // silent regression where a `modelOptions: { temperature: 0.7 }` setting + // was overwritten with `temperature: undefined`. + return { + ...modelOptions, + model: options.model, + messages, + ...(options.temperature !== undefined && { + temperature: options.temperature, + }), + ...(options.maxTokens !== undefined && { + max_tokens: options.maxTokens, + }), + ...(options.topP !== undefined && { top_p: options.topP }), + // Conditional spread: `tools: undefined` would clobber any + // modelOptions.tools the caller set above. + ...(tools && + tools.length > 0 && { + tools, + }), + stream: true, + } + } + + /** + * Converts a single ModelMessage to the Chat Completions API message format. + * Override this in subclasses to handle provider-specific message formats. + */ + protected convertMessage( + message: ModelMessage, + ): OpenAI_SDK.Chat.Completions.ChatCompletionMessageParam { + // Handle tool messages + if (message.role === 'tool') { + return { + role: 'tool', + tool_call_id: message.toolCallId || '', + content: + typeof message.content === 'string' + ? message.content + : JSON.stringify(message.content), + } + } + + // Handle assistant messages + if (message.role === 'assistant') { + const toolCalls = message.toolCalls?.map((tc) => ({ + id: tc.id, + type: 'function' as const, + function: { + name: tc.function.name, + arguments: + typeof tc.function.arguments === 'string' + ? tc.function.arguments + : JSON.stringify(tc.function.arguments), + }, + })) + const hasToolCalls = !!toolCalls && toolCalls.length > 0 + const textContent = this.extractTextContent(message.content) + + // Per the OpenAI Chat Completions contract, an assistant message that + // only carries tool_calls should have `content: null` (or omit content) + // rather than `content: ''`. Empty-string content interacts oddly with + // tokenization on some backends; null is the documented shape. + return { + role: 'assistant', + content: hasToolCalls && !textContent ? null : textContent, + ...(hasToolCalls ? { tool_calls: toolCalls } : {}), + } + } + + // Handle user messages - support multimodal content + const contentParts = this.normalizeContent(message.content) + + // If only text, use simple string format + if (contentParts.length === 1 && contentParts[0]?.type === 'text') { + const text = contentParts[0].content + if (text.length === 0) { + // Single empty text part is the same fail-loud condition as below — + // an empty paid request mask a real intent (caller passed `null`/'', + // or an upstream step normalised everything to an empty string). + throw new Error( + `User message for ${this.name} has empty text content. ` + + `Empty user messages would produce a paid request with no input; ` + + `provide non-empty content or omit the message.`, + ) + } + return { + role: 'user', + content: text, + } + } + + // Otherwise, use array format for multimodal. Fail fast on unsupported + // content parts rather than silently dropping them — a message of all + // unsupported parts would otherwise turn into an empty user prompt and + // mask a real capability mismatch. + const parts: Array = + [] + for (const part of contentParts) { + const converted = this.convertContentPart(part) + if (!converted) { + throw new Error( + `Unsupported content part type for ${this.name}: ${part.type}. ` + + `Override convertContentPart() in a subclass to handle this type, ` + + `or remove it from the message.`, + ) + } + parts.push(converted) + } + + if (parts.length === 0) { + // The original message had no content parts at all (e.g. content was + // explicitly null or []). Sending an empty user message to OpenAI + // produces a paid request with no signal — fail loud instead. + throw new Error( + `User message for ${this.name} has no content parts. ` + + `Empty user messages would produce a paid request with no input; ` + + `provide at least one text/image/audio part or omit the message.`, + ) + } + + return { + role: 'user', + content: parts, + } + } + + /** + * Converts a single ContentPart to the Chat Completions API content part format. + * Override this in subclasses to handle additional content types or provider-specific metadata. + */ + protected convertContentPart( + part: ContentPart, + ): OpenAI_SDK.Chat.Completions.ChatCompletionContentPart | null { + if (part.type === 'text') { + return { type: 'text', text: part.content } + } + + if (part.type === 'image') { + const imageMetadata = part.metadata as + | { detail?: 'auto' | 'low' | 'high' } + | undefined + + // For base64 data, construct a data URI using the mimeType from source. + // Default to a generic octet-stream MIME if the source didn't provide + // one — interpolating `undefined` into the URI ("data:undefined;base64, + // ...") would produce an invalid URI the API rejects. + const imageValue = part.source.value + const imageMime = part.source.mimeType || 'application/octet-stream' + const imageUrl = + part.source.type === 'data' && !imageValue.startsWith('data:') + ? `data:${imageMime};base64,${imageValue}` + : imageValue + + return { + type: 'image_url', + image_url: { + url: imageUrl, + detail: imageMetadata?.detail || 'auto', + }, + } + } + + // Unsupported content type — subclasses can override to handle more types + return null + } + + /** + * Normalizes message content to an array of ContentPart. + * Handles backward compatibility with string content. + */ + protected normalizeContent( + content: string | null | Array, + ): Array { + if (content === null) { + return [] + } + if (typeof content === 'string') { + return [{ type: 'text', content: content }] + } + return content + } + + /** + * Extracts text content from a content value that may be string, null, or ContentPart array. + */ + protected extractTextContent( + content: string | null | Array, + ): string { + if (content === null) { + return '' + } + if (typeof content === 'string') { + return content + } + // It's an array of ContentPart + return content + .filter((p) => p.type === 'text') + .map((p) => p.content) + .join('') + } +} diff --git a/packages/typescript/openai-base/src/adapters/chat-completions-tool-converter.ts b/packages/typescript/openai-base/src/adapters/chat-completions-tool-converter.ts new file mode 100644 index 000000000..2a83eaae3 --- /dev/null +++ b/packages/typescript/openai-base/src/adapters/chat-completions-tool-converter.ts @@ -0,0 +1,70 @@ +import { makeStructuredOutputCompatible } from '../utils/schema-converter' +import type { JSONSchema, Tool } from '@tanstack/ai' +import type OpenAI from 'openai' + +/** + * Chat Completions API tool format. + * This is distinct from the Responses API tool format. + */ +export type ChatCompletionFunctionTool = + OpenAI.Chat.Completions.ChatCompletionTool + +/** + * Converts a standard Tool to OpenAI Chat Completions ChatCompletionTool format. + * + * Tool schemas are already converted to JSON Schema in the ai layer. + * We apply OpenAI-compatible transformations for strict mode: + * - All properties in required array + * - Optional fields made nullable + * - additionalProperties: false + * + * This enables strict mode for all tools automatically. + */ +export function convertFunctionToolToChatCompletionsFormat( + tool: Tool, + schemaConverter: ( + schema: Record, + required: Array, + ) => Record = makeStructuredOutputCompatible, +): ChatCompletionFunctionTool { + const inputSchema = (tool.inputSchema ?? { + type: 'object', + properties: {}, + required: [], + }) as JSONSchema + + // Shallow-copy the converter's result before mutating: a subclass-supplied + // schemaConverter has no contract requirement to return a fresh object, + // and a passthrough `(s) => s` would otherwise have its caller's schema + // mutated by the `additionalProperties = false` assignment below. + const jsonSchema = { + ...schemaConverter(inputSchema, inputSchema.required || []), + } + jsonSchema.additionalProperties = false + + return { + type: 'function', + function: { + name: tool.name, + description: tool.description, + parameters: jsonSchema, + strict: true, + }, + } satisfies ChatCompletionFunctionTool +} + +/** + * Converts an array of standard Tools to Chat Completions format. + * Chat Completions API primarily supports function tools. + */ +export function convertToolsToChatCompletionsFormat( + tools: Array, + schemaConverter?: ( + schema: Record, + required: Array, + ) => Record, +): Array { + return tools.map((tool) => + convertFunctionToolToChatCompletionsFormat(tool, schemaConverter), + ) +} diff --git a/packages/typescript/openai-base/src/adapters/image.ts b/packages/typescript/openai-base/src/adapters/image.ts new file mode 100644 index 000000000..89b8f283f --- /dev/null +++ b/packages/typescript/openai-base/src/adapters/image.ts @@ -0,0 +1,158 @@ +import { BaseImageAdapter } from '@tanstack/ai/adapters' +import { toRunErrorPayload } from '@tanstack/ai/adapter-internals' +import { generateId } from '@tanstack/ai-utils' +import { createOpenAICompatibleClient } from '../utils/client' +import type { + GeneratedImage, + ImageGenerationOptions, + ImageGenerationResult, +} from '@tanstack/ai' +import type OpenAI_SDK from 'openai' +import type { OpenAICompatibleClientConfig } from '../types/config' + +/** + * OpenAI-Compatible Image Generation Adapter + * + * A generalized base class for providers that implement OpenAI-compatible image + * generation APIs. Providers like OpenAI, Grok, and others can extend this class + * and only need to: + * - Set `baseURL` in the config + * - Lock the generic type parameters to provider-specific types + * - Override validation or request building methods for provider-specific constraints + * + * All methods that validate inputs, build requests, or transform responses are + * `protected` so subclasses can override them. + */ +export class OpenAICompatibleImageAdapter< + TModel extends string, + TProviderOptions extends object = Record, + TModelProviderOptionsByName extends Record = Record, + TModelSizeByName extends Record = Record, +> extends BaseImageAdapter< + TModel, + TProviderOptions, + TModelProviderOptionsByName, + TModelSizeByName +> { + readonly kind = 'image' as const + readonly name: string + + protected client: OpenAI_SDK + + constructor( + config: OpenAICompatibleClientConfig, + model: TModel, + name: string = 'openai-compatible', + ) { + super(model, {}) + this.name = name + this.client = createOpenAICompatibleClient(config) + } + + async generateImages( + options: ImageGenerationOptions, + ): Promise { + const { model, prompt, numberOfImages, size } = options + + // Validate inputs + this.validatePrompt({ prompt, model }) + this.validateImageSize(model, size) + this.validateNumberOfImages(model, numberOfImages) + + // Build request based on model type + const request = this.buildRequest(options) + + try { + options.logger.request( + `activity=image provider=${this.name} model=${model} n=${request.n ?? 1} size=${request.size ?? 'default'}`, + { provider: this.name, model }, + ) + const response = await this.client.images.generate({ + ...request, + stream: false, + }) + + return this.transformResponse(model, response) + } catch (error: unknown) { + // Narrow before logging: raw SDK errors can carry request metadata + // (including auth headers) which we must never surface to user loggers. + options.logger.errors(`${this.name}.generateImages fatal`, { + error: toRunErrorPayload(error, `${this.name}.generateImages failed`), + source: `${this.name}.generateImages`, + }) + throw error + } + } + + protected buildRequest( + options: ImageGenerationOptions, + ): OpenAI_SDK.Images.ImageGenerateParams { + const { model, prompt, numberOfImages, size, modelOptions } = options + + return { + model, + prompt, + n: numberOfImages ?? 1, + size: size as OpenAI_SDK.Images.ImageGenerateParams['size'], + ...modelOptions, + } + } + + protected transformResponse( + model: string, + response: OpenAI_SDK.Images.ImagesResponse, + ): ImageGenerationResult { + const images: Array = (response.data ?? []).flatMap( + (item): Array => { + const revisedPrompt = item.revised_prompt + if (item.b64_json) { + return [{ b64Json: item.b64_json, revisedPrompt }] + } + if (item.url) { + return [{ url: item.url, revisedPrompt }] + } + return [] + }, + ) + + return { + id: generateId(this.name), + model, + images, + usage: response.usage + ? { + inputTokens: response.usage.input_tokens, + outputTokens: response.usage.output_tokens, + totalTokens: response.usage.total_tokens, + } + : undefined, + } + } + + protected validatePrompt(options: { prompt: string; model: string }): void { + if (options.prompt.length === 0) { + throw new Error('Prompt cannot be empty.') + } + } + + protected validateImageSize(_model: string, _size: string | undefined): void { + // Default: no size validation — subclasses can override + } + + protected validateNumberOfImages( + _model: string, + numberOfImages: number | undefined, + ): void { + if (numberOfImages === undefined) return + + // The base adapter only enforces "must be at least 1". Per-provider / + // per-model upper bounds vary widely (some support 4, some 10, some + // unlimited), so concrete adapter subclasses are expected to override + // this method with a model-specific cap. + if (numberOfImages < 1) { + throw new Error( + `Number of images must be at least 1. Requested: ${numberOfImages}`, + ) + } + } +} diff --git a/packages/typescript/openai-base/src/adapters/responses-text.ts b/packages/typescript/openai-base/src/adapters/responses-text.ts new file mode 100644 index 000000000..48faadd21 --- /dev/null +++ b/packages/typescript/openai-base/src/adapters/responses-text.ts @@ -0,0 +1,1147 @@ +import { BaseTextAdapter } from '@tanstack/ai/adapters' +import { toRunErrorPayload } from '@tanstack/ai/adapter-internals' +import { generateId, transformNullsToUndefined } from '@tanstack/ai-utils' +import { createOpenAICompatibleClient } from '../utils/client' +import { extractRequestOptions } from '../utils/request-options' +import { makeStructuredOutputCompatible } from '../utils/schema-converter' +import { convertToolsToResponsesFormat } from './responses-tool-converter' +import type { + StructuredOutputOptions, + StructuredOutputResult, +} from '@tanstack/ai/adapters' +import type OpenAI_SDK from 'openai' +import type { Responses } from 'openai/resources' +import type { + ContentPart, + DefaultMessageMetadataByModality, + Modality, + ModelMessage, + StreamChunk, + TextOptions, +} from '@tanstack/ai' +import type { OpenAICompatibleClientConfig } from '../types/config' + +/** Cast an event object to StreamChunk. Adapters construct events with string + * literal types which are structurally compatible with the EventType enum. */ +const asChunk = (chunk: Record) => + chunk as unknown as StreamChunk + +/** + * OpenAI-compatible Responses API Text Adapter + * + * A generalized base class for providers that use the OpenAI Responses API + * (`/v1/responses`). Providers like OpenAI (native), Azure OpenAI, and others + * that implement the Responses API can extend this class and only need to: + * - Set `baseURL` in the config + * - Lock the generic type parameters to provider-specific types + * - Override specific methods for quirks + * + * Key differences from the Chat Completions adapter: + * - Uses `client.responses.create()` instead of `client.chat.completions.create()` + * - Messages use `ResponseInput` format + * - System prompts go in `instructions` field, not as array messages + * - Streaming events are completely different (9+ event types vs simple delta chunks) + * - Supports reasoning/thinking tokens via `response.reasoning_text.delta` + * - Structured output uses `text.format` in the request (not `response_format`) + * - Tool calls use `response.function_call_arguments.delta` + * - Content parts are `input_text`, `input_image`, `input_file` + * + * All methods that build requests or process responses are `protected` so subclasses + * can override them. + */ +export class OpenAICompatibleResponsesTextAdapter< + TModel extends string, + TProviderOptions extends Record = Record, + TInputModalities extends ReadonlyArray = ReadonlyArray, + TMessageMetadata extends DefaultMessageMetadataByModality = + DefaultMessageMetadataByModality, + TToolCapabilities extends ReadonlyArray = ReadonlyArray, +> extends BaseTextAdapter< + TModel, + TProviderOptions, + TInputModalities, + TMessageMetadata, + TToolCapabilities +> { + readonly kind = 'text' as const + readonly name: string + + protected client: OpenAI_SDK + + constructor( + config: OpenAICompatibleClientConfig, + model: TModel, + name: string = 'openai-compatible-responses', + ) { + super({}, model) + this.name = name + this.client = createOpenAICompatibleClient(config) + } + + async *chatStream( + options: TextOptions, + ): AsyncIterable { + // Track tool call metadata by unique ID + // Responses API streams tool calls with deltas — first chunk has ID/name, + // subsequent chunks only have args. + // We assign our own indices as we encounter unique tool call IDs. + const toolCallMetadata = new Map< + string, + { index: number; name: string; started: boolean } + >() + const requestParams = this.mapOptionsToRequest(options) + const timestamp = Date.now() + + // AG-UI lifecycle tracking + const aguiState = { + runId: generateId(this.name), + messageId: generateId(this.name), + timestamp, + hasEmittedRunStarted: false, + } + + try { + options.logger.request( + `activity=chat provider=${this.name} model=${this.model} messages=${options.messages.length} tools=${options.tools?.length ?? 0} stream=true`, + { provider: this.name, model: this.model }, + ) + const response = await this.client.responses.create( + { + ...requestParams, + stream: true, + }, + extractRequestOptions(options.request), + ) + + yield* this.processStreamChunks( + response, + toolCallMetadata, + options, + aguiState, + ) + } catch (error: unknown) { + // Narrow before logging: raw SDK errors can carry request metadata + // (including auth headers) which we must never surface to user loggers. + const errorPayload = toRunErrorPayload( + error, + `${this.name}.chatStream failed`, + ) + + // Emit RUN_STARTED if not yet emitted + if (!aguiState.hasEmittedRunStarted) { + aguiState.hasEmittedRunStarted = true + yield asChunk({ + type: 'RUN_STARTED', + runId: aguiState.runId, + model: options.model, + timestamp, + }) + } + + // Emit AG-UI RUN_ERROR + yield asChunk({ + type: 'RUN_ERROR', + runId: aguiState.runId, + model: options.model, + timestamp, + error: errorPayload, + }) + + options.logger.errors(`${this.name}.chatStream fatal`, { + error: errorPayload, + source: `${this.name}.chatStream`, + }) + } + } + + /** + * Generate structured output using the provider's native JSON Schema response format. + * Uses stream: false to get the complete response in one call. + * + * OpenAI-compatible Responses APIs have strict requirements for structured output: + * - All properties must be in the `required` array + * - Optional fields should have null added to their type union + * - additionalProperties must be false for all objects + * + * The outputSchema is already JSON Schema (converted in the ai layer). + * We apply provider-specific transformations for structured output compatibility. + */ + async structuredOutput( + options: StructuredOutputOptions, + ): Promise> { + const { chatOptions, outputSchema } = options + const requestParams = this.mapOptionsToRequest(chatOptions) + + // Apply provider-specific transformations for structured output compatibility + const jsonSchema = this.makeStructuredOutputCompatible( + outputSchema, + outputSchema.required, + ) + + try { + // Strip streaming-only fields a subclass override of mapOptionsToRequest + // might have returned (parallel to chat-completions's structuredOutput + // cleanup) — sending stream_options to a non-streaming call is a 4xx. + const { + stream: _stream, + stream_options: _streamOptions, + ...cleanParams + } = requestParams as Record + void _stream + void _streamOptions + chatOptions.logger.request( + `activity=structuredOutput provider=${this.name} model=${this.model} messages=${chatOptions.messages.length}`, + { provider: this.name, model: this.model }, + ) + const response = await this.client.responses.create( + { + ...(cleanParams as Omit< + OpenAI_SDK.Responses.ResponseCreateParams, + 'stream' + >), + stream: false, + // Configure structured output via text.format + text: { + format: { + type: 'json_schema', + name: 'structured_output', + schema: jsonSchema, + strict: true, + }, + }, + }, + extractRequestOptions(chatOptions.request), + ) + + // Extract text content from the response. `stream: false` narrows the + // SDK return type to `Response`, but the explicit annotation makes + // that contract local rather than relying on inference through the + // overloaded `client.responses.create` signature. + const rawText = this.extractTextFromResponse( + response satisfies OpenAI_SDK.Responses.Response, + ) + + // Parse the JSON response + let parsed: unknown + try { + parsed = JSON.parse(rawText) + } catch { + throw new Error( + `Failed to parse structured output as JSON. Content: ${rawText.slice(0, 200)}${rawText.length > 200 ? '...' : ''}`, + ) + } + + // Transform null values to undefined to match original Zod schema expectations + // Provider returns null for optional fields we made nullable in the schema + const transformed = transformNullsToUndefined(parsed) + + return { + data: transformed, + rawText, + } + } catch (error: unknown) { + // Narrow before logging: raw SDK errors can carry request metadata + // (including auth headers) which we must never surface to user loggers. + chatOptions.logger.errors(`${this.name}.structuredOutput fatal`, { + error: toRunErrorPayload(error, `${this.name}.structuredOutput failed`), + source: `${this.name}.structuredOutput`, + }) + throw error + } + } + + /** + * Applies provider-specific transformations for structured output compatibility. + * Override this in subclasses to handle provider-specific quirks. + */ + protected makeStructuredOutputCompatible( + schema: Record, + originalRequired?: Array, + ): Record { + return makeStructuredOutputCompatible(schema, originalRequired) + } + + /** + * Extract text content from a non-streaming Responses API response. + * Override this in subclasses for provider-specific response shapes. + */ + protected extractTextFromResponse( + response: OpenAI_SDK.Responses.Response, + ): string { + let textContent = '' + let refusal: string | undefined + + for (const item of response.output) { + if (item.type === 'message') { + for (const part of item.content) { + if (part.type === 'output_text') { + textContent += part.text + } else { + // The Responses SDK currently models message content as + // `output_text | refusal`, so the only non-text branch is a + // refusal. Capture it so we can surface a distinct error below. + refusal = part.refusal || refusal || 'Refused without explanation' + } + } + } + } + + // Surface refusals as an explicit error so callers don't see a generic + // "Failed to parse structured output as JSON. Content: " when the model + // refused for safety / content-policy reasons. + if (!textContent && refusal !== undefined) { + const err = new Error(`Model refused to respond: ${refusal}`) + ;(err as Error & { code?: string }).code = 'refusal' + throw err + } + + return textContent + } + + /** + * Processes streamed chunks from the Responses API and yields AG-UI events. + * Override this in subclasses to handle provider-specific stream behavior. + * + * Handles the following event types: + * - response.created / response.incomplete / response.failed + * - response.output_text.delta + * - response.reasoning_text.delta + * - response.reasoning_summary_text.delta + * - response.content_part.added / response.content_part.done + * - response.output_item.added + * - response.function_call_arguments.delta / response.function_call_arguments.done + * - response.completed + * - error + */ + protected async *processStreamChunks( + stream: AsyncIterable, + toolCallMetadata: Map< + string, + { index: number; name: string; started: boolean } + >, + options: TextOptions, + aguiState: { + runId: string + messageId: string + timestamp: number + hasEmittedRunStarted: boolean + }, + ): AsyncIterable { + let accumulatedContent = '' + let accumulatedReasoning = '' + const timestamp = aguiState.timestamp + + // Track if we've been streaming deltas to avoid duplicating content from done events + let hasStreamedContentDeltas = false + let hasStreamedReasoningDeltas = false + + // Preserve response metadata across events + let model: string = options.model + + // AG-UI lifecycle tracking + let stepId: string | null = null + let hasEmittedTextMessageStart = false + let hasEmittedStepStarted = false + // Track whether we've emitted a terminal RUN_FINISHED so the + // end-of-stream fallback below knows to synthesise one when the upstream + // cuts off without a response.completed event. + let runFinishedEmitted = false + + try { + for await (const chunk of stream) { + options.logger.provider(`provider=${this.name} type=${chunk.type}`, { + provider: this.name, + type: chunk.type, + }) + + // Emit RUN_STARTED on first chunk + if (!aguiState.hasEmittedRunStarted) { + aguiState.hasEmittedRunStarted = true + yield asChunk({ + type: 'RUN_STARTED', + runId: aguiState.runId, + model: model || options.model, + timestamp, + }) + } + + const handleContentPart = (contentPart: { + type: string + text?: string + refusal?: string + }): StreamChunk => { + if (contentPart.type === 'output_text') { + accumulatedContent += contentPart.text || '' + return asChunk({ + type: 'TEXT_MESSAGE_CONTENT', + messageId: aguiState.messageId, + model: model || options.model, + timestamp, + delta: contentPart.text || '', + content: accumulatedContent, + }) + } + + if (contentPart.type === 'reasoning_text') { + accumulatedReasoning += contentPart.text || '' + // Cache the fallback stepId rather than generating a fresh one + // on every call — otherwise multiple reasoning chunks arriving + // before STEP_STARTED was emitted (e.g. via response.content_part.done + // alone) would each get a different stepId and break correlation. + if (!stepId) { + stepId = generateId(this.name) + } + return asChunk({ + type: 'STEP_FINISHED', + stepId, + model: model || options.model, + timestamp, + delta: contentPart.text || '', + content: accumulatedReasoning, + }) + } + // Either a real refusal or an unknown content_part type. Surface + // the part type in the error so unknown parts are debuggable + // instead of being misreported as "Unknown refusal". + const isRefusal = contentPart.type === 'refusal' + const message = isRefusal + ? contentPart.refusal || 'Refused without explanation' + : `Unsupported response content_part type: ${contentPart.type}` + return asChunk({ + type: 'RUN_ERROR', + runId: aguiState.runId, + model: model || options.model, + timestamp, + error: { + message, + code: isRefusal ? 'refusal' : contentPart.type, + }, + }) + } + + // Capture model metadata from any of these events (created starts + // the run; failed/incomplete signal terminal failure). + if ( + chunk.type === 'response.created' || + chunk.type === 'response.incomplete' || + chunk.type === 'response.failed' + ) { + model = chunk.response.model + } + + // response.created marks the start of a fresh run — safe to reset + // the per-run accumulators here. + if (chunk.type === 'response.created') { + hasStreamedContentDeltas = false + hasStreamedReasoningDeltas = false + hasEmittedTextMessageStart = false + hasEmittedStepStarted = false + accumulatedContent = '' + accumulatedReasoning = '' + } + + // response.failed and response.incomplete are TERMINAL events for + // the current response. Close any open AG-UI message lifecycle FIRST + // so consumers tracking start/end pairs don't see an unbalanced + // TEXT_MESSAGE_START. Then surface the error and mark the run as + // finished so the post-loop synthetic terminal block doesn't emit + // a duplicate RUN_FINISHED on top of RUN_ERROR. + if ( + chunk.type === 'response.failed' || + chunk.type === 'response.incomplete' + ) { + if (hasEmittedTextMessageStart) { + yield asChunk({ + type: 'TEXT_MESSAGE_END', + messageId: aguiState.messageId, + model: chunk.response.model, + timestamp, + }) + hasEmittedTextMessageStart = false + } + // Coalesce error + incomplete_details into a single RUN_ERROR + // payload — emitting two distinct events for one terminal upstream + // event would force consumers to handle a non-existent ordering. + const errorMessage = + chunk.response.error?.message || + chunk.response.incomplete_details?.reason || + (chunk.type === 'response.failed' + ? 'Response failed' + : 'Response ended incomplete') + const errorCode = + chunk.response.error?.code || + (chunk.response.incomplete_details ? 'incomplete' : undefined) + // Always emit RUN_ERROR for terminal failure events, even when the + // upstream omitted both `error` and `incomplete_details`. Skipping + // emission on a `response.incomplete` with no detail would let the + // post-loop synthetic block silently coerce the run to a clean + // `RUN_FINISHED { finishReason: 'stop' }` — masking the failure. + yield asChunk({ + type: 'RUN_ERROR', + runId: aguiState.runId, + model: chunk.response.model, + timestamp, + error: { + message: errorMessage, + ...(errorCode !== undefined && { code: errorCode }), + }, + }) + // RUN_ERROR is the terminal event for this run; stop processing + // any further chunks the iterator might still deliver. + runFinishedEmitted = true + return + } + + // Handle output text deltas (token-by-token streaming) + // response.output_text.delta provides incremental text updates + if (chunk.type === 'response.output_text.delta' && chunk.delta) { + // Delta can be an array of strings or a single string + const textDelta = Array.isArray(chunk.delta) + ? chunk.delta.join('') + : typeof chunk.delta === 'string' + ? chunk.delta + : '' + + if (textDelta) { + // Emit TEXT_MESSAGE_START on first text content + if (!hasEmittedTextMessageStart) { + hasEmittedTextMessageStart = true + yield asChunk({ + type: 'TEXT_MESSAGE_START', + messageId: aguiState.messageId, + model: model || options.model, + timestamp, + role: 'assistant', + }) + } + + accumulatedContent += textDelta + hasStreamedContentDeltas = true + yield asChunk({ + type: 'TEXT_MESSAGE_CONTENT', + messageId: aguiState.messageId, + model: model || options.model, + timestamp, + delta: textDelta, + content: accumulatedContent, + }) + } + } + + // Handle reasoning deltas (token-by-token thinking/reasoning streaming) + // response.reasoning_text.delta provides incremental reasoning updates + if (chunk.type === 'response.reasoning_text.delta' && chunk.delta) { + // Delta can be an array of strings or a single string + const reasoningDelta = Array.isArray(chunk.delta) + ? chunk.delta.join('') + : typeof chunk.delta === 'string' + ? chunk.delta + : '' + + if (reasoningDelta) { + // Emit STEP_STARTED on first reasoning content + if (!hasEmittedStepStarted) { + hasEmittedStepStarted = true + stepId = generateId(this.name) + yield asChunk({ + type: 'STEP_STARTED', + stepId, + model: model || options.model, + timestamp, + stepType: 'thinking', + }) + } + + accumulatedReasoning += reasoningDelta + hasStreamedReasoningDeltas = true + yield asChunk({ + type: 'STEP_FINISHED', + stepId: stepId || generateId(this.name), + model: model || options.model, + timestamp, + delta: reasoningDelta, + content: accumulatedReasoning, + }) + } + } + + // Handle reasoning summary deltas (when using reasoning.summary option) + // response.reasoning_summary_text.delta provides incremental summary updates + if ( + chunk.type === 'response.reasoning_summary_text.delta' && + chunk.delta + ) { + const summaryDelta = + typeof chunk.delta === 'string' ? chunk.delta : '' + + if (summaryDelta) { + // Emit STEP_STARTED on first reasoning content + if (!hasEmittedStepStarted) { + hasEmittedStepStarted = true + stepId = generateId(this.name) + yield asChunk({ + type: 'STEP_STARTED', + stepId, + model: model || options.model, + timestamp, + stepType: 'thinking', + }) + } + + accumulatedReasoning += summaryDelta + hasStreamedReasoningDeltas = true + yield asChunk({ + type: 'STEP_FINISHED', + stepId: stepId || generateId(this.name), + model: model || options.model, + timestamp, + delta: summaryDelta, + content: accumulatedReasoning, + }) + } + } + + // handle content_part added events for text, reasoning and refusals + if (chunk.type === 'response.content_part.added') { + const contentPart = chunk.part + // Emit TEXT_MESSAGE_START if this is text content + if ( + contentPart.type === 'output_text' && + !hasEmittedTextMessageStart + ) { + hasEmittedTextMessageStart = true + yield asChunk({ + type: 'TEXT_MESSAGE_START', + messageId: aguiState.messageId, + model: model || options.model, + timestamp, + role: 'assistant', + }) + } + // Emit STEP_STARTED if this is reasoning content + if (contentPart.type === 'reasoning_text' && !hasEmittedStepStarted) { + hasEmittedStepStarted = true + stepId = generateId(this.name) + yield asChunk({ + type: 'STEP_STARTED', + stepId, + model: model || options.model, + timestamp, + stepType: 'thinking', + }) + } + // Mark whichever stream we just emitted into so a subsequent + // `content_part.done` doesn't duplicate the same text. Without + // this flag, an `added` event carrying the full text followed by + // a matching `done` event would emit TEXT_MESSAGE_CONTENT twice. + if (contentPart.type === 'output_text') { + hasStreamedContentDeltas = true + } else if (contentPart.type === 'reasoning_text') { + hasStreamedReasoningDeltas = true + } + const partChunk = handleContentPart(contentPart) + yield partChunk + // handleContentPart returns RUN_ERROR for refusals / unknown + // content_part types — those are terminal events. Don't keep + // processing more chunks (and don't let the post-loop synthetic + // block emit a second terminal event). + if (partChunk.type === 'RUN_ERROR') { + runFinishedEmitted = true + return + } + } + + if (chunk.type === 'response.content_part.done') { + const contentPart = chunk.part + + // Skip emitting chunks for content parts that we've already streamed via deltas + // The done event is just a completion marker, not new content + if (contentPart.type === 'output_text' && hasStreamedContentDeltas) { + // Content already accumulated from deltas, skip + continue + } + if ( + contentPart.type === 'reasoning_text' && + hasStreamedReasoningDeltas + ) { + // Reasoning already accumulated from deltas, skip + continue + } + + // Only emit if we haven't been streaming deltas (e.g., for non-streaming responses) + const doneChunk = handleContentPart(contentPart) + yield doneChunk + if (doneChunk.type === 'RUN_ERROR') { + runFinishedEmitted = true + return + } + } + + // handle output_item.added to capture function call metadata (name) + if (chunk.type === 'response.output_item.added') { + const item = chunk.item + if (item.type === 'function_call' && item.id) { + const existing = toolCallMetadata.get(item.id) + // Only emit TOOL_CALL_START on the FIRST output_item.added for + // an item id. A duplicate emission (which can happen on retried + // streams or replay) would violate AG-UI's start-once contract. + if (!existing?.started) { + if (!existing) { + toolCallMetadata.set(item.id, { + index: chunk.output_index, + name: item.name || '', + started: false, + }) + } + yield asChunk({ + type: 'TOOL_CALL_START', + toolCallId: item.id, + toolCallName: item.name || '', + toolName: item.name || '', + model: model || options.model, + timestamp, + index: chunk.output_index, + }) + toolCallMetadata.get(item.id)!.started = true + } + } + } + + // Handle function call arguments delta (streaming). Drop the + // previously-emitted `args` field — it had inverted polarity + // (populated only when metadata was MISSING, i.e. when the + // matching TOOL_CALL_START hadn't fired) and the chat-completions + // adapter never emitted it, so it leaked partial deltas as + // pseudo-args only on the orphan path. Consumers should accumulate + // `delta` themselves. + // + // Guard with `metadata?.started`: the matching TOOL_CALL_START fires + // from `output_item.added`, and emitting TOOL_CALL_ARGS before that + // would violate the AG-UI lifecycle (ARGS without START). The .done + // handler below applies the same guard. + if ( + chunk.type === 'response.function_call_arguments.delta' && + chunk.delta + ) { + const metadata = toolCallMetadata.get(chunk.item_id) + if (!metadata?.started) { + options.logger.errors( + `${this.name}.processStreamChunks orphan function_call_arguments.delta`, + { + source: `${this.name}.processStreamChunks`, + toolCallId: chunk.item_id, + rawDelta: chunk.delta, + }, + ) + continue + } + yield asChunk({ + type: 'TOOL_CALL_ARGS', + toolCallId: chunk.item_id, + model: model || options.model, + timestamp, + delta: chunk.delta, + }) + } + + if (chunk.type === 'response.function_call_arguments.done') { + const { item_id } = chunk + + // Get the function name from metadata (captured in output_item.added) + const metadata = toolCallMetadata.get(item_id) + // Skip TOOL_CALL_END for items whose start was never emitted (no + // matching `output_item.added`). Emitting END without START would + // produce an unbalanced AG-UI lifecycle event downstream consumers + // can't pair. + if (!metadata?.started) { + options.logger.errors( + `${this.name}.processStreamChunks orphan function_call_arguments.done`, + { + source: `${this.name}.processStreamChunks`, + toolCallId: item_id, + rawArguments: chunk.arguments, + }, + ) + continue + } + const name = metadata.name || '' + + // Parse arguments. Surface parse failures via the logger so a + // model emitting malformed JSON is debuggable instead of silently + // invoking the tool with {}. + let parsedInput: unknown = {} + if (chunk.arguments) { + try { + const parsed = JSON.parse(chunk.arguments) + parsedInput = parsed && typeof parsed === 'object' ? parsed : {} + } catch (parseError) { + options.logger.errors( + `${this.name}.processStreamChunks tool-args JSON parse failed`, + { + error: toRunErrorPayload( + parseError, + `tool ${name} (${item_id}) returned malformed JSON arguments`, + ), + source: `${this.name}.processStreamChunks`, + toolCallId: item_id, + toolName: name, + rawArguments: chunk.arguments, + }, + ) + parsedInput = {} + } + } + + yield asChunk({ + type: 'TOOL_CALL_END', + toolCallId: item_id, + toolCallName: name, + toolName: name, + model: model || options.model, + timestamp, + input: parsedInput, + }) + } + + if (chunk.type === 'response.completed') { + // Emit TEXT_MESSAGE_END if we had text content + if (hasEmittedTextMessageStart) { + yield asChunk({ + type: 'TEXT_MESSAGE_END', + messageId: aguiState.messageId, + model: model || options.model, + timestamp, + }) + hasEmittedTextMessageStart = false + } + + // Determine finish reason. Function-call output → tool_calls. + // Otherwise surface incomplete_details.reason when present so + // callers can distinguish length-limit / content-filter cutoffs + // from a clean stop, mirroring the chat-completions adapter. + const hasFunctionCalls = chunk.response.output.some( + (item: unknown) => + (item as { type: string }).type === 'function_call', + ) + const finishReason: string = hasFunctionCalls + ? 'tool_calls' + : (chunk.response.incomplete_details?.reason ?? 'stop') + + yield asChunk({ + type: 'RUN_FINISHED', + runId: aguiState.runId, + model: model || options.model, + timestamp, + usage: { + promptTokens: chunk.response.usage?.input_tokens || 0, + completionTokens: chunk.response.usage?.output_tokens || 0, + totalTokens: chunk.response.usage?.total_tokens || 0, + }, + finishReason, + }) + runFinishedEmitted = true + } + + if (chunk.type === 'error') { + yield asChunk({ + type: 'RUN_ERROR', + runId: aguiState.runId, + model: model || options.model, + timestamp, + error: { + message: chunk.message, + code: chunk.code ?? undefined, + }, + }) + // RUN_ERROR is terminal — don't let the synthetic RUN_FINISHED + // block fire after a top-level stream error event. + runFinishedEmitted = true + } + } + + // Synthetic terminal RUN_FINISHED if the stream ended without a + // response.completed event (e.g. truncated upstream connection). This + // mirrors the chat-completions adapter's behavior so consumers always + // see a terminal event for every started run. + if (!runFinishedEmitted && aguiState.hasEmittedRunStarted) { + if (hasEmittedTextMessageStart) { + yield asChunk({ + type: 'TEXT_MESSAGE_END', + messageId: aguiState.messageId, + model: model || options.model, + timestamp, + }) + } + yield asChunk({ + type: 'RUN_FINISHED', + runId: aguiState.runId, + model: model || options.model, + timestamp, + usage: undefined, + finishReason: toolCallMetadata.size > 0 ? 'tool_calls' : 'stop', + }) + } + } catch (error: unknown) { + // Narrow before logging: raw SDK errors can carry request metadata + // (including auth headers) which we must never surface to user loggers. + const errorPayload = toRunErrorPayload( + error, + `${this.name}.processStreamChunks failed`, + ) + options.logger.errors(`${this.name}.processStreamChunks fatal`, { + error: errorPayload, + source: `${this.name}.processStreamChunks`, + }) + yield asChunk({ + type: 'RUN_ERROR', + runId: aguiState.runId, + model: options.model, + timestamp, + error: errorPayload, + }) + } + } + + /** + * Maps common TextOptions to Responses API request format. + * Override this in subclasses to add provider-specific options. + */ + protected mapOptionsToRequest( + options: TextOptions, + ): Omit { + const input = this.convertMessagesToInput(options.messages) + + const tools = options.tools + ? convertToolsToResponsesFormat( + options.tools, + this.makeStructuredOutputCompatible.bind(this), + ) + : undefined + + const modelOptions = options.modelOptions + + // Spread modelOptions first, then explicit top-level options when set. + // Mirrors the chat-completions base adapter's precedence so callers + // tuning either backend get identical behaviour. Leaving `modelOptions` + // last (its previous behavior) silently shadowed the canonical + // `options.temperature`/`maxTokens` fields, while spreading first + // without nullish-aware merge would clobber `modelOptions.temperature` + // with `undefined` whenever the caller didn't set the top-level option. + return { + ...modelOptions, + model: options.model, + ...(options.temperature !== undefined && { + temperature: options.temperature, + }), + ...(options.maxTokens !== undefined && { + max_output_tokens: options.maxTokens, + }), + ...(options.topP !== undefined && { top_p: options.topP }), + ...(options.metadata !== undefined && { metadata: options.metadata }), + ...(options.systemPrompts && + options.systemPrompts.length > 0 && { + instructions: options.systemPrompts.join('\n'), + }), + input, + // Conditional spread: `tools: undefined` would clobber any + // modelOptions.tools the caller set above. + ...(tools && tools.length > 0 && { tools }), + } + } + + /** + * Converts ModelMessage[] to Responses API ResponseInput format. + * Override this in subclasses for provider-specific message format quirks. + * + * Key differences from Chat Completions: + * - Tool results use `function_call_output` type (not `tool` role) + * - Assistant tool calls are `function_call` objects (not nested in `tool_calls`) + * - User content uses `input_text`, `input_image`, `input_file` types + * - System prompts go in `instructions`, not as messages + */ + protected convertMessagesToInput( + messages: Array, + ): Responses.ResponseInput { + const result: Responses.ResponseInput = [] + + for (const message of messages) { + // Handle tool messages - convert to FunctionToolCallOutput + if (message.role === 'tool') { + result.push({ + type: 'function_call_output', + call_id: message.toolCallId || '', + output: + typeof message.content === 'string' + ? message.content + : JSON.stringify(message.content), + }) + continue + } + + // Handle assistant messages + if (message.role === 'assistant') { + // If the assistant message has tool calls, add them as FunctionToolCall objects + // Responses API expects arguments as a string (JSON string) + if (message.toolCalls && message.toolCalls.length > 0) { + for (const toolCall of message.toolCalls) { + // Keep arguments as string for Responses API + const argumentsString = + typeof toolCall.function.arguments === 'string' + ? toolCall.function.arguments + : JSON.stringify(toolCall.function.arguments) + + result.push({ + type: 'function_call', + call_id: toolCall.id, + name: toolCall.function.name, + arguments: argumentsString, + }) + } + } + + // Add the assistant's text message if there is content + if (message.content) { + const contentStr = this.extractTextContent(message.content) + if (contentStr) { + result.push({ + type: 'message', + role: 'assistant', + content: contentStr, + }) + } + } + + continue + } + + // Handle user messages (default case) — support multimodal content + const contentParts = this.normalizeContent(message.content) + const inputContent: Array = [] + + for (const part of contentParts) { + inputContent.push(this.convertContentPartToInput(part)) + } + + if (inputContent.length === 0) { + // Fail loud rather than silently sending an empty user message — + // mirrors the chat-completions adapter, where a paid-but-empty + // request would mask the real intent (caller passed `null` content + // or a normalize step dropped everything). + throw new Error( + `User message for ${this.name} has no content parts. ` + + `Empty user messages would produce a paid request with no input; ` + + `provide at least one text/image/audio part or omit the message.`, + ) + } + + result.push({ + type: 'message', + role: 'user', + content: inputContent, + }) + } + + return result + } + + /** + * Converts a ContentPart to Responses API input content item. + * Handles text, image, and audio content parts. + * Override this in subclasses for additional content types or provider-specific metadata. + */ + protected convertContentPartToInput( + part: ContentPart, + ): Responses.ResponseInputContent { + switch (part.type) { + case 'text': + return { + type: 'input_text', + text: part.content, + } + case 'image': { + const imageMetadata = part.metadata as + | { detail?: 'auto' | 'low' | 'high' } + | undefined + if (part.source.type === 'url') { + return { + type: 'input_image', + image_url: part.source.value, + detail: imageMetadata?.detail || 'auto', + } + } + // For base64 data, construct a data URI using the mimeType from + // source. Default to a generic octet-stream MIME if the source + // didn't supply one — letting `undefined` interpolate would produce + // an invalid URI like "data:undefined;base64,...". + const imageValue = part.source.value + const imageMime = part.source.mimeType || 'application/octet-stream' + const imageUrl = imageValue.startsWith('data:') + ? imageValue + : `data:${imageMime};base64,${imageValue}` + return { + type: 'input_image', + image_url: imageUrl, + detail: imageMetadata?.detail || 'auto', + } + } + case 'audio': { + if (part.source.type === 'url') { + return { + type: 'input_file', + file_url: part.source.value, + } + } + // Wrap raw base64 in a data URL — `input_file` rejects bare base64 + // payloads (matches the image branch above which already does this). + // Default the MIME if missing so we never interpolate `undefined`. + const audioValue = part.source.value + const audioMime = part.source.mimeType || 'application/octet-stream' + const audioFileData = audioValue.startsWith('data:') + ? audioValue + : `data:${audioMime};base64,${audioValue}` + return { + type: 'input_file', + file_data: audioFileData, + } + } + + default: + throw new Error(`Unsupported content part type: ${part.type}`) + } + } + + /** + * Normalizes message content to an array of ContentPart. + * Handles backward compatibility with string content. + */ + protected normalizeContent( + content: string | null | Array, + ): Array { + if (content === null) { + return [] + } + if (typeof content === 'string') { + return [{ type: 'text', content: content }] + } + return content + } + + /** + * Extracts text content from a content value that may be string, null, or ContentPart array. + */ + protected extractTextContent( + content: string | null | Array, + ): string { + if (content === null) { + return '' + } + if (typeof content === 'string') { + return content + } + // It's an array of ContentPart + return content + .filter((p) => p.type === 'text') + .map((p) => p.content) + .join('') + } +} diff --git a/packages/typescript/openai-base/src/adapters/responses-tool-converter.ts b/packages/typescript/openai-base/src/adapters/responses-tool-converter.ts new file mode 100644 index 000000000..00132929a --- /dev/null +++ b/packages/typescript/openai-base/src/adapters/responses-tool-converter.ts @@ -0,0 +1,77 @@ +import { makeStructuredOutputCompatible } from '../utils/schema-converter' +import type { JSONSchema, Tool } from '@tanstack/ai' + +/** + * Responses API function tool format. + * This is distinct from the Chat Completions API tool format. + * + * The Responses API uses a flatter structure: + * { type: 'function', name: string, description?: string, parameters: object, strict?: boolean } + * + * vs. Chat Completions: + * { type: 'function', function: { name, description, parameters }, strict?: boolean } + */ +export interface ResponsesFunctionTool { + type: 'function' + name: string + description?: string | null + parameters: Record | null + strict: boolean | null +} + +/** + * Converts a standard Tool to the Responses API FunctionTool format. + * + * Tool schemas are already converted to JSON Schema in the ai layer. + * We apply OpenAI-compatible transformations for strict mode: + * - All properties in required array + * - Optional fields made nullable + * - additionalProperties: false + * + * This enables strict mode for all tools automatically. + */ +export function convertFunctionToolToResponsesFormat( + tool: Tool, + schemaConverter: ( + schema: Record, + required: Array, + ) => Record = makeStructuredOutputCompatible, +): ResponsesFunctionTool { + const inputSchema = (tool.inputSchema ?? { + type: 'object', + properties: {}, + required: [], + }) as JSONSchema + + // Shallow-copy the converter's result before mutating — a subclass-supplied + // schemaConverter has no contract requirement to return a fresh object; + // mutating in place could corrupt the caller's tool definition. + const jsonSchema = { + ...schemaConverter(inputSchema, inputSchema.required || []), + } + jsonSchema.additionalProperties = false + + return { + type: 'function', + name: tool.name, + description: tool.description, + parameters: jsonSchema, + strict: true, + } +} + +/** + * Converts an array of standard Tools to Responses API format. + * The Responses API primarily supports function tools at the base level. + */ +export function convertToolsToResponsesFormat( + tools: Array, + schemaConverter?: ( + schema: Record, + required: Array, + ) => Record, +): Array { + return tools.map((tool) => + convertFunctionToolToResponsesFormat(tool, schemaConverter), + ) +} diff --git a/packages/typescript/openai-base/src/adapters/summarize.ts b/packages/typescript/openai-base/src/adapters/summarize.ts new file mode 100644 index 000000000..fed92b296 --- /dev/null +++ b/packages/typescript/openai-base/src/adapters/summarize.ts @@ -0,0 +1,174 @@ +import { BaseSummarizeAdapter } from '@tanstack/ai/adapters' +import { toRunErrorPayload } from '@tanstack/ai/adapter-internals' +import { generateId } from '@tanstack/ai-utils' +import type { + StreamChunk, + SummarizationOptions, + SummarizationResult, + TextOptions, +} from '@tanstack/ai' + +/** + * Minimal interface for a text adapter that supports chatStream. + * This allows the summarize adapter to work with any OpenAI-compatible + * text adapter without tight coupling to a specific implementation. + */ +export interface ChatStreamCapable { + chatStream: ( + options: TextOptions, + ) => AsyncIterable +} + +/** + * OpenAI-Compatible Summarize Adapter + * + * A thin wrapper around a text adapter that adds summarization-specific prompting. + * Delegates all API calls to the provided text adapter. + * + * Subclasses or instantiators provide a text adapter (or factory) at construction + * time, allowing any OpenAI-compatible provider to get summarization for free by + * reusing its text adapter. + */ +export class OpenAICompatibleSummarizeAdapter< + TModel extends string, + TProviderOptions extends object = Record, +> extends BaseSummarizeAdapter { + readonly name: string + + private textAdapter: ChatStreamCapable + + constructor( + textAdapter: ChatStreamCapable, + model: TModel, + name: string = 'openai-compatible', + ) { + super({}, model) + this.name = name + this.textAdapter = textAdapter + } + + async summarize(options: SummarizationOptions): Promise { + const systemPrompt = this.buildSummarizationPrompt(options) + + let summary = '' + const id = generateId(this.name) + let model = options.model + let usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 } + + options.logger.request( + `activity=summarize provider=${this.name} model=${options.model} text-length=${options.text.length} maxLength=${options.maxLength ?? 'unset'}`, + { provider: this.name, model: options.model }, + ) + + try { + for await (const chunk of this.textAdapter.chatStream({ + model: options.model, + messages: [{ role: 'user', content: options.text }], + systemPrompts: [systemPrompt], + maxTokens: options.maxLength, + temperature: 0.3, + logger: options.logger, + } satisfies TextOptions)) { + if (chunk.type === 'TEXT_MESSAGE_CONTENT') { + if (chunk.content) { + summary = chunk.content + } else if (chunk.delta) { + // Append delta only when present — a content-less chunk with no + // delta would otherwise concat literal `'undefined'`. + summary += chunk.delta + } + model = chunk.model || model + } + if (chunk.type === 'RUN_FINISHED') { + if (chunk.usage) { + usage = chunk.usage + } + } + // Surface failures: the underlying chatStream emits RUN_ERROR instead + // of throwing, so without this branch summarize() would return an + // empty summary and pretend a failed run succeeded. + if (chunk.type === 'RUN_ERROR') { + const message = + (chunk.error && typeof chunk.error.message === 'string' + ? chunk.error.message + : null) ?? 'Summarization failed' + const code = + chunk.error && typeof chunk.error.code === 'string' + ? chunk.error.code + : undefined + const err = new Error(message) + if (code) { + ;(err as Error & { code?: string }).code = code + } + throw err + } + } + } catch (error: unknown) { + // Narrow before logging: raw SDK errors can carry request metadata + // (including auth headers) which we must never surface to user loggers. + options.logger.errors(`${this.name}.summarize fatal`, { + error: toRunErrorPayload(error, `${this.name}.summarize failed`), + source: `${this.name}.summarize`, + }) + throw error + } + + return { id, model, summary, usage } + } + + async *summarizeStream( + options: SummarizationOptions, + ): AsyncIterable { + const systemPrompt = this.buildSummarizationPrompt(options) + + options.logger.request( + `activity=summarizeStream provider=${this.name} model=${options.model} text-length=${options.text.length} maxLength=${options.maxLength ?? 'unset'}`, + { provider: this.name, model: options.model }, + ) + + try { + yield* this.textAdapter.chatStream({ + model: options.model, + messages: [{ role: 'user', content: options.text }], + systemPrompts: [systemPrompt], + maxTokens: options.maxLength, + temperature: 0.3, + logger: options.logger, + } satisfies TextOptions) + } catch (error: unknown) { + options.logger.errors(`${this.name}.summarizeStream fatal`, { + error: toRunErrorPayload(error, `${this.name}.summarizeStream failed`), + source: `${this.name}.summarizeStream`, + }) + throw error + } + } + + protected buildSummarizationPrompt(options: SummarizationOptions): string { + let prompt = 'You are a professional summarizer. ' + + switch (options.style) { + case 'bullet-points': + prompt += 'Provide a summary in bullet point format. ' + break + case 'paragraph': + prompt += 'Provide a summary in paragraph format. ' + break + case 'concise': + prompt += 'Provide a very concise summary in 1-2 sentences. ' + break + default: + prompt += 'Provide a clear and concise summary. ' + } + + if (options.focus && options.focus.length > 0) { + prompt += `Focus on the following aspects: ${options.focus.join(', ')}. ` + } + + if (options.maxLength) { + prompt += `Keep the summary under ${options.maxLength} tokens. ` + } + + return prompt + } +} diff --git a/packages/typescript/openai-base/src/adapters/transcription.ts b/packages/typescript/openai-base/src/adapters/transcription.ts new file mode 100644 index 000000000..702dc6479 --- /dev/null +++ b/packages/typescript/openai-base/src/adapters/transcription.ts @@ -0,0 +1,194 @@ +import { BaseTranscriptionAdapter } from '@tanstack/ai/adapters' +import { toRunErrorPayload } from '@tanstack/ai/adapter-internals' +import { base64ToArrayBuffer, generateId } from '@tanstack/ai-utils' +import { createOpenAICompatibleClient } from '../utils/client' +import type { + TranscriptionOptions, + TranscriptionResult, + TranscriptionSegment, +} from '@tanstack/ai' +import type OpenAI_SDK from 'openai' +import type { OpenAICompatibleClientConfig } from '../types/config' + +/** + * OpenAI-Compatible Transcription (Speech-to-Text) Adapter + * + * A generalized base class for providers that implement OpenAI-compatible audio + * transcription APIs. Providers can extend this class and only need to: + * - Set `baseURL` in the config + * - Lock the generic type parameters to provider-specific types + * - Override audio handling or response mapping methods as needed + * + * All methods that handle audio input or map response formats are `protected` + * so subclasses can override them. + */ +export class OpenAICompatibleTranscriptionAdapter< + TModel extends string, + TProviderOptions extends object = Record, +> extends BaseTranscriptionAdapter { + readonly name: string + + protected client: OpenAI_SDK + + constructor( + config: OpenAICompatibleClientConfig, + model: TModel, + name: string = 'openai-compatible', + ) { + super(model, {}) + this.name = name + this.client = createOpenAICompatibleClient(config) + } + + async transcribe( + options: TranscriptionOptions, + ): Promise { + const { model, audio, language, prompt, responseFormat, modelOptions } = + options + + // Convert audio input to File object + const file = this.prepareAudioFile(audio) + + // Build request + const request: OpenAI_SDK.Audio.TranscriptionCreateParams = { + model, + file, + language, + prompt, + response_format: this.mapResponseFormat(responseFormat), + ...modelOptions, + } + + // Call API - use verbose_json to get timestamps when available + const useVerbose = + responseFormat === 'verbose_json' || + (!responseFormat && this.shouldDefaultToVerbose(model)) + + try { + options.logger.request( + `activity=transcription provider=${this.name} model=${model} verbose=${useVerbose}`, + { provider: this.name, model }, + ) + if (useVerbose) { + const response = await this.client.audio.transcriptions.create({ + ...request, + response_format: 'verbose_json', + }) + + return { + id: generateId(this.name), + model, + text: response.text, + language: response.language, + duration: response.duration, + segments: response.segments?.map( + (seg): TranscriptionSegment => ({ + id: seg.id, + start: seg.start, + end: seg.end, + text: seg.text, + // The OpenAI SDK types `avg_logprob` as `number`, so call Math.exp + // directly. Previously this was guarded with `seg.avg_logprob ?` + // which treated `0` (perfect-confidence) as missing. + confidence: Math.exp(seg.avg_logprob), + }), + ), + words: response.words?.map((w) => ({ + word: w.word, + start: w.start, + end: w.end, + })), + } + } else { + const response = await this.client.audio.transcriptions.create(request) + + return { + id: generateId(this.name), + model, + text: typeof response === 'string' ? response : response.text, + language, + } + } + } catch (error: unknown) { + // Narrow before logging: raw SDK errors can carry request metadata + // (including auth headers) which we must never surface to user loggers. + options.logger.errors(`${this.name}.transcribe fatal`, { + error: toRunErrorPayload(error, `${this.name}.transcribe failed`), + source: `${this.name}.transcribe`, + }) + throw error + } + } + + protected prepareAudioFile(audio: string | File | Blob | ArrayBuffer): File { + // If already a File, return it + if (typeof File !== 'undefined' && audio instanceof File) { + return audio + } + + // If Blob, convert to File + if (typeof Blob !== 'undefined' && audio instanceof Blob) { + this.ensureFileSupport() + return new File([audio], 'audio.mp3', { + type: audio.type || 'audio/mpeg', + }) + } + + // If ArrayBuffer, convert to File + if (typeof ArrayBuffer !== 'undefined' && audio instanceof ArrayBuffer) { + this.ensureFileSupport() + return new File([audio], 'audio.mp3', { type: 'audio/mpeg' }) + } + + // If base64 string, decode and convert to File + if (typeof audio === 'string') { + this.ensureFileSupport() + + // Check if it's a data URL + if (audio.startsWith('data:')) { + const parts = audio.split(',') + const header = parts[0] + const base64Data = parts[1] || '' + const mimeMatch = header?.match(/data:([^;]+)/) + const mimeType = mimeMatch?.[1] || 'audio/mpeg' + const bytes = base64ToArrayBuffer(base64Data) + const extension = mimeType.split('/')[1] || 'mp3' + return new File([bytes], `audio.${extension}`, { type: mimeType }) + } + + // Assume raw base64 + const bytes = base64ToArrayBuffer(audio) + return new File([bytes], 'audio.mp3', { type: 'audio/mpeg' }) + } + + throw new Error('Invalid audio input type') + } + + /** + * Checks that the global `File` constructor is available. + * Throws a descriptive error in environments that lack it (e.g. Node < 20). + */ + private ensureFileSupport(): void { + if (typeof File === 'undefined') { + throw new Error( + '`File` is not available in this environment. ' + + 'Use Node.js 20 or newer, or pass a File object directly.', + ) + } + } + + /** + * Whether the adapter should default to verbose_json when no response format is specified. + * Override in provider-specific subclasses for model-specific behavior. + */ + protected shouldDefaultToVerbose(_model: string): boolean { + return false + } + + protected mapResponseFormat( + format?: 'json' | 'text' | 'srt' | 'verbose_json' | 'vtt', + ): OpenAI_SDK.Audio.TranscriptionCreateParams['response_format'] { + if (!format) return 'json' + return format as OpenAI_SDK.Audio.TranscriptionCreateParams['response_format'] + } +} diff --git a/packages/typescript/openai-base/src/adapters/tts.ts b/packages/typescript/openai-base/src/adapters/tts.ts new file mode 100644 index 000000000..b61c9c095 --- /dev/null +++ b/packages/typescript/openai-base/src/adapters/tts.ts @@ -0,0 +1,124 @@ +import { BaseTTSAdapter } from '@tanstack/ai/adapters' +import { toRunErrorPayload } from '@tanstack/ai/adapter-internals' +import { arrayBufferToBase64, generateId } from '@tanstack/ai-utils' +import { createOpenAICompatibleClient } from '../utils/client' +import type { TTSOptions, TTSResult } from '@tanstack/ai' +import type OpenAI_SDK from 'openai' +import type { OpenAICompatibleClientConfig } from '../types/config' + +/** + * OpenAI-Compatible Text-to-Speech Adapter + * + * A generalized base class for providers that implement OpenAI-compatible TTS APIs. + * Providers can extend this class and only need to: + * - Set `baseURL` in the config + * - Lock the generic type parameters to provider-specific types + * - Override validation methods or request building for provider-specific constraints + * + * All methods that validate inputs or build requests are `protected` so subclasses + * can override them. + */ +export class OpenAICompatibleTTSAdapter< + TModel extends string, + TProviderOptions extends object = Record, +> extends BaseTTSAdapter { + readonly name: string + + protected client: OpenAI_SDK + + constructor( + config: OpenAICompatibleClientConfig, + model: TModel, + name: string = 'openai-compatible', + ) { + super(model, {}) + this.name = name + this.client = createOpenAICompatibleClient(config) + } + + async generateSpeech( + options: TTSOptions, + ): Promise { + const { model, text, voice, format, speed, modelOptions } = options + + // Validate inputs + this.validateAudioInput(text) + this.validateSpeed(speed) + this.validateInstructions(model, modelOptions) + + // Build request + const request: OpenAI_SDK.Audio.SpeechCreateParams = { + model, + input: text, + voice: (voice || 'alloy') as OpenAI_SDK.Audio.SpeechCreateParams['voice'], + response_format: format, + speed, + ...modelOptions, + } + + try { + options.logger.request( + `activity=tts provider=${this.name} model=${model} format=${request.response_format ?? 'default'} voice=${request.voice}`, + { provider: this.name, model }, + ) + const response = await this.client.audio.speech.create(request) + + // Convert response to base64. Buffer is Node-only; use atob fallback in + // browser/edge runtimes where the SDK can run. + const arrayBuffer = await response.arrayBuffer() + const base64 = arrayBufferToBase64(arrayBuffer) + + const outputFormat = (request.response_format as string) || 'mp3' + const contentType = this.getContentType(outputFormat) + + return { + id: generateId(this.name), + model, + audio: base64, + format: outputFormat, + contentType, + } + } catch (error: unknown) { + // Narrow before logging: raw SDK errors can carry request metadata + // (including auth headers) which we must never surface to user loggers. + options.logger.errors(`${this.name}.generateSpeech fatal`, { + error: toRunErrorPayload(error, `${this.name}.generateSpeech failed`), + source: `${this.name}.generateSpeech`, + }) + throw error + } + } + + protected validateAudioInput(text: string): void { + if (text.length > 4096) { + throw new Error('Input text exceeds maximum length of 4096 characters.') + } + } + + protected validateSpeed(speed?: number): void { + if (speed !== undefined) { + if (speed < 0.25 || speed > 4.0) { + throw new Error('Speed must be between 0.25 and 4.0.') + } + } + } + + protected validateInstructions( + _model: string, + _modelOptions?: TProviderOptions, + ): void { + // Default: no instructions validation — subclasses can override + } + + protected getContentType(format: string): string { + const contentTypes: Record = { + mp3: 'audio/mpeg', + opus: 'audio/opus', + aac: 'audio/aac', + flac: 'audio/flac', + wav: 'audio/wav', + pcm: 'audio/pcm', + } + return contentTypes[format] || 'audio/mpeg' + } +} diff --git a/packages/typescript/openai-base/src/adapters/video.ts b/packages/typescript/openai-base/src/adapters/video.ts new file mode 100644 index 000000000..8aaf1ad77 --- /dev/null +++ b/packages/typescript/openai-base/src/adapters/video.ts @@ -0,0 +1,385 @@ +import { BaseVideoAdapter } from '@tanstack/ai/adapters' +import { toRunErrorPayload } from '@tanstack/ai/adapter-internals' +import { arrayBufferToBase64 } from '@tanstack/ai-utils' +import { createOpenAICompatibleClient } from '../utils/client' +import type { + VideoGenerationOptions, + VideoJobResult, + VideoStatusResult, + VideoUrlResult, +} from '@tanstack/ai' +import type OpenAI_SDK from 'openai' +import type { OpenAICompatibleClientConfig } from '../types/config' + +/** + * Threshold for emitting a "this download will probably OOM serverless + * runtimes" warning. Anything larger than this (in bytes) gets surfaced via + * console.warn — workers and small isolates routinely run out of memory once + * a downloaded video is base64-encoded (the encoded form is ~33% larger and + * resides in V8 heap rather than streaming through the runtime's network + * layer). + */ +const LARGE_MEDIA_BUFFER_BYTES = 10 * 1024 * 1024 + +function warnIfLargeMediaBuffer( + byteLength: number, + source: string, + providerName: string, +): void { + if (byteLength <= LARGE_MEDIA_BUFFER_BYTES) return + // No InternalLogger plumbed through to these download paths yet; surface + // via console.warn so Workers / Lambda dashboards still capture it. + console.warn( + `[${providerName}.${source}] downloaded ${(byteLength / 1024 / 1024).toFixed(1)} MiB into memory before base64 encoding. ` + + `Workers/serverless runtimes commonly run out of memory above ~10 MiB. ` + + `Consider streaming the video through a CDN or your own storage layer instead.`, + ) +} + +/** + * OpenAI-Compatible Video Generation Adapter + * + * A generalized base class for providers that implement OpenAI-compatible video + * generation APIs. Uses a job/polling architecture for async video generation. + * + * Providers can extend this class and only need to: + * - Set `baseURL` in the config + * - Lock the generic type parameters to provider-specific types + * - Override validation or request building methods as needed + * + * All methods that validate inputs, build requests, or map responses are `protected` + * so subclasses can override them. + * + * @experimental Video generation is an experimental feature and may change. + */ +export class OpenAICompatibleVideoAdapter< + TModel extends string, + TProviderOptions extends object = Record, + TModelProviderOptionsByName extends Record = Record, + TModelSizeByName extends Record = Record, +> extends BaseVideoAdapter< + TModel, + TProviderOptions, + TModelProviderOptionsByName, + TModelSizeByName +> { + readonly name: string + + protected client: OpenAI_SDK + protected clientConfig: OpenAICompatibleClientConfig + + constructor( + config: OpenAICompatibleClientConfig, + model: TModel, + name: string = 'openai-compatible', + ) { + super(config, model) + this.name = name + this.clientConfig = config + this.client = createOpenAICompatibleClient(config) + } + + /** + * Create a new video generation job. + * + * @experimental Video generation is an experimental feature and may change. + */ + async createVideoJob( + options: VideoGenerationOptions, + ): Promise { + const { model, size, duration, modelOptions } = options + + // Validate inputs + this.validateVideoSize(model, size) + const seconds = duration ?? (modelOptions as any)?.seconds + this.validateVideoSeconds(model, seconds) + + // Build request + const request = this.buildRequest(options) + + try { + options.logger.request( + `activity=video.create provider=${this.name} model=${model} size=${request.size ?? 'default'} seconds=${request.seconds ?? 'default'}`, + { provider: this.name, model }, + ) + // The video API on the OpenAI SDK is still experimental and shipped on + // some SDK versions but not others; access through `videosClient` lets + // subclasses override the entry point or supply a polyfill without + // forcing every call site through `as any`. + const videosClient = this.getVideosClient() + const response = await videosClient.create(request) + + return { + jobId: response.id, + model, + } + } catch (error: any) { + options.logger.errors(`${this.name}.createVideoJob fatal`, { + error: toRunErrorPayload(error, `${this.name}.createVideoJob failed`), + source: `${this.name}.createVideoJob`, + }) + if (error?.message?.includes('videos') || error?.code === 'invalid_api') { + throw new Error( + `Video generation API is not available. The API may require special access. ` + + `Original error: ${error.message}`, + ) + } + throw error + } + } + + /** + * Returns the underlying OpenAI Videos resource. Pulled out as a protected + * accessor so subclasses targeting forks of the SDK can swap the access + * path without forcing each call site to cast through `any`. + */ + protected getVideosClient(): { + create: (req: Record) => Promise<{ id: string }> + retrieve: (id: string) => Promise<{ + id: string + status: string + progress?: number + url?: string + expires_at?: number + error?: { message?: string } + }> + downloadContent?: (id: string) => Promise + content?: (id: string) => Promise + getContent?: (id: string) => Promise + download?: (id: string) => Promise + } { + return (this.client as unknown as { videos: any }).videos + } + + /** + * Get the current status of a video generation job. + * + * @experimental Video generation is an experimental feature and may change. + */ + async getVideoStatus(jobId: string): Promise { + try { + const videosClient = this.getVideosClient() + const response = await videosClient.retrieve(jobId) + + return { + jobId, + status: this.mapStatus(response.status), + progress: response.progress, + error: response.error?.message, + } + } catch (error: any) { + if (error.status === 404) { + return { + jobId, + status: 'failed', + error: 'Job not found', + } + } + throw error + } + } + + /** + * Get the URL to download/view the generated video. + * + * @experimental Video generation is an experimental feature and may change. + */ + async getVideoUrl(jobId: string): Promise { + try { + const videosClient = this.getVideosClient() + + // Prefer retrieve() because many openai-compatible backends (and the + // aimock test harness) return the URL directly on the video resource + // and do not implement a separate /content endpoint. Subclasses can + // override this method if they need to download raw bytes via + // downloadContent()/content(). + const videoInfo = await videosClient.retrieve(jobId) + if (videoInfo.url) { + return { + jobId, + url: videoInfo.url, + expiresAt: videoInfo.expires_at + ? new Date(videoInfo.expires_at) + : undefined, + } + } + + // SDK download fall-through: try the various possible method names in + // decreasing order of modernity. + if (typeof videosClient.downloadContent === 'function') { + const contentResponse = await videosClient.downloadContent(jobId) + const videoBlob = await contentResponse.blob() + const buffer = await videoBlob.arrayBuffer() + warnIfLargeMediaBuffer( + buffer.byteLength, + 'video.downloadContent', + this.name, + ) + const base64 = arrayBufferToBase64(buffer) + const mimeType = + contentResponse.headers.get('content-type') || 'video/mp4' + return { + jobId, + url: `data:${mimeType};base64,${base64}`, + expiresAt: undefined, + } + } + + // The remaining SDK fall-throughs all return a binary payload + // (Blob/Response/ArrayBuffer-shaped), NOT an `{ url, expires_at }` + // object the way the bottom return assumed. Convert to a data URL + // here so the caller actually receives a usable URL. + let response: any + if (typeof videosClient.content === 'function') { + response = await videosClient.content(jobId) + } else if (typeof videosClient.getContent === 'function') { + response = await videosClient.getContent(jobId) + } else if (typeof videosClient.download === 'function') { + response = await videosClient.download(jobId) + } else { + // Last resort: raw fetch with auth header. + const baseUrl = this.clientConfig.baseURL || 'https://api.openai.com/v1' + const apiKey = this.clientConfig.apiKey + + const contentResponse = await fetch( + `${baseUrl}/videos/${jobId}/content`, + { + method: 'GET', + headers: { + Authorization: `Bearer ${apiKey}`, + }, + }, + ) + + if (!contentResponse.ok) { + const contentType = contentResponse.headers.get('content-type') + if (contentType?.includes('application/json')) { + const errorData = await contentResponse.json().catch(() => ({})) + throw new Error( + errorData.error?.message || + `Failed to get video content: ${contentResponse.status}`, + ) + } + throw new Error( + `Failed to get video content: ${contentResponse.status}`, + ) + } + + const videoBlob = await contentResponse.blob() + const buffer = await videoBlob.arrayBuffer() + warnIfLargeMediaBuffer(buffer.byteLength, 'video.fetch', this.name) + const base64 = arrayBufferToBase64(buffer) + const mimeType = + contentResponse.headers.get('content-type') || 'video/mp4' + + return { + jobId, + url: `data:${mimeType};base64,${base64}`, + expiresAt: undefined, + } + } + + // The fall-through SDK methods produce a Blob-ish or fetch-`Response`-ish + // object. Read it as bytes and wrap in a data URL so callers see an + // actual playable URL instead of the API endpoint URL (which is what + // `response.url` would be on a fetch Response). + const fallthroughBlob = + typeof response?.blob === 'function' + ? await response.blob() + : response instanceof Blob + ? response + : null + if (!fallthroughBlob) { + throw new Error( + `Video content download via SDK fall-through returned an unexpected shape (no blob()). ` + + `Override getVideoUrl() in your subclass to handle this provider.`, + ) + } + const fallthroughBuffer = await fallthroughBlob.arrayBuffer() + warnIfLargeMediaBuffer( + fallthroughBuffer.byteLength, + 'video.sdkFallthrough', + this.name, + ) + const fallthroughBase64 = arrayBufferToBase64(fallthroughBuffer) + const fallthroughMime = + (typeof response?.headers?.get === 'function' + ? response.headers.get('content-type') + : undefined) || + fallthroughBlob.type || + 'video/mp4' + return { + jobId, + url: `data:${fallthroughMime};base64,${fallthroughBase64}`, + expiresAt: undefined, + } + } catch (error: any) { + if (error.status === 404) { + throw new Error(`Video job not found: ${jobId}`) + } + if (error.status === 400) { + throw new Error( + `Video is not ready for download. Check status first. Job ID: ${jobId}`, + ) + } + throw error + } + } + + protected buildRequest( + options: VideoGenerationOptions, + ): Record { + const { model, prompt, size, duration, modelOptions } = options + + const request: Record = { + model, + prompt, + } + + if (size) { + request['size'] = size + } else if ((modelOptions as any)?.size) { + request['size'] = (modelOptions as any).size + } + + const seconds = duration ?? (modelOptions as any)?.seconds + if (seconds !== undefined) { + request['seconds'] = String(seconds) + } + + return request + } + + protected validateVideoSize(_model: string, _size?: string): void { + // Default: no size validation — subclasses can override + } + + protected validateVideoSeconds( + _model: string, + _seconds?: number | string, + ): void { + // Default: no duration validation — subclasses can override + } + + protected mapStatus( + apiStatus: string, + ): 'pending' | 'processing' | 'completed' | 'failed' { + switch (apiStatus) { + case 'queued': + case 'pending': + return 'pending' + case 'processing': + case 'in_progress': + return 'processing' + case 'completed': + case 'succeeded': + return 'completed' + case 'failed': + case 'error': + case 'cancelled': + return 'failed' + default: + return 'processing' + } + } +} diff --git a/packages/typescript/openai-base/src/index.ts b/packages/typescript/openai-base/src/index.ts new file mode 100644 index 000000000..ab15140ea --- /dev/null +++ b/packages/typescript/openai-base/src/index.ts @@ -0,0 +1,24 @@ +export { makeStructuredOutputCompatible } from './utils/schema-converter' +export { createOpenAICompatibleClient } from './utils/client' +export type { OpenAICompatibleClientConfig } from './types/config' +export * from './tools/index' +export { OpenAICompatibleChatCompletionsTextAdapter } from './adapters/chat-completions-text' +export { + convertFunctionToolToChatCompletionsFormat, + convertToolsToChatCompletionsFormat, + type ChatCompletionFunctionTool, +} from './adapters/chat-completions-tool-converter' +export { OpenAICompatibleResponsesTextAdapter } from './adapters/responses-text' +export { + convertFunctionToolToResponsesFormat, + convertToolsToResponsesFormat, + type ResponsesFunctionTool, +} from './adapters/responses-tool-converter' +export { OpenAICompatibleImageAdapter } from './adapters/image' +export { + OpenAICompatibleSummarizeAdapter, + type ChatStreamCapable, +} from './adapters/summarize' +export { OpenAICompatibleTranscriptionAdapter } from './adapters/transcription' +export { OpenAICompatibleTTSAdapter } from './adapters/tts' +export { OpenAICompatibleVideoAdapter } from './adapters/video' diff --git a/packages/typescript/openai-base/src/tools/apply-patch-tool.ts b/packages/typescript/openai-base/src/tools/apply-patch-tool.ts new file mode 100644 index 000000000..6bc157aa4 --- /dev/null +++ b/packages/typescript/openai-base/src/tools/apply-patch-tool.ts @@ -0,0 +1,32 @@ +import type OpenAI from 'openai' +import type { Tool } from '@tanstack/ai' + +export type ApplyPatchToolConfig = OpenAI.Responses.ApplyPatchTool + +/** @deprecated Renamed to `ApplyPatchToolConfig`. Will be removed in a future release. */ +export type ApplyPatchTool = ApplyPatchToolConfig + +/** + * Converts a standard Tool to OpenAI ApplyPatchTool format + */ +export function convertApplyPatchToolToAdapterFormat( + _tool: Tool, +): ApplyPatchToolConfig { + return { + type: 'apply_patch', + } +} + +/** + * Creates a standard Tool from ApplyPatchTool parameters. + * + * Base (non-branded) factory. Providers that need branded return types should + * re-wrap this in their own package. + */ +export function applyPatchTool(): Tool { + return { + name: 'apply_patch', + description: 'Apply a patch to modify files', + metadata: {}, + } +} diff --git a/packages/typescript/openai-base/src/tools/code-interpreter-tool.ts b/packages/typescript/openai-base/src/tools/code-interpreter-tool.ts new file mode 100644 index 000000000..53f130588 --- /dev/null +++ b/packages/typescript/openai-base/src/tools/code-interpreter-tool.ts @@ -0,0 +1,39 @@ +import type { Tool } from '@tanstack/ai' +import type OpenAI from 'openai' + +export type CodeInterpreterToolConfig = OpenAI.Responses.Tool.CodeInterpreter + +/** @deprecated Renamed to `CodeInterpreterToolConfig`. Will be removed in a future release. */ +export type CodeInterpreterTool = CodeInterpreterToolConfig + +/** + * Converts a standard Tool to OpenAI CodeInterpreterTool format + */ +export function convertCodeInterpreterToolToAdapterFormat( + tool: Tool, +): CodeInterpreterToolConfig { + const metadata = tool.metadata as CodeInterpreterToolConfig + return { + type: 'code_interpreter', + container: metadata.container, + } +} + +/** + * Creates a standard Tool from CodeInterpreterTool parameters. + * + * Base (non-branded) factory. Providers that need branded return types should + * re-wrap this in their own package. + */ +export function codeInterpreterTool( + container: CodeInterpreterToolConfig, +): Tool { + return { + name: 'code_interpreter', + description: 'Execute code in a sandboxed environment', + metadata: { + type: 'code_interpreter', + container, + }, + } +} diff --git a/packages/typescript/openai-base/src/tools/computer-use-tool.ts b/packages/typescript/openai-base/src/tools/computer-use-tool.ts new file mode 100644 index 000000000..487e6486c --- /dev/null +++ b/packages/typescript/openai-base/src/tools/computer-use-tool.ts @@ -0,0 +1,38 @@ +import type OpenAI from 'openai' +import type { Tool } from '@tanstack/ai' + +export type ComputerUseToolConfig = OpenAI.Responses.ComputerTool + +/** @deprecated Renamed to `ComputerUseToolConfig`. Will be removed in a future release. */ +export type ComputerUseTool = ComputerUseToolConfig + +/** + * Converts a standard Tool to OpenAI ComputerUseTool format + */ +export function convertComputerUseToolToAdapterFormat( + tool: Tool, +): ComputerUseToolConfig { + const metadata = tool.metadata as ComputerUseToolConfig + return { + type: 'computer_use_preview', + display_height: metadata.display_height, + display_width: metadata.display_width, + environment: metadata.environment, + } +} + +/** + * Creates a standard Tool from ComputerUseTool parameters. + * + * Base (non-branded) factory. Providers that need branded return types should + * re-wrap this in their own package. + */ +export function computerUseTool(toolData: ComputerUseToolConfig): Tool { + return { + name: 'computer_use_preview', + description: 'Control a virtual computer', + metadata: { + ...toolData, + }, + } +} diff --git a/packages/typescript/openai-base/src/tools/custom-tool.ts b/packages/typescript/openai-base/src/tools/custom-tool.ts new file mode 100644 index 000000000..6e0cb8e5f --- /dev/null +++ b/packages/typescript/openai-base/src/tools/custom-tool.ts @@ -0,0 +1,33 @@ +import type OpenAI from 'openai' +import type { Tool } from '@tanstack/ai' + +export type CustomToolConfig = OpenAI.Responses.CustomTool + +/** @deprecated Renamed to `CustomToolConfig`. Will be removed in a future release. */ +export type CustomTool = CustomToolConfig + +/** + * Converts a standard Tool to OpenAI CustomTool format + */ +export function convertCustomToolToAdapterFormat(tool: Tool): CustomToolConfig { + const metadata = tool.metadata as CustomToolConfig + return { + type: 'custom', + name: metadata.name, + description: metadata.description, + format: metadata.format, + } +} + +/** + * Creates a standard Tool from CustomTool parameters. + */ +export function customTool(toolData: CustomToolConfig): Tool { + return { + name: 'custom', + description: toolData.description || 'A custom tool', + metadata: { + ...toolData, + }, + } +} diff --git a/packages/typescript/openai-base/src/tools/file-search-tool.ts b/packages/typescript/openai-base/src/tools/file-search-tool.ts new file mode 100644 index 000000000..82eb472aa --- /dev/null +++ b/packages/typescript/openai-base/src/tools/file-search-tool.ts @@ -0,0 +1,51 @@ +import type OpenAI from 'openai' +import type { Tool } from '@tanstack/ai' + +const validateMaxNumResults = (maxNumResults: number | undefined) => { + if ( + maxNumResults !== undefined && + (maxNumResults < 1 || maxNumResults > 50) + ) { + throw new Error('max_num_results must be between 1 and 50.') + } +} + +export type FileSearchToolConfig = OpenAI.Responses.FileSearchTool + +/** @deprecated Renamed to `FileSearchToolConfig`. Will be removed in a future release. */ +export type FileSearchTool = FileSearchToolConfig + +/** + * Converts a standard Tool to OpenAI FileSearchTool format + */ +export function convertFileSearchToolToAdapterFormat( + tool: Tool, +): FileSearchToolConfig { + const metadata = tool.metadata as FileSearchToolConfig + return { + type: 'file_search', + vector_store_ids: metadata.vector_store_ids, + max_num_results: metadata.max_num_results, + ranking_options: metadata.ranking_options, + filters: metadata.filters, + } +} + +/** + * Creates a standard Tool from FileSearchTool parameters. + * + * Validates max_num_results. Base (non-branded) factory; providers that need + * branded return types should re-wrap in their own package. + */ +export function fileSearchTool(toolData: FileSearchToolConfig): Tool { + validateMaxNumResults(toolData.max_num_results) + return { + name: 'file_search', + description: 'Search files in vector stores', + metadata: { + ...toolData, + }, + } +} + +export { validateMaxNumResults } diff --git a/packages/typescript/openai-base/src/tools/function-tool.ts b/packages/typescript/openai-base/src/tools/function-tool.ts new file mode 100644 index 000000000..bf06804c6 --- /dev/null +++ b/packages/typescript/openai-base/src/tools/function-tool.ts @@ -0,0 +1,44 @@ +import { makeStructuredOutputCompatible } from '../utils/schema-converter' +import type { JSONSchema, Tool } from '@tanstack/ai' +import type OpenAI from 'openai' + +export type FunctionToolConfig = OpenAI.Responses.FunctionTool + +/** @deprecated Renamed to `FunctionToolConfig`. Will be removed in a future release. */ +export type FunctionTool = FunctionToolConfig + +/** + * Converts a standard Tool to OpenAI FunctionTool format. + * + * Tool schemas are already converted to JSON Schema in the ai layer. + * We apply OpenAI-specific transformations for strict mode: + * - All properties in required array + * - Optional fields made nullable + * - additionalProperties: false + * + * This enables strict mode for all tools automatically. + */ +export function convertFunctionToolToAdapterFormat( + tool: Tool, +): FunctionToolConfig { + const inputSchema = (tool.inputSchema ?? { + type: 'object', + properties: {}, + required: [], + }) as JSONSchema + + const jsonSchema = makeStructuredOutputCompatible( + inputSchema, + inputSchema.required || [], + ) + + jsonSchema.additionalProperties = false + + return { + type: 'function', + name: tool.name, + description: tool.description, + parameters: jsonSchema, + strict: true, + } satisfies FunctionToolConfig +} diff --git a/packages/typescript/openai-base/src/tools/image-generation-tool.ts b/packages/typescript/openai-base/src/tools/image-generation-tool.ts new file mode 100644 index 000000000..f81fee40f --- /dev/null +++ b/packages/typescript/openai-base/src/tools/image-generation-tool.ts @@ -0,0 +1,51 @@ +import type OpenAI from 'openai' +import type { Tool } from '@tanstack/ai' + +export type ImageGenerationToolConfig = OpenAI.Responses.Tool.ImageGeneration + +/** @deprecated Renamed to `ImageGenerationToolConfig`. Will be removed in a future release. */ +export type ImageGenerationTool = ImageGenerationToolConfig + +const validatePartialImages = (value: number | undefined) => { + if (value !== undefined && (value < 0 || value > 3)) { + throw new Error('partial_images must be between 0 and 3') + } +} + +/** + * Converts a standard Tool to OpenAI ImageGenerationTool format. Spread + * `metadata` first, then force `type: 'image_generation'` last — otherwise a + * `metadata.type` snuck in by a hand-built tool would shadow the literal and + * the dispatcher (which routed by `tool.name`) would emit a tool whose + * runtime `type` doesn't match `image_generation`. + */ +export function convertImageGenerationToolToAdapterFormat( + tool: Tool, +): ImageGenerationToolConfig { + const metadata = tool.metadata as Omit + return { + ...metadata, + type: 'image_generation', + } +} + +/** + * Creates a standard Tool from ImageGenerationTool parameters. + * + * Base (non-branded) factory. Providers that need branded return types should + * re-wrap this in their own package. + */ +export function imageGenerationTool( + toolData: Omit, +): Tool { + validatePartialImages(toolData.partial_images) + return { + name: 'image_generation', + description: 'Generate images based on text descriptions', + metadata: { + ...toolData, + }, + } +} + +export { validatePartialImages } diff --git a/packages/typescript/openai-base/src/tools/index.ts b/packages/typescript/openai-base/src/tools/index.ts new file mode 100644 index 000000000..545710678 --- /dev/null +++ b/packages/typescript/openai-base/src/tools/index.ts @@ -0,0 +1,41 @@ +import type { ApplyPatchToolConfig } from './apply-patch-tool' +import type { CodeInterpreterToolConfig } from './code-interpreter-tool' +import type { ComputerUseToolConfig } from './computer-use-tool' +import type { CustomToolConfig } from './custom-tool' +import type { FileSearchToolConfig } from './file-search-tool' +import type { FunctionToolConfig } from './function-tool' +import type { ImageGenerationToolConfig } from './image-generation-tool' +import type { LocalShellToolConfig } from './local-shell-tool' +import type { MCPToolConfig } from './mcp-tool' +import type { ShellToolConfig } from './shell-tool' +import type { WebSearchPreviewToolConfig } from './web-search-preview-tool' +import type { WebSearchToolConfig } from './web-search-tool' + +export type OpenAITool = + | ApplyPatchToolConfig + | CodeInterpreterToolConfig + | ComputerUseToolConfig + | CustomToolConfig + | FileSearchToolConfig + | FunctionToolConfig + | ImageGenerationToolConfig + | LocalShellToolConfig + | MCPToolConfig + | ShellToolConfig + | WebSearchPreviewToolConfig + | WebSearchToolConfig + +export * from './apply-patch-tool' +export * from './code-interpreter-tool' +export * from './computer-use-tool' +export * from './custom-tool' +export * from './file-search-tool' +export * from './function-tool' +export * from './image-generation-tool' +export * from './local-shell-tool' +export * from './mcp-tool' +export * from './shell-tool' +export * from './tool-choice' +export * from './tool-converter' +export * from './web-search-preview-tool' +export * from './web-search-tool' diff --git a/packages/typescript/openai-base/src/tools/local-shell-tool.ts b/packages/typescript/openai-base/src/tools/local-shell-tool.ts new file mode 100644 index 000000000..dc15f46c5 --- /dev/null +++ b/packages/typescript/openai-base/src/tools/local-shell-tool.ts @@ -0,0 +1,32 @@ +import type OpenAI from 'openai' +import type { Tool } from '@tanstack/ai' + +export type LocalShellToolConfig = OpenAI.Responses.Tool.LocalShell + +/** @deprecated Renamed to `LocalShellToolConfig`. Will be removed in a future release. */ +export type LocalShellTool = LocalShellToolConfig + +/** + * Converts a standard Tool to OpenAI LocalShellTool format + */ +export function convertLocalShellToolToAdapterFormat( + _tool: Tool, +): LocalShellToolConfig { + return { + type: 'local_shell', + } +} + +/** + * Creates a standard Tool from LocalShellTool parameters. + * + * Base (non-branded) factory. Providers that need branded return types should + * re-wrap this in their own package. + */ +export function localShellTool(): Tool { + return { + name: 'local_shell', + description: 'Execute local shell commands', + metadata: {}, + } +} diff --git a/packages/typescript/openai-base/src/tools/mcp-tool.ts b/packages/typescript/openai-base/src/tools/mcp-tool.ts new file mode 100644 index 000000000..6693a466b --- /dev/null +++ b/packages/typescript/openai-base/src/tools/mcp-tool.ts @@ -0,0 +1,47 @@ +import type OpenAI from 'openai' +import type { Tool } from '@tanstack/ai' + +export type MCPToolConfig = OpenAI.Responses.Tool.Mcp + +/** @deprecated Renamed to `MCPToolConfig`. Will be removed in a future release. */ +export type MCPTool = MCPToolConfig + +export function validateMCPtool(tool: MCPToolConfig) { + if (!tool.server_url && !tool.connector_id) { + throw new Error('Either server_url or connector_id must be provided.') + } + if (tool.connector_id && tool.server_url) { + throw new Error('Only one of server_url or connector_id can be provided.') + } +} + +/** + * Converts a standard Tool to OpenAI MCPTool format + */ +export function convertMCPToolToAdapterFormat(tool: Tool): MCPToolConfig { + const metadata = tool.metadata as Omit + + const mcpTool: MCPToolConfig = { + ...metadata, + type: 'mcp', + } + + validateMCPtool(mcpTool) + return mcpTool +} + +/** + * Creates a standard Tool from MCPTool parameters. + * + * Base (non-branded) factory. Providers that need branded return types should + * re-wrap this in their own package. + */ +export function mcpTool(toolData: Omit): Tool { + validateMCPtool({ ...toolData, type: 'mcp' }) + + return { + name: 'mcp', + description: toolData.server_description || '', + metadata: toolData, + } +} diff --git a/packages/typescript/openai-base/src/tools/shell-tool.ts b/packages/typescript/openai-base/src/tools/shell-tool.ts new file mode 100644 index 000000000..4912a33c6 --- /dev/null +++ b/packages/typescript/openai-base/src/tools/shell-tool.ts @@ -0,0 +1,30 @@ +import type OpenAI from 'openai' +import type { Tool } from '@tanstack/ai' + +export type ShellToolConfig = OpenAI.Responses.FunctionShellTool + +/** @deprecated Renamed to `ShellToolConfig`. Will be removed in a future release. */ +export type ShellTool = ShellToolConfig + +/** + * Converts a standard Tool to OpenAI ShellTool format + */ +export function convertShellToolToAdapterFormat(_tool: Tool): ShellToolConfig { + return { + type: 'shell', + } +} + +/** + * Creates a standard Tool from ShellTool parameters. + * + * Base (non-branded) factory. Providers that need branded return types should + * re-wrap this in their own package. + */ +export function shellTool(): Tool { + return { + name: 'shell', + description: 'Execute shell commands', + metadata: {}, + } +} diff --git a/packages/typescript/openai-base/src/tools/tool-choice.ts b/packages/typescript/openai-base/src/tools/tool-choice.ts new file mode 100644 index 000000000..139b80f26 --- /dev/null +++ b/packages/typescript/openai-base/src/tools/tool-choice.ts @@ -0,0 +1,31 @@ +interface MCPToolChoice { + type: 'mcp' + server_label: string +} + +interface FunctionToolChoice { + type: 'function' + name: string +} + +interface CustomToolChoice { + type: 'custom' + name: string +} + +interface HostedToolChoice { + type: + | 'file_search' + | 'web_search_preview' + | 'computer_use_preview' + | 'code_interpreter' + | 'image_generation' + | 'shell' + | 'apply_patch' +} + +export type ToolChoice = + | MCPToolChoice + | FunctionToolChoice + | CustomToolChoice + | HostedToolChoice diff --git a/packages/typescript/openai-base/src/tools/tool-converter.ts b/packages/typescript/openai-base/src/tools/tool-converter.ts new file mode 100644 index 000000000..2855cd3f0 --- /dev/null +++ b/packages/typescript/openai-base/src/tools/tool-converter.ts @@ -0,0 +1,68 @@ +import { convertApplyPatchToolToAdapterFormat } from './apply-patch-tool' +import { convertCodeInterpreterToolToAdapterFormat } from './code-interpreter-tool' +import { convertComputerUseToolToAdapterFormat } from './computer-use-tool' +import { convertCustomToolToAdapterFormat } from './custom-tool' +import { convertFileSearchToolToAdapterFormat } from './file-search-tool' +import { convertFunctionToolToAdapterFormat } from './function-tool' +import { convertImageGenerationToolToAdapterFormat } from './image-generation-tool' +import { convertLocalShellToolToAdapterFormat } from './local-shell-tool' +import { convertMCPToolToAdapterFormat } from './mcp-tool' +import { convertShellToolToAdapterFormat } from './shell-tool' +import { convertWebSearchPreviewToolToAdapterFormat } from './web-search-preview-tool' +import { convertWebSearchToolToAdapterFormat } from './web-search-tool' +import type { OpenAITool } from './index' +import type { Tool } from '@tanstack/ai' + +const SPECIAL_TOOL_NAMES = new Set([ + 'apply_patch', + 'code_interpreter', + 'computer_use_preview', + 'file_search', + 'image_generation', + 'local_shell', + 'mcp', + 'shell', + 'web_search_preview', + 'web_search', + 'custom', +]) + +/** + * Converts an array of standard Tools to OpenAI-specific format + */ +export function convertToolsToProviderFormat( + tools: Array, +): Array { + return tools.map((tool) => { + const toolName = tool.name + + if (SPECIAL_TOOL_NAMES.has(toolName)) { + switch (toolName) { + case 'apply_patch': + return convertApplyPatchToolToAdapterFormat(tool) + case 'code_interpreter': + return convertCodeInterpreterToolToAdapterFormat(tool) + case 'computer_use_preview': + return convertComputerUseToolToAdapterFormat(tool) + case 'file_search': + return convertFileSearchToolToAdapterFormat(tool) + case 'image_generation': + return convertImageGenerationToolToAdapterFormat(tool) + case 'local_shell': + return convertLocalShellToolToAdapterFormat(tool) + case 'mcp': + return convertMCPToolToAdapterFormat(tool) + case 'shell': + return convertShellToolToAdapterFormat(tool) + case 'web_search_preview': + return convertWebSearchPreviewToolToAdapterFormat(tool) + case 'web_search': + return convertWebSearchToolToAdapterFormat(tool) + case 'custom': + return convertCustomToolToAdapterFormat(tool) + } + } + + return convertFunctionToolToAdapterFormat(tool) + }) +} diff --git a/packages/typescript/openai-base/src/tools/web-search-preview-tool.ts b/packages/typescript/openai-base/src/tools/web-search-preview-tool.ts new file mode 100644 index 000000000..0f020fde4 --- /dev/null +++ b/packages/typescript/openai-base/src/tools/web-search-preview-tool.ts @@ -0,0 +1,39 @@ +import type OpenAI from 'openai' +import type { Tool } from '@tanstack/ai' + +export type WebSearchPreviewToolConfig = OpenAI.Responses.WebSearchPreviewTool + +/** @deprecated Renamed to `WebSearchPreviewToolConfig`. Will be removed in a future release. */ +export type WebSearchPreviewTool = WebSearchPreviewToolConfig + +/** + * Converts a standard Tool to OpenAI WebSearchPreviewTool format. Force the + * literal `type: 'web_search_preview'` instead of trusting `metadata.type`, + * since a hand-authored tool with a missing or wrong `type` would emit a + * malformed payload while the dispatcher already routed by `tool.name`. + */ +export function convertWebSearchPreviewToolToAdapterFormat( + tool: Tool, +): WebSearchPreviewToolConfig { + const metadata = tool.metadata as Omit + return { + ...metadata, + type: 'web_search_preview', + } +} + +/** + * Creates a standard Tool from WebSearchPreviewTool parameters. + * + * Base (non-branded) factory. Providers that need branded return types should + * re-wrap this in their own package. + */ +export function webSearchPreviewTool( + toolData: WebSearchPreviewToolConfig, +): Tool { + return { + name: 'web_search_preview', + description: 'Search the web (preview version)', + metadata: toolData, + } +} diff --git a/packages/typescript/openai-base/src/tools/web-search-tool.ts b/packages/typescript/openai-base/src/tools/web-search-tool.ts new file mode 100644 index 000000000..ac5bfdfc9 --- /dev/null +++ b/packages/typescript/openai-base/src/tools/web-search-tool.ts @@ -0,0 +1,38 @@ +import type OpenAI from 'openai' +import type { Tool } from '@tanstack/ai' + +export type WebSearchToolConfig = OpenAI.Responses.WebSearchTool + +/** @deprecated Renamed to `WebSearchToolConfig`. Will be removed in a future release. */ +export type WebSearchTool = WebSearchToolConfig + +/** + * Converts a standard Tool to OpenAI WebSearchTool format. Spread `metadata` + * first, then force `type: 'web_search'` last to keep the runtime `type` + * matching the discriminator the dispatcher routed by — otherwise a tool + * authored by hand with a different `metadata.type` would emit a malformed + * payload. + */ +export function convertWebSearchToolToAdapterFormat( + tool: Tool, +): WebSearchToolConfig { + const metadata = tool.metadata as Omit + return { + ...metadata, + type: 'web_search', + } +} + +/** + * Creates a standard Tool from WebSearchTool parameters. + * + * Base (non-branded) factory. Providers that need branded return types should + * re-wrap this in their own package. + */ +export function webSearchTool(toolData: WebSearchToolConfig): Tool { + return { + name: 'web_search', + description: 'Search the web', + metadata: toolData, + } +} diff --git a/packages/typescript/openai-base/src/types/config.ts b/packages/typescript/openai-base/src/types/config.ts new file mode 100644 index 000000000..976336b42 --- /dev/null +++ b/packages/typescript/openai-base/src/types/config.ts @@ -0,0 +1,5 @@ +import type { ClientOptions } from 'openai' + +export interface OpenAICompatibleClientConfig extends ClientOptions { + apiKey: string +} diff --git a/packages/typescript/openai-base/src/utils/client.ts b/packages/typescript/openai-base/src/utils/client.ts new file mode 100644 index 000000000..8dd54b2fc --- /dev/null +++ b/packages/typescript/openai-base/src/utils/client.ts @@ -0,0 +1,8 @@ +import OpenAI from 'openai' +import type { OpenAICompatibleClientConfig } from '../types/config' + +export function createOpenAICompatibleClient( + config: OpenAICompatibleClientConfig, +): OpenAI { + return new OpenAI(config) +} diff --git a/packages/typescript/openai-base/src/utils/request-options.ts b/packages/typescript/openai-base/src/utils/request-options.ts new file mode 100644 index 000000000..090c868c1 --- /dev/null +++ b/packages/typescript/openai-base/src/utils/request-options.ts @@ -0,0 +1,16 @@ +/** + * Extract `headers` and `signal` from a `Request | RequestInit` for the OpenAI + * SDK's per-call `RequestOptions`. `Request` exposes `headers` as a `Headers` + * instance (HeadersInit-compatible) while `RequestInit` exposes `HeadersInit` + * directly — this helper accepts either shape so callers don't need to cast. + * + * Always returns an object (possibly empty) rather than `undefined` so test + * assertions that match the second argument shape via `expect.anything()` / + * `expect.objectContaining()` keep working when no request override was set. + */ +export function extractRequestOptions( + request: Request | RequestInit | undefined, +): { headers?: HeadersInit; signal?: AbortSignal | null } { + if (!request) return {} + return { headers: request.headers, signal: request.signal ?? undefined } +} diff --git a/packages/typescript/openai-base/src/utils/schema-converter.ts b/packages/typescript/openai-base/src/utils/schema-converter.ts new file mode 100644 index 000000000..fb0164091 --- /dev/null +++ b/packages/typescript/openai-base/src/utils/schema-converter.ts @@ -0,0 +1,89 @@ +/** + * Transform a JSON schema to be compatible with OpenAI's structured output requirements. + * OpenAI requires: + * - All properties must be in the `required` array + * - Optional fields should have null added to their type union + * - additionalProperties must be false for objects + * + * @param schema - JSON schema to transform + * @param originalRequired - Original required array (to know which fields were optional) + * @returns Transformed schema compatible with OpenAI structured output + */ +export function makeStructuredOutputCompatible( + schema: Record, + originalRequired?: Array, +): Record { + const result = { ...schema } + const required = + originalRequired ?? (Array.isArray(result.required) ? result.required : []) + + if (result.type === 'object' && result.properties) { + const properties = { ...result.properties } + const allPropertyNames = Object.keys(properties) + + for (const propName of allPropertyNames) { + let prop = properties[propName] + const wasOptional = !required.includes(propName) + + // Step 1: Recurse into nested structures + if (prop.type === 'object' && prop.properties) { + prop = makeStructuredOutputCompatible(prop, prop.required || []) + } else if (prop.type === 'array' && prop.items) { + prop = { + ...prop, + items: makeStructuredOutputCompatible( + prop.items, + prop.items.required || [], + ), + } + } else if (prop.anyOf) { + prop = makeStructuredOutputCompatible(prop, prop.required || []) + } else if (prop.oneOf) { + throw new Error( + 'oneOf is not supported in OpenAI structured output schemas. Check the supported outputs here: https://platform.openai.com/docs/guides/structured-outputs#supported-types', + ) + } + + // Step 2: Apply null-widening for optional properties (after recursion) + if (wasOptional) { + if (prop.anyOf) { + // For anyOf, add a null variant if not already present + if (!prop.anyOf.some((v: any) => v.type === 'null')) { + prop = { ...prop, anyOf: [...prop.anyOf, { type: 'null' }] } + } + } else if (prop.type && !Array.isArray(prop.type)) { + prop = { ...prop, type: [prop.type, 'null'] } + } else if (Array.isArray(prop.type) && !prop.type.includes('null')) { + prop = { ...prop, type: [...prop.type, 'null'] } + } + } + + properties[propName] = prop + } + + result.properties = properties + result.required = allPropertyNames + result.additionalProperties = false + } + + if (result.type === 'array' && result.items) { + result.items = makeStructuredOutputCompatible( + result.items, + result.items.required || [], + ) + } + + if (result.anyOf && Array.isArray(result.anyOf)) { + result.anyOf = result.anyOf.map((variant) => + makeStructuredOutputCompatible(variant, variant.required || []), + ) + } + + if (result.oneOf) { + throw new Error( + 'oneOf is not supported in OpenAI structured output schemas. Check the supported outputs here: https://platform.openai.com/docs/guides/structured-outputs#supported-types', + ) + } + + return result +} diff --git a/packages/typescript/openai-base/tests/chat-completions-text.test.ts b/packages/typescript/openai-base/tests/chat-completions-text.test.ts new file mode 100644 index 000000000..a4bca2114 --- /dev/null +++ b/packages/typescript/openai-base/tests/chat-completions-text.test.ts @@ -0,0 +1,919 @@ +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest' +import { OpenAICompatibleChatCompletionsTextAdapter } from '../src/adapters/chat-completions-text' +import type { StreamChunk, Tool } from '@tanstack/ai' +import { resolveDebugOption } from '@tanstack/ai/adapter-internals' + +const testLogger = resolveDebugOption(false) + +// Declare mockCreate at module level +let mockCreate: ReturnType + +// Mock the OpenAI SDK +vi.mock('openai', () => { + return { + default: class { + chat = { + completions: { + create: (...args: Array) => mockCreate(...args), + }, + } + }, + } +}) + +// Helper to create async iterable from chunks +function createAsyncIterable(chunks: Array): AsyncIterable { + return { + [Symbol.asyncIterator]() { + let index = 0 + return { + async next() { + if (index < chunks.length) { + return { value: chunks[index++]!, done: false } + } + return { value: undefined as T, done: true } + }, + } + }, + } +} + +// Helper to setup the mock SDK client for streaming responses +function setupMockSdkClient( + streamChunks: Array>, + nonStreamResponse?: Record, +) { + mockCreate = vi.fn().mockImplementation((params) => { + if (params.stream) { + return Promise.resolve(createAsyncIterable(streamChunks)) + } + return Promise.resolve(nonStreamResponse) + }) +} + +const testConfig = { + apiKey: 'test-api-key', + baseURL: 'https://api.test-provider.com/v1', +} + +const weatherTool: Tool = { + name: 'lookup_weather', + description: 'Return the forecast for a location', +} + +describe('OpenAICompatibleChatCompletionsTextAdapter', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + vi.unstubAllEnvs() + }) + + describe('instantiation', () => { + it('creates an adapter with default name', () => { + const adapter = new OpenAICompatibleChatCompletionsTextAdapter( + testConfig, + 'test-model', + ) + + expect(adapter).toBeDefined() + expect(adapter.kind).toBe('text') + expect(adapter.name).toBe('openai-compatible') + expect(adapter.model).toBe('test-model') + }) + + it('creates an adapter with custom name', () => { + const adapter = new OpenAICompatibleChatCompletionsTextAdapter( + testConfig, + 'test-model', + 'my-provider', + ) + + expect(adapter).toBeDefined() + expect(adapter.name).toBe('my-provider') + }) + + it('creates an adapter with custom baseURL', () => { + const adapter = new OpenAICompatibleChatCompletionsTextAdapter( + { + apiKey: 'test-key', + baseURL: 'https://custom.api.example.com/v1', + }, + 'custom-model', + ) + + expect(adapter).toBeDefined() + expect(adapter.model).toBe('custom-model') + }) + }) + + describe('streaming event sequence', () => { + it('emits RUN_STARTED as the first event', async () => { + const streamChunks = [ + { + id: 'chatcmpl-123', + model: 'test-model', + choices: [ + { + delta: { content: 'Hello' }, + finish_reason: null, + }, + ], + }, + { + id: 'chatcmpl-123', + model: 'test-model', + choices: [ + { + delta: {}, + finish_reason: 'stop', + }, + ], + usage: { + prompt_tokens: 5, + completion_tokens: 1, + total_tokens: 6, + }, + }, + ] + + setupMockSdkClient(streamChunks) + const adapter = new OpenAICompatibleChatCompletionsTextAdapter( + testConfig, + 'test-model', + ) + const chunks: Array = [] + + for await (const chunk of adapter.chatStream({ + logger: testLogger, + model: 'test-model', + messages: [{ role: 'user', content: 'Hello' }], + })) { + chunks.push(chunk) + } + + expect(chunks[0]?.type).toBe('RUN_STARTED') + if (chunks[0]?.type === 'RUN_STARTED') { + expect(chunks[0].runId).toBeDefined() + expect(chunks[0].model).toBe('test-model') + } + }) + + it('emits TEXT_MESSAGE_START before TEXT_MESSAGE_CONTENT', async () => { + const streamChunks = [ + { + id: 'chatcmpl-123', + model: 'test-model', + choices: [ + { + delta: { content: 'Hello' }, + finish_reason: null, + }, + ], + }, + { + id: 'chatcmpl-123', + model: 'test-model', + choices: [ + { + delta: {}, + finish_reason: 'stop', + }, + ], + usage: { + prompt_tokens: 5, + completion_tokens: 1, + total_tokens: 6, + }, + }, + ] + + setupMockSdkClient(streamChunks) + const adapter = new OpenAICompatibleChatCompletionsTextAdapter( + testConfig, + 'test-model', + ) + const chunks: Array = [] + + for await (const chunk of adapter.chatStream({ + logger: testLogger, + model: 'test-model', + messages: [{ role: 'user', content: 'Hello' }], + })) { + chunks.push(chunk) + } + + const textStartIndex = chunks.findIndex( + (c) => c.type === 'TEXT_MESSAGE_START', + ) + const textContentIndex = chunks.findIndex( + (c) => c.type === 'TEXT_MESSAGE_CONTENT', + ) + + expect(textStartIndex).toBeGreaterThan(-1) + expect(textContentIndex).toBeGreaterThan(-1) + expect(textStartIndex).toBeLessThan(textContentIndex) + + const textStart = chunks[textStartIndex] + if (textStart?.type === 'TEXT_MESSAGE_START') { + expect(textStart.messageId).toBeDefined() + expect(textStart.role).toBe('assistant') + } + }) + + it('emits proper AG-UI event sequence: RUN_STARTED -> TEXT_MESSAGE_START -> TEXT_MESSAGE_CONTENT -> TEXT_MESSAGE_END -> RUN_FINISHED', async () => { + const streamChunks = [ + { + id: 'chatcmpl-123', + model: 'test-model', + choices: [ + { + delta: { content: 'Hello world' }, + finish_reason: null, + }, + ], + }, + { + id: 'chatcmpl-123', + model: 'test-model', + choices: [ + { + delta: {}, + finish_reason: 'stop', + }, + ], + usage: { + prompt_tokens: 5, + completion_tokens: 2, + total_tokens: 7, + }, + }, + ] + + setupMockSdkClient(streamChunks) + const adapter = new OpenAICompatibleChatCompletionsTextAdapter( + testConfig, + 'test-model', + ) + const chunks: Array = [] + + for await (const chunk of adapter.chatStream({ + logger: testLogger, + model: 'test-model', + messages: [{ role: 'user', content: 'Hello' }], + })) { + chunks.push(chunk) + } + + // Verify proper AG-UI event sequence + const eventTypes = chunks.map((c) => c.type) + + // Should start with RUN_STARTED + expect(eventTypes[0]).toBe('RUN_STARTED') + + // Should have TEXT_MESSAGE_START before TEXT_MESSAGE_CONTENT + const textStartIndex = eventTypes.indexOf('TEXT_MESSAGE_START') + const textContentIndex = eventTypes.indexOf('TEXT_MESSAGE_CONTENT') + expect(textStartIndex).toBeGreaterThan(-1) + expect(textContentIndex).toBeGreaterThan(textStartIndex) + + // Should have TEXT_MESSAGE_END before RUN_FINISHED + const textEndIndex = eventTypes.indexOf('TEXT_MESSAGE_END') + const runFinishedIndex = eventTypes.indexOf('RUN_FINISHED') + expect(textEndIndex).toBeGreaterThan(-1) + expect(runFinishedIndex).toBeGreaterThan(textEndIndex) + + // Verify RUN_FINISHED has proper data + const runFinishedChunk = chunks.find((c) => c.type === 'RUN_FINISHED') + if (runFinishedChunk?.type === 'RUN_FINISHED') { + expect(runFinishedChunk.finishReason).toBe('stop') + expect(runFinishedChunk.usage).toBeDefined() + } + }) + + it('emits TEXT_MESSAGE_END and RUN_FINISHED at the end with usage data', async () => { + const streamChunks = [ + { + id: 'chatcmpl-123', + model: 'test-model', + choices: [ + { + delta: { content: 'Hello' }, + finish_reason: null, + }, + ], + }, + { + id: 'chatcmpl-123', + model: 'test-model', + choices: [ + { + delta: {}, + finish_reason: 'stop', + }, + ], + usage: { + prompt_tokens: 5, + completion_tokens: 1, + total_tokens: 6, + }, + }, + ] + + setupMockSdkClient(streamChunks) + const adapter = new OpenAICompatibleChatCompletionsTextAdapter( + testConfig, + 'test-model', + ) + const chunks: Array = [] + + for await (const chunk of adapter.chatStream({ + logger: testLogger, + model: 'test-model', + messages: [{ role: 'user', content: 'Hello' }], + })) { + chunks.push(chunk) + } + + const textEndChunk = chunks.find((c) => c.type === 'TEXT_MESSAGE_END') + expect(textEndChunk).toBeDefined() + if (textEndChunk?.type === 'TEXT_MESSAGE_END') { + expect(textEndChunk.messageId).toBeDefined() + } + + const runFinishedChunk = chunks.find((c) => c.type === 'RUN_FINISHED') + expect(runFinishedChunk).toBeDefined() + if (runFinishedChunk?.type === 'RUN_FINISHED') { + expect(runFinishedChunk.runId).toBeDefined() + expect(runFinishedChunk.finishReason).toBe('stop') + expect(runFinishedChunk.usage).toMatchObject({ + promptTokens: 5, + completionTokens: 1, + totalTokens: 6, + }) + } + }) + + it('streams content with correct accumulated values', async () => { + const streamChunks = [ + { + id: 'chatcmpl-stream', + model: 'test-model', + choices: [ + { + delta: { content: 'Hello ' }, + finish_reason: null, + }, + ], + }, + { + id: 'chatcmpl-stream', + model: 'test-model', + choices: [ + { + delta: { content: 'world' }, + finish_reason: null, + }, + ], + }, + { + id: 'chatcmpl-stream', + model: 'test-model', + choices: [ + { + delta: {}, + finish_reason: 'stop', + }, + ], + usage: { + prompt_tokens: 5, + completion_tokens: 2, + total_tokens: 7, + }, + }, + ] + + setupMockSdkClient(streamChunks) + const adapter = new OpenAICompatibleChatCompletionsTextAdapter( + testConfig, + 'test-model', + ) + const chunks: Array = [] + + for await (const chunk of adapter.chatStream({ + logger: testLogger, + model: 'test-model', + messages: [{ role: 'user', content: 'Say hello' }], + })) { + chunks.push(chunk) + } + + // Check TEXT_MESSAGE_CONTENT events have correct accumulated content + const contentChunks = chunks.filter( + (c) => c.type === 'TEXT_MESSAGE_CONTENT', + ) + expect(contentChunks.length).toBe(2) + + const firstContent = contentChunks[0] + if (firstContent?.type === 'TEXT_MESSAGE_CONTENT') { + expect(firstContent.delta).toBe('Hello ') + expect(firstContent.content).toBe('Hello ') + } + + const secondContent = contentChunks[1] + if (secondContent?.type === 'TEXT_MESSAGE_CONTENT') { + expect(secondContent.delta).toBe('world') + expect(secondContent.content).toBe('Hello world') + } + }) + }) + + describe('tool call events', () => { + it('emits TOOL_CALL_START -> TOOL_CALL_ARGS -> TOOL_CALL_END', async () => { + const streamChunks = [ + { + id: 'chatcmpl-456', + model: 'test-model', + choices: [ + { + delta: { + tool_calls: [ + { + index: 0, + id: 'call_abc123', + type: 'function', + function: { + name: 'lookup_weather', + arguments: '{"location":', + }, + }, + ], + }, + finish_reason: null, + }, + ], + }, + { + id: 'chatcmpl-456', + model: 'test-model', + choices: [ + { + delta: { + tool_calls: [ + { + index: 0, + function: { + arguments: '"Berlin"}', + }, + }, + ], + }, + finish_reason: null, + }, + ], + }, + { + id: 'chatcmpl-456', + model: 'test-model', + choices: [ + { + delta: {}, + finish_reason: 'tool_calls', + }, + ], + usage: { + prompt_tokens: 10, + completion_tokens: 5, + total_tokens: 15, + }, + }, + ] + + setupMockSdkClient(streamChunks) + const adapter = new OpenAICompatibleChatCompletionsTextAdapter( + testConfig, + 'test-model', + ) + const chunks: Array = [] + + for await (const chunk of adapter.chatStream({ + logger: testLogger, + model: 'test-model', + messages: [{ role: 'user', content: 'Weather in Berlin?' }], + tools: [weatherTool], + })) { + chunks.push(chunk) + } + + // Check AG-UI tool events + const toolStartChunk = chunks.find((c) => c.type === 'TOOL_CALL_START') + expect(toolStartChunk).toBeDefined() + if (toolStartChunk?.type === 'TOOL_CALL_START') { + expect(toolStartChunk.toolCallId).toBe('call_abc123') + expect(toolStartChunk.toolName).toBe('lookup_weather') + } + + const toolArgsChunks = chunks.filter((c) => c.type === 'TOOL_CALL_ARGS') + expect(toolArgsChunks.length).toBeGreaterThan(0) + + const toolEndChunk = chunks.find((c) => c.type === 'TOOL_CALL_END') + expect(toolEndChunk).toBeDefined() + if (toolEndChunk?.type === 'TOOL_CALL_END') { + expect(toolEndChunk.toolCallId).toBe('call_abc123') + expect(toolEndChunk.toolName).toBe('lookup_weather') + expect(toolEndChunk.input).toEqual({ location: 'Berlin' }) + } + + // Check finish reason + const runFinishedChunk = chunks.find((c) => c.type === 'RUN_FINISHED') + if (runFinishedChunk?.type === 'RUN_FINISHED') { + expect(runFinishedChunk.finishReason).toBe('tool_calls') + } + }) + }) + + describe('error handling', () => { + it('emits RUN_ERROR on stream error', async () => { + const streamChunks = [ + { + id: 'chatcmpl-123', + model: 'test-model', + choices: [ + { + delta: { content: 'Hello' }, + finish_reason: null, + }, + ], + }, + ] + + // Create an async iterable that throws mid-stream + const errorIterable = { + [Symbol.asyncIterator]() { + let index = 0 + return { + async next() { + if (index < streamChunks.length) { + return { value: streamChunks[index++]!, done: false } + } + throw new Error('Stream interrupted') + }, + } + }, + } + + mockCreate = vi.fn().mockResolvedValue(errorIterable) + + const adapter = new OpenAICompatibleChatCompletionsTextAdapter( + testConfig, + 'test-model', + ) + const chunks: Array = [] + + for await (const chunk of adapter.chatStream({ + logger: testLogger, + model: 'test-model', + messages: [{ role: 'user', content: 'Hello' }], + })) { + chunks.push(chunk) + } + + // Should emit RUN_ERROR + const runErrorChunk = chunks.find((c) => c.type === 'RUN_ERROR') + expect(runErrorChunk).toBeDefined() + if (runErrorChunk?.type === 'RUN_ERROR') { + expect(runErrorChunk.error.message).toBe('Stream interrupted') + } + }) + + it('emits RUN_STARTED then RUN_ERROR when client.create throws', async () => { + mockCreate = vi.fn().mockRejectedValue(new Error('API key invalid')) + + const adapter = new OpenAICompatibleChatCompletionsTextAdapter( + testConfig, + 'test-model', + ) + const chunks: Array = [] + + for await (const chunk of adapter.chatStream({ + logger: testLogger, + model: 'test-model', + messages: [{ role: 'user', content: 'Hello' }], + })) { + chunks.push(chunk) + } + + // Should have RUN_STARTED followed by RUN_ERROR + expect(chunks.length).toBe(2) + expect(chunks[0]?.type).toBe('RUN_STARTED') + expect(chunks[1]?.type).toBe('RUN_ERROR') + if (chunks[1]?.type === 'RUN_ERROR') { + expect(chunks[1].error.message).toBe('API key invalid') + } + }) + }) + + describe('structured output', () => { + it('generates structured output and parses JSON response', async () => { + const nonStreamResponse = { + choices: [ + { + message: { + content: '{"name":"Alice","age":30}', + }, + }, + ], + } + + setupMockSdkClient([], nonStreamResponse) + + const adapter = new OpenAICompatibleChatCompletionsTextAdapter( + testConfig, + 'test-model', + ) + + const result = await adapter.structuredOutput({ + chatOptions: { + logger: testLogger, + model: 'test-model', + messages: [{ role: 'user', content: 'Give me a person object' }], + }, + outputSchema: { + type: 'object', + properties: { + name: { type: 'string' }, + age: { type: 'number' }, + }, + required: ['name', 'age'], + }, + }) + + expect(result.data).toEqual({ name: 'Alice', age: 30 }) + expect(result.rawText).toBe('{"name":"Alice","age":30}') + + // Verify stream: false was passed (second arg is request options) + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + stream: false, + response_format: expect.objectContaining({ + type: 'json_schema', + }), + }), + expect.anything(), + ) + }) + + it('transforms null values to undefined', async () => { + const nonStreamResponse = { + choices: [ + { + message: { + content: '{"name":"Alice","nickname":null}', + }, + }, + ], + } + + setupMockSdkClient([], nonStreamResponse) + + const adapter = new OpenAICompatibleChatCompletionsTextAdapter( + testConfig, + 'test-model', + ) + + const result = await adapter.structuredOutput({ + chatOptions: { + logger: testLogger, + model: 'test-model', + messages: [{ role: 'user', content: 'Give me a person object' }], + }, + outputSchema: { + type: 'object', + properties: { + name: { type: 'string' }, + nickname: { type: 'string' }, + }, + required: ['name'], + }, + }) + + // null should be transformed to undefined + expect((result.data as any).name).toBe('Alice') + expect((result.data as any).nickname).toBeUndefined() + }) + + it('throws on invalid JSON response', async () => { + const nonStreamResponse = { + choices: [ + { + message: { + content: 'not valid json', + }, + }, + ], + } + + setupMockSdkClient([], nonStreamResponse) + + const adapter = new OpenAICompatibleChatCompletionsTextAdapter( + testConfig, + 'test-model', + ) + + await expect( + adapter.structuredOutput({ + chatOptions: { + logger: testLogger, + model: 'test-model', + messages: [{ role: 'user', content: 'Give me a person object' }], + }, + outputSchema: { + type: 'object', + properties: { + name: { type: 'string' }, + }, + required: ['name'], + }, + }), + ).rejects.toThrow('Failed to parse structured output as JSON') + }) + }) + + describe('subclassing', () => { + it('allows subclassing with custom name', () => { + class MyProviderAdapter extends OpenAICompatibleChatCompletionsTextAdapter { + constructor(apiKey: string, model: string) { + super( + { apiKey, baseURL: 'https://my-provider.com/v1' }, + model, + 'my-provider', + ) + } + } + + const adapter = new MyProviderAdapter('test-key', 'my-model') + expect(adapter.name).toBe('my-provider') + expect(adapter.kind).toBe('text') + expect(adapter.model).toBe('my-model') + }) + }) + + describe('request forwarding', () => { + it('forwards modelOptions to the API request', async () => { + const streamChunks = [ + { + id: 'chatcmpl-123', + model: 'test-model', + choices: [{ delta: { content: 'Hi' }, finish_reason: null }], + }, + { + id: 'chatcmpl-123', + model: 'test-model', + choices: [{ delta: {}, finish_reason: 'stop' }], + usage: { prompt_tokens: 5, completion_tokens: 1, total_tokens: 6 }, + }, + ] + + setupMockSdkClient(streamChunks) + const adapter = new OpenAICompatibleChatCompletionsTextAdapter( + testConfig, + 'test-model', + ) + + const chunks: Array = [] + for await (const chunk of adapter.chatStream({ + logger: testLogger, + model: 'test-model', + messages: [{ role: 'user', content: 'Hello' }], + modelOptions: { frequency_penalty: 0.5, presence_penalty: 0.3 }, + })) { + chunks.push(chunk) + } + + // Verify modelOptions were forwarded + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + frequency_penalty: 0.5, + presence_penalty: 0.3, + }), + expect.anything(), + ) + }) + + it('includes stream_options only for streaming calls', async () => { + const streamChunks = [ + { + id: 'chatcmpl-123', + model: 'test-model', + choices: [{ delta: { content: 'Hi' }, finish_reason: null }], + }, + { + id: 'chatcmpl-123', + model: 'test-model', + choices: [{ delta: {}, finish_reason: 'stop' }], + usage: { prompt_tokens: 5, completion_tokens: 1, total_tokens: 6 }, + }, + ] + + setupMockSdkClient(streamChunks) + const adapter = new OpenAICompatibleChatCompletionsTextAdapter( + testConfig, + 'test-model', + ) + + const chunks: Array = [] + for await (const chunk of adapter.chatStream({ + logger: testLogger, + model: 'test-model', + messages: [{ role: 'user', content: 'Hello' }], + })) { + chunks.push(chunk) + } + + // Streaming call should include stream_options + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + stream: true, + stream_options: { include_usage: true }, + }), + expect.anything(), + ) + }) + + it('does not include stream_options in structured output calls', async () => { + const nonStreamResponse = { + choices: [{ message: { content: '{"name":"Alice"}' } }], + } + + setupMockSdkClient([], nonStreamResponse) + + const adapter = new OpenAICompatibleChatCompletionsTextAdapter( + testConfig, + 'test-model', + ) + + await adapter.structuredOutput({ + chatOptions: { + logger: testLogger, + model: 'test-model', + messages: [{ role: 'user', content: 'Give me a person' }], + }, + outputSchema: { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'], + }, + }) + + // Structured output call should NOT have stream_options + const callArgs = mockCreate.mock.calls[0]?.[0] + expect(callArgs.stream).toBe(false) + expect(callArgs.stream_options).toBeUndefined() + }) + + it('forwards request headers and signal to SDK create calls', async () => { + const streamChunks = [ + { + id: 'chatcmpl-123', + model: 'test-model', + choices: [{ delta: { content: 'Hi' }, finish_reason: null }], + }, + { + id: 'chatcmpl-123', + model: 'test-model', + choices: [{ delta: {}, finish_reason: 'stop' }], + usage: { prompt_tokens: 5, completion_tokens: 1, total_tokens: 6 }, + }, + ] + + setupMockSdkClient(streamChunks) + const adapter = new OpenAICompatibleChatCompletionsTextAdapter( + testConfig, + 'test-model', + ) + + const controller = new AbortController() + const chunks: Array = [] + for await (const chunk of adapter.chatStream({ + logger: testLogger, + model: 'test-model', + messages: [{ role: 'user', content: 'Hello' }], + request: { + headers: { 'X-Custom-Header': 'test-value' }, + signal: controller.signal, + }, + })) { + chunks.push(chunk) + } + + // Verify second argument contains headers and signal + const requestOptions = mockCreate.mock.calls[0]?.[1] + expect(requestOptions).toBeDefined() + expect(requestOptions.headers).toEqual({ + 'X-Custom-Header': 'test-value', + }) + expect(requestOptions.signal).toBe(controller.signal) + }) + }) +}) diff --git a/packages/typescript/openai-base/tests/mcp-tool.test.ts b/packages/typescript/openai-base/tests/mcp-tool.test.ts new file mode 100644 index 000000000..d38e40128 --- /dev/null +++ b/packages/typescript/openai-base/tests/mcp-tool.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest' +import { convertMCPToolToAdapterFormat } from '../src/tools/mcp-tool' +import type { Tool } from '@tanstack/ai' + +describe('convertMCPToolToAdapterFormat', () => { + it('should always set type to mcp even if metadata contains a type field', () => { + const tool: Tool = { + name: 'mcp', + description: 'test mcp tool', + metadata: { + type: 'not_mcp', + server_url: 'https://example.com/mcp', + }, + } + + const result = convertMCPToolToAdapterFormat(tool) + expect(result.type).toBe('mcp') + }) + + it('should preserve metadata fields other than type', () => { + const tool: Tool = { + name: 'mcp', + description: 'test mcp tool', + metadata: { + server_url: 'https://example.com/mcp', + server_description: 'Test server', + }, + } + + const result = convertMCPToolToAdapterFormat(tool) + expect(result.type).toBe('mcp') + expect(result.server_url).toBe('https://example.com/mcp') + }) +}) diff --git a/packages/typescript/openai-base/tests/media-adapters.test.ts b/packages/typescript/openai-base/tests/media-adapters.test.ts new file mode 100644 index 000000000..d49dbf222 --- /dev/null +++ b/packages/typescript/openai-base/tests/media-adapters.test.ts @@ -0,0 +1,367 @@ +/** + * Smoke tests for the OpenAI-compatible media adapters (image, summarize, + * transcription, TTS, video). Each test verifies the adapter instantiates, + * forwards arguments to the OpenAI SDK shape we expect, and surfaces errors + * via `logger.errors` / `RUN_ERROR` rather than swallowing them. The mocks + * stand in for the OpenAI SDK; the real SDK is exercised in the e2e suite. + */ +import { describe, expect, it, beforeEach, vi } from 'vitest' +import { resolveDebugOption } from '@tanstack/ai/adapter-internals' +import { OpenAICompatibleImageAdapter } from '../src/adapters/image' +import { OpenAICompatibleSummarizeAdapter } from '../src/adapters/summarize' +import { OpenAICompatibleTranscriptionAdapter } from '../src/adapters/transcription' +import { OpenAICompatibleTTSAdapter } from '../src/adapters/tts' +import { OpenAICompatibleVideoAdapter } from '../src/adapters/video' +import type { ChatStreamCapable } from '../src/adapters/summarize' +import type { StreamChunk } from '@tanstack/ai' + +const testLogger = resolveDebugOption(false) + +let mockImagesGenerate: ReturnType +let mockTranscriptionsCreate: ReturnType +let mockSpeechCreate: ReturnType +let mockVideosCreate: ReturnType +let mockVideosRetrieve: ReturnType + +vi.mock('openai', () => { + return { + default: class { + images = { + generate: (...args: Array) => mockImagesGenerate(...args), + } + audio = { + transcriptions: { + create: (...args: Array) => + mockTranscriptionsCreate(...args), + }, + speech: { + create: (...args: Array) => mockSpeechCreate(...args), + }, + } + videos = { + create: (...args: Array) => mockVideosCreate(...args), + retrieve: (...args: Array) => mockVideosRetrieve(...args), + } + }, + } +}) + +const config = { + apiKey: 'test-key', + baseURL: 'https://api.test-provider.com/v1', +} + +beforeEach(() => { + vi.clearAllMocks() + mockImagesGenerate = vi.fn() + mockTranscriptionsCreate = vi.fn() + mockSpeechCreate = vi.fn() + mockVideosCreate = vi.fn() + mockVideosRetrieve = vi.fn() +}) + +describe('OpenAICompatibleImageAdapter', () => { + it('forwards model, prompt, n, and size to images.generate', async () => { + mockImagesGenerate.mockResolvedValue({ + data: [{ url: 'https://example.com/img.png' }], + }) + + const adapter = new OpenAICompatibleImageAdapter(config, 'test-model') + const result = await adapter.generateImages({ + logger: testLogger, + model: 'test-model', + prompt: 'a cat', + numberOfImages: 2, + size: '1024x1024', + }) + + expect(mockImagesGenerate).toHaveBeenCalledWith( + expect.objectContaining({ + model: 'test-model', + prompt: 'a cat', + n: 2, + size: '1024x1024', + stream: false, + }), + ) + expect(result.images).toHaveLength(1) + expect(result.images[0]).toMatchObject({ + url: 'https://example.com/img.png', + }) + }) + + it('rejects invalid number of images via base validator', async () => { + const adapter = new OpenAICompatibleImageAdapter(config, 'test-model') + await expect( + adapter.generateImages({ + logger: testLogger, + model: 'test-model', + prompt: 'a cat', + numberOfImages: 0, + }), + ).rejects.toThrow('at least 1') + }) + + it('logs to errors and rethrows on SDK failure', async () => { + const errors = vi.fn() + // testLogger is a class instance — spreading drops prototype methods, so + // wrap with a Proxy that overrides `errors` and forwards everything else. + const logger = new Proxy(testLogger, { + get(target, key) { + if (key === 'errors') return errors + return Reflect.get(target, key) + }, + }) + mockImagesGenerate.mockRejectedValue(new Error('boom')) + + const adapter = new OpenAICompatibleImageAdapter(config, 'test-model') + await expect( + adapter.generateImages({ + logger, + model: 'test-model', + prompt: 'a cat', + }), + ).rejects.toThrow('boom') + expect(errors).toHaveBeenCalled() + }) +}) + +describe('OpenAICompatibleSummarizeAdapter', () => { + function fakeTextAdapter( + chunks: Array, + ): ChatStreamCapable> { + return { + async *chatStream() { + for (const c of chunks) { + yield c + } + }, + } + } + + it('accumulates content from TEXT_MESSAGE_CONTENT chunks', async () => { + const adapter = new OpenAICompatibleSummarizeAdapter( + fakeTextAdapter([ + { + type: 'TEXT_MESSAGE_CONTENT', + delta: 'Hello ', + messageId: 'm1', + model: 'test-model', + timestamp: 1, + } as unknown as StreamChunk, + { + type: 'TEXT_MESSAGE_CONTENT', + delta: 'world', + messageId: 'm1', + model: 'test-model', + timestamp: 2, + } as unknown as StreamChunk, + { + type: 'RUN_FINISHED', + runId: 'r1', + model: 'test-model', + timestamp: 3, + usage: { promptTokens: 10, completionTokens: 5, totalTokens: 15 }, + finishReason: 'stop', + } as unknown as StreamChunk, + ]), + 'test-model', + 'test-provider', + ) + + const result = await adapter.summarize({ + logger: testLogger, + model: 'test-model', + text: 'Long text to summarise.', + }) + + expect(result.summary).toBe('Hello world') + expect(result.usage).toEqual({ + promptTokens: 10, + completionTokens: 5, + totalTokens: 15, + }) + }) + + it('throws and logs when the underlying chatStream emits RUN_ERROR', async () => { + const errors = vi.fn() + // testLogger is a class instance — spreading drops prototype methods, so + // wrap with a Proxy that overrides `errors` and forwards everything else. + const logger = new Proxy(testLogger, { + get(target, key) { + if (key === 'errors') return errors + return Reflect.get(target, key) + }, + }) + + const adapter = new OpenAICompatibleSummarizeAdapter( + { + async *chatStream() { + yield { + type: 'RUN_ERROR', + runId: 'r1', + model: 'test-model', + timestamp: 1, + error: { message: 'upstream rate limit', code: 'rate_limited' }, + } as unknown as StreamChunk + }, + }, + 'test-model', + 'test-provider', + ) + + await expect( + adapter.summarize({ + logger, + model: 'test-model', + text: 'irrelevant', + }), + ).rejects.toThrow('upstream rate limit') + expect(errors).toHaveBeenCalled() + }) +}) + +describe('OpenAICompatibleTranscriptionAdapter', () => { + it('forwards model and language and returns text-only result for non-verbose formats', async () => { + mockTranscriptionsCreate.mockResolvedValue({ text: 'hello world' }) + + const adapter = new OpenAICompatibleTranscriptionAdapter( + config, + 'whisper-1', + ) + const result = await adapter.transcribe({ + logger: testLogger, + model: 'whisper-1', + audio: new Blob([new Uint8Array([1, 2, 3])], { type: 'audio/mpeg' }), + language: 'en', + responseFormat: 'json', + }) + + expect(mockTranscriptionsCreate).toHaveBeenCalledWith( + expect.objectContaining({ + model: 'whisper-1', + language: 'en', + }), + ) + expect(result.text).toBe('hello world') + expect(result.segments).toBeUndefined() + }) + + it('decodes a base64 audio string to a File on the request path', async () => { + mockTranscriptionsCreate.mockResolvedValue({ text: 'decoded' }) + + const adapter = new OpenAICompatibleTranscriptionAdapter( + config, + 'whisper-1', + ) + // 3 raw bytes encoded as base64 + const base64 = 'AQID' + await adapter.transcribe({ + logger: testLogger, + model: 'whisper-1', + audio: base64, + responseFormat: 'json', + }) + + const callArgs = mockTranscriptionsCreate.mock.calls[0]?.[0] + expect(callArgs?.file).toBeDefined() + expect(callArgs?.file).toBeInstanceOf(File) + }) +}) + +describe('OpenAICompatibleTTSAdapter', () => { + it('forwards model/voice/format/speed and returns base64 audio', async () => { + const fakeBuffer = new Uint8Array([1, 2, 3, 4]).buffer + mockSpeechCreate.mockResolvedValue({ + arrayBuffer: () => Promise.resolve(fakeBuffer), + }) + + const adapter = new OpenAICompatibleTTSAdapter(config, 'tts-1') + const result = await adapter.generateSpeech({ + logger: testLogger, + model: 'tts-1', + text: 'Hello', + voice: 'alloy', + format: 'mp3', + speed: 1.0, + }) + + expect(mockSpeechCreate).toHaveBeenCalledWith( + expect.objectContaining({ + model: 'tts-1', + input: 'Hello', + voice: 'alloy', + response_format: 'mp3', + speed: 1.0, + }), + ) + expect(result.audio).toBeTruthy() + expect(result.contentType).toBe('audio/mpeg') + expect(result.format).toBe('mp3') + }) + + it('rejects out-of-range speed via base validator', async () => { + const adapter = new OpenAICompatibleTTSAdapter(config, 'tts-1') + await expect( + adapter.generateSpeech({ + logger: testLogger, + model: 'tts-1', + text: 'Hello', + speed: 5.0, + }), + ).rejects.toThrow('Speed') + }) +}) + +describe('OpenAICompatibleVideoAdapter', () => { + it('createVideoJob forwards model/prompt/size/duration and returns jobId', async () => { + mockVideosCreate.mockResolvedValue({ id: 'job-123' }) + + const adapter = new OpenAICompatibleVideoAdapter(config, 'sora-2') + const result = await adapter.createVideoJob({ + logger: testLogger, + model: 'sora-2', + prompt: 'a sunset', + size: '1080x1920', + duration: 4, + }) + + expect(mockVideosCreate).toHaveBeenCalledWith( + expect.objectContaining({ + model: 'sora-2', + prompt: 'a sunset', + size: '1080x1920', + seconds: '4', + }), + ) + expect(result.jobId).toBe('job-123') + }) + + it('getVideoStatus maps SDK status strings to the AG-UI vocabulary', async () => { + mockVideosRetrieve.mockResolvedValue({ + id: 'job-123', + status: 'queued', + progress: 5, + }) + + const adapter = new OpenAICompatibleVideoAdapter(config, 'sora-2') + const status = await adapter.getVideoStatus('job-123') + + expect(status.status).toBe('pending') + expect(status.progress).toBe(5) + }) + + it('getVideoUrl returns the URL directly when retrieve() exposes one', async () => { + mockVideosRetrieve.mockResolvedValue({ + id: 'job-123', + url: 'https://cdn.example.com/job-123.mp4', + expires_at: 1700000000, + }) + + const adapter = new OpenAICompatibleVideoAdapter(config, 'sora-2') + const result = await adapter.getVideoUrl('job-123') + + expect(result.url).toBe('https://cdn.example.com/job-123.mp4') + expect(result.expiresAt).toBeInstanceOf(Date) + }) +}) diff --git a/packages/typescript/openai-base/tests/responses-text.test.ts b/packages/typescript/openai-base/tests/responses-text.test.ts new file mode 100644 index 000000000..abf9729f2 --- /dev/null +++ b/packages/typescript/openai-base/tests/responses-text.test.ts @@ -0,0 +1,1590 @@ +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest' +import { OpenAICompatibleResponsesTextAdapter } from '../src/adapters/responses-text' +import type { StreamChunk, Tool } from '@tanstack/ai' +import { resolveDebugOption } from '@tanstack/ai/adapter-internals' + +const testLogger = resolveDebugOption(false) + +// Declare mockCreate at module level +let mockResponsesCreate: ReturnType + +// Mock the OpenAI SDK +vi.mock('openai', () => { + return { + default: class { + responses = { + create: (...args: Array) => mockResponsesCreate(...args), + } + }, + } +}) + +// Helper to create async iterable from chunks +function createAsyncIterable(chunks: Array): AsyncIterable { + return { + [Symbol.asyncIterator]() { + let index = 0 + return { + async next() { + if (index < chunks.length) { + return { value: chunks[index++]!, done: false } + } + return { value: undefined as T, done: true } + }, + } + }, + } +} + +// Helper to setup the mock SDK client for streaming/non-streaming responses +function setupMockResponsesClient( + streamChunks: Array>, + nonStreamResponse?: Record, +) { + mockResponsesCreate = vi.fn().mockImplementation((params) => { + if (params.stream) { + return Promise.resolve(createAsyncIterable(streamChunks)) + } + return Promise.resolve(nonStreamResponse) + }) +} + +const testConfig = { + apiKey: 'test-api-key', + baseURL: 'https://api.test-provider.com/v1', +} + +const weatherTool: Tool = { + name: 'lookup_weather', + description: 'Return the forecast for a location', +} + +describe('OpenAICompatibleResponsesTextAdapter', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + vi.unstubAllEnvs() + }) + + describe('instantiation', () => { + it('creates an adapter with default name', () => { + const adapter = new OpenAICompatibleResponsesTextAdapter( + testConfig, + 'test-model', + ) + + expect(adapter).toBeDefined() + expect(adapter.kind).toBe('text') + expect(adapter.name).toBe('openai-compatible-responses') + expect(adapter.model).toBe('test-model') + }) + + it('creates an adapter with custom name', () => { + const adapter = new OpenAICompatibleResponsesTextAdapter( + testConfig, + 'test-model', + 'my-provider', + ) + + expect(adapter).toBeDefined() + expect(adapter.name).toBe('my-provider') + }) + + it('creates an adapter with custom baseURL', () => { + const adapter = new OpenAICompatibleResponsesTextAdapter( + { + apiKey: 'test-key', + baseURL: 'https://custom.api.example.com/v1', + }, + 'custom-model', + ) + + expect(adapter).toBeDefined() + expect(adapter.model).toBe('custom-model') + }) + }) + + describe('streaming event sequence', () => { + it('emits RUN_STARTED as the first event', async () => { + const streamChunks = [ + { + type: 'response.created', + response: { + id: 'resp-123', + model: 'test-model', + status: 'in_progress', + }, + }, + { + type: 'response.output_text.delta', + delta: 'Hello', + }, + { + type: 'response.completed', + response: { + id: 'resp-123', + model: 'test-model', + status: 'completed', + output: [], + usage: { + input_tokens: 5, + output_tokens: 1, + total_tokens: 6, + }, + }, + }, + ] + + setupMockResponsesClient(streamChunks) + const adapter = new OpenAICompatibleResponsesTextAdapter( + testConfig, + 'test-model', + ) + const chunks: Array = [] + + for await (const chunk of adapter.chatStream({ + logger: testLogger, + model: 'test-model', + messages: [{ role: 'user', content: 'Hello' }], + })) { + chunks.push(chunk) + } + + expect(chunks[0]?.type).toBe('RUN_STARTED') + if (chunks[0]?.type === 'RUN_STARTED') { + expect(chunks[0].runId).toBeDefined() + expect(chunks[0].model).toBe('test-model') + } + }) + + it('emits TEXT_MESSAGE_START before TEXT_MESSAGE_CONTENT on output_text.delta', async () => { + const streamChunks = [ + { + type: 'response.created', + response: { + id: 'resp-123', + model: 'test-model', + status: 'in_progress', + }, + }, + { + type: 'response.output_text.delta', + delta: 'Hello', + }, + { + type: 'response.completed', + response: { + id: 'resp-123', + model: 'test-model', + status: 'completed', + output: [], + usage: { + input_tokens: 5, + output_tokens: 1, + total_tokens: 6, + }, + }, + }, + ] + + setupMockResponsesClient(streamChunks) + const adapter = new OpenAICompatibleResponsesTextAdapter( + testConfig, + 'test-model', + ) + const chunks: Array = [] + + for await (const chunk of adapter.chatStream({ + logger: testLogger, + model: 'test-model', + messages: [{ role: 'user', content: 'Hello' }], + })) { + chunks.push(chunk) + } + + const textStartIndex = chunks.findIndex( + (c) => c.type === 'TEXT_MESSAGE_START', + ) + const textContentIndex = chunks.findIndex( + (c) => c.type === 'TEXT_MESSAGE_CONTENT', + ) + + expect(textStartIndex).toBeGreaterThan(-1) + expect(textContentIndex).toBeGreaterThan(-1) + expect(textStartIndex).toBeLessThan(textContentIndex) + + const textStart = chunks[textStartIndex] + if (textStart?.type === 'TEXT_MESSAGE_START') { + expect(textStart.messageId).toBeDefined() + expect(textStart.role).toBe('assistant') + } + }) + + it('emits proper AG-UI event sequence: RUN_STARTED -> TEXT_MESSAGE_START -> TEXT_MESSAGE_CONTENT -> TEXT_MESSAGE_END -> RUN_FINISHED', async () => { + const streamChunks = [ + { + type: 'response.created', + response: { + id: 'resp-123', + model: 'test-model', + status: 'in_progress', + }, + }, + { + type: 'response.output_text.delta', + delta: 'Hello world', + }, + { + type: 'response.completed', + response: { + id: 'resp-123', + model: 'test-model', + status: 'completed', + output: [], + usage: { + input_tokens: 5, + output_tokens: 2, + total_tokens: 7, + }, + }, + }, + ] + + setupMockResponsesClient(streamChunks) + const adapter = new OpenAICompatibleResponsesTextAdapter( + testConfig, + 'test-model', + ) + const chunks: Array = [] + + for await (const chunk of adapter.chatStream({ + logger: testLogger, + model: 'test-model', + messages: [{ role: 'user', content: 'Hello' }], + })) { + chunks.push(chunk) + } + + // Verify proper AG-UI event sequence + const eventTypes = chunks.map((c) => c.type) + + // Should start with RUN_STARTED + expect(eventTypes[0]).toBe('RUN_STARTED') + + // Should have TEXT_MESSAGE_START before TEXT_MESSAGE_CONTENT + const textStartIndex = eventTypes.indexOf('TEXT_MESSAGE_START') + const textContentIndex = eventTypes.indexOf('TEXT_MESSAGE_CONTENT') + expect(textStartIndex).toBeGreaterThan(-1) + expect(textContentIndex).toBeGreaterThan(textStartIndex) + + // Should have TEXT_MESSAGE_END before RUN_FINISHED + const textEndIndex = eventTypes.indexOf('TEXT_MESSAGE_END') + const runFinishedIndex = eventTypes.indexOf('RUN_FINISHED') + expect(textEndIndex).toBeGreaterThan(-1) + expect(runFinishedIndex).toBeGreaterThan(textEndIndex) + + // Verify RUN_FINISHED has proper data + const runFinishedChunk = chunks.find((c) => c.type === 'RUN_FINISHED') + if (runFinishedChunk?.type === 'RUN_FINISHED') { + expect(runFinishedChunk.finishReason).toBe('stop') + expect(runFinishedChunk.usage).toBeDefined() + } + }) + + it('emits TEXT_MESSAGE_END and RUN_FINISHED at the end with usage data', async () => { + const streamChunks = [ + { + type: 'response.created', + response: { + id: 'resp-123', + model: 'test-model', + status: 'in_progress', + }, + }, + { + type: 'response.output_text.delta', + delta: 'Hello', + }, + { + type: 'response.completed', + response: { + id: 'resp-123', + model: 'test-model', + status: 'completed', + output: [], + usage: { + input_tokens: 5, + output_tokens: 1, + total_tokens: 6, + }, + }, + }, + ] + + setupMockResponsesClient(streamChunks) + const adapter = new OpenAICompatibleResponsesTextAdapter( + testConfig, + 'test-model', + ) + const chunks: Array = [] + + for await (const chunk of adapter.chatStream({ + logger: testLogger, + model: 'test-model', + messages: [{ role: 'user', content: 'Hello' }], + })) { + chunks.push(chunk) + } + + const textEndChunk = chunks.find((c) => c.type === 'TEXT_MESSAGE_END') + expect(textEndChunk).toBeDefined() + if (textEndChunk?.type === 'TEXT_MESSAGE_END') { + expect(textEndChunk.messageId).toBeDefined() + } + + const runFinishedChunk = chunks.find((c) => c.type === 'RUN_FINISHED') + expect(runFinishedChunk).toBeDefined() + if (runFinishedChunk?.type === 'RUN_FINISHED') { + expect(runFinishedChunk.runId).toBeDefined() + expect(runFinishedChunk.finishReason).toBe('stop') + expect(runFinishedChunk.usage).toMatchObject({ + promptTokens: 5, + completionTokens: 1, + totalTokens: 6, + }) + } + }) + + it('streams content with correct accumulated values', async () => { + const streamChunks = [ + { + type: 'response.created', + response: { + id: 'resp-123', + model: 'test-model', + status: 'in_progress', + }, + }, + { + type: 'response.output_text.delta', + delta: 'Hello ', + }, + { + type: 'response.output_text.delta', + delta: 'world', + }, + { + type: 'response.completed', + response: { + id: 'resp-123', + model: 'test-model', + status: 'completed', + output: [], + usage: { + input_tokens: 5, + output_tokens: 2, + total_tokens: 7, + }, + }, + }, + ] + + setupMockResponsesClient(streamChunks) + const adapter = new OpenAICompatibleResponsesTextAdapter( + testConfig, + 'test-model', + ) + const chunks: Array = [] + + for await (const chunk of adapter.chatStream({ + logger: testLogger, + model: 'test-model', + messages: [{ role: 'user', content: 'Say hello' }], + })) { + chunks.push(chunk) + } + + // Check TEXT_MESSAGE_CONTENT events have correct accumulated content + const contentChunks = chunks.filter( + (c) => c.type === 'TEXT_MESSAGE_CONTENT', + ) + expect(contentChunks.length).toBe(2) + + const firstContent = contentChunks[0] + if (firstContent?.type === 'TEXT_MESSAGE_CONTENT') { + expect(firstContent.delta).toBe('Hello ') + expect(firstContent.content).toBe('Hello ') + } + + const secondContent = contentChunks[1] + if (secondContent?.type === 'TEXT_MESSAGE_CONTENT') { + expect(secondContent.delta).toBe('world') + expect(secondContent.content).toBe('Hello world') + } + }) + }) + + describe('reasoning/thinking tokens', () => { + it('emits STEP_STARTED and STEP_FINISHED for reasoning_text.delta', async () => { + const streamChunks = [ + { + type: 'response.created', + response: { + id: 'resp-123', + model: 'test-model', + status: 'in_progress', + }, + }, + { + type: 'response.reasoning_text.delta', + delta: 'Let me think about this...', + }, + { + type: 'response.reasoning_text.delta', + delta: ' The answer is clear.', + }, + { + type: 'response.output_text.delta', + delta: 'The answer is 42.', + }, + { + type: 'response.completed', + response: { + id: 'resp-123', + model: 'test-model', + status: 'completed', + output: [], + usage: { + input_tokens: 10, + output_tokens: 20, + total_tokens: 30, + }, + }, + }, + ] + + setupMockResponsesClient(streamChunks) + const adapter = new OpenAICompatibleResponsesTextAdapter( + testConfig, + 'test-model', + ) + const chunks: Array = [] + + for await (const chunk of adapter.chatStream({ + logger: testLogger, + model: 'test-model', + messages: [{ role: 'user', content: 'What is the meaning of life?' }], + })) { + chunks.push(chunk) + } + + const eventTypes = chunks.map((c) => c.type) + + // Should have STEP_STARTED for reasoning + const stepStartIndex = eventTypes.indexOf('STEP_STARTED') + expect(stepStartIndex).toBeGreaterThan(-1) + + const stepStart = chunks[stepStartIndex] + if (stepStart?.type === 'STEP_STARTED') { + expect(stepStart.stepId).toBeDefined() + expect(stepStart.stepType).toBe('thinking') + } + + // Should have STEP_FINISHED events for reasoning deltas + const stepFinished = chunks.filter((c) => c.type === 'STEP_FINISHED') + expect(stepFinished.length).toBe(2) + + // Check accumulated reasoning + if (stepFinished[0]?.type === 'STEP_FINISHED') { + expect(stepFinished[0].delta).toBe('Let me think about this...') + expect(stepFinished[0].content).toBe('Let me think about this...') + } + if (stepFinished[1]?.type === 'STEP_FINISHED') { + expect(stepFinished[1].delta).toBe(' The answer is clear.') + expect(stepFinished[1].content).toBe( + 'Let me think about this... The answer is clear.', + ) + } + + // Should also have text content + const textContent = chunks.filter( + (c) => c.type === 'TEXT_MESSAGE_CONTENT', + ) + expect(textContent.length).toBe(1) + }) + + it('emits STEP_STARTED and STEP_FINISHED for reasoning_summary_text.delta', async () => { + const streamChunks = [ + { + type: 'response.created', + response: { + id: 'resp-123', + model: 'test-model', + status: 'in_progress', + }, + }, + { + type: 'response.reasoning_summary_text.delta', + delta: 'Summary of reasoning...', + }, + { + type: 'response.output_text.delta', + delta: 'Final answer.', + }, + { + type: 'response.completed', + response: { + id: 'resp-123', + model: 'test-model', + status: 'completed', + output: [], + usage: { + input_tokens: 5, + output_tokens: 2, + total_tokens: 7, + }, + }, + }, + ] + + setupMockResponsesClient(streamChunks) + const adapter = new OpenAICompatibleResponsesTextAdapter( + testConfig, + 'test-model', + ) + const chunks: Array = [] + + for await (const chunk of adapter.chatStream({ + logger: testLogger, + model: 'test-model', + messages: [{ role: 'user', content: 'Explain' }], + })) { + chunks.push(chunk) + } + + const stepStart = chunks.find((c) => c.type === 'STEP_STARTED') + expect(stepStart).toBeDefined() + if (stepStart?.type === 'STEP_STARTED') { + expect(stepStart.stepType).toBe('thinking') + } + + const stepFinished = chunks.filter((c) => c.type === 'STEP_FINISHED') + expect(stepFinished.length).toBe(1) + if (stepFinished[0]?.type === 'STEP_FINISHED') { + expect(stepFinished[0].delta).toBe('Summary of reasoning...') + } + }) + }) + + describe('tool call events', () => { + it('emits TOOL_CALL_START -> TOOL_CALL_ARGS -> TOOL_CALL_END', async () => { + const streamChunks = [ + { + type: 'response.created', + response: { + id: 'resp-456', + model: 'test-model', + status: 'in_progress', + }, + }, + { + type: 'response.output_item.added', + output_index: 0, + item: { + type: 'function_call', + id: 'call_abc123', + name: 'lookup_weather', + }, + }, + { + type: 'response.function_call_arguments.delta', + item_id: 'call_abc123', + delta: '{"location":', + }, + { + type: 'response.function_call_arguments.delta', + item_id: 'call_abc123', + delta: '"Berlin"}', + }, + { + type: 'response.function_call_arguments.done', + item_id: 'call_abc123', + arguments: '{"location":"Berlin"}', + }, + { + type: 'response.completed', + response: { + id: 'resp-456', + model: 'test-model', + status: 'completed', + output: [ + { + type: 'function_call', + id: 'call_abc123', + name: 'lookup_weather', + arguments: '{"location":"Berlin"}', + }, + ], + usage: { + input_tokens: 10, + output_tokens: 5, + total_tokens: 15, + }, + }, + }, + ] + + setupMockResponsesClient(streamChunks) + const adapter = new OpenAICompatibleResponsesTextAdapter( + testConfig, + 'test-model', + ) + const chunks: Array = [] + + for await (const chunk of adapter.chatStream({ + logger: testLogger, + model: 'test-model', + messages: [{ role: 'user', content: 'Weather in Berlin?' }], + tools: [weatherTool], + })) { + chunks.push(chunk) + } + + // Check AG-UI tool events + const toolStartChunk = chunks.find((c) => c.type === 'TOOL_CALL_START') + expect(toolStartChunk).toBeDefined() + if (toolStartChunk?.type === 'TOOL_CALL_START') { + expect(toolStartChunk.toolCallId).toBe('call_abc123') + expect(toolStartChunk.toolName).toBe('lookup_weather') + expect(toolStartChunk.index).toBe(0) + } + + const toolArgsChunks = chunks.filter((c) => c.type === 'TOOL_CALL_ARGS') + expect(toolArgsChunks.length).toBe(2) + if (toolArgsChunks[0]?.type === 'TOOL_CALL_ARGS') { + expect(toolArgsChunks[0].delta).toBe('{"location":') + } + if (toolArgsChunks[1]?.type === 'TOOL_CALL_ARGS') { + expect(toolArgsChunks[1].delta).toBe('"Berlin"}') + } + + const toolEndChunk = chunks.find((c) => c.type === 'TOOL_CALL_END') + expect(toolEndChunk).toBeDefined() + if (toolEndChunk?.type === 'TOOL_CALL_END') { + expect(toolEndChunk.toolCallId).toBe('call_abc123') + expect(toolEndChunk.toolName).toBe('lookup_weather') + expect(toolEndChunk.input).toEqual({ location: 'Berlin' }) + } + + // Check finish reason is tool_calls when output contains function_call items + const runFinishedChunk = chunks.find((c) => c.type === 'RUN_FINISHED') + if (runFinishedChunk?.type === 'RUN_FINISHED') { + expect(runFinishedChunk.finishReason).toBe('tool_calls') + } + }) + + it('handles multiple parallel tool calls', async () => { + const streamChunks = [ + { + type: 'response.created', + response: { + id: 'resp-789', + model: 'test-model', + status: 'in_progress', + }, + }, + { + type: 'response.output_item.added', + output_index: 0, + item: { + type: 'function_call', + id: 'call_1', + name: 'lookup_weather', + }, + }, + { + type: 'response.output_item.added', + output_index: 1, + item: { + type: 'function_call', + id: 'call_2', + name: 'lookup_weather', + }, + }, + { + type: 'response.function_call_arguments.delta', + item_id: 'call_1', + delta: '{"location":"Berlin"}', + }, + { + type: 'response.function_call_arguments.delta', + item_id: 'call_2', + delta: '{"location":"Paris"}', + }, + { + type: 'response.function_call_arguments.done', + item_id: 'call_1', + arguments: '{"location":"Berlin"}', + }, + { + type: 'response.function_call_arguments.done', + item_id: 'call_2', + arguments: '{"location":"Paris"}', + }, + { + type: 'response.completed', + response: { + id: 'resp-789', + model: 'test-model', + status: 'completed', + output: [ + { + type: 'function_call', + id: 'call_1', + name: 'lookup_weather', + arguments: '{"location":"Berlin"}', + }, + { + type: 'function_call', + id: 'call_2', + name: 'lookup_weather', + arguments: '{"location":"Paris"}', + }, + ], + usage: { + input_tokens: 10, + output_tokens: 10, + total_tokens: 20, + }, + }, + }, + ] + + setupMockResponsesClient(streamChunks) + const adapter = new OpenAICompatibleResponsesTextAdapter( + testConfig, + 'test-model', + ) + const chunks: Array = [] + + for await (const chunk of adapter.chatStream({ + logger: testLogger, + model: 'test-model', + messages: [ + { + role: 'user', + content: 'Weather in Berlin and Paris?', + }, + ], + tools: [weatherTool], + })) { + chunks.push(chunk) + } + + const toolStarts = chunks.filter((c) => c.type === 'TOOL_CALL_START') + expect(toolStarts.length).toBe(2) + + const toolEnds = chunks.filter((c) => c.type === 'TOOL_CALL_END') + expect(toolEnds.length).toBe(2) + + if (toolEnds[0]?.type === 'TOOL_CALL_END') { + expect(toolEnds[0].input).toEqual({ location: 'Berlin' }) + } + if (toolEnds[1]?.type === 'TOOL_CALL_END') { + expect(toolEnds[1].input).toEqual({ location: 'Paris' }) + } + }) + + it('uses the internal function_call item id for tool call correlation', async () => { + const streamChunks = [ + { + type: 'response.created', + response: { + id: 'resp-callid', + model: 'test-model', + status: 'in_progress', + }, + }, + { + type: 'response.output_item.added', + output_index: 0, + item: { + type: 'function_call', + id: 'fc_internal_001', + call_id: 'call_api_abc123', + name: 'lookup_weather', + }, + }, + { + type: 'response.function_call_arguments.delta', + item_id: 'fc_internal_001', + delta: '{"location":"Tokyo"}', + }, + { + type: 'response.function_call_arguments.done', + item_id: 'fc_internal_001', + arguments: '{"location":"Tokyo"}', + }, + { + type: 'response.completed', + response: { + id: 'resp-callid', + model: 'test-model', + status: 'completed', + output: [ + { + type: 'function_call', + id: 'fc_internal_001', + call_id: 'call_api_abc123', + name: 'lookup_weather', + arguments: '{"location":"Tokyo"}', + }, + ], + usage: { + input_tokens: 10, + output_tokens: 5, + total_tokens: 15, + }, + }, + }, + ] + + setupMockResponsesClient(streamChunks) + const adapter = new OpenAICompatibleResponsesTextAdapter( + testConfig, + 'test-model', + ) + const chunks: Array = [] + + for await (const chunk of adapter.chatStream({ + logger: testLogger, + model: 'test-model', + messages: [{ role: 'user', content: 'Weather in Tokyo?' }], + tools: [weatherTool], + })) { + chunks.push(chunk) + } + + // TOOL_CALL_* events should use the internal function_call item id + // (matches main's OpenAI adapter behavior; the agent loop carries this + // id back as `toolCallId` on the tool ModelMessage, which the Responses + // API accepts as `call_id` for function_call_output). + const toolStart = chunks.find((c) => c.type === 'TOOL_CALL_START') + expect(toolStart).toBeDefined() + if (toolStart?.type === 'TOOL_CALL_START') { + expect(toolStart.toolCallId).toBe('fc_internal_001') + } + + const toolArgs = chunks.filter((c) => c.type === 'TOOL_CALL_ARGS') + expect(toolArgs.length).toBeGreaterThan(0) + if (toolArgs[0]?.type === 'TOOL_CALL_ARGS') { + expect(toolArgs[0].toolCallId).toBe('fc_internal_001') + } + + const toolEnd = chunks.find((c) => c.type === 'TOOL_CALL_END') + expect(toolEnd).toBeDefined() + if (toolEnd?.type === 'TOOL_CALL_END') { + expect(toolEnd.toolCallId).toBe('fc_internal_001') + } + }) + }) + + describe('content_part events', () => { + it('emits TEXT_MESSAGE_START on content_part.added with output_text', async () => { + const streamChunks = [ + { + type: 'response.created', + response: { + id: 'resp-123', + model: 'test-model', + status: 'in_progress', + }, + }, + { + type: 'response.content_part.added', + part: { + type: 'output_text', + text: 'It is sunny', + }, + }, + { + type: 'response.completed', + response: { + id: 'resp-123', + model: 'test-model', + status: 'completed', + output: [], + usage: { + input_tokens: 5, + output_tokens: 3, + total_tokens: 8, + }, + }, + }, + ] + + setupMockResponsesClient(streamChunks) + const adapter = new OpenAICompatibleResponsesTextAdapter( + testConfig, + 'test-model', + ) + const chunks: Array = [] + + for await (const chunk of adapter.chatStream({ + logger: testLogger, + model: 'test-model', + messages: [{ role: 'user', content: 'Weather?' }], + })) { + chunks.push(chunk) + } + + const eventTypes = chunks.map((c) => c.type) + expect(eventTypes).toContain('TEXT_MESSAGE_START') + expect(eventTypes).toContain('TEXT_MESSAGE_CONTENT') + + // TEXT_MESSAGE_START should be before TEXT_MESSAGE_CONTENT + const startIdx = eventTypes.indexOf('TEXT_MESSAGE_START') + const contentIdx = eventTypes.indexOf('TEXT_MESSAGE_CONTENT') + expect(startIdx).toBeLessThan(contentIdx) + }) + + it('skips content_part.done when deltas were already streamed', async () => { + const streamChunks = [ + { + type: 'response.created', + response: { + id: 'resp-123', + model: 'test-model', + status: 'in_progress', + }, + }, + { + type: 'response.output_text.delta', + delta: 'Hello', + }, + { + type: 'response.output_text.delta', + delta: ' world', + }, + { + type: 'response.content_part.done', + part: { + type: 'output_text', + text: 'Hello world', + }, + }, + { + type: 'response.completed', + response: { + id: 'resp-123', + model: 'test-model', + status: 'completed', + output: [], + usage: { + input_tokens: 5, + output_tokens: 2, + total_tokens: 7, + }, + }, + }, + ] + + setupMockResponsesClient(streamChunks) + const adapter = new OpenAICompatibleResponsesTextAdapter( + testConfig, + 'test-model', + ) + const chunks: Array = [] + + for await (const chunk of adapter.chatStream({ + logger: testLogger, + model: 'test-model', + messages: [{ role: 'user', content: 'Hello' }], + })) { + chunks.push(chunk) + } + + // Should only have 2 TEXT_MESSAGE_CONTENT events (from deltas), not 3 + const contentChunks = chunks.filter( + (c) => c.type === 'TEXT_MESSAGE_CONTENT', + ) + expect(contentChunks.length).toBe(2) + }) + }) + + describe('error handling', () => { + it('emits RUN_ERROR on stream error', async () => { + const streamChunks = [ + { + type: 'response.created', + response: { + id: 'resp-123', + model: 'test-model', + status: 'in_progress', + }, + }, + { + type: 'response.output_text.delta', + delta: 'Hello', + }, + ] + + // Create an async iterable that throws mid-stream + const errorIterable = { + [Symbol.asyncIterator]() { + let index = 0 + return { + async next() { + if (index < streamChunks.length) { + return { value: streamChunks[index++]!, done: false } + } + throw new Error('Stream interrupted') + }, + } + }, + } + + mockResponsesCreate = vi.fn().mockResolvedValue(errorIterable) + + const adapter = new OpenAICompatibleResponsesTextAdapter( + testConfig, + 'test-model', + ) + const chunks: Array = [] + + for await (const chunk of adapter.chatStream({ + logger: testLogger, + model: 'test-model', + messages: [{ role: 'user', content: 'Hello' }], + })) { + chunks.push(chunk) + } + + // Should emit RUN_ERROR + const runErrorChunk = chunks.find((c) => c.type === 'RUN_ERROR') + expect(runErrorChunk).toBeDefined() + if (runErrorChunk?.type === 'RUN_ERROR') { + expect(runErrorChunk.error.message).toBe('Stream interrupted') + } + }) + + it('emits RUN_STARTED then RUN_ERROR when client.create throws', async () => { + mockResponsesCreate = vi + .fn() + .mockRejectedValue(new Error('API key invalid')) + + const adapter = new OpenAICompatibleResponsesTextAdapter( + testConfig, + 'test-model', + ) + const chunks: Array = [] + + for await (const chunk of adapter.chatStream({ + logger: testLogger, + model: 'test-model', + messages: [{ role: 'user', content: 'Hello' }], + })) { + chunks.push(chunk) + } + + // Should have RUN_STARTED followed by RUN_ERROR + expect(chunks.length).toBe(2) + expect(chunks[0]?.type).toBe('RUN_STARTED') + expect(chunks[1]?.type).toBe('RUN_ERROR') + if (chunks[1]?.type === 'RUN_ERROR') { + expect(chunks[1].error.message).toBe('API key invalid') + } + }) + + it('emits RUN_ERROR on response.failed event', async () => { + const streamChunks = [ + { + type: 'response.failed', + response: { + id: 'resp-123', + model: 'test-model', + status: 'failed', + error: { + message: 'Content policy violation', + code: 'content_filter', + }, + }, + }, + ] + + setupMockResponsesClient(streamChunks) + const adapter = new OpenAICompatibleResponsesTextAdapter( + testConfig, + 'test-model', + ) + const chunks: Array = [] + + for await (const chunk of adapter.chatStream({ + logger: testLogger, + model: 'test-model', + messages: [{ role: 'user', content: 'bad content' }], + })) { + chunks.push(chunk) + } + + const errorChunk = chunks.find((c) => c.type === 'RUN_ERROR') + expect(errorChunk).toBeDefined() + if (errorChunk?.type === 'RUN_ERROR') { + expect(errorChunk.error.message).toBe('Content policy violation') + } + }) + + it('emits RUN_ERROR on response.incomplete event', async () => { + const streamChunks = [ + { + type: 'response.incomplete', + response: { + id: 'resp-123', + model: 'test-model', + status: 'incomplete', + incomplete_details: { + reason: 'max_output_tokens', + }, + }, + }, + ] + + setupMockResponsesClient(streamChunks) + const adapter = new OpenAICompatibleResponsesTextAdapter( + testConfig, + 'test-model', + ) + const chunks: Array = [] + + for await (const chunk of adapter.chatStream({ + logger: testLogger, + model: 'test-model', + messages: [{ role: 'user', content: 'Write a long story' }], + })) { + chunks.push(chunk) + } + + const errorChunks = chunks.filter((c) => c.type === 'RUN_ERROR') + expect(errorChunks.length).toBeGreaterThan(0) + const incompleteError = errorChunks.find( + (c) => + c.type === 'RUN_ERROR' && c.error.message === 'max_output_tokens', + ) + expect(incompleteError).toBeDefined() + }) + + it('emits RUN_ERROR on error event type', async () => { + const streamChunks = [ + { + type: 'response.created', + response: { + id: 'resp-123', + model: 'test-model', + status: 'in_progress', + }, + }, + { + type: 'error', + message: 'Rate limit exceeded', + code: 'rate_limit', + }, + ] + + setupMockResponsesClient(streamChunks) + const adapter = new OpenAICompatibleResponsesTextAdapter( + testConfig, + 'test-model', + ) + const chunks: Array = [] + + for await (const chunk of adapter.chatStream({ + logger: testLogger, + model: 'test-model', + messages: [{ role: 'user', content: 'Hello' }], + })) { + chunks.push(chunk) + } + + const errorChunk = chunks.find( + (c) => + c.type === 'RUN_ERROR' && c.error.message === 'Rate limit exceeded', + ) + expect(errorChunk).toBeDefined() + if (errorChunk?.type === 'RUN_ERROR') { + expect(errorChunk.error.code).toBe('rate_limit') + } + }) + }) + + describe('structured output', () => { + it('generates structured output and parses JSON response', async () => { + const nonStreamResponse = { + output: [ + { + type: 'message', + content: [ + { + type: 'output_text', + text: '{"name":"Alice","age":30}', + }, + ], + }, + ], + } + + setupMockResponsesClient([], nonStreamResponse) + + const adapter = new OpenAICompatibleResponsesTextAdapter( + testConfig, + 'test-model', + ) + + const result = await adapter.structuredOutput({ + chatOptions: { + logger: testLogger, + model: 'test-model', + messages: [{ role: 'user', content: 'Give me a person object' }], + }, + outputSchema: { + type: 'object', + properties: { + name: { type: 'string' }, + age: { type: 'number' }, + }, + required: ['name', 'age'], + }, + }) + + expect(result.data).toEqual({ name: 'Alice', age: 30 }) + expect(result.rawText).toBe('{"name":"Alice","age":30}') + + // Verify text.format was passed (Responses API format) + expect(mockResponsesCreate).toHaveBeenCalledWith( + expect.objectContaining({ + stream: false, + text: expect.objectContaining({ + format: expect.objectContaining({ + type: 'json_schema', + name: 'structured_output', + strict: true, + }), + }), + }), + expect.anything(), + ) + }) + + it('transforms null values to undefined', async () => { + const nonStreamResponse = { + output: [ + { + type: 'message', + content: [ + { + type: 'output_text', + text: '{"name":"Alice","nickname":null}', + }, + ], + }, + ], + } + + setupMockResponsesClient([], nonStreamResponse) + + const adapter = new OpenAICompatibleResponsesTextAdapter( + testConfig, + 'test-model', + ) + + const result = await adapter.structuredOutput({ + chatOptions: { + logger: testLogger, + model: 'test-model', + messages: [{ role: 'user', content: 'Give me a person object' }], + }, + outputSchema: { + type: 'object', + properties: { + name: { type: 'string' }, + nickname: { type: 'string' }, + }, + required: ['name'], + }, + }) + + // null should be transformed to undefined + expect((result.data as any).name).toBe('Alice') + expect((result.data as any).nickname).toBeUndefined() + }) + + it('throws on invalid JSON response', async () => { + const nonStreamResponse = { + output: [ + { + type: 'message', + content: [ + { + type: 'output_text', + text: 'not valid json', + }, + ], + }, + ], + } + + setupMockResponsesClient([], nonStreamResponse) + + const adapter = new OpenAICompatibleResponsesTextAdapter( + testConfig, + 'test-model', + ) + + await expect( + adapter.structuredOutput({ + chatOptions: { + logger: testLogger, + model: 'test-model', + messages: [{ role: 'user', content: 'Give me a person object' }], + }, + outputSchema: { + type: 'object', + properties: { + name: { type: 'string' }, + }, + required: ['name'], + }, + }), + ).rejects.toThrow('Failed to parse structured output as JSON') + }) + }) + + describe('request mapping', () => { + it('maps options to Responses API payload format', async () => { + const streamChunks = [ + { + type: 'response.created', + response: { + id: 'resp-123', + model: 'test-model', + status: 'in_progress', + }, + }, + { + type: 'response.completed', + response: { + id: 'resp-123', + model: 'test-model', + status: 'completed', + output: [], + usage: { + input_tokens: 5, + output_tokens: 1, + total_tokens: 6, + }, + }, + }, + ] + + setupMockResponsesClient(streamChunks) + const adapter = new OpenAICompatibleResponsesTextAdapter( + testConfig, + 'test-model', + ) + + const chunks: Array = [] + for await (const chunk of adapter.chatStream({ + logger: testLogger, + model: 'test-model', + messages: [{ role: 'user', content: 'Hello' }], + temperature: 0.5, + topP: 0.9, + maxTokens: 1024, + systemPrompts: ['Be helpful'], + tools: [weatherTool], + })) { + chunks.push(chunk) + } + + expect(mockResponsesCreate).toHaveBeenCalledTimes(1) + const [payload] = mockResponsesCreate.mock.calls[0] + + // Verify Responses API field names + expect(payload).toMatchObject({ + model: 'test-model', + temperature: 0.5, + top_p: 0.9, + max_output_tokens: 1024, + stream: true, + instructions: 'Be helpful', + }) + + // Responses API uses 'input' instead of 'messages' + expect(payload.input).toBeDefined() + expect(Array.isArray(payload.input)).toBe(true) + + // Verify tools are included + expect(payload.tools).toBeDefined() + expect(Array.isArray(payload.tools)).toBe(true) + expect(payload.tools.length).toBe(1) + expect(payload.tools[0].type).toBe('function') + expect(payload.tools[0].name).toBe('lookup_weather') + }) + + it('converts user messages to input_text format', async () => { + const streamChunks = [ + { + type: 'response.created', + response: { + id: 'resp-123', + model: 'test-model', + status: 'in_progress', + }, + }, + { + type: 'response.completed', + response: { + id: 'resp-123', + model: 'test-model', + status: 'completed', + output: [], + usage: { + input_tokens: 5, + output_tokens: 1, + total_tokens: 6, + }, + }, + }, + ] + + setupMockResponsesClient(streamChunks) + const adapter = new OpenAICompatibleResponsesTextAdapter( + testConfig, + 'test-model', + ) + + const chunks: Array = [] + for await (const chunk of adapter.chatStream({ + logger: testLogger, + model: 'test-model', + messages: [{ role: 'user', content: 'Hello world' }], + })) { + chunks.push(chunk) + } + + const [payload] = mockResponsesCreate.mock.calls[0] + expect(payload.input).toEqual([ + { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'Hello world' }], + }, + ]) + }) + + it('converts assistant messages with tool calls to function_call format', async () => { + const streamChunks = [ + { + type: 'response.created', + response: { + id: 'resp-123', + model: 'test-model', + status: 'in_progress', + }, + }, + { + type: 'response.completed', + response: { + id: 'resp-123', + model: 'test-model', + status: 'completed', + output: [], + usage: { + input_tokens: 10, + output_tokens: 1, + total_tokens: 11, + }, + }, + }, + ] + + setupMockResponsesClient(streamChunks) + const adapter = new OpenAICompatibleResponsesTextAdapter( + testConfig, + 'test-model', + ) + + const chunks: Array = [] + for await (const chunk of adapter.chatStream({ + logger: testLogger, + model: 'test-model', + messages: [ + { + role: 'assistant', + content: 'Let me check', + toolCalls: [ + { + id: 'call_123', + type: 'function', + function: { + name: 'lookup_weather', + arguments: '{"location":"Berlin"}', + }, + }, + ], + }, + { + role: 'tool', + toolCallId: 'call_123', + content: '{"temp":72}', + }, + ], + })) { + chunks.push(chunk) + } + + const [payload] = mockResponsesCreate.mock.calls[0] + // Should have function_call, message, and function_call_output + expect(payload.input).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'function_call', + call_id: 'call_123', + name: 'lookup_weather', + arguments: '{"location":"Berlin"}', + }), + expect.objectContaining({ + type: 'message', + role: 'assistant', + content: 'Let me check', + }), + expect.objectContaining({ + type: 'function_call_output', + call_id: 'call_123', + output: '{"temp":72}', + }), + ]), + ) + }) + }) + + describe('subclassing', () => { + it('allows subclassing with custom name', () => { + class MyProviderAdapter extends OpenAICompatibleResponsesTextAdapter { + constructor(apiKey: string, model: string) { + super( + { apiKey, baseURL: 'https://my-provider.com/v1' }, + model, + 'my-provider', + ) + } + } + + const adapter = new MyProviderAdapter('test-key', 'my-model') + expect(adapter.name).toBe('my-provider') + expect(adapter.kind).toBe('text') + expect(adapter.model).toBe('my-model') + }) + }) +}) diff --git a/packages/typescript/openai-base/tests/schema-converter.test.ts b/packages/typescript/openai-base/tests/schema-converter.test.ts new file mode 100644 index 000000000..a8fc93bef --- /dev/null +++ b/packages/typescript/openai-base/tests/schema-converter.test.ts @@ -0,0 +1,248 @@ +import { describe, expect, it } from 'vitest' +import { makeStructuredOutputCompatible } from '../src/utils/schema-converter' + +describe('makeStructuredOutputCompatible', () => { + it('should add additionalProperties: false to object schemas', () => { + const schema = { + type: 'object', + properties: { + name: { type: 'string' }, + }, + required: ['name'], + } + + const result = makeStructuredOutputCompatible(schema, ['name']) + expect(result.additionalProperties).toBe(false) + }) + + it('should make all properties required', () => { + const schema = { + type: 'object', + properties: { + name: { type: 'string' }, + age: { type: 'number' }, + }, + required: ['name'], + } + + const result = makeStructuredOutputCompatible(schema, ['name']) + expect(result.required).toEqual(['name', 'age']) + }) + + it('should make optional fields nullable', () => { + const schema = { + type: 'object', + properties: { + name: { type: 'string' }, + nickname: { type: 'string' }, + }, + required: ['name'], + } + + const result = makeStructuredOutputCompatible(schema, ['name']) + expect(result.properties.name.type).toBe('string') + expect(result.properties.nickname.type).toEqual(['string', 'null']) + }) + + it('should handle anyOf (union types) by transforming each variant', () => { + const schema = { + type: 'object', + properties: { + u: { + anyOf: [ + { + type: 'object', + properties: { a: { type: 'string' } }, + required: ['a'], + }, + { + type: 'object', + properties: { b: { type: 'number' } }, + required: ['b'], + }, + ], + }, + }, + required: ['u'], + } + + const result = makeStructuredOutputCompatible(schema, ['u']) + + // Each variant in anyOf should have additionalProperties: false + expect(result.properties.u.anyOf[0].additionalProperties).toBe(false) + expect(result.properties.u.anyOf[1].additionalProperties).toBe(false) + + // Verify complete structure + expect(result.additionalProperties).toBe(false) + expect(result.required).toEqual(['u']) + expect(result.properties.u.anyOf).toHaveLength(2) + expect(result.properties.u.anyOf[0].required).toEqual(['a']) + expect(result.properties.u.anyOf[1].required).toEqual(['b']) + }) + + it('should handle nested objects inside anyOf', () => { + const schema = { + type: 'object', + properties: { + data: { + anyOf: [ + { + type: 'object', + properties: { + nested: { + type: 'object', + properties: { x: { type: 'string' } }, + required: ['x'], + }, + }, + required: ['nested'], + }, + ], + }, + }, + required: ['data'], + } + + const result = makeStructuredOutputCompatible(schema, ['data']) + + // The nested object inside anyOf variant should also have additionalProperties: false + expect(result.properties.data.anyOf[0].additionalProperties).toBe(false) + expect( + result.properties.data.anyOf[0].properties.nested.additionalProperties, + ).toBe(false) + }) + + it('should handle arrays with items', () => { + const schema = { + type: 'object', + properties: { + items: { + type: 'array', + items: { + type: 'object', + properties: { id: { type: 'string' } }, + required: ['id'], + }, + }, + }, + required: ['items'], + } + + const result = makeStructuredOutputCompatible(schema, ['items']) + expect(result.properties.items.items.additionalProperties).toBe(false) + }) + + it('should throw an error for oneOf schemas (not supported by OpenAI)', () => { + const schema = { + type: 'object', + properties: { + u: { + oneOf: [ + { + type: 'object', + properties: { type: { const: 'a' }, value: { type: 'string' } }, + required: ['type', 'value'], + }, + { + type: 'object', + properties: { type: { const: 'b' }, count: { type: 'number' } }, + required: ['type', 'count'], + }, + ], + }, + }, + required: ['u'], + } + + expect(() => makeStructuredOutputCompatible(schema, ['u'])).toThrow( + 'oneOf is not supported in OpenAI structured output schemas', + ) + }) + + it('should use schema.required as default when originalRequired is not provided', () => { + const schema = { + type: 'object', + properties: { + name: { type: 'string' }, + nickname: { type: 'string' }, + }, + required: ['name'], + } + + // Call without second argument — should use schema.required + const result = makeStructuredOutputCompatible(schema) + expect(result.properties.name.type).toBe('string') + expect(result.properties.nickname.type).toEqual(['string', 'null']) + expect(result.required).toEqual(['name', 'nickname']) + }) + + it('should make optional object properties nullable after recursion', () => { + const schema = { + type: 'object', + properties: { + required_obj: { + type: 'object', + properties: { x: { type: 'string' } }, + required: ['x'], + }, + optional_obj: { + type: 'object', + properties: { y: { type: 'number' } }, + required: ['y'], + }, + }, + required: ['required_obj'], + } + + const result = makeStructuredOutputCompatible(schema, ['required_obj']) + + // required_obj should be recursed into but NOT made nullable + expect(result.properties.required_obj.additionalProperties).toBe(false) + expect(result.properties.required_obj.type).toBe('object') + + // optional_obj should be recursed into AND made nullable + expect(result.properties.optional_obj.additionalProperties).toBe(false) + expect(result.properties.optional_obj.type).toEqual(['object', 'null']) + }) + + it('should make optional array properties nullable after recursion', () => { + const schema = { + type: 'object', + properties: { + tags: { + type: 'array', + items: { + type: 'object', + properties: { label: { type: 'string' } }, + required: ['label'], + }, + }, + }, + required: [], + } + + const result = makeStructuredOutputCompatible(schema, []) + + // tags is optional, should be nullable AND have items recursed + expect(result.properties.tags.type).toEqual(['array', 'null']) + expect(result.properties.tags.items.additionalProperties).toBe(false) + }) + + it('should make optional anyOf properties nullable by adding null variant', () => { + const schema = { + type: 'object', + properties: { + value: { + anyOf: [{ type: 'string' }, { type: 'number' }], + }, + }, + required: [], + } + + const result = makeStructuredOutputCompatible(schema, []) + + // optional anyOf should have a null variant added + expect(result.properties.value.anyOf).toContainEqual({ type: 'null' }) + expect(result.properties.value.anyOf).toHaveLength(3) + }) +}) diff --git a/packages/typescript/openai-base/tsconfig.json b/packages/typescript/openai-base/tsconfig.json new file mode 100644 index 000000000..ea11c1096 --- /dev/null +++ b/packages/typescript/openai-base/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*.ts", "src/**/*.tsx"], + "exclude": ["node_modules", "dist", "**/*.config.ts"] +} diff --git a/packages/typescript/openai-base/vite.config.ts b/packages/typescript/openai-base/vite.config.ts new file mode 100644 index 000000000..77bcc2e60 --- /dev/null +++ b/packages/typescript/openai-base/vite.config.ts @@ -0,0 +1,36 @@ +import { defineConfig, mergeConfig } from 'vitest/config' +import { tanstackViteConfig } from '@tanstack/vite-config' +import packageJson from './package.json' + +const config = defineConfig({ + test: { + name: packageJson.name, + dir: './', + watch: false, + globals: true, + environment: 'node', + include: ['tests/**/*.test.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html', 'lcov'], + exclude: [ + 'node_modules/', + 'dist/', + 'tests/', + '**/*.test.ts', + '**/*.config.ts', + '**/types.ts', + ], + include: ['src/**/*.ts'], + }, + }, +}) + +export default mergeConfig( + config, + tanstackViteConfig({ + entry: ['./src/index.ts'], + srcDir: './src', + cjs: false, + }), +) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a0423a2f2..6079ddf59 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -589,7 +589,7 @@ importers: version: 0.561.0(react@19.2.3) nitro: specifier: latest - version: 3.0.260415-beta(chokidar@5.0.0)(dotenv@17.2.3)(giget@2.0.0)(jiti@2.6.1)(miniflare@4.20260504.0)(rollup@4.60.1)(vite@7.3.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 3.0.260429-beta(chokidar@5.0.0)(dotenv@17.2.3)(giget@2.0.0)(jiti@2.6.1)(miniflare@4.20260504.0)(rollup@4.60.1)(vite@7.3.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) radix-ui: specifier: ^1.4.3 version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -650,7 +650,7 @@ importers: version: 7.3.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) vitest: specifier: ^4.0.14 - version: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jsdom@27.3.0(postcss@8.5.9))(vite@7.3.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.0.14)(happy-dom@20.0.11)(jsdom@27.3.0(postcss@8.5.9))(vite@7.3.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) web-vitals: specifier: ^5.1.0 version: 5.1.0 @@ -962,6 +962,9 @@ importers: '@anthropic-ai/sdk': specifier: ^0.71.2 version: 0.71.2(zod@4.2.1) + '@tanstack/ai-utils': + specifier: workspace:* + version: link:../ai-utils devDependencies: '@tanstack/ai': specifier: workspace:* @@ -1136,6 +1139,9 @@ importers: '@elevenlabs/elevenlabs-js': specifier: ^2.44.0 version: 2.44.0 + '@tanstack/ai-utils': + specifier: workspace:* + version: link:../ai-utils devDependencies: '@tanstack/ai': specifier: workspace:* @@ -1165,6 +1171,9 @@ importers: '@fal-ai/client': specifier: ^1.9.4 version: 1.9.4 + '@tanstack/ai-utils': + specifier: workspace:* + version: link:../ai-utils devDependencies: '@tanstack/ai': specifier: workspace:* @@ -1181,6 +1190,9 @@ importers: '@google/genai': specifier: ^1.43.0 version: 1.43.0 + '@tanstack/ai-utils': + specifier: workspace:* + version: link:../ai-utils devDependencies: '@tanstack/ai': specifier: workspace:* @@ -1197,6 +1209,12 @@ importers: packages/typescript/ai-grok: dependencies: + '@tanstack/ai-utils': + specifier: workspace:* + version: link:../ai-utils + '@tanstack/openai-base': + specifier: workspace:* + version: link:../openai-base openai: specifier: ^6.9.1 version: 6.10.0(ws@8.19.0)(zod@4.3.6) @@ -1222,6 +1240,12 @@ importers: '@tanstack/ai': specifier: workspace:^ version: link:../ai + '@tanstack/ai-utils': + specifier: workspace:* + version: link:../ai-utils + '@tanstack/openai-base': + specifier: workspace:* + version: link:../openai-base groq-sdk: specifier: ^0.37.0 version: 0.37.0 @@ -1280,6 +1304,9 @@ importers: packages/typescript/ai-ollama: dependencies: + '@tanstack/ai-utils': + specifier: workspace:* + version: link:../ai-utils ollama: specifier: ^0.6.3 version: 0.6.3 @@ -1296,6 +1323,12 @@ importers: packages/typescript/ai-openai: dependencies: + '@tanstack/ai-utils': + specifier: workspace:* + version: link:../ai-utils + '@tanstack/openai-base': + specifier: workspace:* + version: link:../openai-base openai: specifier: ^6.9.1 version: 6.10.0(ws@8.19.0)(zod@4.2.1) @@ -1321,10 +1354,13 @@ importers: '@openrouter/sdk': specifier: 0.12.14 version: 0.12.14 + '@tanstack/ai-utils': + specifier: workspace:* + version: link:../ai-utils + devDependencies: '@tanstack/ai': specifier: workspace:* version: link:../ai - devDependencies: '@vitest/coverage-v8': specifier: 4.0.14 version: 4.0.14(vitest@4.1.4) @@ -1533,6 +1569,18 @@ importers: specifier: ^7.2.7 version: 7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + packages/typescript/ai-utils: + devDependencies: + '@types/node': + specifier: ^24.10.1 + version: 24.10.3 + '@vitest/coverage-v8': + specifier: 4.0.14 + version: 4.0.14(vitest@4.1.4) + vite: + specifier: ^7.2.7 + version: 7.3.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + packages/typescript/ai-vue: dependencies: '@tanstack/ai-client': @@ -1604,6 +1652,25 @@ importers: specifier: ^2.2.10 version: 2.2.12(typescript@5.9.3) + packages/typescript/openai-base: + dependencies: + '@tanstack/ai-utils': + specifier: workspace:* + version: link:../ai-utils + openai: + specifier: ^6.9.1 + version: 6.10.0(ws@8.19.0)(zod@4.3.6) + devDependencies: + '@tanstack/ai': + specifier: workspace:* + version: link:../ai + '@vitest/coverage-v8': + specifier: 4.0.14 + version: 4.0.14(vitest@4.1.4) + vite: + specifier: ^7.2.7 + version: 7.3.1(@types/node@25.0.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + packages/typescript/preact-ai-devtools: dependencies: '@tanstack/ai-devtools-core': @@ -9513,16 +9580,16 @@ packages: xml2js: optional: true - nitro@3.0.260415-beta: - resolution: {integrity: sha512-J0ntJERWtIdvweZdmkCiF8eOFvP9fIAJR2gpeIDrHbAlYavK41WQfADo/YoZ/LF7RMTZBiPaH/pt2s/nPru9Iw==} + nitro@3.0.260429-beta: + resolution: {integrity: sha512-KweLVCUN5X9v9g+4yxAyRcz3FcOlnjmt9FyrAIWDxJETJmNT7I0JV0clgsONjo2nI0U5gwedXYA3RaNtF5XWzg==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: - '@vercel/queue': ^0.1.4 + '@vercel/queue': ^0.1.6 dotenv: '*' giget: '*' jiti: ^2.6.1 - rollup: ^4.60.1 + rollup: ^4.60.2 vite: ^7 || ^8 xml2js: ^0.6.2 zephyr-agent: ^0.2.0 @@ -17258,7 +17325,7 @@ snapshots: obug: 2.1.1 std-env: 3.10.0 tinyrainbow: 3.1.0 - vitest: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@25.0.1)(@vitest/coverage-v8@4.0.14)(happy-dom@20.0.11)(jsdom@27.3.0(postcss@8.5.9))(vite@7.3.1(@types/node@25.0.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + vitest: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.0.14)(happy-dom@20.0.11)(jsdom@27.3.0(postcss@8.5.9))(vite@7.3.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) transitivePeerDependencies: - supports-color @@ -17296,14 +17363,6 @@ snapshots: optionalDependencies: vite: 7.3.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) - '@vitest/mocker@4.1.4(vite@7.3.1(@types/node@25.0.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': - dependencies: - '@vitest/spy': 4.1.4 - estree-walker: 3.0.3 - magic-string: 0.30.21 - optionalDependencies: - vite: 7.3.1(@types/node@25.0.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) - '@vitest/pretty-format@4.0.14': dependencies: tinyrainbow: 3.1.0 @@ -20737,7 +20796,7 @@ snapshots: - sqlite3 - uploadthing - nitro@3.0.260415-beta(chokidar@5.0.0)(dotenv@17.2.3)(giget@2.0.0)(jiti@2.6.1)(miniflare@4.20260504.0)(rollup@4.60.1)(vite@7.3.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)): + nitro@3.0.260429-beta(chokidar@5.0.0)(dotenv@17.2.3)(giget@2.0.0)(jiti@2.6.1)(miniflare@4.20260504.0)(rollup@4.60.1)(vite@7.3.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)): dependencies: consola: 3.4.2 crossws: 0.4.5(srvx@0.11.15) @@ -23556,7 +23615,7 @@ snapshots: - tsx - yaml - vitest@4.1.4(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jsdom@27.3.0(postcss@8.5.9))(vite@7.3.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)): + vitest@4.1.4(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.0.14)(happy-dom@20.0.11)(jsdom@27.3.0(postcss@8.5.9))(vite@7.3.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)): dependencies: '@vitest/expect': 4.1.4 '@vitest/mocker': 4.1.4(vite@7.3.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) @@ -23581,15 +23640,16 @@ snapshots: optionalDependencies: '@opentelemetry/api': 1.9.1 '@types/node': 24.10.3 + '@vitest/coverage-v8': 4.0.14(vitest@4.1.4) happy-dom: 20.0.11 jsdom: 27.3.0(postcss@8.5.9) transitivePeerDependencies: - msw - vitest@4.1.4(@opentelemetry/api@1.9.1)(@types/node@25.0.1)(@vitest/coverage-v8@4.0.14)(happy-dom@20.0.11)(jsdom@27.3.0(postcss@8.5.9))(vite@7.3.1(@types/node@25.0.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)): + vitest@4.1.4(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jsdom@27.3.0(postcss@8.5.9))(vite@7.3.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)): dependencies: '@vitest/expect': 4.1.4 - '@vitest/mocker': 4.1.4(vite@7.3.1(@types/node@25.0.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/mocker': 4.1.4(vite@7.3.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) '@vitest/pretty-format': 4.1.4 '@vitest/runner': 4.1.4 '@vitest/snapshot': 4.1.4 @@ -23606,16 +23666,16 @@ snapshots: tinyexec: 1.1.1 tinyglobby: 0.2.16 tinyrainbow: 3.1.0 - vite: 7.3.1(@types/node@25.0.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.1 - '@types/node': 25.0.1 - '@vitest/coverage-v8': 4.0.14(vitest@4.1.4) + '@types/node': 24.10.3 happy-dom: 20.0.11 jsdom: 27.3.0(postcss@8.5.9) transitivePeerDependencies: - msw + optional: true vscode-uri@3.1.0: {}