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..05c0a8825 --- /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: delegate shared utilities to `@tanstack/ai-utils` and OpenAI-compatible adapter logic to `@tanstack/openai-base`. No breaking changes — all public APIs remain identical. diff --git a/packages/typescript/ai-anthropic/package.json b/packages/typescript/ai-anthropic/package.json index 32608500e..ac9701a58 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 862b76168..19cd72d34 100644 --- a/packages/typescript/ai-elevenlabs/package.json +++ b/packages/typescript/ai-elevenlabs/package.json @@ -41,7 +41,8 @@ "test:types": "tsc" }, "dependencies": { - "@11labs/client": "^0.2.0" + "@11labs/client": "^0.2.0", + "@tanstack/ai-utils": "workspace:*" }, "peerDependencies": { "@tanstack/ai": "workspace:^", diff --git a/packages/typescript/ai-elevenlabs/src/realtime/token.ts b/packages/typescript/ai-elevenlabs/src/realtime/token.ts index 030d0c9a9..e7802bac0 100644 --- a/packages/typescript/ai-elevenlabs/src/realtime/token.ts +++ b/packages/typescript/ai-elevenlabs/src/realtime/token.ts @@ -1,3 +1,4 @@ +import { getApiKeyFromEnv } from '@tanstack/ai-utils' import type { RealtimeToken, RealtimeTokenAdapter } from '@tanstack/ai' import type { ElevenLabsRealtimeTokenOptions } from './types' @@ -7,25 +8,7 @@ const ELEVENLABS_API_URL = 'https://api.elevenlabs.io/v1' * Get ElevenLabs API key from environment */ function getElevenLabsApiKey(): string { - // Check process.env (Node.js) - if (typeof process !== 'undefined' && process.env.ELEVENLABS_API_KEY) { - return process.env.ELEVENLABS_API_KEY - } - - // Check window.env (Browser with injected env) - if ( - typeof window !== 'undefined' && - (window as unknown as { env?: { ELEVENLABS_API_KEY?: string } }).env - ?.ELEVENLABS_API_KEY - ) { - return (window as unknown as { env: { ELEVENLABS_API_KEY: string } }).env - .ELEVENLABS_API_KEY - } - - throw new Error( - 'ELEVENLABS_API_KEY not found in environment variables. ' + - 'Please set ELEVENLABS_API_KEY in your environment.', - ) + return getApiKeyFromEnv('ELEVENLABS_API_KEY') } /** diff --git a/packages/typescript/ai-fal/package.json b/packages/typescript/ai-fal/package.json index d3eb9e1c2..5e08d0170 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 ad892ad71..4aa73bcac 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 2ca7b0554..46f4af7b3 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..0a2150fbb 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, toCompatibleConfig } 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(toCompatibleConfig(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..0fbfac2a3 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, toCompatibleConfig } 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(toCompatibleConfig(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..c0837295c 100644 --- a/packages/typescript/ai-grok/src/utils/client.ts +++ b/packages/typescript/ai-grok/src/utils/client.ts @@ -1,46 +1,34 @@ -import OpenAI_SDK from 'openai' +import { getApiKeyFromEnv } from '@tanstack/ai-utils' +import type { OpenAICompatibleClientConfig } from '@tanstack/openai-base' import type { ClientOptions } from 'openai' 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', - }) -} - /** * 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 + * Converts a GrokClientConfig to OpenAICompatibleClientConfig. + * Sets the default xAI base URL if not already set. */ -export function generateId(prefix: string): string { - return `${prefix}-${Date.now()}-${Math.random().toString(36).substring(7)}` +export function toCompatibleConfig( + config: GrokClientConfig, +): OpenAICompatibleClientConfig { + return { + ...config, + baseURL: config.baseURL || 'https://api.x.ai/v1', + } as unknown as OpenAICompatibleClientConfig } diff --git a/packages/typescript/ai-grok/src/utils/index.ts b/packages/typescript/ai-grok/src/utils/index.ts index 72c2f529f..15ec9e854 100644 --- a/packages/typescript/ai-grok/src/utils/index.ts +++ b/packages/typescript/ai-grok/src/utils/index.ts @@ -1,7 +1,6 @@ export { - createGrokClient, getGrokApiKeyFromEnv, - generateId, + toCompatibleConfig, 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 7e9c808c5..b0632f2eb 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..b5539cb15 100644 --- a/packages/typescript/ai-groq/src/utils/schema-converter.ts +++ b/packages/typescript/ai-groq/src/utils/schema-converter.ts @@ -1,35 +1,62 @@ +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 (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 (Array.isArray(obj)) { - return obj.map((item) => transformNullsToUndefined(item)) as unknown as T + 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 } /** @@ -39,6 +66,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 +79,16 @@ export function makeGroqStructuredOutputCompatible( schema: Record, originalRequired: Array = [], ): Record { - const result = { ...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'], - } - } - } - } + // Ensure object schemas always have properties (e.g. z.object({}) may produce + // { type: 'object' } without properties). openai-base's transformer skips + // objects without properties, so we normalise first. + const normalised = + schema.type === 'object' && !schema.properties + ? { ...schema, properties: {} } + : schema - 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 - } + const result = makeStructuredOutputCompatible(normalised, originalRequired) - 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..403c837a2 --- /dev/null +++ b/packages/typescript/ai-groq/tests/schema-converter.test.ts @@ -0,0 +1,100 @@ +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 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 b215dd0bb..eb9c6105a 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 9e6581269..c6c7e6d2d 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..86274da76 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, toCompatibleConfig } 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(toCompatibleConfig(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..d64c5af0e 100644 --- a/packages/typescript/ai-openai/src/adapters/summarize.ts +++ b/packages/typescript/ai-openai/src/adapters/summarize.ts @@ -1,12 +1,8 @@ -import { BaseSummarizeAdapter } from '@tanstack/ai/adapters' +import { OpenAICompatibleSummarizeAdapter } from '@tanstack/openai-base' import { getOpenAIApiKeyFromEnv } from '../utils/client' import { OpenAITextAdapter } from './text' +import type { ChatStreamCapable } from '@tanstack/openai-base' import type { OpenAIChatModel } from '../model-meta' -import type { - StreamChunk, - SummarizationOptions, - SummarizationResult, -} from '@tanstack/ai' import type { OpenAIClientConfig } from '../utils/client' /** @@ -32,125 +28,24 @@ 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 + // 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 OpenAITextAdapter( + config, + model, + ) as unknown as ChatStreamCapable, + model, + 'openai', + ) } } diff --git a/packages/typescript/ai-openai/src/adapters/text.ts b/packages/typescript/ai-openai/src/adapters/text.ts index 97752d737..d761a9b91 100644 --- a/packages/typescript/ai-openai/src/adapters/text.ts +++ b/packages/typescript/ai-openai/src/adapters/text.ts @@ -1,15 +1,7 @@ -import { BaseTextAdapter } from '@tanstack/ai/adapters' +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, toCompatibleConfig } from '../utils/client' import type { OPENAI_CHAT_MODELS, OpenAIChatModel, @@ -17,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 */ @@ -96,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, @@ -105,7 +78,7 @@ export class OpenAITextAdapter< ResolveInputModalities, TToolCapabilities extends ReadonlyArray = ResolveToolCapabilities, -> extends BaseTextAdapter< +> extends OpenAICompatibleResponsesTextAdapter< TModel, TProviderOptions, TInputModalities, @@ -115,736 +88,19 @@ 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) { - logger.errors('openai.chatStream fatal', { - error, - source: 'openai.chatStream', - }) - throw error - } - } - - /** - * 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. - */ - 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 - } + super(toCompatibleConfig(config), model, 'openai') } /** - * Extract text content from a non-streaming response + * 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. */ - 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 } - >, + protected override mapOptionsToRequest( 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) { + ): Omit { const modelOptions = options.modelOptions as | Omit< InternalTextProviderOptions, @@ -886,190 +142,6 @@ export class OpenAITextAdapter< 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..7007b8351 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, toCompatibleConfig } 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,18 @@ 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(toCompatibleConfig(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 { + 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..c7843e416 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, toCompatibleConfig } 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(toCompatibleConfig(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..67c37a192 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, toCompatibleConfig } 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(toCompatibleConfig(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..1f24d5b92 100644 --- a/packages/typescript/ai-openai/src/tools/apply-patch-tool.ts +++ b/packages/typescript/ai-openai/src/tools/apply-patch-tool.ts @@ -1,26 +1,16 @@ -import type OpenAI from 'openai' -import type { ProviderTool, Tool } from '@tanstack/ai' +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. 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..eb8014336 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,11 @@ -import type { ProviderTool, Tool } from '@tanstack/ai' -import type OpenAI from 'openai' +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,20 +13,8 @@ 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. */ export function codeInterpreterTool( container: CodeInterpreterToolConfig, 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..dea50c227 100644 --- a/packages/typescript/ai-openai/src/tools/computer-use-tool.ts +++ b/packages/typescript/ai-openai/src/tools/computer-use-tool.ts @@ -1,30 +1,17 @@ -import type OpenAI from 'openai' -import type { ProviderTool, Tool } from '@tanstack/ai' +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' 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, 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..236734140 100644 --- a/packages/typescript/ai-openai/src/tools/file-search-tool.ts +++ b/packages/typescript/ai-openai/src/tools/file-search-tool.ts @@ -1,5 +1,11 @@ -import type OpenAI from 'openai' -import type { ProviderTool, Tool } from '@tanstack/ai' +import type { ProviderTool } from '@tanstack/ai' +import type { FileSearchToolConfig } from '@tanstack/openai-base' + +export { + type FileSearchToolConfig, + type FileSearchTool, + convertFileSearchToolToAdapterFormat, +} from '@tanstack/openai-base' const validateMaxNumResults = (maxNumResults: number | undefined) => { if (maxNumResults && (maxNumResults < 1 || maxNumResults > 50)) { @@ -7,34 +13,14 @@ const validateMaxNumResults = (maxNumResults: number | undefined) => { } } -export type FileSearchToolConfig = OpenAI.Responses.FileSearchTool - -/** @deprecated Renamed to `FileSearchToolConfig`. Will be removed in a future release. */ -export type FileSearchTool = FileSearchToolConfig - 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. 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..a02818962 100644 --- a/packages/typescript/ai-openai/src/tools/image-generation-tool.ts +++ b/packages/typescript/ai-openai/src/tools/image-generation-tool.ts @@ -1,10 +1,11 @@ -import type OpenAI from 'openai' -import type { ProviderTool, Tool } from '@tanstack/ai' +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', @@ -18,20 +19,8 @@ const validatePartialImages = (value: number | undefined) => { } /** - * 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, 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..a8097913f 100644 --- a/packages/typescript/ai-openai/src/tools/local-shell-tool.ts +++ b/packages/typescript/ai-openai/src/tools/local-shell-tool.ts @@ -1,26 +1,16 @@ -import type OpenAI from 'openai' -import type { ProviderTool, Tool } from '@tanstack/ai' +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. diff --git a/packages/typescript/ai-openai/src/tools/mcp-tool.ts b/packages/typescript/ai-openai/src/tools/mcp-tool.ts index 4f224c108..d7d87670c 100644 --- a/packages/typescript/ai-openai/src/tools/mcp-tool.ts +++ b/packages/typescript/ai-openai/src/tools/mcp-tool.ts @@ -1,39 +1,18 @@ -import type OpenAI from 'openai' -import type { ProviderTool, Tool } from '@tanstack/ai' +import { validateMCPtool } 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' }) diff --git a/packages/typescript/ai-openai/src/tools/shell-tool.ts b/packages/typescript/ai-openai/src/tools/shell-tool.ts index 5fc4bdf65..66c375d6a 100644 --- a/packages/typescript/ai-openai/src/tools/shell-tool.ts +++ b/packages/typescript/ai-openai/src/tools/shell-tool.ts @@ -1,24 +1,15 @@ -import type OpenAI from 'openai' -import type { ProviderTool, Tool } from '@tanstack/ai' +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. 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..4b03b5b0f 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,11 @@ -import type OpenAI from 'openai' -import type { ProviderTool, Tool } from '@tanstack/ai' +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,21 +13,8 @@ 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, 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..af2bb523c 100644 --- a/packages/typescript/ai-openai/src/tools/web-search-tool.ts +++ b/packages/typescript/ai-openai/src/tools/web-search-tool.ts @@ -1,25 +1,17 @@ -import type OpenAI from 'openai' -import type { ProviderTool, Tool } from '@tanstack/ai' +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, diff --git a/packages/typescript/ai-openai/src/utils/client.ts b/packages/typescript/ai-openai/src/utils/client.ts index 3915e2ea1..b14ba44bf 100644 --- a/packages/typescript/ai-openai/src/utils/client.ts +++ b/packages/typescript/ai-openai/src/utils/client.ts @@ -1,42 +1,27 @@ -import OpenAI_SDK from 'openai' +import { getApiKeyFromEnv } from '@tanstack/ai-utils' +import type { OpenAICompatibleClientConfig } from '@tanstack/openai-base' import type { ClientOptions } from 'openai' 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) -} - /** * 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 + return getApiKeyFromEnv('OPENAI_API_KEY') } /** - * Generates a unique ID with a prefix + * Converts an OpenAIClientConfig to OpenAICompatibleClientConfig. + * This bridges the type gap between the local config type (which extends + * the local copy of ClientOptions) and the base package's config type + * (which extends its own copy of ClientOptions). */ -export function generateId(prefix: string): string { - return `${prefix}-${Date.now()}-${Math.random().toString(36).substring(7)}` +export function toCompatibleConfig( + config: OpenAIClientConfig, +): OpenAICompatibleClientConfig { + return config as unknown as OpenAICompatibleClientConfig } 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 4f68a3d0a..9a5012546 100644 --- a/packages/typescript/ai-openrouter/package.json +++ b/packages/typescript/ai-openrouter/package.json @@ -44,7 +44,8 @@ ], "dependencies": { "@openrouter/sdk": "0.12.14", - "@tanstack/ai": "workspace:*" + "@tanstack/ai": "workspace:*", + "@tanstack/ai-utils": "workspace:*" }, "devDependencies": { "@vitest/coverage-v8": "4.0.14", 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..996184c49 --- /dev/null +++ b/packages/typescript/ai-utils/package.json @@ -0,0 +1,44 @@ +{ + "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": { + "@vitest/coverage-v8": "4.0.14", + "vite": "^7.2.7" + } +} 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..160149686 --- /dev/null +++ b/packages/typescript/ai-utils/src/index.ts @@ -0,0 +1,5 @@ +export { generateId } from './id' +export { getApiKeyFromEnv } from './env' +export { transformNullsToUndefined } from './transforms' +export type { ModelMeta, Modality } from './model-meta/types' +export { defineModelMeta } from './model-meta/define' diff --git a/packages/typescript/ai-utils/src/model-meta/define.ts b/packages/typescript/ai-utils/src/model-meta/define.ts new file mode 100644 index 000000000..8ce70b4b2 --- /dev/null +++ b/packages/typescript/ai-utils/src/model-meta/define.ts @@ -0,0 +1,50 @@ +import type { ModelMeta } from './types' + +export function defineModelMeta(meta: T): T { + if (meta.supports.input.length === 0) { + throw new Error( + `defineModelMeta: model "${meta.name}" must have at least one input modality`, + ) + } + + if (meta.supports.output.length === 0) { + throw new Error( + `defineModelMeta: model "${meta.name}" must have at least one output modality`, + ) + } + + if (meta.context_window !== undefined && meta.context_window <= 0) { + throw new Error( + `defineModelMeta: model "${meta.name}" context_window must be positive`, + ) + } + + if (meta.max_output_tokens !== undefined && meta.max_output_tokens <= 0) { + throw new Error( + `defineModelMeta: model "${meta.name}" max_output_tokens must be positive`, + ) + } + + if (meta.pricing) { + if (meta.pricing.input.normal < 0) { + throw new Error( + `defineModelMeta: model "${meta.name}" pricing.input.normal must be non-negative`, + ) + } + if ( + meta.pricing.input.cached !== undefined && + meta.pricing.input.cached < 0 + ) { + throw new Error( + `defineModelMeta: model "${meta.name}" pricing.input.cached must be non-negative`, + ) + } + if (meta.pricing.output.normal < 0) { + throw new Error( + `defineModelMeta: model "${meta.name}" pricing.output.normal must be non-negative`, + ) + } + } + + return meta +} diff --git a/packages/typescript/ai-utils/src/model-meta/types.ts b/packages/typescript/ai-utils/src/model-meta/types.ts new file mode 100644 index 000000000..24ba56b28 --- /dev/null +++ b/packages/typescript/ai-utils/src/model-meta/types.ts @@ -0,0 +1,20 @@ +export type Modality = 'text' | 'image' | 'audio' | 'video' | 'document' + +export interface ModelMeta { + name: string + supports: { + input: Array + output: Array + endpoints?: Array + features?: Array + tools?: Array + } + context_window?: number + max_output_tokens?: number + knowledge_cutoff?: string + pricing?: { + input: { normal: number; cached?: number } + output: { normal: number } + } + providerOptions?: TProviderOptions +} diff --git a/packages/typescript/ai-utils/src/transforms.ts b/packages/typescript/ai-utils/src/transforms.ts new file mode 100644 index 000000000..ca54a0e08 --- /dev/null +++ b/packages/typescript/ai-utils/src/transforms.ts @@ -0,0 +1,28 @@ +/** + * Recursively converts null values to undefined in an object. + * Used after receiving structured output from OpenAI-compatible providers, + * which return null for optional fields that were made nullable in the + * JSON Schema strict mode transformation. + */ +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/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/model-meta.test.ts b/packages/typescript/ai-utils/tests/model-meta.test.ts new file mode 100644 index 000000000..30c6e9404 --- /dev/null +++ b/packages/typescript/ai-utils/tests/model-meta.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect } from 'vitest' +import { defineModelMeta } from '../src/model-meta/define' +import type { ModelMeta, Modality } from '../src/model-meta/types' + +describe('defineModelMeta', () => { + it('should return the meta object unchanged for valid input', () => { + const meta = defineModelMeta({ + name: 'gpt-4o', + supports: { + input: ['text', 'image'] as Array, + output: ['text'] as Array, + }, + }) + expect(meta.name).toBe('gpt-4o') + expect(meta.supports.input).toEqual(['text', 'image']) + }) + + it('should accept optional fields', () => { + const meta = defineModelMeta({ + name: 'gpt-4o', + supports: { + input: ['text'] as Array, + output: ['text'] as Array, + features: ['streaming', 'function_calling'], + }, + context_window: 128000, + max_output_tokens: 16384, + knowledge_cutoff: '2024-10', + pricing: { + input: { normal: 2.5, cached: 1.25 }, + output: { normal: 10.0 }, + }, + }) + expect(meta.context_window).toBe(128000) + expect(meta.pricing?.input.cached).toBe(1.25) + }) + + it('should throw for negative pricing', () => { + expect(() => + defineModelMeta({ + name: 'test', + supports: { + input: ['text'] as Array, + output: ['text'] as Array, + }, + pricing: { + input: { normal: -1 }, + output: { normal: 1 }, + }, + }), + ).toThrow('pricing') + }) + + it('should throw for zero context window', () => { + expect(() => + defineModelMeta({ + name: 'test', + supports: { + input: ['text'] as Array, + output: ['text'] as Array, + }, + context_window: 0, + }), + ).toThrow('context_window') + }) + + it('should throw for empty input modalities', () => { + expect(() => + defineModelMeta({ + name: 'test', + supports: { + input: [] as Array, + output: ['text'] as Array, + }, + }), + ).toThrow('input') + }) + + it('should throw for empty output modalities', () => { + expect(() => + defineModelMeta({ + name: 'test', + supports: { + input: ['text'] as Array, + output: [] as Array, + }, + }), + ).toThrow('output') + }) + + it('should throw for negative output pricing', () => { + expect(() => + defineModelMeta({ + name: 'test', + supports: { + input: ['text'] as Array, + output: ['text'] as Array, + }, + pricing: { + input: { normal: 1 }, + output: { normal: -1 }, + }, + }), + ).toThrow('pricing') + }) +}) 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..760611214 --- /dev/null +++ b/packages/typescript/openai-base/package.json @@ -0,0 +1,56 @@ +{ + "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:^", + "zod": "^4.0.0" + }, + "devDependencies": { + "@tanstack/ai": "workspace:*", + "@vitest/coverage-v8": "4.0.14", + "vite": "^7.2.7", + "zod": "^4.2.0" + } +} 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..e9f7a15f7 --- /dev/null +++ b/packages/typescript/openai-base/src/adapters/chat-completions-text.ts @@ -0,0 +1,622 @@ +import { BaseTextAdapter } from '@tanstack/ai/adapters' +import { generateId, transformNullsToUndefined } from '@tanstack/ai-utils' +import { createOpenAICompatibleClient } from '../utils/client' +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 { + const stream = await this.client.chat.completions.create( + { + ...requestParams, + stream: true, + stream_options: { include_usage: true }, + }, + { + headers: (options.request as RequestInit | undefined)?.headers as + | Record + | undefined, + signal: (options.request as RequestInit | undefined)?.signal, + }, + ) + + yield* this.processStreamChunks(stream, options, aguiState) + } 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, + model: options.model, + timestamp, + }) + } + + // Emit AG-UI RUN_ERROR + yield asChunk({ + type: 'RUN_ERROR', + runId: aguiState.runId, + model: options.model, + timestamp, + error: { + message: err.message || 'Unknown error', + code: err.code, + }, + }) + + console.error( + `>>> [${this.name}] chatStream: Fatal error during response creation <<<`, + ) + console.error('>>> Error message:', err.message) + console.error('>>> Error stack:', err.stack) + console.error('>>> Full error:', err) + } + } + + /** + * 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 + 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, + }, + }, + }, + { + headers: (chatOptions.request as RequestInit | undefined)?.headers as + | Record + | undefined, + signal: (chatOptions.request as RequestInit | undefined)?.signal, + }, + ) + + // 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) { + const err = error as Error + console.error( + `>>> [${this.name}] structuredOutput: Error during response creation <<<`, + ) + console.error('>>> Error message:', err.message) + 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 + + // 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) { + 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, + 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, + 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 } + console.error(`[${this.name}] Stream ended with error:`, err.message) + + // Emit AG-UI RUN_ERROR + yield asChunk({ + type: 'RUN_ERROR', + runId: aguiState.runId, + model: options.model, + timestamp, + error: { + message: err.message || 'Unknown error occurred', + code: err.code, + }, + }) + } + } + + /** + * 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 + + return { + ...modelOptions, + model: options.model, + messages, + temperature: options.temperature, + max_tokens: options.maxTokens, + top_p: options.topP, + tools: tools as Array, + 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), + }, + })) + + 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) { + const converted = this.convertContentPart(part) + if (converted) { + parts.push(converted) + } + } + + return { + role: 'user', + content: parts.length > 0 ? 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 + const imageValue = part.source.value + const imageUrl = + part.source.type === 'data' && !imageValue.startsWith('data:') + ? `data:${part.source.mimeType};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..ed468fac1 --- /dev/null +++ b/packages/typescript/openai-base/src/adapters/chat-completions-tool-converter.ts @@ -0,0 +1,66 @@ +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 + + const jsonSchema = schemaConverter(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, + }, + } 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..084394b3f --- /dev/null +++ b/packages/typescript/openai-base/src/adapters/image.ts @@ -0,0 +1,139 @@ +import { BaseImageAdapter } from '@tanstack/ai/adapters' +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) + + const response = await this.client.images.generate({ + ...request, + stream: false, + }) + + return this.transformResponse(model, response) + } + + 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 + + if (numberOfImages < 1 || numberOfImages > 10) { + throw new Error( + `Number of images must be between 1 and 10. 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..9ebe37ce9 --- /dev/null +++ b/packages/typescript/openai-base/src/adapters/responses-text.ts @@ -0,0 +1,901 @@ +import { BaseTextAdapter } from '@tanstack/ai/adapters' +import { generateId, transformNullsToUndefined } from '@tanstack/ai-utils' +import { createOpenAICompatibleClient } from '../utils/client' +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 { + const response = await this.client.responses.create( + { + ...requestParams, + stream: true, + }, + { + headers: options.request?.headers, + signal: options.request?.signal, + }, + ) + + yield* this.processStreamChunks( + response, + toolCallMetadata, + options, + aguiState, + ) + } 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, + model: options.model, + timestamp, + }) + } + + // Emit AG-UI RUN_ERROR + yield asChunk({ + type: 'RUN_ERROR', + runId: aguiState.runId, + model: options.model, + timestamp, + error: { + message: err.message || 'Unknown error', + code: err.code, + }, + }) + + console.error( + `>>> [${this.name}] chatStream: Fatal error during response creation <<<`, + ) + console.error('>>> Error message:', err.message) + console.error('>>> Error stack:', err.stack) + console.error('>>> Full error:', err) + } + } + + /** + * 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 { + const response = await this.client.responses.create( + { + ...requestParams, + 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 as 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) { + const err = error as Error + console.error( + `>>> [${this.name}] structuredOutput: Error during response creation <<<`, + ) + console.error('>>> Error message:', err.message) + 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 = '' + + 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 + } + + /** + * 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 + 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 + let stepId: string | null = null + let hasEmittedTextMessageStart = false + let hasEmittedStepStarted = false + + try { + for await (const chunk of stream) { + chunkCount++ + + // 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 || '' + return asChunk({ + type: 'STEP_FINISHED', + stepId: stepId || generateId(this.name), + model: model || options.model, + timestamp, + delta: contentPart.text || '', + content: accumulatedReasoning, + }) + } + return asChunk({ + type: 'RUN_ERROR', + runId: aguiState.runId, + model: model || options.model, + timestamp, + error: { + message: contentPart.refusal || 'Unknown 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 + accumulatedContent = '' + accumulatedReasoning = '' + if (chunk.response.error) { + yield asChunk({ + type: 'RUN_ERROR', + runId: aguiState.runId, + model: chunk.response.model, + timestamp, + error: chunk.response.error, + }) + } + if (chunk.response.incomplete_details) { + yield asChunk({ + type: 'RUN_ERROR', + runId: aguiState.runId, + model: chunk.response.model, + timestamp, + error: { + message: chunk.response.incomplete_details.reason ?? '', + }, + }) + } + } + + // 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', + }) + } + 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, keyed by the item id that + // subsequent delta/done events reference via `item_id`. + 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, + }) + } + + 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') { + // 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, + }) + } + + // 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: 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: hasFunctionCalls ? 'tool_calls' : 'stop', + }) + } + + 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, + }, + }) + } + } + } catch (error: unknown) { + const err = error as Error & { code?: string } + console.error(`[${this.name}] Stream ended with error:`, err.message) + yield asChunk({ + type: 'RUN_ERROR', + runId: aguiState.runId, + model: options.model, + timestamp, + error: { + message: err.message || 'Unknown error occurred', + code: err.code, + }, + }) + } + } + + /** + * 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 + + return { + model: options.model, + temperature: options.temperature, + max_output_tokens: options.maxTokens, + top_p: options.topP, + metadata: options.metadata, + instructions: options.systemPrompts?.join('\n'), + ...modelOptions, + input, + 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 no content parts, add empty text + if (inputContent.length === 0) { + inputContent.push({ type: 'input_text', text: '' }) + } + + 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 + 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') { + 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. + */ + 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..6d44a8a39 --- /dev/null +++ b/packages/typescript/openai-base/src/adapters/responses-tool-converter.ts @@ -0,0 +1,74 @@ +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 + + const jsonSchema = schemaConverter(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, + } +} + +/** + * 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..894ab9088 --- /dev/null +++ b/packages/typescript/openai-base/src/adapters/summarize.ts @@ -0,0 +1,124 @@ +import { BaseSummarizeAdapter } from '@tanstack/ai/adapters' +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 = '' + let model = options.model + let usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 } + + for await (const chunk of this.textAdapter.chatStream({ + model: options.model as TModel, + messages: [{ role: 'user', content: options.text }], + systemPrompts: [systemPrompt], + maxTokens: options.maxLength, + temperature: 0.3, + logger: options.logger, + } as TextOptions)) { + if (chunk.type === 'TEXT_MESSAGE_CONTENT') { + if (chunk.content) { + summary = chunk.content + } else { + summary += chunk.delta + } + model = chunk.model || model + } + if (chunk.type === 'RUN_FINISHED') { + if (chunk.usage) { + usage = chunk.usage + } + } + } + + return { id, model, summary, usage } + } + + async *summarizeStream( + options: SummarizationOptions, + ): AsyncIterable { + const systemPrompt = this.buildSummarizationPrompt(options) + + yield* this.textAdapter.chatStream({ + model: options.model as TModel, + messages: [{ role: 'user', content: options.text }], + systemPrompts: [systemPrompt], + maxTokens: options.maxLength, + temperature: 0.3, + logger: options.logger, + } as TextOptions) + } + + 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..0e073605b --- /dev/null +++ b/packages/typescript/openai-base/src/adapters/transcription.ts @@ -0,0 +1,202 @@ +import { BaseTranscriptionAdapter } from '@tanstack/ai/adapters' +import { 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)) + + 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, + } + } + } + + 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 = this.decodeBase64(base64Data) + const extension = mimeType.split('/')[1] || 'mp3' + return new File([bytes], `audio.${extension}`, { type: mimeType }) + } + + // Assume raw base64 + const bytes = this.decodeBase64(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.', + ) + } + } + + /** + * Decodes a base64 string to an ArrayBuffer. + * Uses `atob` when available, falling back to `Buffer.from` in Node.js. + */ + private decodeBase64(base64: string): ArrayBuffer { + if (typeof atob === 'function') { + const binaryStr = atob(base64) + const bytes = new Uint8Array(binaryStr.length) + for (let i = 0; i < binaryStr.length; i++) { + bytes[i] = binaryStr.charCodeAt(i) + } + return bytes.buffer + } + + // Node.js fallback + if (typeof Buffer !== 'undefined') { + const buf = Buffer.from(base64, 'base64') + return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) + } + + throw new Error( + 'Neither `atob` nor `Buffer` is available in this environment. ' + + 'Use a File, Blob, or ArrayBuffer input instead.', + ) + } + + /** + * 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..43649daa9 --- /dev/null +++ b/packages/typescript/openai-base/src/adapters/tts.ts @@ -0,0 +1,109 @@ +import { BaseTTSAdapter } from '@tanstack/ai/adapters' +import { 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, + } + + // Call 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 = (request.response_format as string) || 'mp3' + const contentType = this.getContentType(outputFormat) + + return { + id: generateId(this.name), + model, + audio: base64, + format: outputFormat, + contentType, + } + } + + 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..25c9bfa55 --- /dev/null +++ b/packages/typescript/openai-base/src/adapters/video.ts @@ -0,0 +1,286 @@ +import { BaseVideoAdapter } from '@tanstack/ai/adapters' +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' + +/** + * 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 { + const client = this.client as any + const response = await client.videos.create(request) + + return { + jobId: response.id, + model, + } + } catch (error: any) { + 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 + } + } + + /** + * 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 client = this.client as any + 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 + } + } + + /** + * 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 client = this.client as any + + // 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 client.videos.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 client.videos?.downloadContent === 'function') { + const contentResponse = await client.videos.downloadContent(jobId) + 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, + } + } + + let response: any + 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 { + // 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() + 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, + } + } + + 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 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..aa19e2502 --- /dev/null +++ b/packages/typescript/openai-base/src/index.ts @@ -0,0 +1,26 @@ +export { makeStructuredOutputCompatible } from './utils/schema-converter' +export { createOpenAICompatibleClient } from './utils/client' +export type { OpenAICompatibleClientConfig } from './types/config' +export * from './tools/index' +export * from './types/message-metadata' +export * from './types/provider-options' +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..8ea34d7a1 --- /dev/null +++ b/packages/typescript/openai-base/src/tools/file-search-tool.ts @@ -0,0 +1,48 @@ +import type OpenAI from 'openai' +import type { Tool } from '@tanstack/ai' + +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 + +/** + * 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..170f175f0 --- /dev/null +++ b/packages/typescript/openai-base/src/tools/image-generation-tool.ts @@ -0,0 +1,47 @@ +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 + */ +export function convertImageGenerationToolToAdapterFormat( + tool: Tool, +): ImageGenerationToolConfig { + const metadata = tool.metadata as Omit + return { + type: 'image_generation', + ...metadata, + } +} + +/** + * 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..87f1b9e5c --- /dev/null +++ b/packages/typescript/openai-base/src/tools/web-search-preview-tool.ts @@ -0,0 +1,37 @@ +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 + */ +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. + * + * 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..0cf49e259 --- /dev/null +++ b/packages/typescript/openai-base/src/tools/web-search-tool.ts @@ -0,0 +1,31 @@ +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 + */ +export function convertWebSearchToolToAdapterFormat( + tool: Tool, +): WebSearchToolConfig { + const metadata = tool.metadata as WebSearchToolConfig + return metadata +} + +/** + * 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/types/message-metadata.ts b/packages/typescript/openai-base/src/types/message-metadata.ts new file mode 100644 index 000000000..e5179c9b1 --- /dev/null +++ b/packages/typescript/openai-base/src/types/message-metadata.ts @@ -0,0 +1,19 @@ +export interface OpenAICompatibleImageMetadata { + detail?: 'auto' | 'low' | 'high' +} + +export interface OpenAICompatibleAudioMetadata { + format?: 'mp3' | 'wav' | 'flac' | 'ogg' | 'webm' | 'aac' +} + +export interface OpenAICompatibleVideoMetadata {} +export interface OpenAICompatibleDocumentMetadata {} +export interface OpenAICompatibleTextMetadata {} + +export interface OpenAICompatibleMessageMetadataByModality { + text: OpenAICompatibleTextMetadata + image: OpenAICompatibleImageMetadata + audio: OpenAICompatibleAudioMetadata + video: OpenAICompatibleVideoMetadata + document: OpenAICompatibleDocumentMetadata +} diff --git a/packages/typescript/openai-base/src/types/provider-options.ts b/packages/typescript/openai-base/src/types/provider-options.ts new file mode 100644 index 000000000..54c1bdb3d --- /dev/null +++ b/packages/typescript/openai-base/src/types/provider-options.ts @@ -0,0 +1,41 @@ +export interface OpenAICompatibleBaseOptions { + temperature?: number + top_p?: number + max_tokens?: number + frequency_penalty?: number + presence_penalty?: number + stop?: string | Array + user?: string +} + +export interface OpenAICompatibleReasoningOptions { + reasoning?: { + effort?: 'none' | 'minimal' | 'low' | 'medium' | 'high' + summary?: 'auto' | 'detailed' + } +} + +export interface OpenAICompatibleStructuredOutputOptions { + text?: { + format: { + type: string + name?: string + schema?: Record + strict?: boolean + } + } +} + +export interface OpenAICompatibleToolsOptions { + max_tool_calls?: number + parallel_tool_calls?: boolean + tool_choice?: + | 'auto' + | 'none' + | 'required' + | { type: 'function'; function: { name: string } } +} + +export interface OpenAICompatibleStreamingOptions { + stream_options?: { include_usage?: boolean } +} 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/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..b89324d8a --- /dev/null +++ b/packages/typescript/openai-base/tests/chat-completions-text.test.ts @@ -0,0 +1,901 @@ +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest' +import { OpenAICompatibleChatCompletionsTextAdapter } from '../src/adapters/chat-completions-text' +import type { StreamChunk, Tool } from '@tanstack/ai' + +// 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({ + 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({ + 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({ + 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({ + 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({ + 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({ + 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({ + 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({ + 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: { + 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: { + 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: { + 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({ + 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({ + 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: { + 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({ + 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/responses-text.test.ts b/packages/typescript/openai-base/tests/responses-text.test.ts new file mode 100644 index 000000000..061b1076e --- /dev/null +++ b/packages/typescript/openai-base/tests/responses-text.test.ts @@ -0,0 +1,1564 @@ +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest' +import { OpenAICompatibleResponsesTextAdapter } from '../src/adapters/responses-text' +import type { StreamChunk, Tool } from '@tanstack/ai' + +// 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({ + 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({ + 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({ + 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({ + 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({ + 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({ + 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({ + 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({ + 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({ + 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({ + 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({ + 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({ + 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({ + 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({ + 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({ + 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({ + 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({ + 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: { + 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: { + 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: { + 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({ + 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({ + 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({ + 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 cd94bcfa8..1da47cbce 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -959,6 +959,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:* @@ -1130,6 +1133,9 @@ importers: '@11labs/client': specifier: ^0.2.0 version: 0.2.0(@types/dom-mediacapture-record@1.0.22) + '@tanstack/ai-utils': + specifier: workspace:* + version: link:../ai-utils devDependencies: '@tanstack/ai': specifier: workspace:* @@ -1159,6 +1165,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:* @@ -1175,6 +1184,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:* @@ -1194,6 +1206,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 openai: specifier: ^6.9.1 version: 6.10.0(ws@8.19.0)(zod@4.2.1) @@ -1213,6 +1231,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 @@ -1277,6 +1301,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 @@ -1293,6 +1320,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,6 +1354,9 @@ importers: '@tanstack/ai': specifier: workspace:* version: link:../ai + '@tanstack/ai-utils': + specifier: workspace:* + version: link:../ai-utils devDependencies: '@vitest/coverage-v8': specifier: 4.0.14 @@ -1530,6 +1566,15 @@ 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: + '@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/ai-vue: dependencies: '@tanstack/ai-client': @@ -1601,6 +1646,28 @@ 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) + zod: + specifier: ^4.2.0 + version: 4.3.6 + packages/typescript/preact-ai-devtools: dependencies: '@tanstack/ai-devtools-core': @@ -21121,6 +21188,11 @@ snapshots: ws: 8.19.0 zod: 4.2.1 + openai@6.10.0(ws@8.19.0)(zod@4.3.6): + optionalDependencies: + ws: 8.19.0 + zod: 4.3.6 + optionator@0.9.4: dependencies: deep-is: 0.1.4