diff --git a/.changeset/decouple-openrouter-collapse-openai-base.md b/.changeset/decouple-openrouter-collapse-openai-base.md new file mode 100644 index 000000000..3dab48e95 --- /dev/null +++ b/.changeset/decouple-openrouter-collapse-openai-base.md @@ -0,0 +1,35 @@ +--- +'@tanstack/openai-base': minor +'@tanstack/ai-openai': patch +'@tanstack/ai-grok': patch +'@tanstack/ai-groq': patch +'@tanstack/ai-openrouter': patch +--- + +Decouple `@tanstack/ai-openrouter` from the shared OpenAI base, and collapse the base into a thinner shim over the `openai` SDK. + +Three changes that ship together: + +**1. Rename `@tanstack/ai-openai-compatible` → `@tanstack/openai-base`.** The previous name implied a multi-vendor protocol surface. After ai-openrouter is decoupled (see below), the only remaining consumers (`ai-openai`, `ai-grok`, `ai-groq`) all back onto the `openai` SDK with a different `baseURL` — "base" describes that role accurately. Imports change: + +```diff +- import { OpenAICompatibleChatCompletionsTextAdapter } from '@tanstack/ai-openai-compatible' ++ import { OpenAIBaseChatCompletionsTextAdapter } from '@tanstack/openai-base' +- import { OpenAICompatibleResponsesTextAdapter } from '@tanstack/ai-openai-compatible' ++ import { OpenAIBaseResponsesTextAdapter } from '@tanstack/openai-base' +``` + +`@tanstack/ai-openai-compatible@0.2.x` remains published for anyone with a pinned lockfile reference but will receive no further updates. + +**2. `@tanstack/openai-base` adopts the `openai` SDK directly.** The previous package vendored ~720 LOC of hand-written wire-format types (`ChatCompletion`, `ResponseStreamEvent`, etc.) and exposed abstract `callChatCompletion*` / `callResponse*` hooks subclasses had to implement. Both are gone: + +- The base now depends on `openai` again and imports types directly from `openai/resources/...`. The vendored `src/types/` directory is removed; consumers that imported wire types from the package (e.g. `import type { ResponseInput } from '@tanstack/ai-openai-compatible'`) should now import from the openai SDK. +- The abstract SDK-call methods are removed. The base constructor takes a pre-built `OpenAI` client (`new OpenAIBaseChatCompletionsTextAdapter(model, name, openaiClient)`) and calls `client.chat.completions.create` / `client.responses.create` itself. Subclasses (`ai-openai`, `ai-grok`, `ai-groq`) now just construct the SDK with their provider-specific `baseURL` and pass it to `super` — `callChatCompletion*` / `callResponse*` overrides go away. + +The other extension hooks (`extractReasoning`, `extractTextFromResponse`, `processStreamChunks`, `makeStructuredOutputCompatible`, `transformStructuredOutput`, `mapOptionsToRequest`, `convertMessage`) remain. Groq's `processStreamChunks` and `makeStructuredOutputCompatible` overrides (for `x_groq.usage` promotion and Groq's structured-output schema quirks) are unchanged. + +**3. Decouple `@tanstack/ai-openrouter` from the OpenAI base entirely.** OpenRouter ships its own SDK (`@openrouter/sdk`) with a camelCase shape, so inheriting from the OpenAI-shaped base forced a snake_case ↔ camelCase round-trip on every request and stream event. ai-openrouter now extends `BaseTextAdapter` directly and inlines its own stream processors (`OpenRouterTextAdapter` for chat-completions, `OpenRouterResponsesTextAdapter` for the Responses beta), reading OpenRouter's camelCase types natively. The `@tanstack/openai-base` and `openai` dependencies are removed from ai-openrouter; only `@openrouter/sdk`, `@tanstack/ai`, and `@tanstack/ai-utils` remain. + +Public API is unchanged: `openRouterText`, `openRouterResponsesText`, `createOpenRouterText`, `createOpenRouterResponsesText`, the OpenRouter tool factories, provider routing surface (`provider`, `models`, `plugins`, `variant`, `transforms`), app attribution headers (`httpReferer`, `appTitle`), `:variant` model suffixing, `RequestAbortedError` propagation, and the OpenRouter-specific structured-output null-preservation all behave the same. The ~300 LOC of inbound/outbound shape converters (`toOpenRouterRequest`, `toChatCompletion`, `adaptOpenRouterStreamChunks`, `toSnakeResponseResult`, …) are gone. + +`ai-ollama` remains on `BaseTextAdapter` directly — its native API uses a different wire format from Chat Completions and was never on the shared base. diff --git a/docs/adapters/openrouter.md b/docs/adapters/openrouter.md index c61fcff96..b54fa8dbd 100644 --- a/docs/adapters/openrouter.md +++ b/docs/adapters/openrouter.md @@ -35,16 +35,17 @@ const stream = chat({ ## Configuration ```typescript -import { createOpenRouter, type OpenRouterConfig } from "@tanstack/ai-openrouter"; - -const config: OpenRouterConfig = { - apiKey: process.env.OPENROUTER_API_KEY!, - baseURL: "https://openrouter.ai/api/v1", // Optional - httpReferer: "https://your-app.com", // Optional, for rankings - xTitle: "Your App Name", // Optional, for rankings -}; - -const adapter = createOpenRouter(config.apiKey, config); +import { createOpenRouterText } from "@tanstack/ai-openrouter"; + +const adapter = createOpenRouterText( + "openai/gpt-5", + process.env.OPENROUTER_API_KEY!, + { + serverURL: "https://openrouter.ai/api/v1", // Optional + httpReferer: "https://your-app.com", // Optional, for rankings + appTitle: "Your App Name", // Optional, for rankings + }, +); ``` ## Available Models @@ -122,18 +123,52 @@ OpenRouter can automatically route requests to the best available provider: ```typescript const stream = chat({ adapter: openRouterText("openrouter/auto"), - messages, - providerOptions: { + messages, + modelOptions: { models: [ "openai/gpt-4o", "anthropic/claude-3.5-sonnet", "google/gemini-pro", ], - route: "fallback", // Use fallback if primary fails }, }); ``` - + +## Chat Completions vs Responses (beta) + +OpenRouter exposes two OpenAI-compatible wire formats, and the adapter +package ships one of each: + +| Adapter | Endpoint | Status | When to use | +| -------------------------- | ------------------------- | -------- | ---------------------------------------------------------------------------- | +| `openRouterText` | `/v1/chat/completions` | Stable | Default for almost everything. Broadest model + tool support. | +| `openRouterResponsesText` | `/v1/responses` | Beta | OpenAI Responses-shaped request/response; richer multi-turn state on OpenAI-style models. | + +Both adapters route to any underlying model OpenRouter supports +(`anthropic/...`, `google/...`, `meta-llama/...`, etc.) — the wire format +describes how your client talks to OpenRouter, not which provider answers. +`/v1/responses` is OpenAI's newer API surface; OpenRouter implements it so +clients that prefer that wire format can use it across the same 300+ +model catalogue. + +```typescript +import { chat } from "@tanstack/ai"; +import { openRouterResponsesText } from "@tanstack/ai-openrouter"; + +const stream = chat({ + adapter: openRouterResponsesText("anthropic/claude-sonnet-4.5"), + messages: [{ role: "user", content: "Hello!" }], +}); +``` + +Caveats while the Responses adapter is in beta: + +- Function tools are supported; OpenRouter's branded server-tools (web + search, file search, …) are not yet wired through this path — use + `openRouterText` if you need those. +- If in doubt, prefer `openRouterText`. The Chat Completions endpoint has + broader provider coverage and feature parity today. + ## Next Steps - [Getting Started](../getting-started/quick-start) - Learn the basics diff --git a/packages/typescript/ai-anthropic/src/adapters/summarize.ts b/packages/typescript/ai-anthropic/src/adapters/summarize.ts index cdd9fe66f..b9febd09f 100644 --- a/packages/typescript/ai-anthropic/src/adapters/summarize.ts +++ b/packages/typescript/ai-anthropic/src/adapters/summarize.ts @@ -1,214 +1,21 @@ -import { BaseSummarizeAdapter } from '@tanstack/ai/adapters' -import { - createAnthropicClient, - generateId, - getAnthropicApiKeyFromEnv, -} from '../utils' +import { ChatStreamSummarizeAdapter } from '@tanstack/ai/adapters' +import { getAnthropicApiKeyFromEnv } from '../utils' +import { AnthropicTextAdapter } from './text' +import type { InferTextProviderOptions } from '@tanstack/ai/adapters' import type { ANTHROPIC_MODELS } from '../model-meta' -import type { - StreamChunk, - SummarizationOptions, - SummarizationResult, -} from '@tanstack/ai' import type { AnthropicClientConfig } from '../utils' -/** Cast an event object to StreamChunk. */ -const asChunk = (chunk: Record) => - chunk as unknown as StreamChunk - -/** - * Configuration for Anthropic summarize adapter - */ export interface AnthropicSummarizeConfig extends AnthropicClientConfig {} -/** - * Anthropic-specific provider options for summarization - */ -export interface AnthropicSummarizeProviderOptions { - /** Temperature for response generation (0-1) */ - temperature?: number - /** Maximum tokens in the response */ - maxTokens?: number -} - -/** Model type for Anthropic summarization */ export type AnthropicSummarizeModel = (typeof ANTHROPIC_MODELS)[number] -/** - * Anthropic Summarize Adapter - * - * Tree-shakeable adapter for Anthropic summarization functionality. - * Import only what you need for smaller bundle sizes. - */ -export class AnthropicSummarizeAdapter< - TModel extends AnthropicSummarizeModel, -> extends BaseSummarizeAdapter { - readonly kind = 'summarize' as const - readonly name = 'anthropic' as const - - private client: ReturnType - - constructor(config: AnthropicSummarizeConfig, model: TModel) { - super({}, model) - this.client = createAnthropicClient(config) - } - - async summarize(options: SummarizationOptions): Promise { - const { logger } = options - const systemPrompt = this.buildSummarizationPrompt(options) - - logger.request(`activity=summarize provider=anthropic`, { - provider: 'anthropic', - model: options.model, - }) - - try { - const response = await this.client.messages.create({ - model: options.model, - messages: [{ role: 'user', content: options.text }], - system: systemPrompt, - max_tokens: options.maxLength || 500, - temperature: 0.3, - stream: false, - }) - - const content = response.content - .map((c) => (c.type === 'text' ? c.text : '')) - .join('') - - return { - id: response.id, - model: response.model, - summary: content, - usage: { - promptTokens: response.usage.input_tokens, - completionTokens: response.usage.output_tokens, - totalTokens: - response.usage.input_tokens + response.usage.output_tokens, - }, - } - } catch (error) { - logger.errors('anthropic.summarize fatal', { - error, - source: 'anthropic.summarize', - }) - throw error - } - } - - async *summarizeStream( - options: SummarizationOptions, - ): AsyncIterable { - const { logger } = options - const systemPrompt = this.buildSummarizationPrompt(options) - const id = generateId(this.name) - const model = options.model - let accumulatedContent = '' - let inputTokens = 0 - let outputTokens = 0 - - logger.request(`activity=summarize provider=anthropic`, { - provider: 'anthropic', - model, - stream: true, - }) - - try { - const stream = await this.client.messages.create({ - model: options.model, - messages: [{ role: 'user', content: options.text }], - system: systemPrompt, - max_tokens: options.maxLength || 500, - temperature: 0.3, - stream: true, - }) - - for await (const event of stream) { - logger.provider(`provider=anthropic type=${event.type}`, { - chunk: event, - }) - - if (event.type === 'message_start') { - inputTokens = event.message.usage.input_tokens - } else if (event.type === 'content_block_delta') { - if (event.delta.type === 'text_delta') { - const delta = event.delta.text - accumulatedContent += delta - yield asChunk({ - type: 'TEXT_MESSAGE_CONTENT', - messageId: id, - model, - timestamp: Date.now(), - delta, - content: accumulatedContent, - }) - } - } else if (event.type === 'message_delta') { - outputTokens = event.usage.output_tokens - yield asChunk({ - type: 'RUN_FINISHED', - runId: id, - model, - timestamp: Date.now(), - finishReason: event.delta.stop_reason as - | 'stop' - | 'length' - | 'content_filter' - | null, - usage: { - promptTokens: inputTokens, - completionTokens: outputTokens, - totalTokens: inputTokens + outputTokens, - }, - }) - } - } - } catch (error) { - logger.errors('anthropic.summarize fatal', { - error, - source: 'anthropic.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 - } -} - /** * Creates an Anthropic summarize adapter with explicit API key. - * Type resolution happens here at the call site. * - * @param model - The model name (e.g., 'claude-sonnet-4-5', 'claude-3-5-haiku-latest') - * @param apiKey - Your Anthropic API key - * @param config - Optional additional configuration - * @returns Configured Anthropic summarize adapter instance with resolved types + * @example + * ```typescript + * const adapter = createAnthropicSummarize('claude-sonnet-4-5', 'sk-ant-...'); + * ``` */ export function createAnthropicSummarize< TModel extends AnthropicSummarizeModel, @@ -216,22 +23,32 @@ export function createAnthropicSummarize< model: TModel, apiKey: string, config?: Omit, -): AnthropicSummarizeAdapter { - return new AnthropicSummarizeAdapter({ apiKey, ...config }, model) +): ChatStreamSummarizeAdapter< + TModel, + InferTextProviderOptions> +> { + return new ChatStreamSummarizeAdapter( + new AnthropicTextAdapter({ apiKey, ...config }, model), + model, + 'anthropic', + ) } /** - * Creates an Anthropic summarize adapter with automatic API key detection. - * Type resolution happens here at the call site. + * Creates an Anthropic summarize adapter with API key from `ANTHROPIC_API_KEY`. * - * @param model - The model name (e.g., 'claude-sonnet-4-5', 'claude-3-5-haiku-latest') - * @param config - Optional configuration (excluding apiKey which is auto-detected) - * @returns Configured Anthropic summarize adapter instance with resolved types + * @example + * ```typescript + * const adapter = anthropicSummarize('claude-sonnet-4-5'); + * await summarize({ adapter, text: 'Long article text...' }); + * ``` */ export function anthropicSummarize( model: TModel, config?: Omit, -): AnthropicSummarizeAdapter { - const apiKey = getAnthropicApiKeyFromEnv() - return createAnthropicSummarize(model, apiKey, config) +): ChatStreamSummarizeAdapter< + TModel, + InferTextProviderOptions> +> { + return createAnthropicSummarize(model, getAnthropicApiKeyFromEnv(), config) } diff --git a/packages/typescript/ai-anthropic/src/adapters/text.ts b/packages/typescript/ai-anthropic/src/adapters/text.ts index f057a18a4..92d19b8f0 100644 --- a/packages/typescript/ai-anthropic/src/adapters/text.ts +++ b/packages/typescript/ai-anthropic/src/adapters/text.ts @@ -1,3 +1,4 @@ +import { EventType } from '@tanstack/ai' import { BaseTextAdapter } from '@tanstack/ai/adapters' import { convertToolsToProviderFormat } from '../tools/tool-converter' import { validateTextProviderOptions } from '../text/text-provider-options' @@ -48,11 +49,6 @@ import type { } from '../message-types' import type { AnthropicClientConfig } from '../utils' -/** 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 Anthropic text adapter */ @@ -177,8 +173,8 @@ export class AnthropicTextAdapter< error, source: 'anthropic.chatStream', }) - yield asChunk({ - type: 'RUN_ERROR', + yield { + type: EventType.RUN_ERROR, model: options.model, timestamp: Date.now(), message: err.message || 'Unknown error occurred', @@ -187,7 +183,7 @@ export class AnthropicTextAdapter< message: err.message || 'Unknown error occurred', code: err.code || String(err.status), }, - }) + } satisfies StreamChunk } } @@ -629,7 +625,6 @@ export class AnthropicTextAdapter< let accumulatedContent = '' let accumulatedThinking = '' let accumulatedSignature = '' - const timestamp = Date.now() const toolCallsMap = new Map< number, { id: string; name: string; input: string; started: boolean } @@ -657,13 +652,13 @@ export class AnthropicTextAdapter< // Emit RUN_STARTED on first event if (!hasEmittedRunStarted) { hasEmittedRunStarted = true - yield asChunk({ - type: 'RUN_STARTED', + yield { + type: EventType.RUN_STARTED, runId, threadId, model, - timestamp, - }) + timestamp: Date.now(), + } satisfies StreamChunk } if (event.type === 'content_block_start') { @@ -684,94 +679,94 @@ export class AnthropicTextAdapter< reasoningMessageId = genId() // Spec REASONING events - yield asChunk({ - type: 'REASONING_START', + yield { + type: EventType.REASONING_START, messageId: reasoningMessageId, model, - timestamp, - }) - yield asChunk({ - type: 'REASONING_MESSAGE_START', + timestamp: Date.now(), + } satisfies StreamChunk + yield { + type: EventType.REASONING_MESSAGE_START, messageId: reasoningMessageId, role: 'reasoning' as const, model, - timestamp, - }) + timestamp: Date.now(), + } satisfies StreamChunk // Legacy STEP events (kept during transition) - yield asChunk({ - type: 'STEP_STARTED', + yield { + type: EventType.STEP_STARTED, stepName: stepId, stepId, model, - timestamp, + timestamp: Date.now(), stepType: 'thinking', - }) + } satisfies StreamChunk } } else if (event.type === 'content_block_delta') { if (event.delta.type === 'text_delta') { // Close reasoning before text starts if (reasoningMessageId && !hasClosedReasoning) { hasClosedReasoning = true - yield asChunk({ - type: 'REASONING_MESSAGE_END', + yield { + type: EventType.REASONING_MESSAGE_END, messageId: reasoningMessageId, model, - timestamp, - }) - yield asChunk({ - type: 'REASONING_END', + timestamp: Date.now(), + } satisfies StreamChunk + yield { + type: EventType.REASONING_END, messageId: reasoningMessageId, model, - timestamp, - }) + timestamp: Date.now(), + } satisfies StreamChunk } // Emit TEXT_MESSAGE_START on first text content if (!hasEmittedTextMessageStart) { hasEmittedTextMessageStart = true - yield asChunk({ - type: 'TEXT_MESSAGE_START', + yield { + type: EventType.TEXT_MESSAGE_START, messageId, model, - timestamp, + timestamp: Date.now(), role: 'assistant', - }) + } satisfies StreamChunk } const delta = event.delta.text accumulatedContent += delta - yield asChunk({ - type: 'TEXT_MESSAGE_CONTENT', + yield { + type: EventType.TEXT_MESSAGE_CONTENT, messageId, model, - timestamp, + timestamp: Date.now(), delta, content: accumulatedContent, - }) + } satisfies StreamChunk } else if (event.delta.type === 'thinking_delta') { const delta = event.delta.thinking accumulatedThinking += delta // Spec REASONING content event - yield asChunk({ - type: 'REASONING_MESSAGE_CONTENT', + yield { + type: EventType.REASONING_MESSAGE_CONTENT, messageId: reasoningMessageId!, delta, model, - timestamp, - }) + timestamp: Date.now(), + } satisfies StreamChunk // Legacy STEP event - yield asChunk({ - type: 'STEP_FINISHED', + yield { + type: EventType.STEP_FINISHED, stepName: stepId || genId(), stepId: stepId || genId(), model, - timestamp, + timestamp: Date.now(), delta, content: accumulatedThinking, - }) + } satisfies StreamChunk } else if ( (event.delta as { type: string }).type === 'signature_delta' ) { @@ -783,43 +778,43 @@ export class AnthropicTextAdapter< // Emit TOOL_CALL_START on first args delta if (!existing.started) { existing.started = true - yield asChunk({ - type: 'TOOL_CALL_START', + yield { + type: EventType.TOOL_CALL_START, toolCallId: existing.id, toolCallName: existing.name, toolName: existing.name, model, - timestamp, + timestamp: Date.now(), index: currentToolIndex, - }) + } satisfies StreamChunk } existing.input += event.delta.partial_json - yield asChunk({ - type: 'TOOL_CALL_ARGS', + yield { + type: EventType.TOOL_CALL_ARGS, toolCallId: existing.id, model, - timestamp, + timestamp: Date.now(), delta: event.delta.partial_json, args: existing.input, - }) + } satisfies StreamChunk } } } else if (event.type === 'content_block_stop') { if (currentBlockType === 'thinking') { // Emit signature so it can be replayed in multi-turn context if (accumulatedSignature && stepId) { - yield asChunk({ - type: 'STEP_FINISHED', + yield { + type: EventType.STEP_FINISHED, stepName: stepId, stepId, model, - timestamp, + timestamp: Date.now(), delta: '', content: accumulatedThinking, signature: accumulatedSignature, - }) + } satisfies StreamChunk } } else if (currentBlockType === 'tool_use') { const existing = toolCallsMap.get(currentToolIndex) @@ -827,15 +822,15 @@ export class AnthropicTextAdapter< // If tool call wasn't started yet (no args), start it now if (!existing.started) { existing.started = true - yield asChunk({ - type: 'TOOL_CALL_START', + yield { + type: EventType.TOOL_CALL_START, toolCallId: existing.id, toolCallName: existing.name, toolName: existing.name, model, - timestamp, + timestamp: Date.now(), index: currentToolIndex, - }) + } satisfies StreamChunk } // Emit TOOL_CALL_END @@ -847,15 +842,15 @@ export class AnthropicTextAdapter< parsedInput = {} } - yield asChunk({ - type: 'TOOL_CALL_END', + yield { + type: EventType.TOOL_CALL_END, toolCallId: existing.id, toolCallName: existing.name, toolName: existing.name, model, - timestamp, + timestamp: Date.now(), input: parsedInput, - }) + } satisfies StreamChunk // Reset so a new TEXT_MESSAGE_START is emitted if text follows tool calls hasEmittedTextMessageStart = false @@ -863,12 +858,12 @@ export class AnthropicTextAdapter< } else { // Emit TEXT_MESSAGE_END only for text blocks (not tool_use blocks) if (hasEmittedTextMessageStart && accumulatedContent) { - yield asChunk({ - type: 'TEXT_MESSAGE_END', + yield { + type: EventType.TEXT_MESSAGE_END, messageId, model, - timestamp, - }) + timestamp: Date.now(), + } satisfies StreamChunk } } currentBlockType = null @@ -876,32 +871,32 @@ export class AnthropicTextAdapter< // Close reasoning events if still open if (reasoningMessageId && !hasClosedReasoning) { hasClosedReasoning = true - yield asChunk({ - type: 'REASONING_MESSAGE_END', + yield { + type: EventType.REASONING_MESSAGE_END, messageId: reasoningMessageId, model, - timestamp, - }) - yield asChunk({ - type: 'REASONING_END', + timestamp: Date.now(), + } satisfies StreamChunk + yield { + type: EventType.REASONING_END, messageId: reasoningMessageId, model, - timestamp, - }) + timestamp: Date.now(), + } satisfies StreamChunk } // Only emit RUN_FINISHED from message_stop if message_delta didn't already emit one. // message_delta carries the real stop_reason (tool_use, end_turn, etc.), // while message_stop is just a completion signal. if (!hasEmittedRunFinished) { - yield asChunk({ - type: 'RUN_FINISHED', + yield { + type: EventType.RUN_FINISHED, runId, threadId, model, - timestamp, + timestamp: Date.now(), finishReason: 'stop', - }) + } satisfies StreamChunk } } else if (event.type === 'message_delta') { if (event.delta.stop_reason) { @@ -910,28 +905,28 @@ export class AnthropicTextAdapter< // Close reasoning events if still open if (reasoningMessageId && !hasClosedReasoning) { hasClosedReasoning = true - yield asChunk({ - type: 'REASONING_MESSAGE_END', + yield { + type: EventType.REASONING_MESSAGE_END, messageId: reasoningMessageId, model, - timestamp, - }) - yield asChunk({ - type: 'REASONING_END', + timestamp: Date.now(), + } satisfies StreamChunk + yield { + type: EventType.REASONING_END, messageId: reasoningMessageId, model, - timestamp, - }) + timestamp: Date.now(), + } satisfies StreamChunk } switch (event.delta.stop_reason) { case 'tool_use': { - yield asChunk({ - type: 'RUN_FINISHED', + yield { + type: EventType.RUN_FINISHED, runId, threadId, model, - timestamp, + timestamp: Date.now(), finishReason: 'tool_calls', usage: { promptTokens: event.usage.input_tokens || 0, @@ -940,15 +935,14 @@ export class AnthropicTextAdapter< (event.usage.input_tokens || 0) + (event.usage.output_tokens || 0), }, - }) + } satisfies StreamChunk break } case 'max_tokens': { - yield asChunk({ - type: 'RUN_ERROR', - runId, + yield { + type: EventType.RUN_ERROR, model, - timestamp, + timestamp: Date.now(), message: 'The response was cut off because the maximum token limit was reached.', code: 'max_tokens', @@ -957,16 +951,16 @@ export class AnthropicTextAdapter< 'The response was cut off because the maximum token limit was reached.', code: 'max_tokens', }, - }) + } satisfies StreamChunk break } default: { - yield asChunk({ - type: 'RUN_FINISHED', + yield { + type: EventType.RUN_FINISHED, runId, threadId, model, - timestamp, + timestamp: Date.now(), finishReason: 'stop', usage: { promptTokens: event.usage.input_tokens || 0, @@ -975,7 +969,7 @@ export class AnthropicTextAdapter< (event.usage.input_tokens || 0) + (event.usage.output_tokens || 0), }, - }) + } satisfies StreamChunk } } } @@ -988,18 +982,17 @@ export class AnthropicTextAdapter< error, source: 'anthropic.processAnthropicStream', }) - yield asChunk({ - type: 'RUN_ERROR', - runId, + yield { + type: EventType.RUN_ERROR, model, - timestamp, + timestamp: Date.now(), message: err.message || 'Unknown error occurred', code: err.code || String(err.status), error: { message: err.message || 'Unknown error occurred', code: err.code || String(err.status), }, - }) + } satisfies StreamChunk } } } diff --git a/packages/typescript/ai-anthropic/src/index.ts b/packages/typescript/ai-anthropic/src/index.ts index 4100ec183..1ba8e92b8 100644 --- a/packages/typescript/ai-anthropic/src/index.ts +++ b/packages/typescript/ai-anthropic/src/index.ts @@ -11,13 +11,12 @@ export { type AnthropicTextProviderOptions, } from './adapters/text' -// Summarize adapter - for text summarization +// Summarize - thin factory functions over @tanstack/ai's ChatStreamSummarizeAdapter export { - AnthropicSummarizeAdapter, anthropicSummarize, createAnthropicSummarize, type AnthropicSummarizeConfig, - type AnthropicSummarizeProviderOptions, + type AnthropicSummarizeModel, } from './adapters/summarize' // ============================================================================ // Type Exports diff --git a/packages/typescript/ai-client/tests/chat-client-abort.test.ts b/packages/typescript/ai-client/tests/chat-client-abort.test.ts index 71bf71522..882d6d471 100644 --- a/packages/typescript/ai-client/tests/chat-client-abort.test.ts +++ b/packages/typescript/ai-client/tests/chat-client-abort.test.ts @@ -1,12 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { EventType } from '@tanstack/ai' import { ChatClient } from '../src/chat-client' import type { ConnectionAdapter } from '../src/connection-adapters' import type { StreamChunk } from '@tanstack/ai' -/** Cast an event object to StreamChunk for type compatibility with EventType enum. */ -const asChunk = (chunk: Record) => - chunk as unknown as StreamChunk - describe('ChatClient - Abort Signal Handling', () => { let mockAdapter: ConnectionAdapter let receivedAbortSignal: AbortSignal | undefined @@ -20,29 +17,30 @@ describe('ChatClient - Abort Signal Handling', () => { receivedAbortSignal = abortSignal // Simulate streaming chunks (AG-UI format) - yield asChunk({ - type: 'TEXT_MESSAGE_CONTENT', + yield { + type: EventType.TEXT_MESSAGE_CONTENT, messageId: '1', model: 'test', timestamp: Date.now(), delta: 'Hello', content: 'Hello', - }) - yield asChunk({ - type: 'TEXT_MESSAGE_CONTENT', + } satisfies StreamChunk + yield { + type: EventType.TEXT_MESSAGE_CONTENT, messageId: '1', model: 'test', timestamp: Date.now(), delta: ' World', content: 'Hello World', - }) - yield asChunk({ - type: 'RUN_FINISHED', + } satisfies StreamChunk + yield { + type: EventType.RUN_FINISHED, runId: 'run-1', + threadId: 'thread-1', model: 'test', timestamp: Date.now(), finishReason: 'stop', - }) + } satisfies StreamChunk }, } }) @@ -82,24 +80,24 @@ describe('ChatClient - Abort Signal Handling', () => { } try { - yield asChunk({ - type: 'TEXT_MESSAGE_CONTENT', + yield { + type: EventType.TEXT_MESSAGE_CONTENT, messageId: '1', model: 'test', timestamp: Date.now(), delta: 'Hello', content: 'Hello', - }) + } satisfies StreamChunk // Simulate long-running stream await new Promise((resolve) => setTimeout(resolve, 100)) - yield asChunk({ - type: 'TEXT_MESSAGE_CONTENT', + yield { + type: EventType.TEXT_MESSAGE_CONTENT, messageId: '1', model: 'test', timestamp: Date.now(), delta: ' World', content: 'Hello World', - }) + } satisfies StreamChunk } catch (err) { // Abort errors are expected if (err instanceof Error && err.name === 'AbortError') { @@ -137,28 +135,28 @@ describe('ChatClient - Abort Signal Handling', () => { const adapterWithPartial: ConnectionAdapter = { // eslint-disable-next-line @typescript-eslint/require-await async *connect(_messages, _data, abortSignal) { - yield asChunk({ - type: 'TEXT_MESSAGE_CONTENT', + yield { + type: EventType.TEXT_MESSAGE_CONTENT, messageId: '1', model: 'test', timestamp: Date.now(), delta: 'Hello', content: 'Hello', - }) + } satisfies StreamChunk yieldedChunks++ if (abortSignal?.aborted) { return } - yield asChunk({ - type: 'TEXT_MESSAGE_CONTENT', + yield { + type: EventType.TEXT_MESSAGE_CONTENT, messageId: '1', model: 'test', timestamp: Date.now(), delta: ' World', content: 'Hello World', - }) + } satisfies StreamChunk yieldedChunks++ }, } @@ -194,14 +192,14 @@ describe('ChatClient - Abort Signal Handling', () => { const adapterWithAbort: ConnectionAdapter = { // eslint-disable-next-line @typescript-eslint/require-await async *connect(_messages, _data, abortSignal) { - yield asChunk({ - type: 'TEXT_MESSAGE_CONTENT', + yield { + type: EventType.TEXT_MESSAGE_CONTENT, messageId: '1', model: 'test', timestamp: Date.now(), delta: 'Hello', content: 'Hello', - }) + } satisfies StreamChunk if (abortSignal?.aborted) { return @@ -234,14 +232,14 @@ describe('ChatClient - Abort Signal Handling', () => { it('should set isLoading to false after abort', async () => { const adapterWithAbort: ConnectionAdapter = { async *connect(_messages, _data, _abortSignal) { - yield asChunk({ - type: 'TEXT_MESSAGE_CONTENT', + yield { + type: EventType.TEXT_MESSAGE_CONTENT, messageId: '1', model: 'test', timestamp: Date.now(), delta: 'Hello', content: 'Hello', - }) + } satisfies StreamChunk await new Promise((resolve) => setTimeout(resolve, 50)) }, } @@ -276,13 +274,14 @@ describe('ChatClient - Abort Signal Handling', () => { if (abortSignal) { abortSignals.push(abortSignal) } - yield asChunk({ - type: 'RUN_FINISHED', + yield { + type: EventType.RUN_FINISHED, runId: 'run-1', + threadId: 'thread-1', model: 'test', timestamp: Date.now(), finishReason: 'stop', - }) + } satisfies StreamChunk }, } @@ -319,13 +318,14 @@ describe('ChatClient - Abort Signal Handling', () => { // eslint-disable-next-line @typescript-eslint/require-await async *connect(_messages, _data, _abortSignal) { connectCalled = true - yield asChunk({ - type: 'RUN_FINISHED', + yield { + type: EventType.RUN_FINISHED, runId: 'run-1', + threadId: 'thread-1', model: 'test', timestamp: Date.now(), finishReason: 'stop', - }) + } satisfies StreamChunk }, } @@ -367,13 +367,14 @@ describe('ChatClient - Abort Signal Handling', () => { if (abortSignal) { signalsPassedToConnect.push(abortSignal) } - yield asChunk({ - type: 'RUN_FINISHED', + yield { + type: EventType.RUN_FINISHED, runId: 'run-1', + threadId: 'thread-1', model: 'test', timestamp: Date.now(), finishReason: 'stop', - }) + } satisfies StreamChunk }, } diff --git a/packages/typescript/ai-client/tests/chat-client.test.ts b/packages/typescript/ai-client/tests/chat-client.test.ts index ec997c868..ff933bd01 100644 --- a/packages/typescript/ai-client/tests/chat-client.test.ts +++ b/packages/typescript/ai-client/tests/chat-client.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import { EventType } from '@tanstack/ai' import { ChatClient } from '../src/chat-client' import { createMockConnectionAdapter, @@ -15,10 +16,6 @@ import type { import type { StreamChunk } from '@tanstack/ai' import type { UIMessage } from '../src/types' -/** Cast an event object to StreamChunk for type compatibility with EventType enum. */ -const asChunk = (chunk: Record) => - chunk as unknown as StreamChunk - describe('ChatClient', () => { describe('constructor', () => { it('should create a client with default options', () => { @@ -154,8 +151,9 @@ describe('ChatClient', () => { it('stop should not unsubscribe an active subscription', async () => { const adapter = createSubscribeAdapter([ { - type: 'RUN_FINISHED', + type: EventType.RUN_FINISHED, runId: 'run-1', + threadId: 'thread-1', model: 'test', timestamp: Date.now(), finishReason: 'stop', @@ -259,7 +257,7 @@ describe('ChatClient', () => { it('unsubscribe should abort in-flight requests and disconnect', async () => { const adapter = createSubscribeAdapter([ { - type: 'TEXT_MESSAGE_CONTENT', + type: EventType.TEXT_MESSAGE_CONTENT, messageId: 'msg-1', model: 'test', timestamp: Date.now(), @@ -318,7 +316,7 @@ describe('ChatClient', () => { it('should remain pending without terminal run events', async () => { const adapter = createSubscribeAdapter([ { - type: 'TEXT_MESSAGE_CONTENT', + type: EventType.TEXT_MESSAGE_CONTENT, messageId: 'msg-1', model: 'test', timestamp: Date.now(), @@ -354,13 +352,14 @@ describe('ChatClient', () => { it('should flip to true on RUN_STARTED and false on RUN_FINISHED', async () => { const chunks: Array = [ { - type: 'RUN_STARTED', + type: EventType.RUN_STARTED, runId: 'run-1', + threadId: 'thread-1', model: 'test', timestamp: Date.now(), } as unknown as StreamChunk, { - type: 'TEXT_MESSAGE_CONTENT', + type: EventType.TEXT_MESSAGE_CONTENT, messageId: 'msg-1', model: 'test', timestamp: Date.now(), @@ -368,8 +367,9 @@ describe('ChatClient', () => { content: 'Hi', } as unknown as StreamChunk, { - type: 'RUN_FINISHED', + type: EventType.RUN_FINISHED, runId: 'run-1', + threadId: 'thread-1', model: 'test', timestamp: Date.now(), finishReason: 'stop', @@ -393,13 +393,15 @@ describe('ChatClient', () => { it('should flip to false on RUN_ERROR', async () => { const chunks: Array = [ { - type: 'RUN_STARTED', + type: EventType.RUN_STARTED, runId: 'run-1', + threadId: 'thread-1', model: 'test', timestamp: Date.now(), } as unknown as StreamChunk, { - type: 'RUN_ERROR', + type: EventType.RUN_ERROR, + message: 'something went wrong', runId: 'run-1', model: 'test', timestamp: Date.now(), @@ -424,13 +426,14 @@ describe('ChatClient', () => { it('should remain correct through subscribe/unsubscribe cycles', async () => { const chunks: Array = [ { - type: 'RUN_STARTED', + type: EventType.RUN_STARTED, runId: 'run-1', + threadId: 'thread-1', model: 'test', timestamp: Date.now(), } as unknown as StreamChunk, { - type: 'TEXT_MESSAGE_CONTENT', + type: EventType.TEXT_MESSAGE_CONTENT, messageId: 'msg-1', model: 'test', timestamp: Date.now(), @@ -438,8 +441,9 @@ describe('ChatClient', () => { content: 'Hi', } as unknown as StreamChunk, { - type: 'RUN_FINISHED', + type: EventType.RUN_FINISHED, runId: 'run-1', + threadId: 'thread-1', model: 'test', timestamp: Date.now(), finishReason: 'stop', @@ -464,12 +468,13 @@ describe('ChatClient', () => { while (!signal?.aborted) { if (!yieldedStart) { yieldedStart = true - yield asChunk({ - type: 'RUN_STARTED' as const, + yield { + type: EventType.RUN_STARTED as const, runId: 'run-1', + threadId: 'thread-1', model: 'test', timestamp: Date.now(), - }) + } satisfies StreamChunk } await new Promise((resolve) => { const onAbort = () => resolve() @@ -507,12 +512,13 @@ describe('ChatClient', () => { while (!signal?.aborted) { if (!yieldedStart) { yieldedStart = true - yield asChunk({ - type: 'RUN_STARTED' as const, + yield { + type: EventType.RUN_STARTED as const, runId: 'run-1', + threadId: 'thread-1', model: 'test', timestamp: Date.now(), - }) + } satisfies StreamChunk } await new Promise((resolve) => { const onAbort = () => resolve() @@ -546,19 +552,21 @@ describe('ChatClient', () => { it('should not emit duplicate callbacks on repeated same-state events', async () => { const chunks: Array = [ { - type: 'RUN_STARTED', + type: EventType.RUN_STARTED, runId: 'run-1', + threadId: 'thread-1', model: 'test', timestamp: Date.now(), } as unknown as StreamChunk, { - type: 'RUN_STARTED', + type: EventType.RUN_STARTED, runId: 'run-1', + threadId: 'thread-1', model: 'test', timestamp: Date.now(), } as unknown as StreamChunk, { - type: 'TEXT_MESSAGE_CONTENT', + type: EventType.TEXT_MESSAGE_CONTENT, messageId: 'msg-1', model: 'test', timestamp: Date.now(), @@ -566,15 +574,17 @@ describe('ChatClient', () => { content: 'Hi', } as unknown as StreamChunk, { - type: 'RUN_FINISHED', + type: EventType.RUN_FINISHED, runId: 'run-1', + threadId: 'thread-1', model: 'test', timestamp: Date.now(), finishReason: 'stop', } as unknown as StreamChunk, { - type: 'RUN_FINISHED', + type: EventType.RUN_FINISHED, runId: 'run-1', + threadId: 'thread-1', model: 'test', timestamp: Date.now(), finishReason: 'stop', @@ -597,13 +607,14 @@ describe('ChatClient', () => { it('should handle interleaved multi-run events from durable subscription', async () => { const chunks: Array = [ { - type: 'RUN_STARTED', + type: EventType.RUN_STARTED, runId: 'run-1', + threadId: 'thread-1', model: 'test', timestamp: Date.now(), } as unknown as StreamChunk, { - type: 'TEXT_MESSAGE_CONTENT', + type: EventType.TEXT_MESSAGE_CONTENT, messageId: 'msg-1', model: 'test', timestamp: Date.now(), @@ -611,8 +622,9 @@ describe('ChatClient', () => { content: 'A', } as unknown as StreamChunk, { - type: 'RUN_FINISHED', + type: EventType.RUN_FINISHED, runId: 'run-1', + threadId: 'thread-1', model: 'test', timestamp: Date.now(), finishReason: 'stop', @@ -671,14 +683,16 @@ describe('ChatClient', () => { // Simulate two concurrent runs starting chunks.push( { - type: 'RUN_STARTED', + type: EventType.RUN_STARTED, runId: 'run-1', + threadId: 'thread-1', model: 'test', timestamp: Date.now(), } as unknown as StreamChunk, { - type: 'RUN_STARTED', + type: EventType.RUN_STARTED, runId: 'run-2', + threadId: 'thread-1', model: 'test', timestamp: Date.now(), } as unknown as StreamChunk, @@ -690,8 +704,9 @@ describe('ChatClient', () => { // First run finishes — should still be generating because run-2 is active chunks.push({ - type: 'RUN_FINISHED', + type: EventType.RUN_FINISHED, runId: 'run-1', + threadId: 'thread-1', model: 'test', timestamp: Date.now(), finishReason: 'stop', @@ -703,8 +718,9 @@ describe('ChatClient', () => { // Second run finishes — now should be false chunks.push({ - type: 'RUN_FINISHED', + type: EventType.RUN_FINISHED, runId: 'run-2', + threadId: 'thread-1', model: 'test', timestamp: Date.now(), finishReason: 'stop', @@ -756,14 +772,16 @@ describe('ChatClient', () => { // Two runs active chunks.push( { - type: 'RUN_STARTED', + type: EventType.RUN_STARTED, runId: 'run-1', + threadId: 'thread-1', model: 'test', timestamp: Date.now(), } as unknown as StreamChunk, { - type: 'RUN_STARTED', + type: EventType.RUN_STARTED, runId: 'run-2', + threadId: 'thread-1', model: 'test', timestamp: Date.now(), } as unknown as StreamChunk, @@ -775,7 +793,8 @@ describe('ChatClient', () => { // Session-level error without runId clears everything chunks.push({ - type: 'RUN_ERROR', + type: EventType.RUN_ERROR, + message: 'session crashed', model: 'test', timestamp: Date.now(), error: { message: 'session crashed' }, @@ -795,12 +814,13 @@ describe('ChatClient', () => { subscribe: async function* (_signal?: AbortSignal) { if (!yieldedStart) { yieldedStart = true - yield asChunk({ - type: 'RUN_STARTED' as const, + yield { + type: EventType.RUN_STARTED as const, runId: 'run-1', + threadId: 'thread-1', model: 'test', timestamp: Date.now(), - }) + } satisfies StreamChunk await new Promise((resolve) => setTimeout(resolve, 10)) } throw new Error('subscription failed') @@ -1349,7 +1369,7 @@ describe('ChatClient', () => { const noTerminalAdapter = createMockConnectionAdapter({ chunks: [ { - type: 'TEXT_MESSAGE_CONTENT', + type: EventType.TEXT_MESSAGE_CONTENT, messageId: 'msg-1', model: 'test', timestamp: Date.now(), @@ -1991,7 +2011,7 @@ describe('ChatClient', () => { // Yield the tool call and approval request const preChunks: Array = [ { - type: 'TOOL_CALL_START', + type: EventType.TOOL_CALL_START, toolCallId: 'tc-2', toolName: 'dangerous_tool_2', model: 'test', @@ -1999,21 +2019,21 @@ describe('ChatClient', () => { index: 0, } as unknown as StreamChunk, { - type: 'TOOL_CALL_ARGS', + type: EventType.TOOL_CALL_ARGS, toolCallId: 'tc-2', model: 'test', timestamp: Date.now(), delta: '{}', } as unknown as StreamChunk, { - type: 'TOOL_CALL_END', + type: EventType.TOOL_CALL_END, toolCallId: 'tc-2', toolName: 'dangerous_tool_2', model: 'test', timestamp: Date.now(), } as unknown as StreamChunk, { - type: 'CUSTOM', + type: EventType.CUSTOM, model: 'test', timestamp: Date.now(), name: 'approval-requested', @@ -2032,13 +2052,14 @@ describe('ChatClient', () => { resolveStreamPause = resolve }) - yield asChunk({ - type: 'RUN_FINISHED' as const, + yield { + type: EventType.RUN_FINISHED as const, runId: 'run-2', + threadId: 'thread-1', model: 'test', timestamp: Date.now(), finishReason: 'tool_calls' as const, - }) + } satisfies StreamChunk } else if (streamCount === 3) { // Third stream (after second approval): final text response const chunks = createTextChunks('All done!') @@ -2134,20 +2155,21 @@ describe('ChatClient', () => { // Run A starts with text message chunks.push( { - type: 'RUN_STARTED', + type: EventType.RUN_STARTED, runId: 'run-a', + threadId: 'thread-1', model: 'test', timestamp: Date.now(), } as unknown as StreamChunk, { - type: 'TEXT_MESSAGE_START', + type: EventType.TEXT_MESSAGE_START, messageId: 'msg-a', role: 'assistant', model: 'test', timestamp: Date.now(), } as StreamChunk, { - type: 'TEXT_MESSAGE_CONTENT', + type: EventType.TEXT_MESSAGE_CONTENT, messageId: 'msg-a', model: 'test', timestamp: Date.now(), @@ -2160,20 +2182,21 @@ describe('ChatClient', () => { // Run B starts concurrently chunks.push( { - type: 'RUN_STARTED', + type: EventType.RUN_STARTED, runId: 'run-b', + threadId: 'thread-1', model: 'test', timestamp: Date.now(), } as unknown as StreamChunk, { - type: 'TEXT_MESSAGE_START', + type: EventType.TEXT_MESSAGE_START, messageId: 'msg-b', role: 'assistant', model: 'test', timestamp: Date.now(), } as StreamChunk, { - type: 'TEXT_MESSAGE_CONTENT', + type: EventType.TEXT_MESSAGE_CONTENT, messageId: 'msg-b', model: 'test', timestamp: Date.now(), @@ -2185,8 +2208,9 @@ describe('ChatClient', () => { // Run B finishes — Run A should still be active chunks.push({ - type: 'RUN_FINISHED', + type: EventType.RUN_FINISHED, runId: 'run-b', + threadId: 'thread-1', model: 'test', timestamp: Date.now(), finishReason: 'stop', @@ -2196,7 +2220,7 @@ describe('ChatClient', () => { // Run A continues streaming chunks.push({ - type: 'TEXT_MESSAGE_CONTENT', + type: EventType.TEXT_MESSAGE_CONTENT, messageId: 'msg-a', model: 'test', timestamp: Date.now(), @@ -2224,8 +2248,9 @@ describe('ChatClient', () => { // Finish run A chunks.push({ - type: 'RUN_FINISHED', + type: EventType.RUN_FINISHED, runId: 'run-a', + threadId: 'thread-1', model: 'test', timestamp: Date.now(), finishReason: 'stop', @@ -2289,21 +2314,23 @@ describe('ChatClient', () => { // Resumed content for in-progress message (no TEXT_MESSAGE_START) chunks.push( { - type: 'RUN_STARTED', + type: EventType.RUN_STARTED, runId: 'run-1', + threadId: 'thread-1', model: 'test', timestamp: Date.now(), } as unknown as StreamChunk, { - type: 'TEXT_MESSAGE_CONTENT', + type: EventType.TEXT_MESSAGE_CONTENT, messageId: 'asst-1', model: 'test', timestamp: Date.now(), delta: 'time...', } as StreamChunk, { - type: 'RUN_FINISHED', + type: EventType.RUN_FINISHED, runId: 'run-1', + threadId: 'thread-1', model: 'test', timestamp: Date.now(), finishReason: 'stop', diff --git a/packages/typescript/ai-client/tests/connection-adapters.test.ts b/packages/typescript/ai-client/tests/connection-adapters.test.ts index 60c36763a..263f3600d 100644 --- a/packages/typescript/ai-client/tests/connection-adapters.test.ts +++ b/packages/typescript/ai-client/tests/connection-adapters.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { EventType } from '@tanstack/ai' import { fetchHttpStream, fetchServerSentEvents, @@ -8,10 +9,6 @@ import { } from '../src/connection-adapters' import type { StreamChunk } from '@tanstack/ai' -/** Cast an event object to StreamChunk for type compatibility with EventType enum. */ -const asChunk = (chunk: Record) => - chunk as unknown as StreamChunk - describe('connection-adapters', () => { let originalFetch: typeof fetch let fetchMock: ReturnType @@ -63,7 +60,7 @@ describe('connection-adapters', () => { expect(chunks).toHaveLength(1) expect(chunks[0]).toMatchObject({ - type: 'TEXT_MESSAGE_CONTENT', + type: EventType.TEXT_MESSAGE_CONTENT, messageId: 'msg-1', delta: 'Hello', }) @@ -789,14 +786,14 @@ describe('connection-adapters', () => { describe('stream', () => { it('should delegate to stream factory', async () => { const streamFactory = vi.fn().mockImplementation(function* () { - yield asChunk({ - type: 'TEXT_MESSAGE_CONTENT', + yield { + type: EventType.TEXT_MESSAGE_CONTENT, messageId: 'msg-1', model: 'test', timestamp: Date.now(), delta: 'Hello', content: 'Hello', - }) + } satisfies StreamChunk }) const adapter = stream(streamFactory) @@ -814,13 +811,14 @@ describe('connection-adapters', () => { it('should pass data to stream factory', async () => { const streamFactory = vi.fn().mockImplementation(function* () { - yield asChunk({ - type: 'RUN_FINISHED', + yield { + type: EventType.RUN_FINISHED, runId: 'run-1', + threadId: 'thread-1', model: 'test', timestamp: Date.now(), finishReason: 'stop', - }) + } satisfies StreamChunk }) const adapter = stream(streamFactory) @@ -874,14 +872,14 @@ describe('connection-adapters', () => { it('should synthesize RUN_FINISHED when wrapped connect stream has no terminal event', async () => { const base = stream(async function* () { - yield asChunk({ - type: 'TEXT_MESSAGE_CONTENT', + yield { + type: EventType.TEXT_MESSAGE_CONTENT, messageId: 'msg-1', model: 'test', timestamp: Date.now(), delta: 'Hi', content: 'Hi', - }) + } satisfies StreamChunk }) const adapter = normalizeConnectionAdapter(base) @@ -933,13 +931,14 @@ describe('connection-adapters', () => { it('should not synthesize duplicate RUN_ERROR when stream already emitted one before throwing', async () => { const base = stream(async function* () { - yield asChunk({ - type: 'RUN_ERROR', + yield { + type: EventType.RUN_ERROR, + message: 'already failed', timestamp: Date.now(), error: { message: 'already failed', }, - }) + } satisfies StreamChunk throw new Error('connect exploded') }) @@ -972,14 +971,14 @@ describe('connection-adapters', () => { describe('rpcStream', () => { it('should delegate to RPC call', async () => { const rpcCall = vi.fn().mockImplementation(function* () { - yield asChunk({ - type: 'TEXT_MESSAGE_CONTENT', + yield { + type: EventType.TEXT_MESSAGE_CONTENT, messageId: 'msg-1', model: 'test', timestamp: Date.now(), delta: 'Hello', content: 'Hello', - }) + } satisfies StreamChunk }) const adapter = rpcStream(rpcCall) @@ -994,20 +993,21 @@ describe('connection-adapters', () => { expect(rpcCall).toHaveBeenCalled() expect(chunks).toHaveLength(1) expect(chunks[0]).toMatchObject({ - type: 'TEXT_MESSAGE_CONTENT', + type: EventType.TEXT_MESSAGE_CONTENT, delta: 'Hello', }) }) it('should pass messages and data to RPC call', async () => { const rpcCall = vi.fn().mockImplementation(function* () { - yield asChunk({ - type: 'RUN_FINISHED', + yield { + type: EventType.RUN_FINISHED, runId: 'run-1', + threadId: 'thread-1', model: 'test', timestamp: Date.now(), finishReason: 'stop', - }) + } satisfies StreamChunk }) const adapter = rpcStream(rpcCall) diff --git a/packages/typescript/ai-client/tests/generation-client.test.ts b/packages/typescript/ai-client/tests/generation-client.test.ts index e17a09f23..4ce69353c 100644 --- a/packages/typescript/ai-client/tests/generation-client.test.ts +++ b/packages/typescript/ai-client/tests/generation-client.test.ts @@ -1,12 +1,9 @@ import { describe, it, expect, vi } from 'vitest' +import { EventType } from '@tanstack/ai' import { GenerationClient } from '../src/generation-client' import type { StreamChunk } from '@tanstack/ai' import type { ConnectConnectionAdapter } from '../src/connection-adapters' -/** Cast an event object to StreamChunk for type compatibility with EventType enum. */ -const asChunk = (chunk: Record) => - chunk as unknown as StreamChunk - // Helper to create a mock connect-based adapter from StreamChunks function createMockConnection( chunks: Array, @@ -133,19 +130,25 @@ describe('GenerationClient', () => { const onResult = vi.fn() const connection = createMockConnection([ - asChunk({ type: 'RUN_STARTED', runId: 'run-1', timestamp: Date.now() }), - asChunk({ - type: 'CUSTOM', + { + type: EventType.RUN_STARTED, + runId: 'run-1', + threadId: 'thread-1', + timestamp: Date.now(), + } satisfies StreamChunk, + { + type: EventType.CUSTOM, name: 'generation:result', value: mockResult, timestamp: Date.now(), - }), - asChunk({ - type: 'RUN_FINISHED', + } satisfies StreamChunk, + { + type: EventType.RUN_FINISHED, runId: 'run-1', + threadId: 'thread-1', finishReason: 'stop', timestamp: Date.now(), - }), + } satisfies StreamChunk, ]) const client = new GenerationClient({ @@ -164,13 +167,19 @@ describe('GenerationClient', () => { const onError = vi.fn() const connection = createMockConnection([ - asChunk({ type: 'RUN_STARTED', runId: 'run-1', timestamp: Date.now() }), - asChunk({ - type: 'RUN_ERROR', + { + type: EventType.RUN_STARTED, + runId: 'run-1', + threadId: 'thread-1', + timestamp: Date.now(), + } satisfies StreamChunk, + { + type: EventType.RUN_ERROR, + message: 'Generation failed', runId: 'run-1', error: { message: 'Generation failed' }, timestamp: Date.now(), - }), + } satisfies StreamChunk, ]) const client = new GenerationClient({ @@ -189,25 +198,31 @@ describe('GenerationClient', () => { const onProgress = vi.fn() const connection = createMockConnection([ - asChunk({ type: 'RUN_STARTED', runId: 'run-1', timestamp: Date.now() }), - asChunk({ - type: 'CUSTOM', + { + type: EventType.RUN_STARTED, + runId: 'run-1', + threadId: 'thread-1', + timestamp: Date.now(), + } satisfies StreamChunk, + { + type: EventType.CUSTOM, name: 'generation:progress', value: { progress: 50, message: 'Halfway' }, timestamp: Date.now(), - }), - asChunk({ - type: 'CUSTOM', + } satisfies StreamChunk, + { + type: EventType.CUSTOM, name: 'generation:result', value: { id: '1' }, timestamp: Date.now(), - }), - asChunk({ - type: 'RUN_FINISHED', + } satisfies StreamChunk, + { + type: EventType.RUN_FINISHED, runId: 'run-1', + threadId: 'thread-1', finishReason: 'stop', timestamp: Date.now(), - }), + } satisfies StreamChunk, ]) const client = new GenerationClient({ @@ -225,19 +240,21 @@ describe('GenerationClient', () => { const chunks: Array = [ { - type: 'RUN_STARTED', + type: EventType.RUN_STARTED, runId: 'run-1', + threadId: 'thread-1', timestamp: Date.now(), } as unknown as StreamChunk, { - type: 'CUSTOM', + type: EventType.CUSTOM, name: 'generation:result', value: { id: '1' }, timestamp: Date.now(), } as unknown as StreamChunk, { - type: 'RUN_FINISHED', + type: EventType.RUN_FINISHED, runId: 'run-1', + threadId: 'thread-1', finishReason: 'stop', timestamp: Date.now(), } as unknown as StreamChunk, @@ -257,18 +274,19 @@ describe('GenerationClient', () => { it('should pass body and input as data to connection', async () => { const connectSpy = vi.fn(async function* () { - yield asChunk({ - type: 'CUSTOM' as const, + yield { + type: EventType.CUSTOM as const, name: 'generation:result', value: { id: '1' }, timestamp: Date.now(), - }) - yield asChunk({ - type: 'RUN_FINISHED' as const, + } satisfies StreamChunk + yield { + type: EventType.RUN_FINISHED as const, runId: 'run-1', + threadId: 'thread-1', finishReason: 'stop' as const, timestamp: Date.now(), - }) + } satisfies StreamChunk }) const connection: ConnectConnectionAdapter = { @@ -334,12 +352,13 @@ describe('GenerationClient', () => { describe('updateOptions()', () => { it('should update body without recreating client', async () => { const connectSpy = vi.fn(async function* () { - yield asChunk({ - type: 'RUN_FINISHED' as const, + yield { + type: EventType.RUN_FINISHED as const, runId: 'run-1', + threadId: 'thread-1', finishReason: 'stop' as const, timestamp: Date.now(), - }) + } satisfies StreamChunk }) const connection: ConnectConnectionAdapter = { connect: connectSpy } @@ -366,23 +385,24 @@ describe('GenerationClient', () => { const connection: ConnectConnectionAdapter = { async *connect(_msgs, _data, signal) { - yield asChunk({ - type: 'RUN_STARTED' as const, + yield { + type: EventType.RUN_STARTED as const, runId: 'run-1', + threadId: 'thread-1', timestamp: Date.now(), - }) + } satisfies StreamChunk // Wait until abort is triggered await new Promise((resolve) => { signal?.addEventListener('abort', () => resolve()) }) // Adapter honors abort signal and stops yielding if (signal?.aborted) return - yield asChunk({ - type: 'CUSTOM' as const, + yield { + type: EventType.CUSTOM as const, name: 'generation:result', value: { id: '1' }, timestamp: Date.now(), - }) + } satisfies StreamChunk }, } @@ -464,13 +484,19 @@ describe('GenerationClient', () => { const onResult = vi.fn() const connection = createMockConnection([ - asChunk({ type: 'RUN_STARTED', runId: 'run-1', timestamp: Date.now() }), - asChunk({ - type: 'RUN_FINISHED', + { + type: EventType.RUN_STARTED, + runId: 'run-1', + threadId: 'thread-1', + timestamp: Date.now(), + } satisfies StreamChunk, + { + type: EventType.RUN_FINISHED, runId: 'run-1', + threadId: 'thread-1', finishReason: 'stop', timestamp: Date.now(), - }), + } satisfies StreamChunk, ]) const client = new GenerationClient({ @@ -489,19 +515,25 @@ describe('GenerationClient', () => { const onChunk = vi.fn() const connection = createMockConnection([ - asChunk({ type: 'RUN_STARTED', runId: 'run-1', timestamp: Date.now() }), - asChunk({ - type: 'CUSTOM', + { + type: EventType.RUN_STARTED, + runId: 'run-1', + threadId: 'thread-1', + timestamp: Date.now(), + } satisfies StreamChunk, + { + type: EventType.CUSTOM, name: 'unknown:event', value: { foo: 'bar' }, timestamp: Date.now(), - }), - asChunk({ - type: 'RUN_FINISHED', + } satisfies StreamChunk, + { + type: EventType.RUN_FINISHED, runId: 'run-1', + threadId: 'thread-1', finishReason: 'stop', timestamp: Date.now(), - }), + } satisfies StreamChunk, ]) const client = new GenerationClient({ @@ -594,19 +626,25 @@ describe('GenerationClient', () => { it('should transform result from stream CUSTOM event', async () => { const connection = createMockConnection([ - asChunk({ type: 'RUN_STARTED', runId: 'run-1', timestamp: Date.now() }), - asChunk({ - type: 'CUSTOM', + { + type: EventType.RUN_STARTED, + runId: 'run-1', + threadId: 'thread-1', + timestamp: Date.now(), + } satisfies StreamChunk, + { + type: EventType.CUSTOM, name: 'generation:result', value: { id: '1', images: [] }, timestamp: Date.now(), - }), - asChunk({ - type: 'RUN_FINISHED', + } satisfies StreamChunk, + { + type: EventType.RUN_FINISHED, runId: 'run-1', + threadId: 'thread-1', finishReason: 'stop', timestamp: Date.now(), - }), + } satisfies StreamChunk, ]) const client = new GenerationClient< @@ -697,19 +735,21 @@ describe('GenerationClient', () => { const response = createSSEResponse([ JSON.stringify({ - type: 'RUN_STARTED', + type: EventType.RUN_STARTED, runId: 'run-1', + threadId: 'thread-1', timestamp: 100, }), JSON.stringify({ - type: 'CUSTOM', + type: EventType.CUSTOM, name: 'generation:result', value: mockResult, timestamp: 200, }), JSON.stringify({ - type: 'RUN_FINISHED', + type: EventType.RUN_FINISHED, runId: 'run-1', + threadId: 'thread-1', finishReason: 'stop', timestamp: 300, }), @@ -732,12 +772,14 @@ describe('GenerationClient', () => { const response = createSSEResponse([ JSON.stringify({ - type: 'RUN_STARTED', + type: EventType.RUN_STARTED, runId: 'run-1', + threadId: 'thread-1', timestamp: 100, }), JSON.stringify({ - type: 'RUN_ERROR', + type: EventType.RUN_ERROR, + message: 'Generation failed', runId: 'run-1', error: { message: 'Generation failed' }, timestamp: 200, @@ -761,19 +803,21 @@ describe('GenerationClient', () => { const response = createSSEResponse([ JSON.stringify({ - type: 'RUN_STARTED', + type: EventType.RUN_STARTED, runId: 'run-1', + threadId: 'thread-1', timestamp: 100, }), JSON.stringify({ - type: 'CUSTOM', + type: EventType.CUSTOM, name: 'generation:result', value: { id: '1' }, timestamp: 200, }), JSON.stringify({ - type: 'RUN_FINISHED', + type: EventType.RUN_FINISHED, runId: 'run-1', + threadId: 'thread-1', finishReason: 'stop', timestamp: 300, }), @@ -794,25 +838,27 @@ describe('GenerationClient', () => { const response = createSSEResponse([ JSON.stringify({ - type: 'RUN_STARTED', + type: EventType.RUN_STARTED, runId: 'run-1', + threadId: 'thread-1', timestamp: 100, }), JSON.stringify({ - type: 'CUSTOM', + type: EventType.CUSTOM, name: 'generation:progress', value: { progress: 50, message: 'Halfway' }, timestamp: 200, }), JSON.stringify({ - type: 'CUSTOM', + type: EventType.CUSTOM, name: 'generation:result', value: { id: '1' }, timestamp: 300, }), JSON.stringify({ - type: 'RUN_FINISHED', + type: EventType.RUN_FINISHED, runId: 'run-1', + threadId: 'thread-1', finishReason: 'stop', timestamp: 400, }), @@ -852,14 +898,15 @@ describe('GenerationClient', () => { const fetcherSpy = vi.fn(async (_input: { prompt: string }) => { return createSSEResponse([ JSON.stringify({ - type: 'CUSTOM', + type: EventType.CUSTOM, name: 'generation:result', value: { id: '1' }, timestamp: 100, }), JSON.stringify({ - type: 'RUN_FINISHED', + type: EventType.RUN_FINISHED, runId: 'run-1', + threadId: 'thread-1', finishReason: 'stop', timestamp: 200, }), diff --git a/packages/typescript/ai-client/tests/video-generation-client.test.ts b/packages/typescript/ai-client/tests/video-generation-client.test.ts index 7118dbf1b..a0aa267f0 100644 --- a/packages/typescript/ai-client/tests/video-generation-client.test.ts +++ b/packages/typescript/ai-client/tests/video-generation-client.test.ts @@ -1,12 +1,9 @@ import { describe, it, expect, vi } from 'vitest' +import { EventType } from '@tanstack/ai' import { VideoGenerationClient } from '../src/video-generation-client' import type { StreamChunk } from '@tanstack/ai' import type { ConnectConnectionAdapter } from '../src/connection-adapters' -/** Cast an event object to StreamChunk for type compatibility with EventType enum. */ -const asChunk = (chunk: Record) => - chunk as unknown as StreamChunk - // Helper to create a mock connect-based adapter from StreamChunks function createMockConnection( chunks: Array, @@ -145,15 +142,20 @@ describe('VideoGenerationClient', () => { const onStatusUpdate = vi.fn() const connection = createMockConnection([ - asChunk({ type: 'RUN_STARTED', runId: 'run-1', timestamp: Date.now() }), - asChunk({ - type: 'CUSTOM', + { + type: EventType.RUN_STARTED, + runId: 'run-1', + threadId: 'thread-1', + timestamp: Date.now(), + } satisfies StreamChunk, + { + type: EventType.CUSTOM, name: 'video:job:created', value: { jobId: 'job-123' }, timestamp: Date.now(), - }), - asChunk({ - type: 'CUSTOM', + } satisfies StreamChunk, + { + type: EventType.CUSTOM, name: 'video:status', value: { jobId: 'job-123', @@ -161,9 +163,9 @@ describe('VideoGenerationClient', () => { progress: 50, }, timestamp: Date.now(), - }), - asChunk({ - type: 'CUSTOM', + } satisfies StreamChunk, + { + type: EventType.CUSTOM, name: 'video:status', value: { jobId: 'job-123', @@ -171,9 +173,9 @@ describe('VideoGenerationClient', () => { progress: 100, }, timestamp: Date.now(), - }), - asChunk({ - type: 'CUSTOM', + } satisfies StreamChunk, + { + type: EventType.CUSTOM, name: 'generation:result', value: { jobId: 'job-123', @@ -181,13 +183,14 @@ describe('VideoGenerationClient', () => { url: 'https://example.com/video.mp4', }, timestamp: Date.now(), - }), - asChunk({ - type: 'RUN_FINISHED', + } satisfies StreamChunk, + { + type: EventType.RUN_FINISHED, runId: 'run-1', + threadId: 'thread-1', finishReason: 'stop', timestamp: Date.now(), - }), + } satisfies StreamChunk, ]) const client = new VideoGenerationClient({ @@ -215,9 +218,14 @@ describe('VideoGenerationClient', () => { const onVideoStatusChange = vi.fn() const connection = createMockConnection([ - asChunk({ type: 'RUN_STARTED', runId: 'run-1', timestamp: Date.now() }), - asChunk({ - type: 'CUSTOM', + { + type: EventType.RUN_STARTED, + runId: 'run-1', + threadId: 'thread-1', + timestamp: Date.now(), + } satisfies StreamChunk, + { + type: EventType.CUSTOM, name: 'video:status', value: { jobId: 'job-1', @@ -225,9 +233,9 @@ describe('VideoGenerationClient', () => { progress: 25, }, timestamp: Date.now(), - }), - asChunk({ - type: 'CUSTOM', + } satisfies StreamChunk, + { + type: EventType.CUSTOM, name: 'generation:result', value: { jobId: 'job-1', @@ -235,13 +243,14 @@ describe('VideoGenerationClient', () => { url: 'https://example.com/video.mp4', }, timestamp: Date.now(), - }), - asChunk({ - type: 'RUN_FINISHED', + } satisfies StreamChunk, + { + type: EventType.RUN_FINISHED, runId: 'run-1', + threadId: 'thread-1', finishReason: 'stop', timestamp: Date.now(), - }), + } satisfies StreamChunk, ]) const client = new VideoGenerationClient({ @@ -268,13 +277,19 @@ describe('VideoGenerationClient', () => { const onError = vi.fn() const connection = createMockConnection([ - asChunk({ type: 'RUN_STARTED', runId: 'run-1', timestamp: Date.now() }), - asChunk({ - type: 'RUN_ERROR', + { + type: EventType.RUN_STARTED, + runId: 'run-1', + threadId: 'thread-1', + timestamp: Date.now(), + } satisfies StreamChunk, + { + type: EventType.RUN_ERROR, + message: 'Video generation failed', runId: 'run-1', error: { message: 'Video generation failed' }, timestamp: Date.now(), - }), + } satisfies StreamChunk, ]) const client = new VideoGenerationClient({ @@ -293,9 +308,14 @@ describe('VideoGenerationClient', () => { const onProgress = vi.fn() const connection = createMockConnection([ - asChunk({ type: 'RUN_STARTED', runId: 'run-1', timestamp: Date.now() }), - asChunk({ - type: 'CUSTOM', + { + type: EventType.RUN_STARTED, + runId: 'run-1', + threadId: 'thread-1', + timestamp: Date.now(), + } satisfies StreamChunk, + { + type: EventType.CUSTOM, name: 'video:status', value: { jobId: 'job-1', @@ -303,13 +323,14 @@ describe('VideoGenerationClient', () => { progress: 50, }, timestamp: Date.now(), - }), - asChunk({ - type: 'RUN_FINISHED', + } satisfies StreamChunk, + { + type: EventType.RUN_FINISHED, runId: 'run-1', + threadId: 'thread-1', finishReason: 'stop', timestamp: Date.now(), - }), + } satisfies StreamChunk, ]) const client = new VideoGenerationClient({ @@ -326,19 +347,25 @@ describe('VideoGenerationClient', () => { const onProgress = vi.fn() const connection = createMockConnection([ - asChunk({ type: 'RUN_STARTED', runId: 'run-1', timestamp: Date.now() }), - asChunk({ - type: 'CUSTOM', + { + type: EventType.RUN_STARTED, + runId: 'run-1', + threadId: 'thread-1', + timestamp: Date.now(), + } satisfies StreamChunk, + { + type: EventType.CUSTOM, name: 'generation:progress', value: { progress: 75, message: 'Almost done' }, timestamp: Date.now(), - }), - asChunk({ - type: 'RUN_FINISHED', + } satisfies StreamChunk, + { + type: EventType.RUN_FINISHED, runId: 'run-1', + threadId: 'thread-1', finishReason: 'stop', timestamp: Date.now(), - }), + } satisfies StreamChunk, ]) const client = new VideoGenerationClient({ @@ -355,9 +382,14 @@ describe('VideoGenerationClient', () => { const onChunk = vi.fn() const connection = createMockConnection([ - asChunk({ type: 'RUN_STARTED', runId: 'run-1', timestamp: Date.now() }), - asChunk({ - type: 'CUSTOM', + { + type: EventType.RUN_STARTED, + runId: 'run-1', + threadId: 'thread-1', + timestamp: Date.now(), + } satisfies StreamChunk, + { + type: EventType.CUSTOM, name: 'generation:result', value: { jobId: 'job-1', @@ -365,13 +397,14 @@ describe('VideoGenerationClient', () => { url: 'https://example.com/video.mp4', }, timestamp: Date.now(), - }), - asChunk({ - type: 'RUN_FINISHED', + } satisfies StreamChunk, + { + type: EventType.RUN_FINISHED, runId: 'run-1', + threadId: 'thread-1', finishReason: 'stop', timestamp: Date.now(), - }), + } satisfies StreamChunk, ]) const client = new VideoGenerationClient({ @@ -386,12 +419,13 @@ describe('VideoGenerationClient', () => { it('should pass body and input as data to connection', async () => { const connectSpy = vi.fn(async function* () { - yield asChunk({ - type: 'RUN_FINISHED' as const, + yield { + type: EventType.RUN_FINISHED as const, runId: 'run-1', + threadId: 'thread-1', finishReason: 'stop' as const, timestamp: Date.now(), - }) + } satisfies StreamChunk }) const connection: ConnectConnectionAdapter = { connect: connectSpy } @@ -445,15 +479,20 @@ describe('VideoGenerationClient', () => { const onVideoStatusChange = vi.fn() const connection = createMockConnection([ - asChunk({ type: 'RUN_STARTED', runId: 'run-1', timestamp: Date.now() }), - asChunk({ - type: 'CUSTOM', + { + type: EventType.RUN_STARTED, + runId: 'run-1', + threadId: 'thread-1', + timestamp: Date.now(), + } satisfies StreamChunk, + { + type: EventType.CUSTOM, name: 'video:job:created', value: { jobId: 'job-123' }, timestamp: Date.now(), - }), - asChunk({ - type: 'CUSTOM', + } satisfies StreamChunk, + { + type: EventType.CUSTOM, name: 'video:status', value: { jobId: 'job-123', @@ -461,9 +500,9 @@ describe('VideoGenerationClient', () => { progress: 50, }, timestamp: Date.now(), - }), - asChunk({ - type: 'CUSTOM', + } satisfies StreamChunk, + { + type: EventType.CUSTOM, name: 'generation:result', value: { jobId: 'job-123', @@ -471,13 +510,14 @@ describe('VideoGenerationClient', () => { url: 'https://example.com/video.mp4', }, timestamp: Date.now(), - }), - asChunk({ - type: 'RUN_FINISHED', + } satisfies StreamChunk, + { + type: EventType.RUN_FINISHED, runId: 'run-1', + threadId: 'thread-1', finishReason: 'stop', timestamp: Date.now(), - }), + } satisfies StreamChunk, ]) const client = new VideoGenerationClient({ @@ -503,12 +543,13 @@ describe('VideoGenerationClient', () => { describe('updateOptions()', () => { it('should update body without recreating client', async () => { const connectSpy = vi.fn(async function* () { - yield asChunk({ - type: 'RUN_FINISHED' as const, + yield { + type: EventType.RUN_FINISHED as const, runId: 'run-1', + threadId: 'thread-1', finishReason: 'stop' as const, timestamp: Date.now(), - }) + } satisfies StreamChunk }) const connection: ConnectConnectionAdapter = { connect: connectSpy } @@ -536,25 +577,26 @@ describe('VideoGenerationClient', () => { const connection: ConnectConnectionAdapter = { async *connect(_msgs, _data, signal) { - yield asChunk({ - type: 'RUN_STARTED' as const, + yield { + type: EventType.RUN_STARTED as const, runId: 'run-1', + threadId: 'thread-1', timestamp: Date.now(), - }) - yield asChunk({ - type: 'CUSTOM' as const, + } satisfies StreamChunk + yield { + type: EventType.CUSTOM as const, name: 'video:job:created', value: { jobId: 'job-123' }, timestamp: Date.now(), - }) + } satisfies StreamChunk // Wait until abort is triggered await new Promise((resolve) => { signal?.addEventListener('abort', () => resolve()) }) // Adapter honors abort signal and stops yielding if (signal?.aborted) return - yield asChunk({ - type: 'CUSTOM' as const, + yield { + type: EventType.CUSTOM as const, name: 'generation:result', value: { jobId: 'job-123', @@ -562,7 +604,7 @@ describe('VideoGenerationClient', () => { url: 'https://example.com/video.mp4', }, timestamp: Date.now(), - }) + } satisfies StreamChunk }, } diff --git a/packages/typescript/ai-gemini/src/adapters/summarize.ts b/packages/typescript/ai-gemini/src/adapters/summarize.ts index e5b3330b5..2fc39a63f 100644 --- a/packages/typescript/ai-gemini/src/adapters/summarize.ts +++ b/packages/typescript/ai-gemini/src/adapters/summarize.ts @@ -1,274 +1,56 @@ -import { FinishReason } from '@google/genai' -import { - createGeminiClient, - generateId, - getGeminiApiKeyFromEnv, -} from '../utils' -import type { GoogleGenAI } from '@google/genai' +import { ChatStreamSummarizeAdapter } from '@tanstack/ai/adapters' +import { getGeminiApiKeyFromEnv } from '../utils' +import { GeminiTextAdapter } from './text' +import type { InferTextProviderOptions } from '@tanstack/ai/adapters' +import type { GEMINI_MODELS } from '../model-meta' import type { GeminiClientConfig } from '../utils' -import type { SummarizeAdapter } from '@tanstack/ai/adapters' -import type { - StreamChunk, - SummarizationOptions, - SummarizationResult, -} from '@tanstack/ai' -/** Cast an event object to StreamChunk. */ -const asChunk = (chunk: Record) => - chunk as unknown as StreamChunk - -/** - * Configuration for Gemini summarize adapter - */ export interface GeminiSummarizeConfig extends GeminiClientConfig {} -/** - * Available Gemini models for summarization - */ -export const GeminiSummarizeModels = [ - 'gemini-3.1-flash-lite-preview', - 'gemini-2.0-flash', - 'gemini-1.5-flash', - 'gemini-1.5-pro', - 'gemini-2.0-flash-lite', -] as const - -export type GeminiSummarizeModel = (typeof GeminiSummarizeModels)[number] - -/** - * Provider-specific options for Gemini summarization - */ -export interface GeminiSummarizeProviderOptions { - /** Generation configuration */ - generationConfig?: { - temperature?: number - topP?: number - topK?: number - maxOutputTokens?: number - stopSequences?: Array - } - /** Safety settings */ - safetySettings?: Array<{ - category: string - threshold: string - }> -} - -export interface GeminiSummarizeAdapterOptions { - // Additional adapter options can be added here -} - -/** - * Gemini Summarize Adapter - * A tree-shakeable summarization adapter for Google Gemini - */ -export class GeminiSummarizeAdapter< - TModel extends GeminiSummarizeModel, -> implements SummarizeAdapter { - readonly kind = 'summarize' as const - readonly name = 'gemini' as const - readonly model: TModel - - // Type-only property - never assigned at runtime - declare '~types': { - providerOptions: GeminiSummarizeProviderOptions - } - - private client: GoogleGenAI - constructor(config: GeminiSummarizeConfig, model: TModel) { - this.client = createGeminiClient(config) - this.model = model - } - - async summarize(options: SummarizationOptions): Promise { - const { logger } = options - const model = options.model - - logger.request(`activity=summarize provider=gemini`, { - provider: 'gemini', - model, - }) - - // Build the system prompt based on format - const formatInstructions = this.getFormatInstructions(options.style) - const lengthInstructions = options.maxLength - ? ` Keep the summary under ${options.maxLength} tokens.` - : '' - - const systemPrompt = `You are a helpful assistant that summarizes text. ${formatInstructions}${lengthInstructions}` - - try { - const response = await this.client.models.generateContent({ - model, - contents: [ - { - role: 'user', - parts: [ - { text: `Please summarize the following:\n\n${options.text}` }, - ], - }, - ], - config: { - systemInstruction: systemPrompt, - }, - }) - - const summary = response.text ?? '' - const inputTokens = response.usageMetadata?.promptTokenCount ?? 0 - const outputTokens = response.usageMetadata?.candidatesTokenCount ?? 0 - - return { - id: generateId('sum'), - model, - summary, - usage: { - promptTokens: inputTokens, - completionTokens: outputTokens, - totalTokens: inputTokens + outputTokens, - }, - } - } catch (error) { - logger.errors('gemini.summarize fatal', { - error, - source: 'gemini.summarize', - }) - throw error - } - } - - async *summarizeStream( - options: SummarizationOptions, - ): AsyncIterable { - const { logger } = options - const model = options.model - const id = generateId('sum') - let accumulatedContent = '' - let inputTokens = 0 - let outputTokens = 0 - - // Build the system prompt based on format - const formatInstructions = this.getFormatInstructions(options.style) - const lengthInstructions = options.maxLength - ? ` Keep the summary under ${options.maxLength} words.` - : '' - - const systemPrompt = `You are a helpful assistant that summarizes text. ${formatInstructions}${lengthInstructions}` - - logger.request(`activity=summarize provider=gemini`, { - provider: 'gemini', - model, - stream: true, - }) - - try { - const result = await this.client.models.generateContentStream({ - model, - contents: [ - { - role: 'user', - parts: [ - { text: `Please summarize the following:\n\n${options.text}` }, - ], - }, - ], - config: { - systemInstruction: systemPrompt, - }, - }) - - for await (const chunk of result) { - logger.provider(`provider=gemini`, { chunk }) - // Track usage metadata - if (chunk.usageMetadata) { - inputTokens = chunk.usageMetadata.promptTokenCount ?? inputTokens - outputTokens = - chunk.usageMetadata.candidatesTokenCount ?? outputTokens - } - - if (chunk.candidates?.[0]?.content?.parts) { - for (const part of chunk.candidates[0].content.parts) { - if (part.text) { - accumulatedContent += part.text - yield asChunk({ - type: 'TEXT_MESSAGE_CONTENT', - messageId: id, - model, - timestamp: Date.now(), - delta: part.text, - content: accumulatedContent, - }) - } - } - } - - // Check for finish reason - const finishReason = chunk.candidates?.[0]?.finishReason - if ( - finishReason === FinishReason.STOP || - finishReason === FinishReason.MAX_TOKENS || - finishReason === FinishReason.SAFETY - ) { - yield asChunk({ - type: 'RUN_FINISHED', - runId: id, - model, - timestamp: Date.now(), - finishReason: - finishReason === FinishReason.STOP - ? 'stop' - : finishReason === FinishReason.MAX_TOKENS - ? 'length' - : 'content_filter', - usage: { - promptTokens: inputTokens, - completionTokens: outputTokens, - totalTokens: inputTokens + outputTokens, - }, - }) - } - } - } catch (error) { - logger.errors('gemini.summarize fatal', { - error, - source: 'gemini.summarize', - }) - throw error - } - } - - private getFormatInstructions( - style?: 'paragraph' | 'bullet-points' | 'concise', - ): string { - switch (style) { - case 'bullet-points': - return 'Provide the summary as bullet points.' - case 'concise': - return 'Provide a very brief one or two sentence summary.' - case 'paragraph': - default: - return 'Provide the summary in paragraph form.' - } - } -} +export type GeminiSummarizeModel = (typeof GEMINI_MODELS)[number] /** - * Creates a Gemini summarize adapter with explicit API key and model + * Creates a Gemini summarize adapter with explicit API key and model. + * + * Note: keeps the historical (apiKey, model, config) argument order to + * avoid breaking existing callers. + * + * @example + * ```typescript + * const adapter = createGeminiSummarize('AIza...', 'gemini-2.0-flash'); + * ``` */ export function createGeminiSummarize( apiKey: string, model: TModel, config?: Omit, -): GeminiSummarizeAdapter { - return new GeminiSummarizeAdapter({ ...config, apiKey }, model) +): ChatStreamSummarizeAdapter< + TModel, + InferTextProviderOptions> +> { + return new ChatStreamSummarizeAdapter( + new GeminiTextAdapter({ ...config, apiKey }, model), + model, + 'gemini', + ) } /** - * Creates a Gemini summarize adapter with API key from environment and required model + * Creates a Gemini summarize adapter with API key from `GOOGLE_API_KEY` / + * `GEMINI_API_KEY` environment variables. + * + * @example + * ```typescript + * const adapter = geminiSummarize('gemini-2.0-flash'); + * await summarize({ adapter, text: 'Long article text...' }); + * ``` */ export function geminiSummarize( model: TModel, config?: Omit, -): GeminiSummarizeAdapter { - const apiKey = getGeminiApiKeyFromEnv() - return new GeminiSummarizeAdapter({ ...config, apiKey }, model) +): ChatStreamSummarizeAdapter< + TModel, + InferTextProviderOptions> +> { + return createGeminiSummarize(getGeminiApiKeyFromEnv(), model, config) } diff --git a/packages/typescript/ai-gemini/src/adapters/text.ts b/packages/typescript/ai-gemini/src/adapters/text.ts index ea744f456..e52a74859 100644 --- a/packages/typescript/ai-gemini/src/adapters/text.ts +++ b/packages/typescript/ai-gemini/src/adapters/text.ts @@ -1,4 +1,5 @@ import { FinishReason } from '@google/genai' +import { EventType } from '@tanstack/ai' import { BaseTextAdapter } from '@tanstack/ai/adapters' import { convertToolsToProviderFormat } from '../tools/tool-converter' import { @@ -39,11 +40,6 @@ import type { } from '../message-types' import type { GeminiClientConfig } from '../utils' -/** 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 Gemini text adapter */ @@ -136,15 +132,14 @@ export class GeminiTextAdapter< yield* this.processStreamChunks(result, options, logger) } catch (error) { - const timestamp = Date.now() logger.errors('gemini.chatStream fatal', { error, source: 'gemini.chatStream', }) - yield asChunk({ - type: 'RUN_ERROR', + yield { + type: EventType.RUN_ERROR, model: options.model, - timestamp, + timestamp: Date.now(), message: error instanceof Error ? error.message @@ -155,7 +150,7 @@ export class GeminiTextAdapter< ? error.message : 'An unknown error occurred during the chat stream.', }, - }) + } satisfies StreamChunk } } @@ -240,7 +235,6 @@ export class GeminiTextAdapter< logger: InternalLogger, ): AsyncIterable { const model = options.model - const timestamp = Date.now() let accumulatedContent = '' let accumulatedThinking = '' const toolCallMap = new Map< @@ -271,13 +265,13 @@ export class GeminiTextAdapter< // Emit RUN_STARTED on first chunk if (!hasEmittedRunStarted) { hasEmittedRunStarted = true - yield asChunk({ - type: 'RUN_STARTED', + yield { + type: EventType.RUN_STARTED, runId, threadId, model, - timestamp, - }) + timestamp: Date.now(), + } satisfies StreamChunk } if (chunk.candidates?.[0]?.content?.parts) { @@ -293,92 +287,92 @@ export class GeminiTextAdapter< reasoningMessageId = generateId(this.name) // Spec REASONING events - yield asChunk({ - type: 'REASONING_START', + yield { + type: EventType.REASONING_START, messageId: reasoningMessageId, model, - timestamp, - }) - yield asChunk({ - type: 'REASONING_MESSAGE_START', + timestamp: Date.now(), + } satisfies StreamChunk + yield { + type: EventType.REASONING_MESSAGE_START, messageId: reasoningMessageId, role: 'reasoning' as const, model, - timestamp, - }) + timestamp: Date.now(), + } satisfies StreamChunk // Legacy STEP events (kept during transition) - yield asChunk({ - type: 'STEP_STARTED', + yield { + type: EventType.STEP_STARTED, stepName: stepId, stepId, model, - timestamp, + timestamp: Date.now(), stepType: 'thinking', - }) + } satisfies StreamChunk } accumulatedThinking += part.text // Spec REASONING content event - yield asChunk({ - type: 'REASONING_MESSAGE_CONTENT', + yield { + type: EventType.REASONING_MESSAGE_CONTENT, messageId: reasoningMessageId!, delta: part.text, model, - timestamp, - }) + timestamp: Date.now(), + } satisfies StreamChunk // Legacy STEP event - yield asChunk({ - type: 'STEP_FINISHED', + yield { + type: EventType.STEP_FINISHED, stepName: stepId || generateId(this.name), stepId: stepId || generateId(this.name), model, - timestamp, + timestamp: Date.now(), delta: part.text, content: accumulatedThinking, - }) + } satisfies StreamChunk } else if (part.text.trim()) { // Close reasoning before text starts if (reasoningMessageId && !hasClosedReasoning) { hasClosedReasoning = true - yield asChunk({ - type: 'REASONING_MESSAGE_END', + yield { + type: EventType.REASONING_MESSAGE_END, messageId: reasoningMessageId, model, - timestamp, - }) - yield asChunk({ - type: 'REASONING_END', + timestamp: Date.now(), + } satisfies StreamChunk + yield { + type: EventType.REASONING_END, messageId: reasoningMessageId, model, - timestamp, - }) + timestamp: Date.now(), + } satisfies StreamChunk } // Skip whitespace-only text parts (e.g. "\n" during auto-continuation) // Emit TEXT_MESSAGE_START on first text content if (!hasEmittedTextMessageStart) { hasEmittedTextMessageStart = true - yield asChunk({ - type: 'TEXT_MESSAGE_START', + yield { + type: EventType.TEXT_MESSAGE_START, messageId, model, - timestamp, + timestamp: Date.now(), role: 'assistant', - }) + } satisfies StreamChunk } accumulatedContent += part.text - yield asChunk({ - type: 'TEXT_MESSAGE_CONTENT', + yield { + type: EventType.TEXT_MESSAGE_CONTENT, messageId, model, - timestamp, + timestamp: Date.now(), delta: part.text, content: accumulatedContent, - }) + } satisfies StreamChunk } } @@ -430,31 +424,31 @@ export class GeminiTextAdapter< // Emit TOOL_CALL_START if not already started if (!toolCallData.started) { toolCallData.started = true - yield asChunk({ - type: 'TOOL_CALL_START', + yield { + type: EventType.TOOL_CALL_START, toolCallId, toolCallName: toolCallData.name, toolName: toolCallData.name, model, - timestamp, + timestamp: Date.now(), index: toolCallData.index, ...(toolCallData.thoughtSignature && { metadata: { thoughtSignature: toolCallData.thoughtSignature, } satisfies GeminiToolCallMetadata, }), - }) + } satisfies StreamChunk } // Emit TOOL_CALL_ARGS - yield asChunk({ - type: 'TOOL_CALL_ARGS', + yield { + type: EventType.TOOL_CALL_ARGS, toolCallId, model, - timestamp, + timestamp: Date.now(), delta: toolCallData.args, args: toolCallData.args, - }) + } satisfies StreamChunk } } } else if (chunk.data && chunk.data.trim()) { @@ -462,24 +456,24 @@ export class GeminiTextAdapter< // Emit TEXT_MESSAGE_START on first text content if (!hasEmittedTextMessageStart) { hasEmittedTextMessageStart = true - yield asChunk({ - type: 'TEXT_MESSAGE_START', + yield { + type: EventType.TEXT_MESSAGE_START, messageId, model, - timestamp, + timestamp: Date.now(), role: 'assistant', - }) + } satisfies StreamChunk } accumulatedContent += chunk.data - yield asChunk({ - type: 'TEXT_MESSAGE_CONTENT', + yield { + type: EventType.TEXT_MESSAGE_CONTENT, messageId, model, - timestamp, + timestamp: Date.now(), delta: chunk.data, content: accumulatedContent, - }) + } satisfies StreamChunk } if (chunk.candidates?.[0]?.finishReason) { @@ -508,15 +502,15 @@ export class GeminiTextAdapter< }) // Emit TOOL_CALL_START - yield asChunk({ - type: 'TOOL_CALL_START', + yield { + type: EventType.TOOL_CALL_START, toolCallId, toolCallName: functionCall.name || '', toolName: functionCall.name || '', model, - timestamp, + timestamp: Date.now(), index: nextToolIndex - 1, - }) + } satisfies StreamChunk // Emit TOOL_CALL_END with parsed input let parsedInput: unknown = {} @@ -531,15 +525,15 @@ export class GeminiTextAdapter< parsedInput = {} } - yield asChunk({ - type: 'TOOL_CALL_END', + yield { + type: EventType.TOOL_CALL_END, toolCallId, toolCallName: functionCall.name || '', toolName: functionCall.name || '', model, - timestamp, + timestamp: Date.now(), input: parsedInput, - }) + } satisfies StreamChunk } } } @@ -555,15 +549,15 @@ export class GeminiTextAdapter< parsedInput = {} } - yield asChunk({ - type: 'TOOL_CALL_END', + yield { + type: EventType.TOOL_CALL_END, toolCallId, toolCallName: toolCallData.name, toolName: toolCallData.name, model, - timestamp, + timestamp: Date.now(), input: parsedInput, - }) + } satisfies StreamChunk } // Reset so a new TEXT_MESSAGE_START is emitted if text follows tool calls @@ -572,11 +566,11 @@ export class GeminiTextAdapter< } if (finishReason === FinishReason.MAX_TOKENS) { - yield asChunk({ - type: 'RUN_ERROR', + yield { + type: EventType.RUN_ERROR, runId, model, - timestamp, + timestamp: Date.now(), message: 'The response was cut off because the maximum token limit was reached.', code: 'max_tokens', @@ -585,42 +579,42 @@ export class GeminiTextAdapter< 'The response was cut off because the maximum token limit was reached.', code: 'max_tokens', }, - }) + } satisfies StreamChunk } // Close reasoning events if still open if (reasoningMessageId && !hasClosedReasoning) { hasClosedReasoning = true - yield asChunk({ - type: 'REASONING_MESSAGE_END', + yield { + type: EventType.REASONING_MESSAGE_END, messageId: reasoningMessageId, model, - timestamp, - }) - yield asChunk({ - type: 'REASONING_END', + timestamp: Date.now(), + } satisfies StreamChunk + yield { + type: EventType.REASONING_END, messageId: reasoningMessageId, model, - timestamp, - }) + timestamp: Date.now(), + } satisfies StreamChunk } // Emit TEXT_MESSAGE_END if we had text content if (hasEmittedTextMessageStart) { - yield asChunk({ - type: 'TEXT_MESSAGE_END', + yield { + type: EventType.TEXT_MESSAGE_END, messageId, model, - timestamp, - }) + timestamp: Date.now(), + } satisfies StreamChunk } - yield asChunk({ - type: 'RUN_FINISHED', + yield { + type: EventType.RUN_FINISHED, runId, threadId, model, - timestamp, + timestamp: Date.now(), finishReason: toolCallMap.size > 0 ? 'tool_calls' : 'stop', usage: chunk.usageMetadata ? { @@ -629,7 +623,7 @@ export class GeminiTextAdapter< totalTokens: chunk.usageMetadata.totalTokenCount ?? 0, } : undefined, - }) + } satisfies StreamChunk } } } diff --git a/packages/typescript/ai-gemini/src/index.ts b/packages/typescript/ai-gemini/src/index.ts index 58c767d9a..a77e542ef 100644 --- a/packages/typescript/ai-gemini/src/index.ts +++ b/packages/typescript/ai-gemini/src/index.ts @@ -11,15 +11,12 @@ export { type GeminiTextProviderOptions, } from './adapters/text' -// Summarize adapter +// Summarize - thin factory functions over @tanstack/ai's ChatStreamSummarizeAdapter export { - GeminiSummarizeAdapter, - GeminiSummarizeModels, createGeminiSummarize, geminiSummarize, - type GeminiSummarizeAdapterOptions, + type GeminiSummarizeConfig, type GeminiSummarizeModel, - type GeminiSummarizeProviderOptions, } from './adapters/summarize' // Image adapter diff --git a/packages/typescript/ai-gemini/tests/gemini-adapter.test.ts b/packages/typescript/ai-gemini/tests/gemini-adapter.test.ts index 3da5d4c20..b894da9f3 100644 --- a/packages/typescript/ai-gemini/tests/gemini-adapter.test.ts +++ b/packages/typescript/ai-gemini/tests/gemini-adapter.test.ts @@ -8,7 +8,7 @@ import { type SafetySetting, } from '@google/genai' import { GeminiTextAdapter } from '../src/adapters/text' -import { GeminiSummarizeAdapter } from '../src/adapters/summarize' +import { createGeminiSummarize } from '../src/adapters/summarize' import type { GeminiTextProviderOptions } from '../src/adapters/text' import type { Schema } from '@google/genai' @@ -53,7 +53,7 @@ vi.mock('@google/genai', async () => { const createTextAdapter = () => new GeminiTextAdapter({ apiKey: 'test-key' }, 'gemini-2.5-pro') const createSummarizeAdapter = () => - new GeminiSummarizeAdapter('test-key', 'gemini-2.0-flash') + createGeminiSummarize('test-key', 'gemini-2.0-flash') const weatherTool: Tool = { name: 'lookup_weather', @@ -755,15 +755,26 @@ describe('GeminiAdapter through AI', () => { expect(funcResponsePart.functionResponse.id).toBe('fc_001') }) - it('uses summarize function with models API', async () => { + it('routes summarize() through the gemini chat-stream path', async () => { const summaryText = 'Short and sweet.' - mocks.generateContentSpy.mockResolvedValueOnce({ - text: summaryText, - usageMetadata: { - promptTokenCount: 10, - candidatesTokenCount: 5, + const streamChunks = [ + { + candidates: [ + { + content: { parts: [{ text: summaryText }] }, + finishReason: 'STOP', + }, + ], + usageMetadata: { + promptTokenCount: 10, + candidatesTokenCount: 5, + totalTokenCount: 15, + }, }, - }) + ] + mocks.generateContentStreamSpy.mockResolvedValueOnce( + createStream(streamChunks), + ) const adapter = createSummarizeAdapter() const result = await summarize({ @@ -773,10 +784,13 @@ describe('GeminiAdapter through AI', () => { style: 'paragraph', }) - expect(mocks.generateContentSpy).toHaveBeenCalledTimes(1) - const [payload] = mocks.generateContentSpy.mock.calls[0] + expect(mocks.generateContentStreamSpy).toHaveBeenCalledTimes(1) + const [payload] = mocks.generateContentStreamSpy.mock.calls[0] expect(payload.model).toBe('gemini-2.0-flash') - expect(payload.config.systemInstruction).toContain('summarizes text') + expect(payload.config.systemInstruction).toContain( + 'professional summarizer', + ) + expect(payload.config.systemInstruction).toContain('paragraph format') expect(payload.config.systemInstruction).toContain('123 tokens') expect(result.summary).toBe(summaryText) }) diff --git a/packages/typescript/ai-grok/package.json b/packages/typescript/ai-grok/package.json index 063c64780..559b8f740 100644 --- a/packages/typescript/ai-grok/package.json +++ b/packages/typescript/ai-grok/package.json @@ -45,7 +45,8 @@ ], "dependencies": { "@tanstack/ai-utils": "workspace:*", - "@tanstack/openai-base": "workspace:*" + "@tanstack/openai-base": "workspace:*", + "openai": "^6.9.1" }, "devDependencies": { "@tanstack/ai": "workspace:*", diff --git a/packages/typescript/ai-grok/src/adapters/image.ts b/packages/typescript/ai-grok/src/adapters/image.ts index 53ef336b9..13e17e8ca 100644 --- a/packages/typescript/ai-grok/src/adapters/image.ts +++ b/packages/typescript/ai-grok/src/adapters/image.ts @@ -1,10 +1,19 @@ -import { OpenAICompatibleImageAdapter } from '@tanstack/openai-base' +import OpenAI from 'openai' +import { BaseImageAdapter } from '@tanstack/ai/adapters' +import { toRunErrorPayload } from '@tanstack/ai/adapter-internals' +import { generateId } from '@tanstack/ai-utils' import { getGrokApiKeyFromEnv, withGrokDefaults } from '../utils/client' import { validateImageSize, validateNumberOfImages, validatePrompt, } from '../image/image-provider-options' +import type { + GeneratedImage, + ImageGenerationOptions, + ImageGenerationResult, +} from '@tanstack/ai' +import type OpenAI_SDK from 'openai' import type { GrokImageModel } from '../model-meta' import type { GrokImageModelProviderOptionsByName, @@ -19,19 +28,11 @@ import type { GrokClientConfig } from '../utils' export interface GrokImageConfig extends GrokClientConfig {} /** - * Grok Image Generation Adapter - * - * Tree-shakeable adapter for Grok image generation functionality. - * Supports grok-2-image-1212 model. - * - * Features: - * - Model-specific type-safe provider options - * - Size validation per model - * - Number of images validation + * Grok Image Generation Adapter. Supports grok-2-image-1212. */ export class GrokImageAdapter< TModel extends GrokImageModel, -> extends OpenAICompatibleImageAdapter< +> extends BaseImageAdapter< TModel, GrokImageProviderOptions, GrokImageModelProviderOptionsByName, @@ -40,51 +41,75 @@ export class GrokImageAdapter< readonly kind = 'image' as const readonly name = 'grok' as const + protected client: OpenAI + constructor(config: GrokImageConfig, model: TModel) { - super(withGrokDefaults(config), model, 'grok') + super(model, {}) + this.client = new OpenAI(withGrokDefaults(config)) } - protected override validatePrompt(options: { - prompt: string - model: string - }): void { - validatePrompt(options) - } + async generateImages( + options: ImageGenerationOptions, + ): Promise { + const { model, prompt, numberOfImages, size, modelOptions } = options - protected override validateImageSize( - model: string, - size: string | undefined, - ): void { + validatePrompt({ prompt, model }) validateImageSize(model, size) - } - - protected override validateNumberOfImages( - model: string, - numberOfImages: number | undefined, - ): void { validateNumberOfImages(model, numberOfImages) + + const request: OpenAI_SDK.Images.ImageGenerateParams = { + model, + prompt, + n: numberOfImages ?? 1, + size: size as OpenAI_SDK.Images.ImageGenerateParams['size'], + ...modelOptions, + } + + try { + options.logger.request( + `activity=image provider=${this.name} model=${model} n=${request.n ?? 1} size=${request.size ?? 'default'}`, + { provider: this.name, model }, + ) + const response = await this.client.images.generate({ + ...request, + stream: false, + }) + + 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, + } + } catch (error: unknown) { + options.logger.errors(`${this.name}.generateImages fatal`, { + error: toRunErrorPayload(error, `${this.name}.generateImages failed`), + source: `${this.name}.generateImages`, + }) + throw error + } } } -/** - * Creates a Grok image adapter with explicit API key. - * Type resolution happens here at the call site. - * - * @param model - The model name (e.g., 'grok-2-image-1212') - * @param apiKey - Your xAI API key - * @param config - Optional additional configuration - * @returns Configured Grok image adapter instance with resolved types - * - * @example - * ```typescript - * const adapter = createGrokImage('grok-2-image-1212', "xai-..."); - * - * const result = await generateImage({ - * adapter, - * prompt: 'A cute baby sea otter' - * }); - * ``` - */ export function createGrokImage( model: TModel, apiKey: string, @@ -93,30 +118,6 @@ export function createGrokImage( return new GrokImageAdapter({ apiKey, ...config }, model) } -/** - * Creates a Grok image adapter with automatic API key detection from environment variables. - * Type resolution happens here at the call site. - * - * Looks for `XAI_API_KEY` in: - * - `process.env` (Node.js) - * - `window.env` (Browser with injected env) - * - * @param model - The model name (e.g., 'grok-2-image-1212') - * @param config - Optional configuration (excluding apiKey which is auto-detected) - * @returns Configured Grok image adapter instance with resolved types - * @throws Error if XAI_API_KEY is not found in environment - * - * @example - * ```typescript - * // Automatically uses XAI_API_KEY from environment - * const adapter = grokImage('grok-2-image-1212'); - * - * const result = await generateImage({ - * adapter, - * prompt: 'A beautiful sunset over mountains' - * }); - * ``` - */ export function grokImage( model: TModel, config?: Omit, diff --git a/packages/typescript/ai-grok/src/adapters/summarize.ts b/packages/typescript/ai-grok/src/adapters/summarize.ts index f13984bac..0177d2983 100644 --- a/packages/typescript/ai-grok/src/adapters/summarize.ts +++ b/packages/typescript/ai-grok/src/adapters/summarize.ts @@ -1,65 +1,16 @@ -import { OpenAICompatibleSummarizeAdapter } from '@tanstack/openai-base' +import { ChatStreamSummarizeAdapter } from '@tanstack/ai/adapters' import { getGrokApiKeyFromEnv } from '../utils' import { GrokTextAdapter } from './text' -import type { ChatStreamCapable } from '@tanstack/openai-base' +import type { InferTextProviderOptions } from '@tanstack/ai/adapters' import type { GROK_CHAT_MODELS } from '../model-meta' import type { GrokClientConfig } from '../utils' -/** - * Configuration for Grok summarize adapter - */ export interface GrokSummarizeConfig extends GrokClientConfig {} -/** - * Grok-specific provider options for summarization - */ -export interface GrokSummarizeProviderOptions { - /** Temperature for response generation (0-2) */ - temperature?: number - /** Maximum tokens in the response */ - maxTokens?: number -} - -/** Model type for Grok summarization */ export type GrokSummarizeModel = (typeof GROK_CHAT_MODELS)[number] -/** - * Grok Summarize Adapter - * - * A thin wrapper around the text adapter that adds summarization-specific prompting. - * Delegates all API calls to the GrokTextAdapter. - */ -export class GrokSummarizeAdapter< - TModel extends GrokSummarizeModel, -> extends OpenAICompatibleSummarizeAdapter< - TModel, - GrokSummarizeProviderOptions -> { - readonly kind = 'summarize' as const - readonly name = 'grok' as const - - constructor(config: GrokSummarizeConfig, model: TModel) { - // 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', - ) - } -} - /** * Creates a Grok summarize adapter with explicit API key. - * Type resolution happens here at the call site. - * - * @param model - The model name (e.g., 'grok-3', 'grok-4') - * @param apiKey - Your xAI API key - * @param config - Optional additional configuration - * @returns Configured Grok summarize adapter instance with resolved types * * @example * ```typescript @@ -70,38 +21,32 @@ export function createGrokSummarize( model: TModel, apiKey: string, config?: Omit, -): GrokSummarizeAdapter { - return new GrokSummarizeAdapter({ apiKey, ...config }, model) +): ChatStreamSummarizeAdapter< + TModel, + InferTextProviderOptions> +> { + return new ChatStreamSummarizeAdapter( + new GrokTextAdapter({ apiKey, ...config }, model), + model, + 'grok', + ) } /** - * Creates a Grok summarize adapter with automatic API key detection from environment variables. - * Type resolution happens here at the call site. - * - * Looks for `XAI_API_KEY` in: - * - `process.env` (Node.js) - * - `window.env` (Browser with injected env) - * - * @param model - The model name (e.g., 'grok-3', 'grok-4') - * @param config - Optional configuration (excluding apiKey which is auto-detected) - * @returns Configured Grok summarize adapter instance with resolved types - * @throws Error if XAI_API_KEY is not found in environment + * Creates a Grok summarize adapter with API key from `XAI_API_KEY`. * * @example * ```typescript - * // Automatically uses XAI_API_KEY from environment * const adapter = grokSummarize('grok-3'); - * - * await summarize({ - * adapter, - * text: "Long article text..." - * }); + * await summarize({ adapter, text: "Long article text..." }); * ``` */ export function grokSummarize( model: TModel, config?: Omit, -): GrokSummarizeAdapter { - const apiKey = getGrokApiKeyFromEnv() - return createGrokSummarize(model, apiKey, config) +): ChatStreamSummarizeAdapter< + TModel, + InferTextProviderOptions> +> { + return createGrokSummarize(model, getGrokApiKeyFromEnv(), config) } diff --git a/packages/typescript/ai-grok/src/adapters/text.ts b/packages/typescript/ai-grok/src/adapters/text.ts index c0c22e3c4..0a25fa3fb 100644 --- a/packages/typescript/ai-grok/src/adapters/text.ts +++ b/packages/typescript/ai-grok/src/adapters/text.ts @@ -1,4 +1,5 @@ -import { OpenAICompatibleChatCompletionsTextAdapter } from '@tanstack/openai-base' +import OpenAI from 'openai' +import { OpenAIBaseChatCompletionsTextAdapter } from '@tanstack/openai-base' import { getGrokApiKeyFromEnv, withGrokDefaults } from '../utils/client' import type { GROK_CHAT_MODELS, @@ -34,9 +35,9 @@ 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. + * Delegates implementation to {@link OpenAIBaseChatCompletionsTextAdapter} + * 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], @@ -45,7 +46,7 @@ export class GrokTextAdapter< ResolveInputModalities, TToolCapabilities extends ReadonlyArray = ResolveToolCapabilities, -> extends OpenAICompatibleChatCompletionsTextAdapter< +> extends OpenAIBaseChatCompletionsTextAdapter< TModel, TProviderOptions, TInputModalities, @@ -56,7 +57,7 @@ export class GrokTextAdapter< readonly name = 'grok' as const constructor(config: GrokTextConfig, model: TModel) { - super(withGrokDefaults(config), model, 'grok') + super(model, 'grok', new OpenAI(withGrokDefaults(config))) } } diff --git a/packages/typescript/ai-grok/src/index.ts b/packages/typescript/ai-grok/src/index.ts index 85b8999f8..142ab3346 100644 --- a/packages/typescript/ai-grok/src/index.ts +++ b/packages/typescript/ai-grok/src/index.ts @@ -11,13 +11,11 @@ export { type GrokTextProviderOptions, } from './adapters/text' -// Summarize adapter - for text summarization +// Summarize - thin factory functions over @tanstack/ai's ChatStreamSummarizeAdapter export { - GrokSummarizeAdapter, createGrokSummarize, grokSummarize, type GrokSummarizeConfig, - type GrokSummarizeProviderOptions, type GrokSummarizeModel, } from './adapters/summarize' diff --git a/packages/typescript/ai-grok/src/utils/client.ts b/packages/typescript/ai-grok/src/utils/client.ts index 890224592..8ca0c047e 100644 --- a/packages/typescript/ai-grok/src/utils/client.ts +++ b/packages/typescript/ai-grok/src/utils/client.ts @@ -1,7 +1,9 @@ import { getApiKeyFromEnv } from '@tanstack/ai-utils' -import type { OpenAICompatibleClientConfig } from '@tanstack/openai-base' +import type { ClientOptions } from 'openai' -export interface GrokClientConfig extends OpenAICompatibleClientConfig {} +export interface GrokClientConfig extends Omit { + apiKey: string +} /** * Gets Grok API key from environment variables @@ -21,9 +23,7 @@ export function getGrokApiKeyFromEnv(): string { * Returns a Grok client config with the default xAI base URL applied * when not already set. */ -export function withGrokDefaults( - config: GrokClientConfig, -): OpenAICompatibleClientConfig { +export function withGrokDefaults(config: GrokClientConfig): GrokClientConfig { return { ...config, baseURL: config.baseURL || 'https://api.x.ai/v1', diff --git a/packages/typescript/ai-groq/package.json b/packages/typescript/ai-groq/package.json index ee3a18a5b..dee3e860e 100644 --- a/packages/typescript/ai-groq/package.json +++ b/packages/typescript/ai-groq/package.json @@ -53,6 +53,6 @@ "dependencies": { "@tanstack/ai-utils": "workspace:*", "@tanstack/openai-base": "workspace:*", - "groq-sdk": "^0.37.0" + "openai": "^6.9.1" } } diff --git a/packages/typescript/ai-groq/src/adapters/text.ts b/packages/typescript/ai-groq/src/adapters/text.ts index 34f44ba81..879342187 100644 --- a/packages/typescript/ai-groq/src/adapters/text.ts +++ b/packages/typescript/ai-groq/src/adapters/text.ts @@ -1,72 +1,45 @@ -import { BaseTextAdapter } from '@tanstack/ai/adapters' -import { validateTextProviderOptions } from '../text/text-provider-options' -import { convertToolsToProviderFormat } from '../tools' -import { - createGroqClient, - generateId, - getGroqApiKeyFromEnv, - makeGroqStructuredOutputCompatible, - transformNullsToUndefined, -} from '../utils' +import OpenAI from 'openai' +import { OpenAIBaseChatCompletionsTextAdapter } from '@tanstack/openai-base' +import { getGroqApiKeyFromEnv, withGroqDefaults } from '../utils/client' +import { makeGroqStructuredOutputCompatible } from '../utils/schema-converter' +import type { Modality, TextOptions } from '@tanstack/ai' +import type { ChatCompletionChunk } from 'openai/resources/chat/completions/completions' import type { GROQ_CHAT_MODELS, GroqChatModelToolCapabilitiesByName, ResolveInputModalities, ResolveProviderOptions, } from '../model-meta' -import type { - StructuredOutputOptions, - StructuredOutputResult, -} from '@tanstack/ai/adapters' -import type { InternalLogger } from '@tanstack/ai/adapter-internals' -import type GROQ_SDK from 'groq-sdk' -import type { ChatCompletionCreateParamsStreaming } from 'groq-sdk/resources/chat/completions' -import type { - ContentPart, - Modality, - ModelMessage, - StreamChunk, - TextOptions, -} from '@tanstack/ai' -import type { - ExternalTextProviderOptions, - InternalTextProviderOptions, -} from '../text/text-provider-options' -import type { - ChatCompletionContentPart, - ChatCompletionMessageParam, - GroqImageMetadata, - GroqMessageMetadataByModality, -} from '../message-types' +import type { GroqMessageMetadataByModality } from '../message-types' import type { GroqClientConfig } from '../utils' -type GroqTextProviderOptions = ExternalTextProviderOptions - type ResolveToolCapabilities = TModel extends keyof GroqChatModelToolCapabilitiesByName ? 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 Groq text adapter */ export interface GroqTextConfig extends GroqClientConfig {} /** - * Alias for TextProviderOptions for external use + * Re-export of the public provider options type */ export type { ExternalTextProviderOptions as GroqTextProviderOptions } from '../text/text-provider-options' /** * Groq Text (Chat) Adapter * - * Tree-shakeable adapter for Groq chat/text completion functionality. - * Uses the Groq SDK which provides an OpenAI-compatible Chat Completions API. + * Tree-shakeable adapter for Groq chat/text completion. Groq exposes an + * OpenAI-compatible Chat Completions endpoint at `/openai/v1`, so we drive + * it with the OpenAI SDK via a `baseURL` override (the same pattern as + * `ai-grok`). + * + * Quirk: when usage is present on a stream, Groq historically delivered it + * under `chunk.x_groq.usage` rather than `chunk.usage`. The override below + * promotes it to the standard location so the base's RUN_FINISHED usage + * accounting works unchanged. */ export class GroqTextAdapter< TModel extends (typeof GROQ_CHAT_MODELS)[number], @@ -75,7 +48,7 @@ export class GroqTextAdapter< ResolveInputModalities, TToolCapabilities extends ReadonlyArray = ResolveToolCapabilities, -> extends BaseTextAdapter< +> extends OpenAIBaseChatCompletionsTextAdapter< TModel, TProviderOptions, TInputModalities, @@ -85,526 +58,61 @@ export class GroqTextAdapter< readonly kind = 'text' as const readonly name = 'groq' as const - private client: GROQ_SDK - constructor(config: GroqTextConfig, model: TModel) { - super({}, model) - this.client = createGroqClient(config) + super(model, 'groq', new OpenAI(withGroqDefaults(config))) } - async *chatStream( - options: TextOptions, - ): AsyncIterable { - const requestParams = this.mapTextOptionsToGroq(options) - const timestamp = Date.now() - const { logger } = options - - 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=groq model=${this.model} messages=${options.messages.length} tools=${options.tools?.length ?? 0} stream=true`, - { provider: 'groq', model: this.model }, - ) - const stream = await this.client.chat.completions.create({ - ...requestParams, - stream: true, - }) - - yield* this.processGroqStreamChunks(stream, options, aguiState, logger) - } catch (error: unknown) { - const err = error as Error & { code?: string } - - if (!aguiState.hasEmittedRunStarted) { - aguiState.hasEmittedRunStarted = true - yield asChunk({ - type: 'RUN_STARTED', - runId: aguiState.runId, - threadId: aguiState.threadId, - model: options.model, - timestamp, - }) - } - - 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('groq.chatStream fatal', { - error, - source: 'groq.chatStream', - }) - } + protected override makeStructuredOutputCompatible( + schema: Record, + originalRequired?: Array, + ): Record { + return makeGroqStructuredOutputCompatible(schema, originalRequired) } - /** - * Generate structured output using Groq's JSON Schema response format. - * Uses stream: false to get the complete response in one call. - * - * Groq 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 Groq-specific transformations for structured output compatibility. - */ - async structuredOutput( - options: StructuredOutputOptions, - ): Promise> { - const { chatOptions, outputSchema } = options - const requestParams = this.mapTextOptionsToGroq(chatOptions) - const { logger } = chatOptions - - const jsonSchema = makeGroqStructuredOutputCompatible( - outputSchema, - outputSchema.required || [], - ) - - try { - logger.request( - `activity=chat provider=groq model=${this.model} messages=${chatOptions.messages.length} tools=${chatOptions.tools?.length ?? 0} stream=false`, - { provider: 'groq', 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, - }, - }, - }) - - const rawText = response.choices[0]?.message.content || '' - - 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 ? '...' : ''}`, - ) - } - - const transformed = transformNullsToUndefined(parsed) - - return { - data: transformed, - rawText, - } - } catch (error: unknown) { - logger.errors('groq.structuredOutput fatal', { - error, - source: 'groq.structuredOutput', - }) - throw error - } - } - - /** - * Processes streaming chunks from the Groq API and yields AG-UI stream events. - * Handles text content deltas, tool call assembly, and lifecycle events. - */ - private async *processGroqStreamChunks( - stream: AsyncIterable, + protected override async *processStreamChunks( + 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 - - const toolCallsInProgress = new Map< - number, - { - id: string - name: string - arguments: string - started: boolean - } - >() - - try { - for await (const chunk of stream) { - logger.provider(`provider=groq`, { chunk }) - const choice = chunk.choices[0] - - if (!choice) continue - - 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 - - if (deltaContent) { - if (!hasEmittedTextMessageStart) { - hasEmittedTextMessageStart = true - yield asChunk({ - type: 'TEXT_MESSAGE_START', - messageId: aguiState.messageId, - model: chunk.model || options.model, - timestamp, - role: 'assistant', - }) - } - - accumulatedContent += deltaContent - - yield asChunk({ - type: 'TEXT_MESSAGE_CONTENT', - messageId: aguiState.messageId, - model: chunk.model || options.model, - timestamp, - delta: deltaContent, - content: accumulatedContent, - }) - } - - if (deltaToolCalls) { - for (const toolCallDelta of deltaToolCalls) { - const index = toolCallDelta.index - - if (!toolCallsInProgress.has(index)) { - toolCallsInProgress.set(index, { - id: toolCallDelta.id || '', - name: toolCallDelta.function?.name || '', - arguments: '', - started: false, - }) - } - - const toolCall = toolCallsInProgress.get(index)! - - 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 - } - - 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, - }) - } - - 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, - }) - } - } - } - - if (choice.finish_reason) { - if ( - choice.finish_reason === 'tool_calls' || - toolCallsInProgress.size > 0 - ) { - for (const [, toolCall] of toolCallsInProgress) { - if (!toolCall.started || !toolCall.id || !toolCall.name) { - continue - } - - let parsedInput: unknown = {} - try { - parsedInput = toolCall.arguments - ? JSON.parse(toolCall.arguments) - : {} - } catch { - parsedInput = {} - } - - 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' - : choice.finish_reason === 'length' - ? 'length' - : 'stop' - - if (hasEmittedTextMessageStart) { - yield asChunk({ - type: 'TEXT_MESSAGE_END', - messageId: aguiState.messageId, - model: chunk.model || options.model, - timestamp, - }) - } - - const groqUsage = chunk.x_groq?.usage - - yield asChunk({ - type: 'RUN_FINISHED', - runId: aguiState.runId, - threadId: aguiState.threadId, - model: chunk.model || options.model, - timestamp, - usage: groqUsage - ? { - promptTokens: groqUsage.prompt_tokens || 0, - completionTokens: groqUsage.completion_tokens || 0, - totalTokens: groqUsage.total_tokens || 0, - } - : undefined, - finishReason: computedFinishReason, - }) - } - } - } catch (error: unknown) { - const err = error as Error & { code?: string } - logger.errors('groq stream ended with error', { - error, - source: 'groq.processGroqStreamChunks', - }) - - 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 TextOptions to Groq-specific Chat Completions request parameters. - */ - private mapTextOptionsToGroq( - options: TextOptions, - ): 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 - - const messages: Array = [] - - if (options.systemPrompts && options.systemPrompts.length > 0) { - messages.push({ - role: 'system', - content: options.systemPrompts.join('\n'), - }) - } - - for (const message of options.messages) { - messages.push(this.convertMessageToGroq(message)) - } - - return { - model: options.model, - messages, - temperature: options.temperature, - max_tokens: options.maxTokens, - top_p: options.topP, - tools, - stream: true, - } - } - - /** - * Converts a TanStack AI ModelMessage to a Groq ChatCompletionMessageParam. - * Handles tool, assistant, and user messages including multimodal content. - */ - private convertMessageToGroq( - message: ModelMessage, - ): ChatCompletionMessageParam { - if (message.role === 'tool') { - return { - role: 'tool', - tool_call_id: message.toolCallId || '', - content: - typeof message.content === 'string' - ? message.content - : JSON.stringify(message.content), - } - } - - 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 } : {}), - } - } - - const contentParts = this.normalizeContent(message.content) - - if (contentParts.length === 1 && contentParts[0]?.type === 'text') { - return { - role: 'user', - content: contentParts[0].content, - } - } - - 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 GroqImageMetadata | undefined - 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 + ) { + yield* super.processStreamChunks( + promoteGroqUsage(stream), + options, + aguiState, + ) } +} - /** - * 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 +/** + * Promotes Groq's non-standard `x_groq.usage` to the standard `chunk.usage` + * slot the base reads. Pass-through for chunks that already carry usage at + * the documented location. + */ +async function* promoteGroqUsage( + stream: AsyncIterable, +): AsyncIterable { + for await (const chunk of stream) { + const groqChunk = chunk as typeof chunk & { + x_groq?: { usage?: ChatCompletionChunk['usage'] } + } + if (!chunk.usage && groqChunk.x_groq?.usage) { + yield { ...chunk, usage: groqChunk.x_groq.usage } + } else { + yield chunk } - return content - .filter((p) => p.type === 'text') - .map((p) => p.content) - .join('') } } /** * Creates a Groq text adapter with explicit API key. - * Type resolution happens here at the call site. - * - * @param model - The model name (e.g., 'llama-3.3-70b-versatile', 'openai/gpt-oss-120b') - * @param apiKey - Your Groq API key - * @param config - Optional additional configuration - * @returns Configured Groq text adapter instance with resolved types * * @example * ```typescript * const adapter = createGroqText('llama-3.3-70b-versatile', "gsk_..."); - * // adapter has type-safe providerOptions for llama-3.3-70b-versatile * ``` */ export function createGroqText< @@ -618,27 +126,11 @@ export function createGroqText< } /** - * Creates a Groq text adapter with automatic API key detection from environment variables. - * Type resolution happens here at the call site. - * - * Looks for `GROQ_API_KEY` in: - * - `process.env` (Node.js) - * - `window.env` (Browser with injected env) - * - * @param model - The model name (e.g., 'llama-3.3-70b-versatile', 'openai/gpt-oss-120b') - * @param config - Optional configuration (excluding apiKey which is auto-detected) - * @returns Configured Groq text adapter instance with resolved types - * @throws Error if GROQ_API_KEY is not found in environment + * Creates a Groq text adapter with API key from `GROQ_API_KEY`. * * @example * ```typescript - * // Automatically uses GROQ_API_KEY from environment * const adapter = groqText('llama-3.3-70b-versatile'); - * - * const stream = chat({ - * adapter, - * messages: [{ role: "user", content: "Hello!" }] - * }); * ``` */ export function groqText( diff --git a/packages/typescript/ai-groq/src/message-types.ts b/packages/typescript/ai-groq/src/message-types.ts index 42c218189..ffa574d90 100644 --- a/packages/typescript/ai-groq/src/message-types.ts +++ b/packages/typescript/ai-groq/src/message-types.ts @@ -1,72 +1,22 @@ /** * Groq-specific message types for the Chat Completions API. * - * These type definitions mirror the Groq SDK types and are used internally - * by the adapter to avoid tight coupling to the SDK's exported types. + * Groq's wire format is OpenAI Chat Completions plus a few Groq-specific + * extensions (compound tools, citation/service-tier provider options, + * etc.). These type definitions describe that wire shape directly — the + * Groq SDK was dropped in favour of pointing the OpenAI SDK at Groq's + * `/openai/v1` base URL, so this file is the source of truth for + * Groq-only fields rather than a mirror of an external SDK's types. * * @see https://console.groq.com/docs/api-reference#chat */ -export interface ChatCompletionContentPartText { - /** The text content. */ - text: string - - /** The type of the content part. */ - type: 'text' -} - -export interface ChatCompletionContentPartImage { - image_url: { - /** Either a URL of the image or the base64 encoded image data. */ - url: string - - /** Specifies the detail level of the image. */ - detail?: 'auto' | 'low' | 'high' - } - - /** The type of the content part. */ - type: 'image_url' -} - -export interface ChatCompletionMessageToolCall { - /** The ID of the tool call. */ - id: string - - /** The function that the model called. */ - function: { - /** - * The arguments to call the function with, as generated by the model in JSON - * format. Note that the model does not always generate valid JSON, and may - * hallucinate parameters not defined by your function schema. Validate the - * arguments in your code before calling your function. - */ - arguments: string - - /** The name of the function to call. */ - name: string - } - - /** The type of the tool. Currently, only `function` is supported. */ - type: 'function' -} - -export interface ChatCompletionRequestMessageContentPartDocument { - document: { - /** The JSON document data. */ - data: { [key: string]: unknown } - - /** Optional unique identifier for the document. */ - id?: string | null - } - - /** The type of the content part. */ - type: 'document' -} - export type FunctionParameters = { [key: string]: unknown } export interface ChatCompletionNamedToolChoice { - Function: { + /** Always `function` for a named tool choice. */ + type: 'function' + function: { /** The name of the function to call. */ name: string } @@ -113,34 +63,6 @@ export type ChatCompletionToolChoiceOption = | 'required' | ChatCompletionNamedToolChoice -export type ChatCompletionContentPart = - | ChatCompletionContentPartText - | ChatCompletionContentPartImage - | ChatCompletionRequestMessageContentPartDocument - -export interface ChatCompletionAssistantMessageParam { - /** The role of the messages author, in this case `assistant`. */ - role: 'assistant' - - /** - * The contents of the assistant message. Required unless `tool_calls` or - * `function_call` is specified. - */ - content?: string | Array | null - - /** An optional name for the participant. */ - name?: string - - /** - * The reasoning output by the assistant if reasoning_format was set to 'parsed'. - * This field is only useable with qwen3 models. - */ - reasoning?: string | null - - /** The tool calls generated by the model, such as function calls. */ - tool_calls?: Array -} - export interface ChatCompletionTool { /** * The type of the tool. `function`, `browser_search`, and `code_interpreter` are @@ -151,48 +73,6 @@ export interface ChatCompletionTool { function?: FunctionDefinition } -export interface ChatCompletionToolMessageParam { - /** The contents of the tool message. */ - content: string | Array - - /** The role of the messages author, in this case `tool`. */ - role: 'tool' - - /** Tool call that this message is responding to. */ - tool_call_id: string -} - -export interface ChatCompletionSystemMessageParam { - /** The contents of the system message. */ - content: string | Array - - /** The role of the messages author, in this case `system`. */ - role: 'system' | 'developer' - - /** An optional name for the participant. */ - name?: string -} - -export interface ChatCompletionUserMessageParam { - /** The contents of the user message. */ - content: string | Array - - /** The role of the messages author, in this case `user`. */ - role: 'user' - - /** An optional name for the participant. */ - name?: string -} - -/** - * Union of all supported chat completion message params. - */ -export type ChatCompletionMessageParam = - | ChatCompletionSystemMessageParam - | ChatCompletionUserMessageParam - | ChatCompletionAssistantMessageParam - | ChatCompletionToolMessageParam - export interface CompoundCustomModels { /** Custom model to use for answering. */ answering_model?: string | null diff --git a/packages/typescript/ai-groq/src/text/text-provider-options.ts b/packages/typescript/ai-groq/src/text/text-provider-options.ts index c3ee2309e..5fc9fc226 100644 --- a/packages/typescript/ai-groq/src/text/text-provider-options.ts +++ b/packages/typescript/ai-groq/src/text/text-provider-options.ts @@ -1,6 +1,4 @@ import type { - ChatCompletionMessageParam, - ChatCompletionTool, ChatCompletionToolChoiceOption, CompoundCustom, Document, @@ -185,41 +183,7 @@ export interface GroqTextProviderOptions { user?: string | null } -/** - * Internal options interface used for validation within the adapter. - * Extends provider options with required fields for API requests. - */ -export interface InternalTextProviderOptions extends GroqTextProviderOptions { - /** An array of messages comprising the conversation. */ - messages: Array - - /** - * The model name (e.g. "llama-3.3-70b-versatile", "openai/gpt-oss-120b"). - * @see https://console.groq.com/docs/models - */ - model: string - - /** Whether to stream partial message deltas as server-sent events. */ - stream?: boolean | null - - /** - * Tools the model may call (functions, code_interpreter, etc). - * @see https://console.groq.com/docs/tool-use - */ - tools?: Array -} - /** * External provider options (what users pass in) */ export type ExternalTextProviderOptions = GroqTextProviderOptions - -/** - * Validates text provider options. - * Basic validation stub — Groq API handles detailed validation. - */ -export function validateTextProviderOptions( - _options: InternalTextProviderOptions, -): void { - // Groq API handles detailed validation -} diff --git a/packages/typescript/ai-groq/src/utils/client.ts b/packages/typescript/ai-groq/src/utils/client.ts index 4e4f64580..f121cb384 100644 --- a/packages/typescript/ai-groq/src/utils/client.ts +++ b/packages/typescript/ai-groq/src/utils/client.ts @@ -1,29 +1,32 @@ -import { generateId as _generateId, getApiKeyFromEnv } from '@tanstack/ai-utils' -import Groq_SDK from 'groq-sdk' -import type { ClientOptions } from 'groq-sdk' +import { getApiKeyFromEnv } from '@tanstack/ai-utils' +import type { ClientOptions } from 'openai' -export interface GroqClientConfig extends ClientOptions { +export interface GroqClientConfig extends Omit { apiKey: string } -/** - * Creates a Groq SDK client instance - */ -export function createGroqClient(config: GroqClientConfig): Groq_SDK { - return new Groq_SDK(config) -} - /** * Gets Groq API key from environment variables * @throws Error if GROQ_API_KEY is not found */ export function getGroqApiKeyFromEnv(): string { - return getApiKeyFromEnv('GROQ_API_KEY') + try { + return getApiKeyFromEnv('GROQ_API_KEY') + } catch { + 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.', + ) + } } /** - * Generates a unique ID with a prefix + * Returns a Groq client config with Groq's OpenAI-compatible base URL + * applied when not already set. The Groq endpoint accepts the OpenAI SDK + * verbatim, so the adapter drives it via the OpenAI SDK with this baseURL. */ -export function generateId(prefix: string): string { - return _generateId(prefix) +export function withGroqDefaults(config: GroqClientConfig): GroqClientConfig { + return { + ...config, + baseURL: config.baseURL || 'https://api.groq.com/openai/v1', + } } diff --git a/packages/typescript/ai-groq/src/utils/index.ts b/packages/typescript/ai-groq/src/utils/index.ts index 17899f56a..ad3497219 100644 --- a/packages/typescript/ai-groq/src/utils/index.ts +++ b/packages/typescript/ai-groq/src/utils/index.ts @@ -1,9 +1,9 @@ export { - createGroqClient, getGroqApiKeyFromEnv, - generateId, + withGroqDefaults, type GroqClientConfig, } from './client' +export { generateId } from '@tanstack/ai-utils' export { makeGroqStructuredOutputCompatible, transformNullsToUndefined, diff --git a/packages/typescript/ai-groq/src/utils/schema-converter.ts b/packages/typescript/ai-groq/src/utils/schema-converter.ts index 6db95a620..d5d5e01b9 100644 --- a/packages/typescript/ai-groq/src/utils/schema-converter.ts +++ b/packages/typescript/ai-groq/src/utils/schema-converter.ts @@ -62,7 +62,7 @@ function removeEmptyRequired(schema: Record): Record { /** * Recursively normalise object schemas so any `{ type: 'object' }` node * without `properties` gets an empty `properties: {}` object. The - * openai-base transformer only descends into objects that already have + * ai-openai-base transformer only descends into objects that already have * `properties` set, so a Zod `z.object({})` nested inside `properties`, * `items`, `additionalProperties`, or a combinator branch would otherwise * skip the strict-mode rewrite and fail Groq validation. @@ -140,7 +140,7 @@ export function makeGroqStructuredOutputCompatible( schema: Record, originalRequired: Array = [], ): Record { - // Recursively patch every `{ type: 'object' }` node so the openai-base + // Recursively patch every `{ type: 'object' }` node so the ai-openai-base // transformer descends into nested empty objects too. const normalised = normalizeObjectSchemas(schema) diff --git a/packages/typescript/ai-groq/tests/groq-adapter.test.ts b/packages/typescript/ai-groq/tests/groq-adapter.test.ts index a053aeea8..f09944346 100644 --- a/packages/typescript/ai-groq/tests/groq-adapter.test.ts +++ b/packages/typescript/ai-groq/tests/groq-adapter.test.ts @@ -8,22 +8,26 @@ import { type Mock, } from 'vitest' import { resolveDebugOption } from '@tanstack/ai/adapter-internals' -import { createGroqText, groqText } from '../src/adapters/text' +import { + createGroqText as _realCreateGroqText, + groqText as _realGroqText, +} from '../src/adapters/text' 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: Mock<(...args: Array) => unknown> - -// Mock the Groq SDK -vi.mock('groq-sdk', () => { +// Stub the OpenAI SDK so adapter construction doesn't open a real network +// handle. The per-test mock client is injected post-construction via +// `setupMockSdkClient` (mirrors the ai-grok pattern). We avoid relying on +// vi.mock to intercept transitive openai imports — the built ai-openai-base +// dist resolves `openai` independently and is unaffected by vi.mock here. +vi.mock('openai', () => { return { default: class { chat = { completions: { - create: (...args: Array) => mockCreate(...args), + create: vi.fn(), }, } }, @@ -47,18 +51,39 @@ function createAsyncIterable(chunks: Array): AsyncIterable { } } -// Helper to setup the mock SDK client for streaming responses +// Sets up a mock client on the most recently created adapter. Tests use the +// existing call order: `setupMockSdkClient(chunks)` first, then `const adapter +// = createGroqText(...)`. The wrapped factories below apply the pending +// mock to the returned adapter so it intercepts subsequent chatStream/ +// structuredOutput calls. +let pendingMockCreate: Mock<(...args: Array) => unknown> | undefined + function setupMockSdkClient( streamChunks: Array>, nonStreamResponse?: Record, -) { - mockCreate = vi.fn().mockImplementation((params) => { +): Mock<(...args: Array) => unknown> { + pendingMockCreate = vi.fn().mockImplementation((params) => { if (params.stream) { return Promise.resolve(createAsyncIterable(streamChunks)) } return Promise.resolve(nonStreamResponse) }) + return pendingMockCreate +} + +function applyPendingMock(adapter: T): T { + if (pendingMockCreate) { + ;(adapter as any).client = { + chat: { completions: { create: pendingMockCreate } }, + } + pendingMockCreate = undefined + } + return adapter } +const createGroqText: typeof _realCreateGroqText = (model, apiKey, config) => + applyPendingMock(_realCreateGroqText(model, apiKey, config)) +const groqText: typeof _realGroqText = (model, config) => + applyPendingMock(_realGroqText(model, config)) const weatherTool: Tool = { name: 'lookup_weather', @@ -66,6 +91,13 @@ const weatherTool: Tool = { } describe('Groq adapters', () => { + // Reset the module-level `pendingMockCreate` between tests so a previous + // test's setupMockSdkClient call can't leak into a later test that + // instantiates the adapter without setting up a mock. + beforeEach(() => { + pendingMockCreate = undefined + }) + afterEach(() => { vi.unstubAllEnvs() }) @@ -422,7 +454,7 @@ describe('Groq AG-UI event emission', () => { }, } - mockCreate = vi.fn().mockResolvedValue(errorIterable) + pendingMockCreate = vi.fn().mockResolvedValue(errorIterable) const adapter = createGroqText('llama-3.3-70b-versatile', 'test-api-key') const chunks: Array = [] diff --git a/packages/typescript/ai-groq/tests/schema-converter.test.ts b/packages/typescript/ai-groq/tests/schema-converter.test.ts index b6daa00e0..d05fcdb15 100644 --- a/packages/typescript/ai-groq/tests/schema-converter.test.ts +++ b/packages/typescript/ai-groq/tests/schema-converter.test.ts @@ -65,7 +65,7 @@ describe('makeGroqStructuredOutputCompatible', () => { it('should normalise nested empty-object schemas in properties', () => { // Reproduces the bug where a nested `{ type: 'object' }` without - // `properties` slipped past the openai-base transformer because the + // `properties` slipped past the ai-openai-base transformer because the // ai-groq layer only normalised the top-level node. const schema = { type: 'object', @@ -79,7 +79,7 @@ describe('makeGroqStructuredOutputCompatible', () => { expect(result.properties.child.type).toBe('object') expect(result.properties.child.properties).toEqual({}) - // openai-base sets additionalProperties: false on every rewritten object + // ai-openai-base sets additionalProperties: false on every rewritten object expect(result.properties.child.additionalProperties).toBe(false) }) diff --git a/packages/typescript/ai-ollama/src/adapters/summarize.ts b/packages/typescript/ai-ollama/src/adapters/summarize.ts index 0b8407e4b..f4315cad0 100644 --- a/packages/typescript/ai-ollama/src/adapters/summarize.ts +++ b/packages/typescript/ai-ollama/src/adapters/summarize.ts @@ -1,241 +1,56 @@ -import { - createOllamaClient, - estimateTokens, - generateId, - getOllamaHostFromEnv, -} from '../utils' - +import { ChatStreamSummarizeAdapter } from '@tanstack/ai/adapters' +import { getOllamaHostFromEnv } from '../utils' +import { OllamaTextAdapter } from './text' +import type { InferTextProviderOptions } from '@tanstack/ai/adapters' import type { OLLAMA_TEXT_MODELS as OllamaSummarizeModels } from '../model-meta' -import type { Ollama } from 'ollama' -import type { SummarizeAdapter } from '@tanstack/ai/adapters' -import type { - StreamChunk, - SummarizationOptions, - SummarizationResult, -} from '@tanstack/ai' - -/** Cast an event object to StreamChunk. */ -const asChunk = (chunk: Record) => - chunk as unknown as StreamChunk export type OllamaSummarizeModel = | (typeof OllamaSummarizeModels)[number] | (string & {}) -/** - * Ollama-specific provider options for summarization - */ -export interface OllamaSummarizeProviderOptions { - /** Number of GPU layers to use */ - num_gpu?: number - /** Number of threads to use */ - num_thread?: number - /** Context window size */ - num_ctx?: number - /** Number of tokens to predict */ - num_predict?: number - /** Temperature for sampling */ - temperature?: number - /** Top-p sampling */ - top_p?: number - /** Top-k sampling */ - top_k?: number - /** Repeat penalty */ - repeat_penalty?: number -} - export interface OllamaSummarizeAdapterOptions { host?: string } /** - * Ollama Summarize Adapter - * A tree-shakeable summarization adapter for Ollama - */ -export class OllamaSummarizeAdapter< - TModel extends OllamaSummarizeModel, -> implements SummarizeAdapter { - readonly kind = 'summarize' as const - readonly name = 'ollama' as const - readonly model: TModel - - // Type-only property - never assigned at runtime - declare '~types': { - providerOptions: OllamaSummarizeProviderOptions - } - - private client: Ollama - constructor( - hostOrClient: string | Ollama | undefined, - model: TModel, - _options: OllamaSummarizeAdapterOptions = {}, - ) { - if (typeof hostOrClient === 'string' || hostOrClient === undefined) { - this.client = createOllamaClient({ host: hostOrClient }) - } else { - this.client = hostOrClient - } - this.model = model - } - - async summarize(options: SummarizationOptions): Promise { - const { logger } = options - const model = options.model - - logger.request(`activity=summarize provider=ollama`, { - provider: 'ollama', - model, - }) - - const prompt = this.buildSummarizationPrompt(options) - - try { - const response = await this.client.generate({ - model, - prompt, - options: { - temperature: 0.3, - num_predict: options.maxLength ?? 500, - }, - stream: false, - }) - - const promptTokens = estimateTokens(prompt) - const completionTokens = estimateTokens(response.response) - - return { - id: generateId('sum'), - model: response.model, - summary: response.response, - usage: { - promptTokens, - completionTokens, - totalTokens: promptTokens + completionTokens, - }, - } - } catch (error) { - logger.errors('ollama.summarize fatal', { - error, - source: 'ollama.summarize', - }) - throw error - } - } - - async *summarizeStream( - options: SummarizationOptions, - ): AsyncIterable { - const { logger } = options - const model = options.model - const id = generateId('sum') - const prompt = this.buildSummarizationPrompt(options) - let accumulatedContent = '' - - logger.request(`activity=summarize provider=ollama`, { - provider: 'ollama', - model, - stream: true, - }) - - try { - const stream = await this.client.generate({ - model, - prompt, - options: { - temperature: 0.3, - num_predict: options.maxLength ?? 500, - }, - stream: true, - }) - - for await (const chunk of stream) { - logger.provider(`provider=ollama`, { chunk }) - - if (chunk.response) { - accumulatedContent += chunk.response - yield asChunk({ - type: 'TEXT_MESSAGE_CONTENT', - messageId: id, - model: chunk.model, - timestamp: Date.now(), - delta: chunk.response, - content: accumulatedContent, - }) - } - - if (chunk.done) { - const promptTokens = estimateTokens(prompt) - const completionTokens = estimateTokens(accumulatedContent) - yield asChunk({ - type: 'RUN_FINISHED', - runId: id, - model: chunk.model, - timestamp: Date.now(), - finishReason: 'stop', - usage: { - promptTokens, - completionTokens, - totalTokens: promptTokens + completionTokens, - }, - }) - } - } - } catch (error) { - logger.errors('ollama.summarize fatal', { - error, - source: 'ollama.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 'concise': - prompt += 'Provide a very brief one or two sentence summary. ' - break - case 'paragraph': - default: - prompt += 'Provide a clear and concise summary in paragraph format. ' - } - - if (options.maxLength) { - prompt += `Keep the summary under ${options.maxLength} words. ` - } - - if (options.focus && options.focus.length > 0) { - prompt += `Focus on: ${options.focus.join(', ')}. ` - } - - prompt += `\n\nText to summarize:\n${options.text}\n\nSummary:` - - return prompt - } -} - -/** - * Creates an Ollama summarize adapter with explicit host and model + * Creates an Ollama summarize adapter with explicit host and model. + * + * @example + * ```typescript + * const adapter = createOllamaSummarize('mistral', 'http://localhost:11434'); + * ``` */ export function createOllamaSummarize( model: TModel, host?: string, - options?: OllamaSummarizeAdapterOptions, -): OllamaSummarizeAdapter { - return new OllamaSummarizeAdapter(host, model, options) + _options?: OllamaSummarizeAdapterOptions, +): ChatStreamSummarizeAdapter< + TModel, + InferTextProviderOptions> +> { + return new ChatStreamSummarizeAdapter( + new OllamaTextAdapter(host, model), + model, + 'ollama', + ) } /** - * Creates an Ollama summarize adapter with host from environment and required model + * Creates an Ollama summarize adapter with host from `OLLAMA_HOST` env var + * (falling back to the Ollama default). + * + * @example + * ```typescript + * const adapter = ollamaSummarize('mistral'); + * await summarize({ adapter, text: 'Long article text...' }); + * ``` */ export function ollamaSummarize( model: TModel, options?: OllamaSummarizeAdapterOptions, -): OllamaSummarizeAdapter { - const host = getOllamaHostFromEnv() - return new OllamaSummarizeAdapter(host, model, options) +): ChatStreamSummarizeAdapter< + TModel, + InferTextProviderOptions> +> { + return createOllamaSummarize(model, getOllamaHostFromEnv(), options) } diff --git a/packages/typescript/ai-ollama/src/adapters/text.ts b/packages/typescript/ai-ollama/src/adapters/text.ts index 209951569..907418330 100644 --- a/packages/typescript/ai-ollama/src/adapters/text.ts +++ b/packages/typescript/ai-ollama/src/adapters/text.ts @@ -1,3 +1,4 @@ +import { EventType } from '@tanstack/ai' import { BaseTextAdapter } from '@tanstack/ai/adapters' import { createOllamaClient, generateId, getOllamaHostFromEnv } from '../utils' @@ -24,11 +25,6 @@ import type { } from 'ollama' import type { StreamChunk, TextOptions, Tool } from '@tanstack/ai' -/** 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 - export type OllamaTextModel = | (typeof OLLAMA_TEXT_MODELS)[number] | (string & {}) @@ -227,7 +223,6 @@ export class OllamaTextAdapter extends BaseTextAdapter< logger: InternalLogger, ): AsyncIterable { let accumulatedContent = '' - const timestamp = Date.now() let accumulatedReasoning = '' const toolCallsEmitted = new Set() @@ -247,13 +242,13 @@ export class OllamaTextAdapter extends BaseTextAdapter< // Emit RUN_STARTED on first chunk if (!hasEmittedRunStarted) { hasEmittedRunStarted = true - yield asChunk({ - type: 'RUN_STARTED', + yield { + type: EventType.RUN_STARTED, runId, threadId, model: chunk.model, - timestamp, - }) + timestamp: Date.now(), + } satisfies StreamChunk } const handleToolCall = (toolCall: ToolCall): Array => { @@ -268,17 +263,15 @@ export class OllamaTextAdapter extends BaseTextAdapter< // Emit TOOL_CALL_START if not already emitted for this tool call if (!toolCallsEmitted.has(toolCallId)) { toolCallsEmitted.add(toolCallId) - events.push( - asChunk({ - type: 'TOOL_CALL_START', - toolCallId, - toolCallName: actualToolCall.function.name || '', - toolName: actualToolCall.function.name || '', - model: chunk.model, - timestamp, - index: actualToolCall.function.index, - }), - ) + events.push({ + type: EventType.TOOL_CALL_START, + toolCallId, + toolCallName: actualToolCall.function.name || '', + toolName: actualToolCall.function.name || '', + model: chunk.model, + timestamp: Date.now(), + index: actualToolCall.function.index, + } satisfies StreamChunk) } // Serialize arguments to a string for the TOOL_CALL_ARGS event @@ -295,29 +288,25 @@ export class OllamaTextAdapter extends BaseTextAdapter< } // Emit TOOL_CALL_ARGS with full args (Ollama doesn't stream args incrementally) - events.push( - asChunk({ - type: 'TOOL_CALL_ARGS', - toolCallId, - model: chunk.model, - timestamp, - delta: argsStr, - args: argsStr, - }), - ) + events.push({ + type: EventType.TOOL_CALL_ARGS, + toolCallId, + model: chunk.model, + timestamp: Date.now(), + delta: argsStr, + args: argsStr, + } satisfies StreamChunk) // Emit TOOL_CALL_END - events.push( - asChunk({ - type: 'TOOL_CALL_END', - toolCallId, - toolCallName: actualToolCall.function.name || '', - toolName: actualToolCall.function.name || '', - model: chunk.model, - timestamp, - input: parsedInput, - }), - ) + events.push({ + type: EventType.TOOL_CALL_END, + toolCallId, + toolCallName: actualToolCall.function.name || '', + toolName: actualToolCall.function.name || '', + model: chunk.model, + timestamp: Date.now(), + input: parsedInput, + } satisfies StreamChunk) return events } @@ -335,36 +324,36 @@ export class OllamaTextAdapter extends BaseTextAdapter< // Close reasoning events if still open if (reasoningMessageId && !hasClosedReasoning) { hasClosedReasoning = true - yield asChunk({ - type: 'REASONING_MESSAGE_END', + yield { + type: EventType.REASONING_MESSAGE_END, messageId: reasoningMessageId, model: chunk.model, - timestamp, - }) - yield asChunk({ - type: 'REASONING_END', + timestamp: Date.now(), + } satisfies StreamChunk + yield { + type: EventType.REASONING_END, messageId: reasoningMessageId, model: chunk.model, - timestamp, - }) + timestamp: Date.now(), + } satisfies StreamChunk } // Emit TEXT_MESSAGE_END if we had text content if (hasEmittedTextMessageStart) { - yield asChunk({ - type: 'TEXT_MESSAGE_END', + yield { + type: EventType.TEXT_MESSAGE_END, messageId, model: chunk.model, - timestamp, - }) + timestamp: Date.now(), + } satisfies StreamChunk } - yield asChunk({ - type: 'RUN_FINISHED', + yield { + type: EventType.RUN_FINISHED, runId, threadId, model: chunk.model, - timestamp, + timestamp: Date.now(), finishReason: toolCallsEmitted.size > 0 ? 'tool_calls' : 'stop', usage: { promptTokens: chunk.prompt_eval_count || 0, @@ -372,7 +361,7 @@ export class OllamaTextAdapter extends BaseTextAdapter< totalTokens: (chunk.prompt_eval_count || 0) + (chunk.eval_count || 0), }, - }) + } satisfies StreamChunk continue } @@ -380,41 +369,41 @@ export class OllamaTextAdapter extends BaseTextAdapter< // Close reasoning before text starts if (reasoningMessageId && !hasClosedReasoning) { hasClosedReasoning = true - yield asChunk({ - type: 'REASONING_MESSAGE_END', + yield { + type: EventType.REASONING_MESSAGE_END, messageId: reasoningMessageId, model: chunk.model, - timestamp, - }) - yield asChunk({ - type: 'REASONING_END', + timestamp: Date.now(), + } satisfies StreamChunk + yield { + type: EventType.REASONING_END, messageId: reasoningMessageId, model: chunk.model, - timestamp, - }) + timestamp: Date.now(), + } satisfies StreamChunk } // Emit TEXT_MESSAGE_START on first text content if (!hasEmittedTextMessageStart) { hasEmittedTextMessageStart = true - yield asChunk({ - type: 'TEXT_MESSAGE_START', + yield { + type: EventType.TEXT_MESSAGE_START, messageId, model: chunk.model, - timestamp, + timestamp: Date.now(), role: 'assistant', - }) + } satisfies StreamChunk } accumulatedContent += chunk.message.content - yield asChunk({ - type: 'TEXT_MESSAGE_CONTENT', + yield { + type: EventType.TEXT_MESSAGE_CONTENT, messageId, model: chunk.model, - timestamp, + timestamp: Date.now(), delta: chunk.message.content, content: accumulatedContent, - }) + } satisfies StreamChunk } if (chunk.message.tool_calls && chunk.message.tool_calls.length > 0) { @@ -434,52 +423,52 @@ export class OllamaTextAdapter extends BaseTextAdapter< reasoningMessageId = generateId('msg') // Spec REASONING events - yield asChunk({ - type: 'REASONING_START', + yield { + type: EventType.REASONING_START, messageId: reasoningMessageId, model: chunk.model, - timestamp, - }) - yield asChunk({ - type: 'REASONING_MESSAGE_START', + timestamp: Date.now(), + } satisfies StreamChunk + yield { + type: EventType.REASONING_MESSAGE_START, messageId: reasoningMessageId, role: 'reasoning' as const, model: chunk.model, - timestamp, - }) + timestamp: Date.now(), + } satisfies StreamChunk // Legacy STEP events (kept during transition) - yield asChunk({ - type: 'STEP_STARTED', + yield { + type: EventType.STEP_STARTED, stepName: stepId, stepId, model: chunk.model, - timestamp, + timestamp: Date.now(), stepType: 'thinking', - }) + } satisfies StreamChunk } accumulatedReasoning += chunk.message.thinking // Spec REASONING content event - yield asChunk({ - type: 'REASONING_MESSAGE_CONTENT', + yield { + type: EventType.REASONING_MESSAGE_CONTENT, messageId: reasoningMessageId!, delta: chunk.message.thinking, model: chunk.model, - timestamp, - }) + timestamp: Date.now(), + } satisfies StreamChunk // Legacy STEP event - yield asChunk({ - type: 'STEP_FINISHED', + yield { + type: EventType.STEP_FINISHED, stepName: stepId || generateId('step'), stepId: stepId || generateId('step'), model: chunk.model, - timestamp, + timestamp: Date.now(), delta: chunk.message.thinking, content: accumulatedReasoning, - }) + } satisfies StreamChunk } } } diff --git a/packages/typescript/ai-ollama/src/index.ts b/packages/typescript/ai-ollama/src/index.ts index 781aa7eab..05c8d0e81 100644 --- a/packages/typescript/ai-ollama/src/index.ts +++ b/packages/typescript/ai-ollama/src/index.ts @@ -13,14 +13,12 @@ export { } from './adapters/text' export { OLLAMA_TEXT_MODELS as OllamaTextModels } from './model-meta' -// Summarize adapter +// Summarize - thin factory functions over @tanstack/ai's ChatStreamSummarizeAdapter export { - OllamaSummarizeAdapter, createOllamaSummarize, ollamaSummarize, type OllamaSummarizeAdapterOptions, type OllamaSummarizeModel, - type OllamaSummarizeProviderOptions, } from './adapters/summarize' export { OLLAMA_TEXT_MODELS as OllamaSummarizeModels } from './model-meta' diff --git a/packages/typescript/ai-openai/src/adapters/image.ts b/packages/typescript/ai-openai/src/adapters/image.ts index e1220738f..2a5159e50 100644 --- a/packages/typescript/ai-openai/src/adapters/image.ts +++ b/packages/typescript/ai-openai/src/adapters/image.ts @@ -1,10 +1,19 @@ -import { OpenAICompatibleImageAdapter } from '@tanstack/openai-base' +import OpenAI from 'openai' +import { BaseImageAdapter } from '@tanstack/ai/adapters' +import { toRunErrorPayload } from '@tanstack/ai/adapter-internals' +import { generateId } from '@tanstack/ai-utils' import { getOpenAIApiKeyFromEnv } from '../utils/client' import { validateImageSize, validateNumberOfImages, validatePrompt, } from '../image/image-provider-options' +import type { + GeneratedImage, + ImageGenerationOptions, + ImageGenerationResult, +} from '@tanstack/ai' +import type OpenAI_SDK from 'openai' import type { OpenAIImageModel } from '../model-meta' import type { OpenAIImageModelProviderOptionsByName, @@ -23,15 +32,10 @@ export interface OpenAIImageConfig extends OpenAIClientConfig {} * * Tree-shakeable adapter for OpenAI image generation functionality. * Supports gpt-image-1, gpt-image-1-mini, dall-e-3, and dall-e-2 models. - * - * Features: - * - Model-specific type-safe provider options - * - Size validation per model - * - Number of images validation */ export class OpenAIImageAdapter< TModel extends OpenAIImageModel, -> extends OpenAICompatibleImageAdapter< +> extends BaseImageAdapter< TModel, OpenAIImageProviderOptions, OpenAIImageModelProviderOptionsByName, @@ -40,51 +44,77 @@ export class OpenAIImageAdapter< readonly kind = 'image' as const readonly name = 'openai' as const + protected client: OpenAI + constructor(config: OpenAIImageConfig, model: TModel) { - super(config, model, 'openai') + super(model, {}) + this.client = new OpenAI(config) } - protected override validatePrompt(options: { - prompt: string - model: string - }): void { - validatePrompt(options) - } + async generateImages( + options: ImageGenerationOptions, + ): Promise { + const { model, prompt, numberOfImages, size, modelOptions } = options - protected override validateImageSize( - model: string, - size: string | undefined, - ): void { + validatePrompt({ prompt, model }) validateImageSize(model, size) - } - - protected override validateNumberOfImages( - model: string, - numberOfImages: number | undefined, - ): void { validateNumberOfImages(model, numberOfImages) + + const request: OpenAI_SDK.Images.ImageGenerateParams = { + model, + prompt, + n: numberOfImages ?? 1, + size: size as OpenAI_SDK.Images.ImageGenerateParams['size'], + ...modelOptions, + } + + try { + options.logger.request( + `activity=image provider=${this.name} model=${model} n=${request.n ?? 1} size=${request.size ?? 'default'}`, + { provider: this.name, model }, + ) + const response = await this.client.images.generate({ + ...request, + stream: false, + }) + + 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, + } + } catch (error: unknown) { + // Narrow before logging: raw SDK errors can carry request metadata + // (including auth headers) which we must never surface to user loggers. + options.logger.errors(`${this.name}.generateImages fatal`, { + error: toRunErrorPayload(error, `${this.name}.generateImages failed`), + source: `${this.name}.generateImages`, + }) + throw error + } } } -/** - * Creates an OpenAI image adapter with explicit API key. - * Type resolution happens here at the call site. - * - * @param model - The model name (e.g., 'dall-e-3', 'gpt-image-1') - * @param apiKey - Your OpenAI API key - * @param config - Optional additional configuration - * @returns Configured OpenAI image adapter instance with resolved types - * - * @example - * ```typescript - * const adapter = createOpenaiImage('dall-e-3', "sk-..."); - * - * const result = await generateImage({ - * adapter, - * prompt: 'A cute baby sea otter' - * }); - * ``` - */ export function createOpenaiImage( model: TModel, apiKey: string, @@ -93,30 +123,6 @@ export function createOpenaiImage( return new OpenAIImageAdapter({ apiKey, ...config }, model) } -/** - * Creates an OpenAI image adapter with automatic API key detection from environment variables. - * Type resolution happens here at the call site. - * - * Looks for `OPENAI_API_KEY` in: - * - `process.env` (Node.js) - * - `window.env` (Browser with injected env) - * - * @param model - The model name (e.g., 'dall-e-3', 'gpt-image-1') - * @param config - Optional configuration (excluding apiKey which is auto-detected) - * @returns Configured OpenAI image adapter instance with resolved types - * @throws Error if OPENAI_API_KEY is not found in environment - * - * @example - * ```typescript - * // Automatically uses OPENAI_API_KEY from environment - * const adapter = openaiImage('dall-e-3'); - * - * const result = await generateImage({ - * adapter, - * prompt: 'A beautiful sunset over mountains' - * }); - * ``` - */ export function openaiImage( model: TModel, config?: Omit, diff --git a/packages/typescript/ai-openai/src/adapters/summarize.ts b/packages/typescript/ai-openai/src/adapters/summarize.ts index 6e143bfab..64dd71a8c 100644 --- a/packages/typescript/ai-openai/src/adapters/summarize.ts +++ b/packages/typescript/ai-openai/src/adapters/summarize.ts @@ -1,52 +1,14 @@ -import { OpenAICompatibleSummarizeAdapter } from '@tanstack/openai-base' +import { ChatStreamSummarizeAdapter } from '@tanstack/ai/adapters' import { getOpenAIApiKeyFromEnv } from '../utils/client' import { OpenAITextAdapter } from './text' +import type { InferTextProviderOptions } from '@tanstack/ai/adapters' import type { OpenAIChatModel } from '../model-meta' import type { OpenAIClientConfig } from '../utils/client' -/** - * Configuration for OpenAI summarize adapter - */ export interface OpenAISummarizeConfig extends OpenAIClientConfig {} -/** - * OpenAI-specific provider options for summarization - */ -export interface OpenAISummarizeProviderOptions { - /** Temperature for response generation (0-2) */ - temperature?: number - /** Maximum tokens in the response */ - maxTokens?: number -} - -/** - * OpenAI Summarize Adapter - * - * A thin wrapper around the text adapter that adds summarization-specific prompting. - * Delegates all API calls to the OpenAITextAdapter. - */ -export class OpenAISummarizeAdapter< - TModel extends OpenAIChatModel, -> extends OpenAICompatibleSummarizeAdapter< - TModel, - OpenAISummarizeProviderOptions -> { - readonly kind = 'summarize' as const - readonly name = 'openai' as const - - constructor(config: OpenAISummarizeConfig, model: TModel) { - super(new OpenAITextAdapter(config, model), model, 'openai') - } -} - /** * Creates an OpenAI summarize adapter with explicit API key. - * Type resolution happens here at the call site. - * - * @param model - The model name (e.g., 'gpt-4o-mini', 'gpt-4o') - * @param apiKey - Your OpenAI API key - * @param config - Optional additional configuration - * @returns Configured OpenAI summarize adapter instance with resolved types * * @example * ```typescript @@ -57,38 +19,32 @@ export function createOpenaiSummarize( model: TModel, apiKey: string, config?: Omit, -): OpenAISummarizeAdapter { - return new OpenAISummarizeAdapter({ apiKey, ...config }, model) +): ChatStreamSummarizeAdapter< + TModel, + InferTextProviderOptions> +> { + return new ChatStreamSummarizeAdapter( + new OpenAITextAdapter({ apiKey, ...config }, model), + model, + 'openai', + ) } /** - * Creates an OpenAI summarize adapter with automatic API key detection from environment variables. - * Type resolution happens here at the call site. - * - * Looks for `OPENAI_API_KEY` in: - * - `process.env` (Node.js) - * - `window.env` (Browser with injected env) - * - * @param model - The model name (e.g., 'gpt-4o-mini', 'gpt-4o') - * @param config - Optional configuration (excluding apiKey which is auto-detected) - * @returns Configured OpenAI summarize adapter instance with resolved types - * @throws Error if OPENAI_API_KEY is not found in environment + * Creates an OpenAI summarize adapter with API key from `OPENAI_API_KEY`. * * @example * ```typescript - * // Automatically uses OPENAI_API_KEY from environment * const adapter = openaiSummarize('gpt-4o-mini'); - * - * await summarize({ - * adapter, - * text: "Long article text..." - * }); + * await summarize({ adapter, text: "Long article text..." }); * ``` */ export function openaiSummarize( model: TModel, config?: Omit, -): OpenAISummarizeAdapter { - const apiKey = getOpenAIApiKeyFromEnv() - return createOpenaiSummarize(model, apiKey, config) +): ChatStreamSummarizeAdapter< + TModel, + InferTextProviderOptions> +> { + return createOpenaiSummarize(model, getOpenAIApiKeyFromEnv(), config) } diff --git a/packages/typescript/ai-openai/src/adapters/text.ts b/packages/typescript/ai-openai/src/adapters/text.ts index 2e9f91e9c..a17b95267 100644 --- a/packages/typescript/ai-openai/src/adapters/text.ts +++ b/packages/typescript/ai-openai/src/adapters/text.ts @@ -1,4 +1,5 @@ -import { OpenAICompatibleResponsesTextAdapter } from '@tanstack/openai-base' +import OpenAI from 'openai' +import { OpenAIBaseResponsesTextAdapter } from '@tanstack/openai-base' import { validateTextProviderOptions } from '../text/text-provider-options' import { convertToolsToProviderFormat } from '../tools' import { getOpenAIApiKeyFromEnv } from '../utils/client' @@ -9,7 +10,7 @@ import type { OpenAIChatModelToolCapabilitiesByName, OpenAIModelInputModalitiesByName, } from '../model-meta' -import type OpenAI_SDK from 'openai' +import type { ResponseCreateParams } from 'openai/resources/responses/responses' import type { Modality, TextOptions } from '@tanstack/ai' import type { ExternalTextProviderOptions, @@ -67,9 +68,12 @@ type ResolveToolCapabilities = * OpenAI Text (Chat) Adapter * * Tree-shakeable adapter for OpenAI chat/text completion functionality. - * Delegates implementation to {@link OpenAICompatibleResponsesTextAdapter} from - * `@tanstack/openai-base` and threads OpenAI-specific tool-capability typing - * through the 5th generic of the base class. + * Delegates implementation to {@link OpenAIBaseResponsesTextAdapter} from + * `@tanstack/openai-base`. The base calls `openai.responses.create` + * directly; this subclass just hands it a configured client and overrides + * `mapOptionsToRequest` to route through OpenAI's full tool converter + * (supporting file_search, web_search, etc.) and to apply provider option + * validation. */ export class OpenAITextAdapter< TModel extends OpenAIChatModel, @@ -78,7 +82,7 @@ export class OpenAITextAdapter< ResolveInputModalities, TToolCapabilities extends ReadonlyArray = ResolveToolCapabilities, -> extends OpenAICompatibleResponsesTextAdapter< +> extends OpenAIBaseResponsesTextAdapter< TModel, TProviderOptions, TInputModalities, @@ -89,7 +93,7 @@ export class OpenAITextAdapter< readonly name = 'openai' as const constructor(config: OpenAITextConfig, model: TModel) { - super(config, model, 'openai') + super(model, 'openai', new OpenAI(config)) } /** @@ -100,7 +104,7 @@ export class OpenAITextAdapter< */ protected override mapOptionsToRequest( options: TextOptions, - ): Omit { + ): Omit { // The structural type the validator expects is broader than what // `TProviderOptions` is bound to per-model, so narrow via the internal // shape rather than re-exposing it on the public override signature. @@ -125,10 +129,7 @@ export class OpenAITextAdapter< // previous override spread `...modelOptions` LAST and wrote // `temperature: options.temperature` unconditionally — re-introducing the // exact regression the base class's nullish-aware merge fixes. - const requestParams: Omit< - OpenAI_SDK.Responses.ResponseCreateParams, - 'stream' - > = { + const requestParams: Omit = { ...modelOptions, model: options.model, ...(options.temperature !== undefined && { diff --git a/packages/typescript/ai-openai/src/adapters/transcription.ts b/packages/typescript/ai-openai/src/adapters/transcription.ts index 5a7742298..556aa0f8a 100644 --- a/packages/typescript/ai-openai/src/adapters/transcription.ts +++ b/packages/typescript/ai-openai/src/adapters/transcription.ts @@ -1,5 +1,14 @@ -import { OpenAICompatibleTranscriptionAdapter } from '@tanstack/openai-base' +import OpenAI from 'openai' +import { BaseTranscriptionAdapter } from '@tanstack/ai/adapters' +import { toRunErrorPayload } from '@tanstack/ai/adapter-internals' +import { base64ToArrayBuffer, generateId } from '@tanstack/ai-utils' import { getOpenAIApiKeyFromEnv } from '../utils/client' +import type { + TranscriptionOptions, + TranscriptionResult, + TranscriptionSegment, +} from '@tanstack/ai' +import type OpenAI_SDK from 'openai' import type { OpenAITranscriptionModel } from '../model-meta' import type { OpenAITranscriptionProviderOptions } from '../audio/transcription-provider-options' import type { OpenAIClientConfig } from '../utils/client' @@ -10,58 +19,152 @@ import type { OpenAIClientConfig } from '../utils/client' export interface OpenAITranscriptionConfig extends OpenAIClientConfig {} /** - * OpenAI Transcription (Speech-to-Text) Adapter - * - * Tree-shakeable adapter for OpenAI audio transcription functionality. - * Supports whisper-1, gpt-4o-transcribe, gpt-4o-mini-transcribe, and gpt-4o-transcribe-diarize models. - * - * Features: - * - Multiple transcription models with different capabilities - * - Language detection or specification - * - Multiple output formats: json, text, srt, verbose_json, vtt - * - Word and segment-level timestamps (with verbose_json) - * - Speaker diarization (with gpt-4o-transcribe-diarize) + * OpenAI Transcription (Speech-to-Text) Adapter. + * Supports whisper-1 and gpt-4o-transcribe* models. Verbose JSON output + * (timestamps + segments) only available on whisper-1. */ export class OpenAITranscriptionAdapter< TModel extends OpenAITranscriptionModel, -> extends OpenAICompatibleTranscriptionAdapter< - TModel, - OpenAITranscriptionProviderOptions -> { +> extends BaseTranscriptionAdapter { readonly name = 'openai' as const + protected client: OpenAI + constructor(config: OpenAITranscriptionConfig, model: TModel) { - super(config, model, 'openai') + super(model, {}) + this.client = new OpenAI(config) + } + + async transcribe( + options: TranscriptionOptions, + ): Promise { + const { model, audio, language, prompt, responseFormat, modelOptions } = + options + + const file = this.prepareAudioFile(audio) + + const request: OpenAI_SDK.Audio.TranscriptionCreateParams = { + model, + file, + language, + prompt, + response_format: this.mapResponseFormat(responseFormat), + ...modelOptions, + } + + // Only Whisper supports verbose_json. The gpt-4o-* transcribe models + // accept only json/text and reject verbose_json with HTTP 400. + const useVerbose = + responseFormat === 'verbose_json' || + (!responseFormat && model === 'whisper-1') + + try { + options.logger.request( + `activity=transcription provider=${this.name} model=${model} verbose=${useVerbose}`, + { provider: this.name, model }, + ) + if (useVerbose) { + const response = (await this.client.audio.transcriptions.create({ + ...request, + response_format: 'verbose_json', + })) as OpenAI_SDK.Audio.Transcriptions.TranscriptionVerbose + + return { + id: generateId(this.name), + model, + text: response.text, + language: response.language, + duration: response.duration, + segments: response.segments?.map( + (seg): TranscriptionSegment => ({ + id: seg.id, + start: seg.start, + end: seg.end, + text: seg.text, + // The OpenAI SDK types `avg_logprob` as `number`, so call Math.exp + // directly. Guarding with `seg.avg_logprob ?` would treat `0` + // (perfect confidence) as missing. + confidence: Math.exp(seg.avg_logprob), + }), + ), + words: response.words?.map((w) => ({ + word: w.word, + start: w.start, + end: w.end, + })), + } + } else { + const response = await this.client.audio.transcriptions.create(request) + + return { + id: generateId(this.name), + model, + text: typeof response === 'string' ? response : response.text, + language, + } + } + } catch (error: unknown) { + options.logger.errors(`${this.name}.transcribe fatal`, { + error: toRunErrorPayload(error, `${this.name}.transcribe failed`), + source: `${this.name}.transcribe`, + }) + throw error + } + } + + protected prepareAudioFile(audio: string | File | Blob | ArrayBuffer): File { + if (typeof File !== 'undefined' && audio instanceof File) { + return audio + } + if (typeof Blob !== 'undefined' && audio instanceof Blob) { + this.ensureFileSupport() + return new File([audio], 'audio.mp3', { + type: audio.type || 'audio/mpeg', + }) + } + if (typeof ArrayBuffer !== 'undefined' && audio instanceof ArrayBuffer) { + this.ensureFileSupport() + return new File([audio], 'audio.mp3', { type: 'audio/mpeg' }) + } + if (typeof audio === 'string') { + this.ensureFileSupport() + + if (audio.startsWith('data:')) { + const parts = audio.split(',') + const header = parts[0] + const base64Data = parts[1] || '' + const mimeMatch = header?.match(/data:([^;]+)/) + const mimeType = mimeMatch?.[1] || 'audio/mpeg' + const bytes = base64ToArrayBuffer(base64Data) + const extension = mimeType.split('/')[1] || 'mp3' + return new File([bytes], `audio.${extension}`, { type: mimeType }) + } + + const bytes = base64ToArrayBuffer(audio) + return new File([bytes], 'audio.mp3', { type: 'audio/mpeg' }) + } + + throw new Error('Invalid audio input type') + } + + // Throws on Node < 20 where the global `File` constructor isn't available. + 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.', + ) + } } - protected override shouldDefaultToVerbose(model: string): boolean { - // Only Whisper supports `verbose_json`. The gpt-4o-* transcribe models - // accept only `json` and `text` and reject `verbose_json` with HTTP 400, - // so they must NOT default to verbose. The previous logic was inverted. - return model === 'whisper-1' + 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'] } } -/** - * Creates an OpenAI transcription adapter with explicit API key. - * Type resolution happens here at the call site. - * - * @param model - The model name (e.g., 'whisper-1') - * @param apiKey - Your OpenAI API key - * @param config - Optional additional configuration - * @returns Configured OpenAI transcription adapter instance with resolved types - * - * @example - * ```typescript - * const adapter = createOpenaiTranscription('whisper-1', "sk-..."); - * - * const result = await generateTranscription({ - * adapter, - * audio: audioFile, - * language: 'en' - * }); - * ``` - */ export function createOpenaiTranscription< TModel extends OpenAITranscriptionModel, >( @@ -72,32 +175,6 @@ export function createOpenaiTranscription< return new OpenAITranscriptionAdapter({ apiKey, ...config }, model) } -/** - * Creates an OpenAI transcription adapter with automatic API key detection from environment variables. - * Type resolution happens here at the call site. - * - * Looks for `OPENAI_API_KEY` in: - * - `process.env` (Node.js) - * - `window.env` (Browser with injected env) - * - * @param model - The model name (e.g., 'whisper-1') - * @param config - Optional configuration (excluding apiKey which is auto-detected) - * @returns Configured OpenAI transcription adapter instance with resolved types - * @throws Error if OPENAI_API_KEY is not found in environment - * - * @example - * ```typescript - * // Automatically uses OPENAI_API_KEY from environment - * const adapter = openaiTranscription('whisper-1'); - * - * const result = await generateTranscription({ - * adapter, - * audio: audioFile - * }); - * - * console.log(result.text) - * ``` - */ export function openaiTranscription( model: TModel, config?: Omit, diff --git a/packages/typescript/ai-openai/src/adapters/tts.ts b/packages/typescript/ai-openai/src/adapters/tts.ts index 59d302970..382d0096c 100644 --- a/packages/typescript/ai-openai/src/adapters/tts.ts +++ b/packages/typescript/ai-openai/src/adapters/tts.ts @@ -1,10 +1,15 @@ -import { OpenAICompatibleTTSAdapter } from '@tanstack/openai-base' +import OpenAI from 'openai' +import { BaseTTSAdapter } from '@tanstack/ai/adapters' +import { toRunErrorPayload } from '@tanstack/ai/adapter-internals' +import { arrayBufferToBase64, generateId } from '@tanstack/ai-utils' import { getOpenAIApiKeyFromEnv } from '../utils/client' import { validateAudioInput, validateInstructions, validateSpeed, } from '../audio/audio-provider-options' +import type { TTSOptions, TTSResult } from '@tanstack/ai' +import type OpenAI_SDK from 'openai' import type { OpenAITTSModel } from '../model-meta' import type { OpenAITTSProviderOptions } from '../audio/tts-provider-options' import type { OpenAIClientConfig } from '../utils/client' @@ -17,38 +22,31 @@ export interface OpenAITTSConfig extends OpenAIClientConfig {} /** * OpenAI Text-to-Speech Adapter * - * Tree-shakeable adapter for OpenAI TTS functionality. * Supports tts-1, tts-1-hd, and gpt-4o-audio-preview models. - * - * Features: - * - Multiple voice options: alloy, ash, ballad, coral, echo, fable, onyx, nova, sage, shimmer, verse - * - Multiple output formats: mp3, opus, aac, flac, wav, pcm - * - Speed control (0.25 to 4.0) + * Voices: alloy, ash, ballad, coral, echo, fable, onyx, nova, sage, shimmer, verse. + * Formats: mp3, opus, aac, flac, wav, pcm. Speed 0.25 to 4.0. */ export class OpenAITTSAdapter< TModel extends OpenAITTSModel, -> extends OpenAICompatibleTTSAdapter { +> extends BaseTTSAdapter { readonly name = 'openai' as const + protected client: OpenAI + constructor(config: OpenAITTSConfig, model: TModel) { - super(config, model, 'openai') + super(model, {}) + this.client = new OpenAI(config) } - 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' }) - } + async generateSpeech( + options: TTSOptions, + ): Promise { + const { model, text, voice, format, speed, modelOptions } = options - protected override validateSpeed(speed?: number): void { + validateAudioInput({ input: text, model: this.model, voice: 'alloy' }) if (speed !== undefined) { validateSpeed({ speed, model: this.model, input: '', voice: 'alloy' }) } - } - - protected override validateInstructions( - model: string, - modelOptions?: OpenAITTSProviderOptions, - ): void { if (modelOptions) { validateInstructions({ ...modelOptions, @@ -57,29 +55,58 @@ export class OpenAITTSAdapter< voice: 'alloy', }) } + + const request: OpenAI_SDK.Audio.SpeechCreateParams = { + model, + input: text, + voice: (voice || 'alloy') as OpenAI_SDK.Audio.SpeechCreateParams['voice'], + response_format: format, + speed, + ...modelOptions, + } + + try { + options.logger.request( + `activity=tts provider=${this.name} model=${model} format=${request.response_format ?? 'default'} voice=${request.voice}`, + { provider: this.name, model }, + ) + const response = await this.client.audio.speech.create(request) + + // Convert response to base64. Buffer is Node-only; use atob fallback in + // browser/edge runtimes where the SDK can run. + const arrayBuffer = await response.arrayBuffer() + const base64 = arrayBufferToBase64(arrayBuffer) + + const outputFormat = (request.response_format as string) || 'mp3' + const contentTypes: Record = { + mp3: 'audio/mpeg', + opus: 'audio/opus', + aac: 'audio/aac', + flac: 'audio/flac', + wav: 'audio/wav', + pcm: 'audio/pcm', + } + const contentType = contentTypes[outputFormat] || 'audio/mpeg' + + return { + id: generateId(this.name), + model, + audio: base64, + format: outputFormat, + contentType, + } + } catch (error: unknown) { + // Narrow before logging: raw SDK errors can carry request metadata + // (including auth headers) which we must never surface to user loggers. + options.logger.errors(`${this.name}.generateSpeech fatal`, { + error: toRunErrorPayload(error, `${this.name}.generateSpeech failed`), + source: `${this.name}.generateSpeech`, + }) + throw error + } } } -/** - * Creates an OpenAI speech adapter with explicit API key. - * Type resolution happens here at the call site. - * - * @param model - The model name (e.g., 'tts-1', 'tts-1-hd') - * @param apiKey - Your OpenAI API key - * @param config - Optional additional configuration - * @returns Configured OpenAI speech adapter instance with resolved types - * - * @example - * ```typescript - * const adapter = createOpenaiSpeech('tts-1-hd', "sk-..."); - * - * const result = await generateSpeech({ - * adapter, - * text: 'Hello, world!', - * voice: 'nova' - * }); - * ``` - */ export function createOpenaiSpeech( model: TModel, apiKey: string, @@ -88,32 +115,6 @@ export function createOpenaiSpeech( return new OpenAITTSAdapter({ apiKey, ...config }, model) } -/** - * Creates an OpenAI speech adapter with automatic API key detection from environment variables. - * Type resolution happens here at the call site. - * - * Looks for `OPENAI_API_KEY` in: - * - `process.env` (Node.js) - * - `window.env` (Browser with injected env) - * - * @param model - The model name (e.g., 'tts-1', 'tts-1-hd') - * @param config - Optional configuration (excluding apiKey which is auto-detected) - * @returns Configured OpenAI speech adapter instance with resolved types - * @throws Error if OPENAI_API_KEY is not found in environment - * - * @example - * ```typescript - * // Automatically uses OPENAI_API_KEY from environment - * const adapter = openaiSpeech('tts-1'); - * - * const result = await generateSpeech({ - * adapter, - * text: 'Welcome to TanStack AI!', - * voice: 'alloy', - * format: 'mp3' - * }); - * ``` - */ export function openaiSpeech( model: TModel, config?: Omit, diff --git a/packages/typescript/ai-openai/src/adapters/video.ts b/packages/typescript/ai-openai/src/adapters/video.ts index e5bc9aee1..133f204f9 100644 --- a/packages/typescript/ai-openai/src/adapters/video.ts +++ b/packages/typescript/ai-openai/src/adapters/video.ts @@ -1,4 +1,7 @@ -import { OpenAICompatibleVideoAdapter } from '@tanstack/openai-base' +import OpenAI from 'openai' +import { BaseVideoAdapter } from '@tanstack/ai/adapters' +import { toRunErrorPayload } from '@tanstack/ai/adapter-internals' +import { arrayBufferToBase64 } from '@tanstack/ai-utils' import { getOpenAIApiKeyFromEnv } from '../utils/client' import { toApiSeconds, @@ -6,16 +9,38 @@ import { validateVideoSize, } from '../video/video-provider-options' import type { VideoModel } from 'openai/resources' +import type { + VideoGenerationOptions, + VideoJobResult, + VideoStatusResult, + VideoUrlResult, +} from '@tanstack/ai' +import type OpenAI_SDK from 'openai' import type { OpenAIVideoModel } from '../model-meta' import type { OpenAIVideoModelProviderOptionsByName, OpenAIVideoModelSizeByName, OpenAIVideoProviderOptions, } from '../video/video-provider-options' -import type { VideoGenerationOptions } from '@tanstack/ai' -import type OpenAI_SDK from 'openai' import type { OpenAIClientConfig } from '../utils/client' +/** + * Threshold for emitting a "this download will probably OOM serverless + * runtimes" warning. Anything larger than this (in bytes) gets surfaced via + * console.warn — workers and small isolates routinely run out of memory once + * a downloaded video is base64-encoded. + */ +const LARGE_MEDIA_BUFFER_BYTES = 10 * 1024 * 1024 + +function warnIfLargeMediaBuffer(byteLength: number, source: string): void { + if (byteLength <= LARGE_MEDIA_BUFFER_BYTES) return + console.warn( + `[openai.${source}] downloaded ${(byteLength / 1024 / 1024).toFixed(1)} MiB into memory before base64 encoding. ` + + `Workers/serverless runtimes commonly run out of memory above ~10 MiB. ` + + `Consider streaming the video through a CDN or your own storage layer instead.`, + ) +} + /** * Configuration for OpenAI video adapter. * @@ -24,22 +49,13 @@ import type { OpenAIClientConfig } from '../utils/client' export interface OpenAIVideoConfig extends OpenAIClientConfig {} /** - * OpenAI Video Generation Adapter - * - * Tree-shakeable adapter for OpenAI video generation functionality using Sora-2. - * Uses a jobs/polling architecture for async video generation. + * OpenAI Video Generation Adapter (Sora-2). Job/polling architecture. * * @experimental Video generation is an experimental feature and may change. - * - * Features: - * - Async job-based video generation - * - Status polling for job progress - * - URL retrieval for completed videos - * - Model-specific type-safe provider options */ export class OpenAIVideoAdapter< TModel extends OpenAIVideoModel, -> extends OpenAICompatibleVideoAdapter< +> extends BaseVideoAdapter< TModel, OpenAIVideoProviderOptions, OpenAIVideoModelProviderOptionsByName, @@ -47,71 +63,246 @@ export class OpenAIVideoAdapter< > { readonly name = 'openai' as const + protected client: OpenAI + protected clientConfig: OpenAIVideoConfig + constructor(config: OpenAIVideoConfig, model: TModel) { - super(config, model, 'openai') + super(config, model) + this.clientConfig = config + this.client = new OpenAI(config) } - protected override validateVideoSize(model: string, size?: string): void { - validateVideoSize(model, size) - } + async createVideoJob( + options: VideoGenerationOptions, + ): Promise { + const { model, size, duration, modelOptions } = options - protected override validateVideoSeconds( - model: string, - seconds?: number | string, - ): void { + validateVideoSize(model, size) + const seconds = duration ?? modelOptions?.seconds validateVideoSeconds(model, seconds) - } - - protected override buildRequest( - options: VideoGenerationOptions, - ): OpenAI_SDK.Videos.VideoCreateParams { - const { model, prompt, size, duration, modelOptions } = options const request: OpenAI_SDK.Videos.VideoCreateParams = { model: model as VideoModel, - prompt, + prompt: options.prompt, } - - // Add size/resolution - // Supported: '1280x720', '720x1280', '1792x1024', '1024x1792' if (size) { request.size = size as OpenAI_SDK.Videos.VideoCreateParams['size'] } else if (modelOptions?.size) { request.size = modelOptions.size } - - // Add seconds (duration) - // Supported: '4', '8', or '12' - yes, the API wants strings - const seconds = duration ?? modelOptions?.seconds if (seconds !== undefined) { request.seconds = toApiSeconds(seconds) } - return request + try { + options.logger.request( + `activity=video.create provider=${this.name} model=${model} size=${request.size ?? 'default'} seconds=${request.seconds ?? 'default'}`, + { provider: this.name, model }, + ) + const videosClient = this.getVideosClient() + const response = await videosClient.create(request) + return { jobId: response.id, model } + } catch (error: any) { + options.logger.errors(`${this.name}.createVideoJob fatal`, { + error: toRunErrorPayload(error, `${this.name}.createVideoJob failed`), + source: `${this.name}.createVideoJob`, + }) + if (error?.message?.includes('videos') || error?.code === 'invalid_api') { + throw new Error( + `Video generation API is not available. The API may require special access. ` + + `Original error: ${error.message}`, + ) + } + throw error + } + } + + /** + * The video API on the OpenAI SDK is still experimental and shipped on some + * SDK versions but not others; access through `videosClient` lets us treat + * the path uniformly even when the SDK lacks first-class typings here. + */ + private getVideosClient(): { + create: (req: Record) => Promise<{ id: string }> + retrieve: (id: string) => Promise<{ + id: string + status: string + progress?: number + url?: string + expires_at?: number + error?: { message?: string } + }> + downloadContent?: (id: string) => Promise + content?: (id: string) => Promise + getContent?: (id: string) => Promise + download?: (id: string) => Promise + } { + return (this.client as { videos: any }).videos + } + + async getVideoStatus(jobId: string): Promise { + try { + const videosClient = this.getVideosClient() + const response = await videosClient.retrieve(jobId) + return { + jobId, + status: this.mapStatus(response.status), + progress: response.progress, + error: response.error?.message, + } + } catch (error: any) { + if (error.status === 404) { + return { jobId, status: 'failed', error: 'Job not found' } + } + throw error + } + } + + async getVideoUrl(jobId: string): Promise { + try { + const videosClient = this.getVideosClient() + + // Prefer retrieve() because many openai-compatible backends (and the + // aimock test harness) return the URL directly on the video resource + // and do not implement a separate /content endpoint. + const videoInfo = await videosClient.retrieve(jobId) + if (videoInfo.url) { + return { + jobId, + url: videoInfo.url, + expiresAt: videoInfo.expires_at + ? new Date(videoInfo.expires_at) + : undefined, + } + } + + // SDK download fall-through: try the various possible method names. + if (typeof videosClient.downloadContent === 'function') { + const contentResponse = await videosClient.downloadContent(jobId) + const videoBlob = await contentResponse.blob() + const buffer = await videoBlob.arrayBuffer() + warnIfLargeMediaBuffer(buffer.byteLength, 'video.downloadContent') + const base64 = arrayBufferToBase64(buffer) + const mimeType = + contentResponse.headers.get('content-type') || 'video/mp4' + return { + jobId, + url: `data:${mimeType};base64,${base64}`, + expiresAt: undefined, + } + } + + let response: any + if (typeof videosClient.content === 'function') { + response = await videosClient.content(jobId) + } else if (typeof videosClient.getContent === 'function') { + response = await videosClient.getContent(jobId) + } else if (typeof videosClient.download === 'function') { + response = await videosClient.download(jobId) + } else { + // Last resort: raw fetch with auth header. + const baseUrl = this.clientConfig.baseURL || 'https://api.openai.com/v1' + const apiKey = this.clientConfig.apiKey + + const contentResponse = await fetch( + `${baseUrl}/videos/${jobId}/content`, + { method: 'GET', headers: { Authorization: `Bearer ${apiKey}` } }, + ) + + if (!contentResponse.ok) { + const contentType = contentResponse.headers.get('content-type') + if (contentType?.includes('application/json')) { + const errorData = await contentResponse.json().catch(() => ({})) + throw new Error( + errorData.error?.message || + `Failed to get video content: ${contentResponse.status}`, + ) + } + throw new Error( + `Failed to get video content: ${contentResponse.status}`, + ) + } + + const videoBlob = await contentResponse.blob() + const buffer = await videoBlob.arrayBuffer() + warnIfLargeMediaBuffer(buffer.byteLength, 'video.fetch') + const base64 = arrayBufferToBase64(buffer) + const mimeType = + contentResponse.headers.get('content-type') || 'video/mp4' + return { + jobId, + url: `data:${mimeType};base64,${base64}`, + expiresAt: undefined, + } + } + + // The fall-through SDK methods produce a Blob-ish or fetch-`Response`-ish + // object. Read as bytes + wrap in a data URL so callers see a playable + // URL instead of an endpoint URL. + const fallthroughBlob = + typeof response?.blob === 'function' + ? await response.blob() + : response instanceof Blob + ? response + : null + if (!fallthroughBlob) { + throw new Error( + `Video content download via SDK fall-through returned an unexpected shape (no blob()).`, + ) + } + const fallthroughBuffer = await fallthroughBlob.arrayBuffer() + warnIfLargeMediaBuffer( + fallthroughBuffer.byteLength, + 'video.sdkFallthrough', + ) + const fallthroughBase64 = arrayBufferToBase64(fallthroughBuffer) + const fallthroughMime = + (typeof response?.headers?.get === 'function' + ? response.headers.get('content-type') + : undefined) || + fallthroughBlob.type || + 'video/mp4' + return { + jobId, + url: `data:${fallthroughMime};base64,${fallthroughBase64}`, + expiresAt: undefined, + } + } catch (error: any) { + if (error.status === 404) { + throw new Error(`Video job not found: ${jobId}`) + } + if (error.status === 400) { + throw new Error( + `Video is not ready for download. Check status first. Job ID: ${jobId}`, + ) + } + throw error + } + } + + protected 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' + } } } -/** - * Creates an OpenAI video adapter with an explicit API key. - * Type resolution happens here at the call site. - * - * @experimental Video generation is an experimental feature and may change. - * - * @param model - The model name (e.g., 'sora-2') - * @param apiKey - Your OpenAI API key - * @param config - Optional additional configuration - * @returns Configured OpenAI video adapter instance with resolved types - * - * @example - * ```typescript - * const adapter = createOpenaiVideo('sora-2', 'your-api-key'); - * - * const { jobId } = await generateVideo({ - * adapter, - * prompt: 'A beautiful sunset over the ocean' - * }); - * ``` - */ export function createOpenaiVideo( model: TModel, apiKey: string, @@ -120,39 +311,6 @@ export function createOpenaiVideo( return new OpenAIVideoAdapter({ apiKey, ...config }, model) } -/** - * Creates an OpenAI video adapter with automatic API key detection from environment variables. - * Type resolution happens here at the call site. - * - * Looks for `OPENAI_API_KEY` in: - * - `process.env` (Node.js) - * - `window.env` (Browser with injected env) - * - * @experimental Video generation is an experimental feature and may change. - * - * @param model - The model name (e.g., 'sora-2') - * @param config - Optional configuration (excluding apiKey which is auto-detected) - * @returns Configured OpenAI video adapter instance with resolved types - * @throws Error if OPENAI_API_KEY is not found in environment - * - * @example - * ```typescript - * // Automatically uses OPENAI_API_KEY from environment - * const adapter = openaiVideo('sora-2'); - * - * // Create a video generation job - * const { jobId } = await generateVideo({ - * adapter, - * prompt: 'A cat playing piano' - * }); - * - * // Poll for status - * const status = await getVideoJobStatus({ - * adapter, - * jobId - * }); - * ``` - */ export function openaiVideo( model: TModel, config?: Omit, diff --git a/packages/typescript/ai-openai/src/index.ts b/packages/typescript/ai-openai/src/index.ts index b2d6a1d26..170833aea 100644 --- a/packages/typescript/ai-openai/src/index.ts +++ b/packages/typescript/ai-openai/src/index.ts @@ -11,13 +11,11 @@ export { type OpenAITextProviderOptions, } from './adapters/text' -// Summarize adapter - for text summarization +// Summarize - thin factory functions over @tanstack/ai's ChatStreamSummarizeAdapter export { - OpenAISummarizeAdapter, createOpenaiSummarize, openaiSummarize, type OpenAISummarizeConfig, - type OpenAISummarizeProviderOptions, } from './adapters/summarize' // Image adapter - for image generation diff --git a/packages/typescript/ai-openai/src/text/text-provider-options.ts b/packages/typescript/ai-openai/src/text/text-provider-options.ts index ba9d60498..e3e8be740 100644 --- a/packages/typescript/ai-openai/src/text/text-provider-options.ts +++ b/packages/typescript/ai-openai/src/text/text-provider-options.ts @@ -1,4 +1,5 @@ import type OpenAI from 'openai' +import type { ResponseInput } from 'openai/resources/responses/responses' import type { ApplyPatchTool } from '../tools/apply-patch-tool' import type { CodeInterpreterTool } from '../tools/code-interpreter-tool' import type { ComputerUseTool } from '../tools/computer-use-tool' @@ -246,7 +247,7 @@ export type ExternalTextProviderOptions = OpenAIBaseOptions & * Tip: gate these by model capability in your SDK, not just by presence. */ export interface InternalTextProviderOptions extends ExternalTextProviderOptions { - input: string | OpenAI.Responses.ResponseInput + input: string | ResponseInput /** * A system (or developer) message inserted into the model's context. diff --git a/packages/typescript/ai-openai/src/utils/client.ts b/packages/typescript/ai-openai/src/utils/client.ts index 97f1efe50..b8ef4a06c 100644 --- a/packages/typescript/ai-openai/src/utils/client.ts +++ b/packages/typescript/ai-openai/src/utils/client.ts @@ -1,7 +1,14 @@ import { getApiKeyFromEnv } from '@tanstack/ai-utils' -import type { OpenAICompatibleClientConfig } from '@tanstack/openai-base' +import type { ClientOptions } from 'openai' -export interface OpenAIClientConfig extends OpenAICompatibleClientConfig {} +/** + * OpenAI client configuration. Pass through to `new OpenAI(...)`. `apiKey` + * is required so the openai-compatible adapters don't need to handle a + * missing-key case at construction time. + */ +export interface OpenAIClientConfig extends Omit { + apiKey: string +} /** * Gets OpenAI API key from environment variables diff --git a/packages/typescript/ai-openrouter/package.json b/packages/typescript/ai-openrouter/package.json index a8c82d4ec..892a80cbd 100644 --- a/packages/typescript/ai-openrouter/package.json +++ b/packages/typescript/ai-openrouter/package.json @@ -53,6 +53,7 @@ "zod": "^4.2.0" }, "peerDependencies": { - "@tanstack/ai": "workspace:^" + "@tanstack/ai": "workspace:^", + "zod": "^4.0.0" } } diff --git a/packages/typescript/ai-openrouter/src/adapters/responses-text.ts b/packages/typescript/ai-openrouter/src/adapters/responses-text.ts new file mode 100644 index 000000000..3ade50cf5 --- /dev/null +++ b/packages/typescript/ai-openrouter/src/adapters/responses-text.ts @@ -0,0 +1,1500 @@ +import { OpenRouter } from '@openrouter/sdk' +import { EventType } from '@tanstack/ai' +import { BaseTextAdapter } from '@tanstack/ai/adapters' +import { toRunErrorPayload } from '@tanstack/ai/adapter-internals' +import { generateId, transformNullsToUndefined } from '@tanstack/ai-utils' +import { extractRequestOptions } from '../internal/request-options' +import { makeStructuredOutputCompatible } from '../internal/schema-converter' +import { convertFunctionToolToResponsesFormat } from '../internal/responses-tool-converter' +import { isWebSearchTool } from '../tools/web-search-tool' +import { getOpenRouterApiKeyFromEnv } from '../utils' +import type { SDKOptions } from '@openrouter/sdk' +import type { ResponsesFunctionTool } from '../internal/responses-tool-converter' +import type { + InputsUnion, + OpenResponsesResult, + ResponsesRequest, + StreamEvents, +} from '@openrouter/sdk/models' +import type { + StructuredOutputOptions, + StructuredOutputResult, +} from '@tanstack/ai/adapters' +import type { + ContentPart, + ModelMessage, + StreamChunk, + TextOptions, + Tool, +} from '@tanstack/ai' +import type { ExternalResponsesProviderOptions } from '../text/responses-provider-options' +import type { + OPENROUTER_CHAT_MODELS, + OpenRouterChatModelToolCapabilitiesByName, + OpenRouterModelInputModalitiesByName, +} from '../model-meta' +import type { OpenRouterMessageMetadataByModality } from '../message-types' + +/** Element type of `ResponsesRequest.input` when it's the array form (the + * SDK union also allows a bare string). Pinning to the array element lets + * the convertMessagesToInput logic narrow to the per-item discriminated + * union so a TS rename surfaces here. */ +type InputsItem = Extract>[number] +/** ResponsesRequest input content part shape (per-content-part discriminated union). */ +type ResponsesInputContent = unknown + +export interface OpenRouterResponsesConfig extends SDKOptions {} +export type OpenRouterResponsesTextModels = + (typeof OPENROUTER_CHAT_MODELS)[number] +export type OpenRouterResponsesTextProviderOptions = + ExternalResponsesProviderOptions + +type ResolveInputModalities = + TModel extends keyof OpenRouterModelInputModalitiesByName + ? OpenRouterModelInputModalitiesByName[TModel] + : readonly ['text', 'image'] + +type ResolveToolCapabilities = + TModel extends keyof OpenRouterChatModelToolCapabilitiesByName + ? NonNullable + : readonly [] + +/** + * OpenRouter Responses (beta) Adapter — standalone implementation that talks + * to OpenRouter's `/v1/responses` (beta) endpoint via the `@openrouter/sdk` + * SDK. + * + * The wire format is OpenAI-Responses-compatible (so OpenRouter can route + * Responses requests to GPT, Claude, Gemini, etc.) but the SDK exposes the + * request/response in camelCase TS shapes (`callId`, `imageUrl`, + * `fileData`, `outputIndex`, `itemId`, `inputTokens`, `incompleteDetails`, + * etc.). This adapter operates directly in those camelCase shapes — there's + * no snake_case ↔ camelCase round-trip. + * + * v1 routes function tools only. Passing a `webSearchTool()` brand throws + * — OpenRouter's Responses API exposes richer server-tool variants + * (WebSearchServerToolOpenRouter / Preview20250311WebSearchServerTool / + * …) that will land in a follow-up. + */ +export class OpenRouterResponsesTextAdapter< + TModel extends OpenRouterResponsesTextModels, + TToolCapabilities extends ReadonlyArray = + ResolveToolCapabilities, +> extends BaseTextAdapter< + TModel, + OpenRouterResponsesTextProviderOptions, + ResolveInputModalities, + OpenRouterMessageMetadataByModality, + TToolCapabilities +> { + readonly kind = 'text' as const + readonly name = 'openrouter-responses' as const + + protected orClient: OpenRouter + + constructor(config: OpenRouterResponsesConfig, model: TModel) { + super({}, model) + this.orClient = new OpenRouter(config) + } + + async *chatStream( + options: TextOptions, + ): AsyncIterable { + // Track tool call metadata by unique ID. The 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 ids. + const toolCallMetadata = new Map< + string, + { + index: number + name: string + started: boolean + ended?: boolean + pendingArguments?: string + } + >() + + // AG-UI lifecycle tracking + const aguiState = { + runId: generateId(this.name), + threadId: options.threadId ?? generateId(this.name), + messageId: generateId(this.name), + hasEmittedRunStarted: false, + } + + try { + // mapOptionsToRequest can throw on caller-side validation failures + // (empty user content, unsupported parts, webSearchTool() rejection). + // Keep it inside the try so those failures surface as RUN_ERROR events + // instead of iterator throws. + const responsesRequest = this.mapOptionsToRequest(options) + options.logger.request( + `activity=chat provider=${this.name} model=${this.model} messages=${options.messages.length} tools=${options.tools?.length ?? 0} stream=true`, + { provider: this.name, model: this.model }, + ) + const reqOptions = extractRequestOptions(options.request) + const response = (await this.orClient.beta.responses.send( + { responsesRequest: { ...responsesRequest, stream: true } }, + { + signal: reqOptions.signal ?? undefined, + ...(reqOptions.headers && { headers: reqOptions.headers }), + }, + )) as AsyncIterable + + yield* this.processStreamChunks( + response, + toolCallMetadata, + options, + aguiState, + ) + } catch (error: unknown) { + // Narrow before logging: raw SDK errors can carry request metadata + // (including auth headers) which we must never surface to user loggers. + const errorPayload = toRunErrorPayload( + error, + `${this.name}.chatStream failed`, + ) + + // Emit RUN_STARTED if not yet emitted + if (!aguiState.hasEmittedRunStarted) { + aguiState.hasEmittedRunStarted = true + yield { + type: EventType.RUN_STARTED, + runId: aguiState.runId, + threadId: aguiState.threadId, + model: options.model, + timestamp: Date.now(), + } satisfies StreamChunk + } + + yield { + type: EventType.RUN_ERROR, + model: options.model, + timestamp: Date.now(), + message: errorPayload.message, + code: errorPayload.code, + error: errorPayload, + } satisfies StreamChunk + + options.logger.errors(`${this.name}.chatStream fatal`, { + error: errorPayload, + source: `${this.name}.chatStream`, + }) + } + } + + /** + * Generate structured output via OpenRouter's Responses API + * `text.format: { type: 'json_schema', ... }`. Uses stream: false. + */ + async structuredOutput( + options: StructuredOutputOptions, + ): Promise> { + const { chatOptions, outputSchema } = options + const responsesRequest = this.mapOptionsToRequest(chatOptions) + + const jsonSchema = this.makeStructuredOutputCompatible( + outputSchema, + outputSchema.required, + ) + + try { + chatOptions.logger.request( + `activity=structuredOutput provider=${this.name} model=${this.model} messages=${chatOptions.messages.length}`, + { provider: this.name, model: this.model }, + ) + const reqOptions = extractRequestOptions(chatOptions.request) + const response = await this.orClient.beta.responses.send( + { + responsesRequest: { + ...responsesRequest, + stream: false, + text: { + format: { + type: 'json_schema', + name: 'structured_output', + schema: jsonSchema, + strict: true, + }, + } as ResponsesRequest['text'], + }, + }, + { + signal: reqOptions.signal ?? undefined, + ...(reqOptions.headers && { headers: reqOptions.headers }), + }, + ) + + const rawText = this.extractTextFromResponse(response) + + if (rawText.length === 0) { + throw new Error( + `${this.name}.structuredOutput: response contained no content`, + ) + } + + 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 ? '...' : ''}`, + ) + } + + // OpenRouter override: pass nulls through unchanged. + const transformed = this.transformStructuredOutput(parsed) + + return { + data: transformed, + rawText, + } + } catch (error: unknown) { + chatOptions.logger.errors(`${this.name}.structuredOutput fatal`, { + error: toRunErrorPayload(error, `${this.name}.structuredOutput failed`), + source: `${this.name}.structuredOutput`, + }) + throw error + } + } + + protected makeStructuredOutputCompatible( + schema: Record, + originalRequired?: Array, + ): Record { + return makeStructuredOutputCompatible(schema, originalRequired) + } + + /** + * OpenRouter routes through a wide variety of upstream providers; some + * return `null` as a distinct sentinel rather than collapsing it to absent. + * Stripping nulls would erase that distinction, so we passthrough. + * + * `transformNullsToUndefined` is imported for parity with the other + * provider adapters but intentionally not invoked here. + */ + protected transformStructuredOutput(parsed: unknown): unknown { + void transformNullsToUndefined + return parsed + } + + /** + * Extract text content from a non-streaming Responses API response. + * Reads OpenRouter's camelCase `OpenResponsesResult` shape directly. + */ + protected extractTextFromResponse(response: OpenResponsesResult): string { + let textContent = '' + let refusal: string | undefined + let sawMessageItem = false + const observedItemTypes = new Set() + + for (const rawItem of response.output) { + const item = rawItem as { type: string; content?: ReadonlyArray } + observedItemTypes.add(item.type) + if (item.type === 'message') { + sawMessageItem = true + for (const part of item.content ?? []) { + // Cast off the discriminated union before the type discrimination + // so future SDK variants (e.g. `output_audio`, `output_image`) hit + // the explicit error path rather than being misreported as refusals + // when they get added to the union. + const partType = (part as { type: string }).type + if (partType === 'output_text') { + textContent += (part as { text?: string }).text ?? '' + } else if (partType === 'refusal') { + const refusalText = (part as { refusal?: string }).refusal + refusal = refusalText || refusal || 'Refused without explanation' + } else { + throw new Error( + `${this.name}.extractTextFromResponse: unsupported message content part type "${partType}"`, + ) + } + } + } + } + + // Surface refusals as an explicit error so callers don't see a generic + // "Failed to parse structured output as JSON. Content: " when the model + // refused for safety / content-policy reasons. + if (!textContent && refusal !== undefined) { + const err = new Error(`Model refused to respond: ${refusal}`) + ;(err as Error & { code?: string }).code = 'refusal' + throw err + } + + // Response had items but none carried message text (e.g. only + // function_call or reasoning items). Surface that explicitly so a + // downstream structured-output caller doesn't see a misleading + // "Failed to parse JSON. Content: " from an empty string. + if (!textContent && response.output.length > 0 && !sawMessageItem) { + throw new Error( + `${this.name}.extractTextFromResponse: response.output contained items of type(s) [${[...observedItemTypes].sort().join(', ')}] but no message text — the model returned a non-text response`, + ) + } + + return textContent + } + + /** + * Processes streamed events from the OpenRouter Responses API and yields + * AG-UI events. Reads the SDK's camelCase event shape directly + * (`itemId`, `outputIndex`, `incompleteDetails`, `inputTokens`, etc.). + * + * Speakeasy's discriminated-union parser falls back to + * `{ raw, type: 'UNKNOWN', isUnknown: true }` when an event's strict + * per-variant schema rejects (missing optional fields like `sequenceNumber` + * that some upstreams omit). The `raw` payload is the original wire-shape + * event in snake_case. We translate snake_case keys to camelCase for those + * unknown events so the rest of the processor reads a uniform shape. + */ + protected async *processStreamChunks( + stream: AsyncIterable, + toolCallMetadata: Map< + string, + { + index: number + name: string + started: boolean + ended?: boolean + pendingArguments?: string + } + >, + options: TextOptions, + aguiState: { + runId: string + threadId: string + messageId: string + hasEmittedRunStarted: boolean + }, + ): AsyncIterable { + let accumulatedContent = '' + let accumulatedReasoning = '' + + let hasStreamedContentDeltas = false + let hasStreamedReasoningDeltas = false + + let model: string = options.model + + let stepId: string | null = null + let hasEmittedTextMessageStart = false + let hasEmittedStepStarted = false + let runFinishedEmitted = false + + try { + for await (const rawEvent of stream) { + const chunk = normalizeStreamEvent(rawEvent) + options.logger.provider(`provider=${this.name} type=${chunk.type}`, { + provider: this.name, + type: chunk.type, + }) + + // Emit RUN_STARTED on first chunk + if (!aguiState.hasEmittedRunStarted) { + aguiState.hasEmittedRunStarted = true + yield { + type: EventType.RUN_STARTED, + runId: aguiState.runId, + threadId: aguiState.threadId, + model: model || options.model, + timestamp: Date.now(), + } satisfies StreamChunk + } + + const handleContentPart = (contentPart: { + type: string + text?: string + refusal?: string + }): StreamChunk => { + if (contentPart.type === 'output_text') { + accumulatedContent += contentPart.text || '' + return { + type: EventType.TEXT_MESSAGE_CONTENT, + messageId: aguiState.messageId, + model: model || options.model, + timestamp: Date.now(), + delta: contentPart.text || '', + content: accumulatedContent, + } satisfies StreamChunk + } + + if (contentPart.type === 'reasoning_text') { + accumulatedReasoning += contentPart.text || '' + // Cache the fallback stepId rather than generating a fresh one + // on every call. + if (!stepId) { + stepId = generateId(this.name) + } + return { + type: EventType.STEP_FINISHED, + stepName: stepId, + stepId, + model: model || options.model, + timestamp: Date.now(), + delta: contentPart.text || '', + content: accumulatedReasoning, + } satisfies StreamChunk + } + // Either a real refusal or an unknown content_part type. Surface + // the part type in the error so unknown parts are debuggable + // instead of being misreported as "Unknown refusal". + const isRefusal = contentPart.type === 'refusal' + const message = isRefusal + ? contentPart.refusal || 'Refused without explanation' + : `Unsupported response content_part type: ${contentPart.type}` + const code = isRefusal ? 'refusal' : contentPart.type + return { + type: EventType.RUN_ERROR, + model: model || options.model, + timestamp: Date.now(), + message, + code, + error: { message, code }, + } satisfies StreamChunk + } + + // Capture model metadata from any of these events. + if ( + chunk.type === 'response.created' || + chunk.type === 'response.in_progress' || + chunk.type === 'response.incomplete' || + chunk.type === 'response.failed' + ) { + const r = chunk.response as { model?: string } | undefined + if (r?.model) model = r.model + } + + // response.created marks the start of a fresh run — safe to reset + // the per-run accumulators here. + if (chunk.type === 'response.created') { + hasStreamedContentDeltas = false + hasStreamedReasoningDeltas = false + hasEmittedTextMessageStart = false + hasEmittedStepStarted = false + accumulatedContent = '' + accumulatedReasoning = '' + } + + // response.failed and response.incomplete are TERMINAL events. + if ( + chunk.type === 'response.failed' || + chunk.type === 'response.incomplete' + ) { + if (hasEmittedTextMessageStart) { + yield { + type: EventType.TEXT_MESSAGE_END, + messageId: aguiState.messageId, + model, + timestamp: Date.now(), + } satisfies StreamChunk + hasEmittedTextMessageStart = false + } + const r = (chunk.response ?? {}) as { + error?: { message?: string; code?: unknown } | null + incompleteDetails?: { reason?: string } | null + } + const errorMessage = + r.error?.message || + r.incompleteDetails?.reason || + (chunk.type === 'response.failed' + ? 'Response failed' + : 'Response ended incomplete') + const errorCode = + normalizeCode(r.error?.code) ?? + (r.incompleteDetails ? 'incomplete' : undefined) ?? + undefined + yield { + type: EventType.RUN_ERROR, + model, + timestamp: Date.now(), + message: errorMessage, + ...(errorCode !== undefined && { code: errorCode }), + error: { + message: errorMessage, + ...(errorCode !== undefined && { code: errorCode }), + }, + } satisfies StreamChunk + runFinishedEmitted = true + return + } + + // Handle output text deltas (token-by-token streaming) + if (chunk.type === 'response.output_text.delta' && chunk.delta) { + const textDelta = Array.isArray(chunk.delta) + ? chunk.delta.join('') + : typeof chunk.delta === 'string' + ? chunk.delta + : '' + + if (textDelta) { + if (!hasEmittedTextMessageStart) { + hasEmittedTextMessageStart = true + yield { + type: EventType.TEXT_MESSAGE_START, + messageId: aguiState.messageId, + model: model || options.model, + timestamp: Date.now(), + role: 'assistant', + } satisfies StreamChunk + } + + accumulatedContent += textDelta + hasStreamedContentDeltas = true + yield { + type: EventType.TEXT_MESSAGE_CONTENT, + messageId: aguiState.messageId, + model: model || options.model, + timestamp: Date.now(), + delta: textDelta, + content: accumulatedContent, + } satisfies StreamChunk + } + } + + // Handle reasoning deltas + if (chunk.type === 'response.reasoning_text.delta' && chunk.delta) { + const reasoningDelta = Array.isArray(chunk.delta) + ? chunk.delta.join('') + : typeof chunk.delta === 'string' + ? chunk.delta + : '' + + if (reasoningDelta) { + if (!hasEmittedStepStarted) { + hasEmittedStepStarted = true + stepId = generateId(this.name) + yield { + type: EventType.STEP_STARTED, + stepName: stepId, + stepId, + model: model || options.model, + timestamp: Date.now(), + stepType: 'thinking', + } satisfies StreamChunk + } + + accumulatedReasoning += reasoningDelta + hasStreamedReasoningDeltas = true + const fallbackStepId = stepId || generateId(this.name) + yield { + type: EventType.STEP_FINISHED, + stepName: fallbackStepId, + stepId: fallbackStepId, + model: model || options.model, + timestamp: Date.now(), + delta: reasoningDelta, + content: accumulatedReasoning, + } satisfies StreamChunk + } + } + + // Handle reasoning summary deltas + if ( + chunk.type === 'response.reasoning_summary_text.delta' && + chunk.delta + ) { + const summaryDelta = + typeof chunk.delta === 'string' ? chunk.delta : '' + + if (summaryDelta) { + if (!hasEmittedStepStarted) { + hasEmittedStepStarted = true + stepId = generateId(this.name) + yield { + type: EventType.STEP_STARTED, + stepName: stepId, + stepId, + model: model || options.model, + timestamp: Date.now(), + stepType: 'thinking', + } satisfies StreamChunk + } + + accumulatedReasoning += summaryDelta + hasStreamedReasoningDeltas = true + const fallbackStepId = stepId || generateId(this.name) + yield { + type: EventType.STEP_FINISHED, + stepName: fallbackStepId, + stepId: fallbackStepId, + model: model || options.model, + timestamp: Date.now(), + delta: summaryDelta, + content: accumulatedReasoning, + } satisfies StreamChunk + } + } + + // handle content_part added events for text, reasoning and refusals + if (chunk.type === 'response.content_part.added') { + const contentPart = chunk.part as { + type: string + text?: string + refusal?: string + } + if ( + contentPart.type === 'output_text' && + !hasEmittedTextMessageStart + ) { + hasEmittedTextMessageStart = true + yield { + type: EventType.TEXT_MESSAGE_START, + messageId: aguiState.messageId, + model: model || options.model, + timestamp: Date.now(), + role: 'assistant', + } satisfies StreamChunk + } + if (contentPart.type === 'reasoning_text' && !hasEmittedStepStarted) { + hasEmittedStepStarted = true + stepId = generateId(this.name) + yield { + type: EventType.STEP_STARTED, + stepName: stepId, + stepId, + model: model || options.model, + timestamp: Date.now(), + stepType: 'thinking', + } satisfies StreamChunk + } + if (contentPart.type === 'output_text') { + hasStreamedContentDeltas = true + } else if (contentPart.type === 'reasoning_text') { + hasStreamedReasoningDeltas = true + } + const partChunk = handleContentPart(contentPart) + yield partChunk + if (partChunk.type === 'RUN_ERROR') { + runFinishedEmitted = true + return + } + } + + if (chunk.type === 'response.content_part.done') { + const contentPart = chunk.part as { + type: string + text?: string + refusal?: string + } + + // Skip emitting chunks for content parts that we've already streamed via deltas + if (contentPart.type === 'output_text' && hasStreamedContentDeltas) { + continue + } + if ( + contentPart.type === 'reasoning_text' && + hasStreamedReasoningDeltas + ) { + continue + } + + // Upstreams that emit `content_part.done` without any preceding + // deltas (or `content_part.added`) still need a START event before + // CONTENT. + if ( + contentPart.type === 'output_text' && + !hasEmittedTextMessageStart + ) { + hasEmittedTextMessageStart = true + yield { + type: EventType.TEXT_MESSAGE_START, + messageId: aguiState.messageId, + model: model || options.model, + timestamp: Date.now(), + role: 'assistant', + } satisfies StreamChunk + } else if ( + contentPart.type === 'reasoning_text' && + !hasEmittedStepStarted + ) { + hasEmittedStepStarted = true + stepId = generateId(this.name) + yield { + type: EventType.STEP_STARTED, + stepName: stepId, + stepId, + model: model || options.model, + timestamp: Date.now(), + stepType: 'thinking', + } satisfies StreamChunk + } + + const doneChunk = handleContentPart(contentPart) + yield doneChunk + if (doneChunk.type === 'RUN_ERROR') { + runFinishedEmitted = true + return + } + } + + // handle output_item.added to capture function call metadata (name) + if (chunk.type === 'response.output_item.added') { + const item = chunk.item as { + type: string + id?: string + name?: string + } + if (item.type === 'function_call' && item.id) { + const existing = toolCallMetadata.get(item.id) + if (!existing) { + toolCallMetadata.set(item.id, { + index: chunk.outputIndex ?? 0, + name: item.name || '', + started: false, + }) + } else if (!existing.name && item.name) { + existing.name = item.name + } + const metadata = toolCallMetadata.get(item.id)! + if (!metadata.started && metadata.name) { + yield { + type: EventType.TOOL_CALL_START, + toolCallId: item.id, + toolCallName: metadata.name, + toolName: metadata.name, + model: model || options.model, + timestamp: Date.now(), + index: chunk.outputIndex ?? 0, + } satisfies StreamChunk + metadata.started = true + } + } + } + + // Handle function call arguments delta (streaming). + if ( + chunk.type === 'response.function_call_arguments.delta' && + chunk.delta + ) { + const itemId = chunk.itemId ?? '' + const metadata = toolCallMetadata.get(itemId) + if (!metadata?.started) { + options.logger.errors( + `${this.name}.processStreamChunks orphan function_call_arguments.delta`, + { + source: `${this.name}.processStreamChunks`, + toolCallId: itemId, + rawDelta: chunk.delta, + }, + ) + continue + } + yield { + type: EventType.TOOL_CALL_ARGS, + toolCallId: itemId, + model: model || options.model, + timestamp: Date.now(), + delta: typeof chunk.delta === 'string' ? chunk.delta : '', + } satisfies StreamChunk + } + + if (chunk.type === 'response.function_call_arguments.done') { + const itemId = chunk.itemId ?? '' + + const metadata = toolCallMetadata.get(itemId) + if (!metadata?.started) { + if (metadata) { + metadata.pendingArguments = chunk.arguments + } + options.logger.errors( + `${this.name}.processStreamChunks deferring function_call_arguments.done — TOOL_CALL_START not yet emitted (waiting for name)`, + { + source: `${this.name}.processStreamChunks`, + toolCallId: itemId, + rawArguments: chunk.arguments, + }, + ) + continue + } + if (metadata.ended) continue + const name = metadata.name || '' + metadata.ended = true + + let parsedInput: unknown = {} + if (chunk.arguments) { + try { + const parsed = JSON.parse(chunk.arguments) + parsedInput = parsed && typeof parsed === 'object' ? parsed : {} + } catch (parseError) { + options.logger.errors( + `${this.name}.processStreamChunks tool-args JSON parse failed`, + { + error: toRunErrorPayload( + parseError, + `tool ${name} (${itemId}) returned malformed JSON arguments`, + ), + source: `${this.name}.processStreamChunks`, + toolCallId: itemId, + toolName: name, + rawArguments: chunk.arguments, + }, + ) + parsedInput = {} + } + } + + yield { + type: EventType.TOOL_CALL_END, + toolCallId: itemId, + toolCallName: name, + toolName: name, + model: model || options.model, + timestamp: Date.now(), + input: parsedInput, + } satisfies StreamChunk + } + + // `output_item.done` is the last point at which a function_call's + // name is guaranteed to be on the wire. + if (chunk.type === 'response.output_item.done') { + const item = chunk.item as { + type: string + id?: string + name?: string + arguments?: string + } + if (item.type === 'function_call' && item.id) { + const metadata = toolCallMetadata.get(item.id) ?? { + index: chunk.outputIndex ?? 0, + name: item.name || '', + started: false, + } + if (!toolCallMetadata.has(item.id)) { + toolCallMetadata.set(item.id, metadata) + } else if (!metadata.name && item.name) { + metadata.name = item.name + } + if (!metadata.started && metadata.name) { + yield { + type: EventType.TOOL_CALL_START, + toolCallId: item.id, + toolCallName: metadata.name, + toolName: metadata.name, + model: model || options.model, + timestamp: Date.now(), + index: metadata.index, + } satisfies StreamChunk + metadata.started = true + } + const rawArgs = + typeof item.arguments === 'string' && item.arguments.length > 0 + ? item.arguments + : metadata.pendingArguments + if (metadata.started && !metadata.ended && rawArgs !== undefined) { + const name = metadata.name || '' + let parsedInput: unknown = {} + if (rawArgs) { + try { + const parsed = JSON.parse(rawArgs) + parsedInput = + parsed && typeof parsed === 'object' ? parsed : {} + } catch (parseError) { + options.logger.errors( + `${this.name}.processStreamChunks tool-args JSON parse failed (output_item.done backfill)`, + { + error: toRunErrorPayload( + parseError, + `tool ${name} (${item.id}) returned malformed JSON arguments`, + ), + source: `${this.name}.processStreamChunks`, + toolCallId: item.id, + toolName: name, + rawArguments: rawArgs, + }, + ) + parsedInput = {} + } + } + yield { + type: EventType.TOOL_CALL_END, + toolCallId: item.id, + toolCallName: name, + toolName: name, + model: model || options.model, + timestamp: Date.now(), + input: parsedInput, + } satisfies StreamChunk + metadata.ended = true + metadata.pendingArguments = undefined + } + } + } + + if (chunk.type === 'response.completed') { + const responseObj = (chunk.response ?? {}) as { + output?: ReadonlyArray + usage?: { + inputTokens?: number + outputTokens?: number + totalTokens?: number + } | null + incompleteDetails?: { reason?: string } | null + } + const outputItems = Array.isArray(responseObj.output) + ? responseObj.output + : [] + + // Final backstop for function_call lifecycle. + for (const rawItem of outputItems) { + const item = rawItem as { + type?: string + id?: string + name?: string + arguments?: string + } + if (item.type !== 'function_call' || !item.id) continue + const metadata = toolCallMetadata.get(item.id) ?? { + index: 0, + name: item.name || '', + started: false, + } + if (!toolCallMetadata.has(item.id)) { + toolCallMetadata.set(item.id, metadata) + } else if (!metadata.name && item.name) { + metadata.name = item.name + } + if (!metadata.started && metadata.name) { + yield { + type: EventType.TOOL_CALL_START, + toolCallId: item.id, + toolCallName: metadata.name, + toolName: metadata.name, + model: model || options.model, + timestamp: Date.now(), + index: metadata.index, + } satisfies StreamChunk + metadata.started = true + } + const rawArgs = + typeof item.arguments === 'string' && item.arguments.length > 0 + ? item.arguments + : metadata.pendingArguments + if (metadata.started && !metadata.ended) { + const name = metadata.name || '' + let parsedInput: unknown = {} + if (rawArgs) { + try { + const parsed = JSON.parse(rawArgs) + parsedInput = + parsed && typeof parsed === 'object' ? parsed : {} + } catch (parseError) { + options.logger.errors( + `${this.name}.processStreamChunks tool-args JSON parse failed (response.completed backfill)`, + { + error: toRunErrorPayload( + parseError, + `tool ${name} (${item.id}) returned malformed JSON arguments`, + ), + source: `${this.name}.processStreamChunks`, + toolCallId: item.id, + toolName: name, + rawArguments: rawArgs, + }, + ) + parsedInput = {} + } + } + yield { + type: EventType.TOOL_CALL_END, + toolCallId: item.id, + toolCallName: name, + toolName: name, + model: model || options.model, + timestamp: Date.now(), + input: parsedInput, + } satisfies StreamChunk + metadata.ended = true + metadata.pendingArguments = undefined + } + } + + if (hasEmittedTextMessageStart) { + yield { + type: EventType.TEXT_MESSAGE_END, + messageId: aguiState.messageId, + model: model || options.model, + timestamp: Date.now(), + } satisfies StreamChunk + hasEmittedTextMessageStart = false + } + + const hasFunctionCalls = outputItems.some( + (item) => (item as { type?: string }).type === 'function_call', + ) + const incompleteReason = responseObj.incompleteDetails?.reason + const finishReason: + | 'tool_calls' + | 'length' + | 'content_filter' + | 'stop' = hasFunctionCalls + ? 'tool_calls' + : incompleteReason === 'max_output_tokens' + ? 'length' + : incompleteReason === 'content_filter' + ? 'content_filter' + : 'stop' + + yield { + type: EventType.RUN_FINISHED, + runId: aguiState.runId, + threadId: aguiState.threadId, + model: model || options.model, + timestamp: Date.now(), + usage: { + promptTokens: responseObj.usage?.inputTokens || 0, + completionTokens: responseObj.usage?.outputTokens || 0, + totalTokens: responseObj.usage?.totalTokens || 0, + }, + finishReason, + } satisfies StreamChunk + runFinishedEmitted = true + } + + if (chunk.type === 'error') { + const code = normalizeCode(chunk.code) + yield { + type: EventType.RUN_ERROR, + model: model || options.model, + timestamp: Date.now(), + message: chunk.message ?? '', + ...(code !== undefined && { code }), + error: { + message: chunk.message ?? '', + ...(code !== undefined && { code }), + }, + } satisfies StreamChunk + runFinishedEmitted = true + return + } + } + + // Synthetic terminal RUN_FINISHED if the stream ended without a + // response.completed event. + if (!runFinishedEmitted && aguiState.hasEmittedRunStarted) { + if (hasEmittedTextMessageStart) { + yield { + type: EventType.TEXT_MESSAGE_END, + messageId: aguiState.messageId, + model: model || options.model, + timestamp: Date.now(), + } satisfies StreamChunk + } + yield { + type: EventType.RUN_FINISHED, + runId: aguiState.runId, + threadId: aguiState.threadId, + model: model || options.model, + timestamp: Date.now(), + usage: undefined, + finishReason: toolCallMetadata.size > 0 ? 'tool_calls' : 'stop', + } satisfies StreamChunk + } + } catch (error: unknown) { + const errorPayload = toRunErrorPayload( + error, + `${this.name}.processStreamChunks failed`, + ) + options.logger.errors(`${this.name}.processStreamChunks fatal`, { + error: errorPayload, + source: `${this.name}.processStreamChunks`, + }) + yield { + type: EventType.RUN_ERROR, + model: options.model, + timestamp: Date.now(), + message: errorPayload.message, + code: errorPayload.code, + error: errorPayload, + } satisfies StreamChunk + } + } + + /** + * Build an OpenRouter `ResponsesRequest` (camelCase) from `TextOptions`. + */ + protected mapOptionsToRequest( + options: TextOptions, + ): Omit { + // Fail loud on webSearchTool() — v1 only routes function tools. + if (options.tools) { + for (const tool of options.tools) { + if (isWebSearchTool(tool as Tool)) { + throw new Error( + `OpenRouterResponsesTextAdapter does not yet support webSearchTool(). ` + + `Use the chat-completions adapter (openRouterText) for web search ` + + `tools, or pass function tools only to this adapter.`, + ) + } + } + } + + const modelOptions = options.modelOptions as + | (Partial & { variant?: string }) + | undefined + const variantSuffix = modelOptions?.variant + ? `:${modelOptions.variant}` + : '' + + const input = this.convertMessagesToInput(options.messages) + + // ResponsesFunctionTool already matches OpenRouter's + // ResponsesRequestToolFunction shape: + // `{ type:'function', name, parameters, description, strict }`. + const tools: Array | undefined = options.tools + ? options.tools.map((tool) => + convertFunctionToolToResponsesFormat( + tool, + this.makeStructuredOutputCompatible.bind(this), + ), + ) + : undefined + + const built: Pick< + ResponsesRequest, + | 'model' + | 'input' + | 'instructions' + | 'metadata' + | 'temperature' + | 'topP' + | 'maxOutputTokens' + | 'tools' + | 'toolChoice' + | 'parallelToolCalls' + > = { + ...modelOptions, + model: options.model + variantSuffix, + ...(options.temperature !== undefined && { + temperature: options.temperature, + }), + ...(options.maxTokens !== undefined && { + maxOutputTokens: options.maxTokens, + }), + ...(options.topP !== undefined && { topP: options.topP }), + ...(options.metadata !== undefined && { metadata: options.metadata }), + ...(options.systemPrompts && + options.systemPrompts.length > 0 && { + instructions: options.systemPrompts.join('\n'), + }), + input: input as ResponsesRequest['input'], + ...(tools && + tools.length > 0 && { + tools: tools as ResponsesRequest['tools'], + }), + } + + return built + } + + /** + * Convert a list of ModelMessage to OpenRouter's `InputsUnion` array form. + * Emits camelCase shapes (`callId`, `imageUrl`, `videoUrl`, `fileData`, + * `fileUrl`). + */ + protected convertMessagesToInput( + messages: Array, + ): Array { + const result: Array = [] + + for (const message of messages) { + if (message.role === 'tool') { + result.push({ + type: 'function_call_output', + callId: message.toolCallId || '', + output: + typeof message.content === 'string' + ? message.content + : this.extractTextContent(message.content), + } as InputsItem) + continue + } + + if (message.role === 'assistant') { + if (message.toolCalls && message.toolCalls.length > 0) { + for (const toolCall of message.toolCalls) { + const argumentsString = + typeof toolCall.function.arguments === 'string' + ? toolCall.function.arguments + : JSON.stringify(toolCall.function.arguments) + result.push({ + type: 'function_call', + callId: toolCall.id, + id: toolCall.id, + name: toolCall.function.name, + arguments: argumentsString, + } as InputsItem) + } + } + + if (message.content) { + const contentStr = this.extractTextContent(message.content) + if (contentStr) { + result.push({ + type: 'message', + role: 'assistant', + content: contentStr, + } as InputsItem) + } + } + continue + } + + // user — fail loud on empty / unsupported content. + const contentParts = this.normalizeContent(message.content) + const inputContent: Array = [] + for (const part of contentParts) { + inputContent.push(this.convertContentPartToInput(part)) + } + if (inputContent.length === 0) { + throw new Error( + `User message for ${this.name} has no content parts. ` + + `Empty user messages would produce a paid request with no input; ` + + `provide at least one text/image/audio part or omit the message.`, + ) + } + result.push({ + type: 'message', + role: 'user', + content: inputContent, + } as InputsItem) + } + + return result + } + + protected convertContentPartToInput( + part: ContentPart, + ): ResponsesInputContent { + switch (part.type) { + case 'text': + return { + type: 'input_text', + text: part.content, + } + case 'image': { + const meta = part.metadata as + | { detail?: 'auto' | 'low' | 'high' } + | undefined + const value = part.source.value + const imageUrl = + part.source.type === 'data' && !value.startsWith('data:') + ? `data:${part.source.mimeType || 'application/octet-stream'};base64,${value}` + : value + return { + type: 'input_image', + imageUrl, + detail: meta?.detail || 'auto', + } + } + case 'audio': { + if (part.source.type === 'url') { + // OpenRouter's `input_audio` carries `{ data, format }` not a URL — + // fall back to `input_file` for URLs so we don't silently drop the + // audio reference. + return { + type: 'input_file', + fileUrl: part.source.value, + } + } + return { + type: 'input_audio', + inputAudio: { data: part.source.value, format: 'mp3' }, + } + } + case 'video': + return { + type: 'input_video', + videoUrl: part.source.value, + } + case 'document': { + if (part.source.type === 'url') { + return { + type: 'input_file', + fileUrl: part.source.value, + } + } + const mime = part.source.mimeType || 'application/octet-stream' + const data = part.source.value.startsWith('data:') + ? part.source.value + : `data:${mime};base64,${part.source.value}` + return { + type: 'input_file', + fileData: data, + } + } + default: + throw new Error( + `Unsupported content part type for ${this.name}: ${(part as { type: string }).type}`, + ) + } + } + + protected normalizeContent( + content: string | null | Array, + ): Array { + if (content === null) { + return [] + } + if (typeof content === 'string') { + return [{ type: 'text', content: content }] + } + return content + } + + protected extractTextContent( + content: string | null | Array, + ): string { + if (content === null) { + return '' + } + if (typeof content === 'string') { + return content + } + return content + .filter((p) => p.type === 'text') + .map((p) => p.content) + .join('') + } +} + +/** + * Normalised event shape we read off each OpenRouter SDK stream event after + * camel-case translation. Models the loose superset of fields we consult + * across all event-type branches; specific branches narrow further inline. + */ +interface NormalizedStreamEvent { + type: string + itemId?: string + outputIndex?: number + contentIndex?: number + delta?: string | Array + text?: string + arguments?: string + message?: string + code?: unknown + param?: string | null + sequenceNumber?: number + response?: unknown + item?: unknown + part?: unknown +} + +/** + * Translate the SDK's discriminated-union event into a uniform camelCase + * shape our processor reads. + * + * The SDK's discriminated-union parser falls back to + * `{ raw, type: 'UNKNOWN', isUnknown: true }` when an event's strict per- + * variant schema rejects (missing optional-ish fields like `sequenceNumber`/ + * `logprobs` that some upstreams — including aimock — omit). The `raw` + * payload is the original wire-shape event in snake_case. We translate + * snake_case keys to camelCase for those unknown events so the rest of the + * processor reads a uniform shape. + * + * Known events already have camelCase fields and are passed through. + */ +function normalizeStreamEvent(event: StreamEvents): NormalizedStreamEvent { + const e = event as { + isUnknown?: boolean + raw?: unknown + type?: string + [k: string]: unknown + } + + if (e.isUnknown && e.raw && typeof e.raw === 'object') { + const raw = e.raw as Record + // Translate the snake_case wire-shape fields we need into camelCase. The + // adapter only consults the fields below; any others are passed through + // verbatim so downstream extraction (e.g. for unknown event types) still + // sees them. + const out: Record = { ...raw } + if ('item_id' in raw) out.itemId = raw.item_id + if ('output_index' in raw) out.outputIndex = raw.output_index + if ('content_index' in raw) out.contentIndex = raw.content_index + if ('sequence_number' in raw) out.sequenceNumber = raw.sequence_number + if ('summary_index' in raw) out.summaryIndex = raw.summary_index + if ('response' in raw && raw.response && typeof raw.response === 'object') { + out.response = camelCaseResponseShape( + raw.response as Record, + ) + } + if ('item' in raw && raw.item && typeof raw.item === 'object') { + out.item = camelCaseOutputItem(raw.item as Record) + } + if ('part' in raw) out.part = raw.part + out.type = + typeof raw.type === 'string' ? raw.type : (e.type as string) || 'unknown' + return out as unknown as NormalizedStreamEvent + } + + return event as unknown as NormalizedStreamEvent +} + +/** Translate snake_case keys in a `response` payload to camelCase for the + * fields our terminal-event handlers read. Unknown keys passthrough. */ +function camelCaseResponseShape( + src: Record, +): Record { + const out: Record = { ...src } + if ('incomplete_details' in src) + out.incompleteDetails = src.incomplete_details + if ( + 'input_tokens' in src || + 'output_tokens' in src || + 'total_tokens' in src + ) { + // never mutate src; rewrite usage in place if present. + } + if (src.usage && typeof src.usage === 'object') { + const u = src.usage as Record + out.usage = { + ...u, + ...('input_tokens' in u && { inputTokens: u.input_tokens }), + ...('output_tokens' in u && { outputTokens: u.output_tokens }), + ...('total_tokens' in u && { totalTokens: u.total_tokens }), + } + } + if (Array.isArray(src.output)) { + out.output = src.output.map((item) => + item && typeof item === 'object' + ? camelCaseOutputItem(item as Record) + : item, + ) + } + return out +} + +/** Translate snake_case keys in an output item to camelCase. */ +function camelCaseOutputItem( + src: Record, +): Record { + const out: Record = { ...src } + if ('call_id' in src) out.callId = src.call_id + return out +} + +/** Normalize an `error.code` to the string slot our RUN_ERROR event reads. */ +function normalizeCode(code: unknown): string | undefined { + if (typeof code === 'string') return code + if (typeof code === 'number' && Number.isFinite(code)) return String(code) + return undefined +} + +export function createOpenRouterResponsesText< + TModel extends OpenRouterResponsesTextModels, +>( + model: TModel, + apiKey: string, + config?: Omit, +): OpenRouterResponsesTextAdapter> { + return new OpenRouterResponsesTextAdapter({ apiKey, ...config }, model) +} + +export function openRouterResponsesText< + TModel extends OpenRouterResponsesTextModels, +>( + model: TModel, + config?: Omit, +): OpenRouterResponsesTextAdapter> { + const apiKey = getOpenRouterApiKeyFromEnv() + return createOpenRouterResponsesText(model, apiKey, config) +} diff --git a/packages/typescript/ai-openrouter/src/adapters/summarize.ts b/packages/typescript/ai-openrouter/src/adapters/summarize.ts index aa7513522..21004335d 100644 --- a/packages/typescript/ai-openrouter/src/adapters/summarize.ts +++ b/packages/typescript/ai-openrouter/src/adapters/summarize.ts @@ -1,11 +1,7 @@ -import { BaseSummarizeAdapter } from '@tanstack/ai/adapters' +import { ChatStreamSummarizeAdapter } from '@tanstack/ai/adapters' import { getOpenRouterApiKeyFromEnv } from '../utils' import { OpenRouterTextAdapter } from './text' -import type { - StreamChunk, - SummarizationOptions, - SummarizationResult, -} from '@tanstack/ai' +import type { InferTextProviderOptions } from '@tanstack/ai/adapters' import type { OpenRouterConfig } from './text' import type { OPENROUTER_CHAT_MODELS } from '../model-meta' import type { SDKOptions } from '@openrouter/sdk' @@ -22,202 +18,45 @@ export interface OpenRouterSummarizeConfig extends OpenRouterConfig { maxTokens?: number } -/** - * OpenRouter-specific provider options for summarization - */ -export interface OpenRouterSummarizeProviderOptions { - /** Temperature for response generation (0-2) */ - temperature?: number - /** Maximum tokens in the response */ - maxTokens?: number -} - -/** - * OpenRouter Summarize Adapter - * - * A thin wrapper around the text adapter that adds summarization-specific prompting. - * Delegates all API calls to the OpenRouterTextAdapter. - */ -export class OpenRouterSummarizeAdapter< - TModel extends OpenRouterTextModels, -> extends BaseSummarizeAdapter { - readonly kind = 'summarize' as const - readonly name = 'openrouter' as const - - private textAdapter: OpenRouterTextAdapter - private temperature: number - private maxTokens: number | undefined - - constructor(config: OpenRouterSummarizeConfig, model: TModel) { - super({}, model) - this.textAdapter = new OpenRouterTextAdapter(config, model) - this.temperature = config.temperature ?? 0.3 - this.maxTokens = config.maxTokens - } - - async summarize(options: SummarizationOptions): Promise { - const { logger } = options - const systemPrompt = this.buildSummarizationPrompt(options) - - logger.request(`activity=summarize provider=openrouter`, { - provider: 'openrouter', - model: options.model, - }) - - 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: this.maxTokens ?? options.maxLength, - temperature: this.temperature, - 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 - } - } - // AG-UI RUN_ERROR event - if (chunk.type === 'RUN_ERROR') { - throw new Error(`Error during summarization: ${chunk.error?.message}`) - } - } - } catch (error) { - logger.errors('openrouter.summarize fatal', { - error, - source: 'openrouter.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=openrouter`, { - provider: 'openrouter', - model: options.model, - stream: true, - }) - - try { - yield* this.textAdapter.chatStream({ - model: options.model, - messages: [{ role: 'user', content: options.text }], - systemPrompts: [systemPrompt], - maxTokens: this.maxTokens ?? options.maxLength, - temperature: this.temperature, - logger, - }) - } catch (error) { - logger.errors('openrouter.summarize fatal', { - error, - source: 'openrouter.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 - } -} - /** * Creates an OpenRouter summarize adapter with explicit API key. - * Type resolution happens here at the call site. - * - * @param model - The model name (e.g., 'openai/gpt-4o-mini', 'anthropic/claude-3-5-sonnet') - * @param apiKey - Your OpenRouter API key - * @param config - Optional additional configuration - * @returns Configured OpenRouter summarize adapter instance with resolved types * * @example * ```typescript - * const adapter = createOpenRouterSummarize('openai/gpt-4o-mini', "sk-or-..."); + * const adapter = createOpenRouterSummarize('openai/gpt-4o-mini', 'sk-or-...'); * ``` */ export function createOpenRouterSummarize( model: TModel, apiKey: string, config?: Omit, -): OpenRouterSummarizeAdapter { - return new OpenRouterSummarizeAdapter({ apiKey, ...config }, model) +): ChatStreamSummarizeAdapter< + TModel, + InferTextProviderOptions> +> { + return new ChatStreamSummarizeAdapter( + new OpenRouterTextAdapter({ apiKey, ...config }, model), + model, + 'openrouter', + ) } /** - * Creates an OpenRouter summarize adapter with automatic API key detection from environment variables. - * Type resolution happens here at the call site. - * - * Looks for `OPENROUTER_API_KEY` in: - * - `process.env` (Node.js) - * - `window.env` (Browser with injected env) - * - * @param model - The model name (e.g., 'openai/gpt-4o-mini', 'anthropic/claude-3-5-sonnet') - * @param config - Optional configuration (excluding apiKey which is auto-detected) - * @returns Configured OpenRouter summarize adapter instance with resolved types - * @throws Error if OPENROUTER_API_KEY is not found in environment + * Creates an OpenRouter summarize adapter with API key from + * `OPENROUTER_API_KEY` in `process.env` (Node) or `window.env` (browser). * * @example * ```typescript - * // Automatically uses OPENROUTER_API_KEY from environment * const adapter = openRouterSummarize('openai/gpt-4o-mini'); - * - * await summarize({ - * adapter, - * text: "Long article text..." - * }); + * await summarize({ adapter, text: 'Long article text...' }); * ``` */ export function openRouterSummarize( model: TModel, config?: Omit, -): OpenRouterSummarizeAdapter { - const apiKey = getOpenRouterApiKeyFromEnv() - return createOpenRouterSummarize(model, apiKey, config) +): ChatStreamSummarizeAdapter< + TModel, + InferTextProviderOptions> +> { + return createOpenRouterSummarize(model, getOpenRouterApiKeyFromEnv(), config) } diff --git a/packages/typescript/ai-openrouter/src/adapters/text.ts b/packages/typescript/ai-openrouter/src/adapters/text.ts index 29427171c..f913a9bfc 100644 --- a/packages/typescript/ai-openrouter/src/adapters/text.ts +++ b/packages/typescript/ai-openrouter/src/adapters/text.ts @@ -1,19 +1,20 @@ import { OpenRouter } from '@openrouter/sdk' -import { RequestAbortedError } from '@openrouter/sdk/models/errors' -import { convertSchemaToJsonSchema } from '@tanstack/ai' +import { EventType } from '@tanstack/ai' import { BaseTextAdapter } from '@tanstack/ai/adapters' +import { toRunErrorPayload } from '@tanstack/ai/adapter-internals' +import { generateId, transformNullsToUndefined } from '@tanstack/ai-utils' +import { extractRequestOptions } from '../internal/request-options' +import { makeStructuredOutputCompatible } from '../internal/schema-converter' import { convertToolsToProviderFormat } from '../tools' -import { - getOpenRouterApiKeyFromEnv, - generateId as utilGenerateId, -} from '../utils' +import { getOpenRouterApiKeyFromEnv } from '../utils' import type { SDKOptions } from '@openrouter/sdk' import type { - OPENROUTER_CHAT_MODELS, - OpenRouterChatModelToolCapabilitiesByName, - OpenRouterModelInputModalitiesByName, - OpenRouterModelOptionsByName, -} from '../model-meta' + ChatContentItems, + ChatMessages, + ChatRequest, + ChatStreamChoice, + ChatStreamChunk, +} from '@openrouter/sdk/models' import type { StructuredOutputOptions, StructuredOutputResult, @@ -24,23 +25,17 @@ import type { StreamChunk, TextOptions, } from '@tanstack/ai' +import type { + OPENROUTER_CHAT_MODELS, + OpenRouterChatModelToolCapabilitiesByName, + OpenRouterModelInputModalitiesByName, + OpenRouterModelOptionsByName, +} from '../model-meta' import type { ExternalTextProviderOptions } from '../text/text-provider-options' import type { OpenRouterImageMetadata, OpenRouterMessageMetadataByModality, } from '../message-types' -import type { - ChatContentItems, - ChatMessages, - ChatRequest, - ChatStreamChoice, - ChatUsage, -} from '@openrouter/sdk/models' - -/** 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 export interface OpenRouterConfig extends SDKOptions {} export type OpenRouterTextModels = (typeof OPENROUTER_CHAT_MODELS)[number] @@ -62,33 +57,29 @@ type ResolveToolCapabilities = ? NonNullable : readonly [] -// Internal buffer for accumulating streamed tool calls -interface ToolCallBuffer { - id: string - name: string - arguments: string - started: boolean // Track if TOOL_CALL_START has been emitted -} - -// AG-UI lifecycle state tracking -interface AGUIState { - runId: string - threadId: string - messageId: string - stepId: string | null - reasoningMessageId: string | null - hasClosedReasoning: boolean - hasEmittedRunStarted: boolean - hasEmittedTextMessageStart: boolean - hasEmittedTextMessageEnd: boolean - hasEmittedRunFinished: boolean - hasEmittedStepStarted: boolean - deferredUsage: - | { promptTokens: number; completionTokens: number; totalTokens: number } - | undefined - computedFinishReason: string | undefined -} - +/** + * OpenRouter Text (Chat) Adapter — standalone implementation that talks to + * OpenRouter's `/v1/chat/completions` endpoint via the `@openrouter/sdk` SDK. + * + * The wire format is OpenAI-Chat-Completions-compatible, but the SDK exposes + * the request/response in camelCase TS shapes (`toolCalls`, `finishReason`, + * `maxCompletionTokens`, `responseFormat: { jsonSchema: ... }`, etc.). This + * adapter operates directly in those camelCase shapes — there's no + * snake_case ↔ camelCase round-trip. + * + * Behaviour preserved from the pre-decoupling implementation: + * - Provider routing surface (`provider`, `models`, `plugins`, `variant`, + * `transforms`) passes through `modelOptions`. + * - App attribution headers (`httpReferer`, `appTitle`) and base URL + * overrides flow through the SDK `SDKOptions` constructor. + * - `RequestAbortedError` from the SDK propagates up — `chatStream` wraps + * unknown errors into a single RUN_ERROR event via `toRunErrorPayload`. + * - Model variant suffixing (e.g. `:thinking`, `:free`) via + * `modelOptions.variant`. + * - OpenRouter-specific reasoning extraction (`delta.reasoningDetails`). + * - OpenRouter preserves nulls in structured-output results + * (`transformStructuredOutput` is a passthrough). + */ export class OpenRouterTextAdapter< TModel extends OpenRouterTextModels, TToolCapabilities extends ReadonlyArray = @@ -103,582 +94,722 @@ export class OpenRouterTextAdapter< readonly kind = 'text' as const readonly name = 'openrouter' as const - private client: OpenRouter + protected orClient: OpenRouter constructor(config: OpenRouterConfig, model: TModel) { super({}, model) - this.client = new OpenRouter(config) + this.orClient = new OpenRouter(config) } async *chatStream( options: TextOptions>, ): AsyncIterable { - const timestamp = Date.now() - const toolCallBuffers = new Map() - let accumulatedReasoning = '' - let accumulatedContent = '' - let responseId: string | null = null - let currentModel = options.model - const { logger } = options - // AG-UI lifecycle tracking - const aguiState: AGUIState = { - runId: options.runId ?? this.generateId(), - threadId: options.threadId ?? this.generateId(), - messageId: this.generateId(), - stepId: null, - reasoningMessageId: null, - hasClosedReasoning: false, + // AG-UI lifecycle tracking (mutable state object for ESLint compatibility) + const aguiState = { + runId: generateId(this.name), + threadId: options.threadId ?? generateId(this.name), + messageId: generateId(this.name), hasEmittedRunStarted: false, - hasEmittedTextMessageStart: false, - hasEmittedTextMessageEnd: false, - hasEmittedRunFinished: false, - hasEmittedStepStarted: false, - deferredUsage: undefined, - computedFinishReason: undefined, } try { - const requestParams = this.mapTextOptionsToSDK(options) - logger.request( - `activity=chat provider=openrouter model=${this.model} messages=${options.messages.length} tools=${options.tools?.length ?? 0} stream=true`, - { provider: 'openrouter', model: this.model }, + // mapOptionsToRequest can throw (e.g. fail-loud guards in convertMessage + // for empty content or unsupported parts). Keep it inside the try so + // those failures surface as a single RUN_ERROR event, matching every + // other failure mode here — callers iterating chatStream then only need + // one error-handling path. + const chatRequest = this.mapOptionsToRequest(options) + options.logger.request( + `activity=chat provider=${this.name} model=${this.model} messages=${options.messages.length} tools=${options.tools?.length ?? 0} stream=true`, + { provider: this.name, model: this.model }, ) - const stream = await this.client.chat.send( - { chatRequest: { ...requestParams, stream: true } }, - { signal: options.request?.signal }, - ) - - for await (const chunk of stream) { - logger.provider(`provider=openrouter`, { chunk }) - if (chunk.id) responseId = chunk.id - if (chunk.model) currentModel = chunk.model - - // Emit RUN_STARTED on first chunk - if (!aguiState.hasEmittedRunStarted) { - aguiState.hasEmittedRunStarted = true - yield asChunk({ - type: 'RUN_STARTED', - runId: aguiState.runId, - threadId: aguiState.threadId, - model: currentModel || options.model, - timestamp, - }) - } - - if (chunk.error) { - // Emit AG-UI RUN_ERROR - yield asChunk({ - type: 'RUN_ERROR', - runId: aguiState.runId, - model: currentModel || options.model, - timestamp, - message: chunk.error.message || 'Unknown error', - code: String(chunk.error.code), - error: { - message: chunk.error.message || 'Unknown error', - code: String(chunk.error.code), - }, - }) - continue - } - - for (const choice of chunk.choices) { - yield* this.processChoice( - choice, - toolCallBuffers, - { - id: responseId || this.generateId(), - model: currentModel, - timestamp, - }, - { reasoning: accumulatedReasoning, content: accumulatedContent }, - (r, c) => { - accumulatedReasoning = r - accumulatedContent = c + const reqOptions = extractRequestOptions(options.request) + const stream = (await this.orClient.chat.send( + { + chatRequest: { + ...chatRequest, + stream: true, + streamOptions: { + ...(chatRequest.streamOptions ?? {}), + includeUsage: true, }, - chunk.usage, - aguiState, - ) - } - } + }, + }, + { + signal: reqOptions.signal ?? undefined, + ...(reqOptions.headers && { headers: reqOptions.headers }), + }, + )) as AsyncIterable - // Emit RUN_FINISHED after the stream ends so we capture usage from - // any chunk (some SDKs send usage on a separate trailing chunk). - if (aguiState.hasEmittedRunFinished && aguiState.computedFinishReason) { - yield asChunk({ - type: 'RUN_FINISHED', - runId: aguiState.runId, - threadId: aguiState.threadId, - model: currentModel || options.model, - timestamp, - usage: aguiState.deferredUsage, - finishReason: aguiState.computedFinishReason, - }) - } - } catch (error) { - logger.errors('openrouter.chatStream fatal', { + yield* this.processStreamChunks(stream, options, aguiState) + } catch (error: unknown) { + // Narrow before logging: raw SDK errors can carry request metadata + // (including auth headers) which we must never surface to user loggers. + const errorPayload = toRunErrorPayload( error, - source: 'openrouter.chatStream', - }) - // Emit RUN_STARTED if not yet emitted (error on first call) + `${this.name}.chatStream failed`, + ) + + // Emit RUN_STARTED if not yet emitted if (!aguiState.hasEmittedRunStarted) { aguiState.hasEmittedRunStarted = true - yield asChunk({ - type: 'RUN_STARTED', + yield { + type: EventType.RUN_STARTED, runId: aguiState.runId, threadId: aguiState.threadId, model: options.model, - timestamp, - }) - } - - if (error instanceof RequestAbortedError) { - // Emit AG-UI RUN_ERROR - yield asChunk({ - type: 'RUN_ERROR', - runId: aguiState.runId, - model: options.model, - timestamp, - message: 'Request aborted', - code: 'aborted', - error: { - message: 'Request aborted', - code: 'aborted', - }, - }) - return + timestamp: Date.now(), + } satisfies StreamChunk } // Emit AG-UI RUN_ERROR - yield asChunk({ - type: 'RUN_ERROR', - runId: aguiState.runId, + yield { + type: EventType.RUN_ERROR, model: options.model, - timestamp, - message: (error as Error).message || 'Unknown error', - error: { - message: (error as Error).message || 'Unknown error', - }, + timestamp: Date.now(), + message: errorPayload.message, + code: errorPayload.code, + error: errorPayload, + } satisfies StreamChunk + + options.logger.errors(`${this.name}.chatStream fatal`, { + error: errorPayload, + source: `${this.name}.chatStream`, }) } } + /** + * Generate structured output via OpenRouter's `responseFormat: { type: + * 'json_schema', jsonSchema: ... }` (camelCase). Uses stream: false to get + * the complete response in one call. + * + * The outputSchema is already JSON Schema (converted in the ai layer). + * We apply OpenAI-strict transformations for cross-provider compatibility. + */ async structuredOutput( options: StructuredOutputOptions>, ): Promise> { const { chatOptions, outputSchema } = options - const { logger } = chatOptions - - const requestParams = this.mapTextOptionsToSDK(chatOptions) + const chatRequest = this.mapOptionsToRequest(chatOptions) - // OpenRouter uses OpenAI-style strict JSON schema. Upstream providers - // (OpenAI especially) reject schemas that aren't strict-compatible — all - // properties required, additionalProperties: false, optional fields - // nullable. Apply that transformation before sending. - const strictSchema = convertSchemaToJsonSchema(outputSchema, { - forStructuredOutput: true, - }) + const jsonSchema = this.makeStructuredOutputCompatible( + outputSchema, + outputSchema.required, + ) try { - logger.request( - `activity=chat provider=openrouter model=${this.model} messages=${chatOptions.messages.length} tools=${chatOptions.tools?.length ?? 0} stream=false`, - { provider: 'openrouter', model: this.model }, + // Strip streamOptions which is only valid for streaming calls + const { streamOptions: _streamOptions, ...cleanParams } = chatRequest + void _streamOptions + chatOptions.logger.request( + `activity=structuredOutput provider=${this.name} model=${this.model} messages=${chatOptions.messages.length}`, + { provider: this.name, model: this.model }, ) - const result = await this.client.chat.send( + const reqOptions = extractRequestOptions(chatOptions.request) + const response = await this.orClient.chat.send( { chatRequest: { - ...requestParams, + ...cleanParams, stream: false, responseFormat: { type: 'json_schema', jsonSchema: { name: 'structured_output', - schema: strictSchema, + schema: jsonSchema, strict: true, }, }, }, }, - { signal: chatOptions.request?.signal }, + { + signal: reqOptions.signal ?? undefined, + ...(reqOptions.headers && { headers: reqOptions.headers }), + }, ) - const content = result.choices[0]?.message.content - const rawText = typeof content === 'string' ? content : '' - if (!rawText) { - throw new Error('Structured output response contained no content') - } - const parsed = JSON.parse(rawText) - return { data: parsed, rawText } - } catch (error: unknown) { - logger.errors('openrouter.structuredOutput fatal', { - error, - source: 'openrouter.structuredOutput', - }) - if (error instanceof RequestAbortedError) { - throw new Error('Structured output generation aborted') + + // Extract text content from the response. Fail loud on empty content + // rather than letting it cascade into a JSON-parse error on '' — the + // root cause (the model returned no content for the structured request) + // is then visible in logs. + const message = response.choices[0]?.message + const rawText = + typeof message?.content === 'string' ? message.content : '' + if (rawText.length === 0) { + throw new Error( + `${this.name}.structuredOutput: response contained no content`, + ) } - if (error instanceof SyntaxError) { + + // Parse the JSON response + let parsed: unknown + try { + parsed = JSON.parse(rawText) + } catch { throw new Error( - `Failed to parse structured output as JSON: ${error.message}`, + `Failed to parse structured output as JSON. Content: ${rawText.slice(0, 200)}${rawText.length > 200 ? '...' : ''}`, ) } - const err = error as Error - throw new Error( - `Structured output generation failed: ${err.message || 'Unknown error occurred'}`, - ) + + // OpenRouter override: pass nulls through unchanged (consumers that + // discriminate "field present but null" from "field absent" rely on + // this). + const transformed = this.transformStructuredOutput(parsed) + + return { + data: transformed, + rawText, + } + } catch (error: unknown) { + // Narrow before logging: raw SDK errors can carry request metadata + // (including auth headers) which we must never surface to user loggers. + chatOptions.logger.errors(`${this.name}.structuredOutput fatal`, { + error: toRunErrorPayload(error, `${this.name}.structuredOutput failed`), + source: `${this.name}.structuredOutput`, + }) + throw error } } - protected override generateId(): string { - return utilGenerateId(this.name) + /** + * Applies provider-specific transformations for structured output compatibility. + */ + protected makeStructuredOutputCompatible( + schema: Record, + originalRequired?: Array, + ): Record { + return makeStructuredOutputCompatible(schema, originalRequired) } - private *processChoice( - choice: ChatStreamChoice, - toolCallBuffers: Map, - meta: { id: string; model: string; timestamp: number }, - accumulated: { reasoning: string; content: string }, - updateAccumulated: (reasoning: string, content: string) => void, - usage: ChatUsage | undefined, - aguiState: AGUIState, - ): Iterable { - const delta = choice.delta - const finishReason = choice.finishReason - - if (delta.reasoningDetails) { - for (const detail of delta.reasoningDetails) { - if (detail.type === 'reasoning.text') { - const text = detail.text || '' - - // Emit STEP_STARTED and REASONING events on first reasoning content - if (!aguiState.hasEmittedStepStarted) { - aguiState.hasEmittedStepStarted = true - aguiState.stepId = this.generateId() - aguiState.reasoningMessageId = this.generateId() - - // Spec REASONING events - yield asChunk({ - type: 'REASONING_START', - messageId: aguiState.reasoningMessageId, - model: meta.model, - timestamp: meta.timestamp, - }) - yield asChunk({ - type: 'REASONING_MESSAGE_START', - messageId: aguiState.reasoningMessageId, - role: 'reasoning' as const, - model: meta.model, - timestamp: meta.timestamp, - }) - - // Legacy STEP events (kept during transition) - yield asChunk({ - type: 'STEP_STARTED', - stepName: aguiState.stepId, - stepId: aguiState.stepId, - model: meta.model, - timestamp: meta.timestamp, - stepType: 'thinking', - }) - } + /** + * Final shaping pass applied to parsed structured-output JSON before it is + * returned to the caller. OpenRouter routes through a wide variety of + * upstream providers; some return `null` as a distinct sentinel ("the field + * exists, the value is null") rather than collapsing it to absent. Stripping + * nulls would erase that distinction, so we passthrough. + * + * `transformNullsToUndefined` is imported for parity with the other + * provider adapters but intentionally not invoked here. + */ + protected transformStructuredOutput(parsed: unknown): unknown { + void transformNullsToUndefined + return parsed + } + + /** + * Processes streamed chunks from OpenRouter's chat-completions API and + * yields AG-UI events. Reads the SDK's camelCase chunk shape directly + * (`delta.toolCalls`, `delta.reasoningDetails`, `chunk.usage.promptTokens`, + * `choice.finishReason`, etc.). + */ + protected async *processStreamChunks( + stream: AsyncIterable, + options: TextOptions>, + aguiState: { + runId: string + threadId: string + messageId: string + hasEmittedRunStarted: boolean + }, + ): AsyncIterable { + let accumulatedContent = '' + let hasEmittedTextMessageStart = false + let lastModel: string | undefined + // Track usage from any chunk that carries it. With + // `streamOptions: { includeUsage: true }` OpenRouter emits a terminal + // chunk whose `choices` is `[]` and only the `usage` field is populated; + // the earlier `finishReason` chunk does NOT include token counts. We must + // therefore defer RUN_FINISHED until the iterator is exhausted so we can + // pick up usage from the trailing chunk regardless of arrival order. + let lastUsage: ChatStreamChunk['usage'] | undefined + let pendingFinishReason: ChatStreamChoice['finishReason'] | undefined + + // Track tool calls being streamed (arguments come in chunks). + const toolCallsInProgress = new Map< + number, + { + id: string + name: string + arguments: string + started: boolean // Track if TOOL_CALL_START has been emitted + } + >() + + // Reasoning lifecycle (driven by inline reasoning extraction below). + let reasoningMessageId: string | undefined + let hasClosedReasoning = false + // Legacy STEP_STARTED/STEP_FINISHED pair emitted alongside REASONING_* + // for back-compat with consumers (UI, devtools) that haven't migrated + // to the spec REASONING_* events yet. + let stepId: string | undefined + let accumulatedReasoning = '' + // Track whether ANY tool call lifecycle was actually completed across the + // entire stream. Lets us downgrade a `tool_calls` finishReason to `stop` + // when the upstream signalled tool calls but never produced a complete + // start/end pair — emitting RUN_FINISHED { finishReason: 'tool_calls' } + // with no matching TOOL_CALL_END would leave consumers waiting for tool + // results that never arrive. + let emittedAnyToolCallEnd = false - accumulated.reasoning += text - updateAccumulated(accumulated.reasoning, accumulated.content) - - // Spec REASONING content event - yield asChunk({ - type: 'REASONING_MESSAGE_CONTENT', - messageId: aguiState.reasoningMessageId!, - delta: text, - model: meta.model, - timestamp: meta.timestamp, - }) - continue + try { + for await (const chunk of stream) { + const choiceForLog = chunk.choices[0] + options.logger.provider( + `provider=${this.name} finishReason=${choiceForLog?.finishReason ?? 'none'} hasContent=${!!choiceForLog?.delta.content} hasToolCalls=${!!choiceForLog?.delta.toolCalls} hasUsage=${!!chunk.usage}`, + { provider: this.name, model: chunk.model }, + ) + + // Surface upstream errors so they can be routed to RUN_ERROR. Stream + // chunks may carry an `error` field (provider-side failures that + // happen mid-stream rather than as an SDK throw). + if (chunk.error) { + throw Object.assign( + new Error(chunk.error.message || 'OpenRouter stream error'), + { code: chunk.error.code }, + ) } - if (detail.type === 'reasoning.summary') { - const text = detail.summary || '' - - // Emit STEP_STARTED and REASONING events on first reasoning content - if (!aguiState.hasEmittedStepStarted) { - aguiState.hasEmittedStepStarted = true - aguiState.stepId = this.generateId() - aguiState.reasoningMessageId = this.generateId() - - // Spec REASONING events - yield asChunk({ - type: 'REASONING_START', - messageId: aguiState.reasoningMessageId, - model: meta.model, - timestamp: meta.timestamp, - }) - yield asChunk({ - type: 'REASONING_MESSAGE_START', - messageId: aguiState.reasoningMessageId, - role: 'reasoning' as const, - model: meta.model, - timestamp: meta.timestamp, - }) - - // Legacy STEP events (kept during transition) - yield asChunk({ - type: 'STEP_STARTED', - stepName: aguiState.stepId, - stepId: aguiState.stepId, - model: meta.model, - timestamp: meta.timestamp, - stepType: 'thinking', - }) - } - accumulated.reasoning += text - updateAccumulated(accumulated.reasoning, accumulated.content) - - // Spec REASONING content event - yield asChunk({ - type: 'REASONING_MESSAGE_CONTENT', - messageId: aguiState.reasoningMessageId!, - delta: text, - model: meta.model, - timestamp: meta.timestamp, - }) - continue + // Capture usage from any chunk (including the terminal usage-only + // chunk emitted when `streamOptions.includeUsage` is on). + if (chunk.usage) { + lastUsage = chunk.usage + } + if (chunk.model) { + lastModel = chunk.model } - } - } - if (delta.content) { - // Close reasoning before text starts - if (aguiState.reasoningMessageId && !aguiState.hasClosedReasoning) { - aguiState.hasClosedReasoning = true - yield asChunk({ - type: 'REASONING_MESSAGE_END', - messageId: aguiState.reasoningMessageId, - model: meta.model, - timestamp: meta.timestamp, - }) - yield asChunk({ - type: 'REASONING_END', - messageId: aguiState.reasoningMessageId, - model: meta.model, - timestamp: meta.timestamp, - }) - - // Legacy: single STEP_FINISHED to close the STEP_STARTED - if (aguiState.stepId) { - yield asChunk({ - type: 'STEP_FINISHED', - stepName: aguiState.stepId, - stepId: aguiState.stepId, - model: meta.model, - timestamp: meta.timestamp, - content: accumulated.reasoning, - }) + // Emit RUN_STARTED on the first chunk of any kind so callers see a + // run lifecycle even on streams that arrive entirely as usage-only + // (no choices). Without this, a usage-first stream would skip + // RUN_STARTED via `if (!choice) continue` below and the post-loop + // synthetic block would also skip RUN_FINISHED (it gates on + // `hasEmittedRunStarted`). + if (!aguiState.hasEmittedRunStarted) { + aguiState.hasEmittedRunStarted = true + yield { + type: EventType.RUN_STARTED, + runId: aguiState.runId, + threadId: aguiState.threadId, + model: chunk.model || options.model, + timestamp: Date.now(), + } satisfies StreamChunk } - } - // Emit TEXT_MESSAGE_START on first text content - if (!aguiState.hasEmittedTextMessageStart) { - aguiState.hasEmittedTextMessageStart = true - yield asChunk({ - type: 'TEXT_MESSAGE_START', - messageId: aguiState.messageId, - model: meta.model, - timestamp: meta.timestamp, - role: 'assistant', - }) - } + // Reasoning content (OpenRouter emits this as `delta.reasoningDetails`). + // Run before reading choice/delta so reasoning-only chunks (no `choices`) + // still drive the REASONING_* lifecycle. + const reasoningText = extractReasoningText(chunk) + if (reasoningText) { + if (!reasoningMessageId) { + reasoningMessageId = generateId(this.name) + stepId = generateId(this.name) + yield { + type: EventType.REASONING_START, + messageId: reasoningMessageId, + model: chunk.model || options.model, + timestamp: Date.now(), + } satisfies StreamChunk + yield { + type: EventType.REASONING_MESSAGE_START, + messageId: reasoningMessageId, + role: 'reasoning' as const, + model: chunk.model || options.model, + timestamp: Date.now(), + } satisfies StreamChunk + // Legacy STEP_STARTED (single emission, paired with the + // STEP_FINISHED below when reasoning closes). + yield { + type: EventType.STEP_STARTED, + stepName: stepId, + stepId, + model: chunk.model || options.model, + timestamp: Date.now(), + stepType: 'thinking', + } satisfies StreamChunk + } + accumulatedReasoning += reasoningText + yield { + type: EventType.REASONING_MESSAGE_CONTENT, + messageId: reasoningMessageId, + delta: reasoningText, + model: chunk.model || options.model, + timestamp: Date.now(), + } satisfies StreamChunk + } - accumulated.content += delta.content - updateAccumulated(accumulated.reasoning, accumulated.content) - - // Emit AG-UI TEXT_MESSAGE_CONTENT - yield asChunk({ - type: 'TEXT_MESSAGE_CONTENT', - messageId: aguiState.messageId, - model: meta.model, - timestamp: meta.timestamp, - delta: delta.content, - content: accumulated.content, - }) - } + const choice = chunk.choices[0] + + if (!choice) continue + + const delta = choice.delta + const deltaContent = delta.content + const deltaToolCalls = delta.toolCalls + + // Handle content delta + if (deltaContent) { + // Close reasoning before text starts so consumers see a clean + // REASONING_END before any TEXT_MESSAGE_START. + if (reasoningMessageId && !hasClosedReasoning) { + hasClosedReasoning = true + yield { + type: EventType.REASONING_MESSAGE_END, + messageId: reasoningMessageId, + model: chunk.model || options.model, + timestamp: Date.now(), + } satisfies StreamChunk + yield { + type: EventType.REASONING_END, + messageId: reasoningMessageId, + model: chunk.model || options.model, + timestamp: Date.now(), + } satisfies StreamChunk + if (stepId) { + yield { + type: EventType.STEP_FINISHED, + stepName: stepId, + stepId, + model: chunk.model || options.model, + timestamp: Date.now(), + content: accumulatedReasoning, + } satisfies StreamChunk + } + } - if (delta.toolCalls) { - for (const tc of delta.toolCalls) { - const existing = toolCallBuffers.get(tc.index) - if (!existing) { - if (!tc.id) { - continue + // Emit TEXT_MESSAGE_START on first text content + if (!hasEmittedTextMessageStart) { + hasEmittedTextMessageStart = true + yield { + type: EventType.TEXT_MESSAGE_START, + messageId: aguiState.messageId, + model: chunk.model || options.model, + timestamp: Date.now(), + role: 'assistant', + } satisfies StreamChunk } - toolCallBuffers.set(tc.index, { - id: tc.id, - name: tc.function?.name ?? '', - arguments: tc.function?.arguments ?? '', - started: false, - }) - } else { - if (tc.function?.name) existing.name = tc.function.name - if (tc.function?.arguments) - existing.arguments += tc.function.arguments - } - // Get the current buffer (existing or newly created) - const buffer = toolCallBuffers.get(tc.index)! - - // Emit TOOL_CALL_START when we have id and name - if (buffer.id && buffer.name && !buffer.started) { - buffer.started = true - yield asChunk({ - type: 'TOOL_CALL_START', - toolCallId: buffer.id, - toolCallName: buffer.name, - toolName: buffer.name, - model: meta.model, - timestamp: meta.timestamp, - index: tc.index, - }) + accumulatedContent += deltaContent + + // Emit AG-UI TEXT_MESSAGE_CONTENT + yield { + type: EventType.TEXT_MESSAGE_CONTENT, + messageId: aguiState.messageId, + model: chunk.model || options.model, + timestamp: Date.now(), + delta: deltaContent, + content: accumulatedContent, + } satisfies StreamChunk } - // Emit TOOL_CALL_ARGS for argument deltas - if (tc.function?.arguments && buffer.started) { - yield asChunk({ - type: 'TOOL_CALL_ARGS', - toolCallId: buffer.id, - model: meta.model, - timestamp: meta.timestamp, - delta: tc.function.arguments, - }) + // Handle tool calls - they come in as deltas (camelCase toolCalls) + 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 { + type: EventType.TOOL_CALL_START, + toolCallId: toolCall.id, + toolCallName: toolCall.name, + toolName: toolCall.name, + model: chunk.model || options.model, + timestamp: Date.now(), + index, + } satisfies StreamChunk + } + + // Emit TOOL_CALL_ARGS for argument deltas + if (toolCallDelta.function?.arguments && toolCall.started) { + yield { + type: EventType.TOOL_CALL_ARGS, + toolCallId: toolCall.id, + model: chunk.model || options.model, + timestamp: Date.now(), + delta: toolCallDelta.function.arguments, + } satisfies StreamChunk + } + } } - } - } - if (delta.refusal) { - // Emit AG-UI RUN_ERROR for refusal - yield asChunk({ - type: 'RUN_ERROR', - runId: aguiState.runId, - model: meta.model, - timestamp: meta.timestamp, - message: delta.refusal, - code: 'refusal', - error: { message: delta.refusal, code: 'refusal' }, - }) - } + // Handle finishReason. We DO emit TOOL_CALL_END and TEXT_MESSAGE_END + // here because the corresponding _START events have already fired, + // and tool execution downstream wants to begin as soon as possible. + // RUN_FINISHED is deferred until the iterator is fully exhausted so + // we can capture the trailing usage chunk that arrives AFTER this + // chunk when streamOptions.includeUsage is on. + if (choice.finishReason) { + if ( + choice.finishReason === 'tool_calls' || + toolCallsInProgress.size > 0 + ) { + for (const [, toolCall] of toolCallsInProgress) { + // Skip tool calls that never emitted TOOL_CALL_START — emitting + // a stray TOOL_CALL_END here would violate AG-UI lifecycle + // (END without matching START) for partial deltas where the + // upstream never sent both id and name. + if (!toolCall.started) continue + + // Parse arguments for TOOL_CALL_END. Surface parse failures via + // the logger so a model emitting malformed JSON for tool args + // is debuggable instead of silently invoking the tool with {}. + let parsedInput: unknown = {} + if (toolCall.arguments) { + try { + const parsed: unknown = JSON.parse(toolCall.arguments) + parsedInput = + parsed && typeof parsed === 'object' ? parsed : {} + } catch (parseError) { + options.logger.errors( + `${this.name}.processStreamChunks tool-args JSON parse failed`, + { + error: toRunErrorPayload( + parseError, + `tool ${toolCall.name} (${toolCall.id}) returned malformed JSON arguments`, + ), + source: `${this.name}.processStreamChunks`, + toolCallId: toolCall.id, + toolName: toolCall.name, + rawArguments: toolCall.arguments, + }, + ) + parsedInput = {} + } + } + + // Emit AG-UI TOOL_CALL_END + yield { + type: EventType.TOOL_CALL_END, + toolCallId: toolCall.id, + toolCallName: toolCall.name, + toolName: toolCall.name, + model: chunk.model || options.model, + timestamp: Date.now(), + input: parsedInput, + } satisfies StreamChunk + emittedAnyToolCallEnd = true + } + // Clear tool-call state after emission so a subsequent + // `finishReason: 'stop'` chunk (or the post-loop synthetic + // block) doesn't see lingering entries and misreport the finish. + toolCallsInProgress.clear() + } + + // Emit TEXT_MESSAGE_END if we had text content + if (hasEmittedTextMessageStart) { + yield { + type: EventType.TEXT_MESSAGE_END, + messageId: aguiState.messageId, + model: chunk.model || options.model, + timestamp: Date.now(), + } satisfies StreamChunk + hasEmittedTextMessageStart = false + } - if (finishReason) { - // Capture usage from whichever chunk provides it (may arrive on a - // later duplicate finishReason chunk from the SDK). - if (usage) { - aguiState.deferredUsage = { - promptTokens: usage.promptTokens || 0, - completionTokens: usage.completionTokens || 0, - totalTokens: usage.totalTokens || 0, + // Remember the upstream finishReason; RUN_FINISHED is emitted at + // end-of-stream so we pick up the trailing usage-only chunk too. + pendingFinishReason = choice.finishReason } } - // Guard: only emit finish events once. OpenAI-compatible APIs often - // send two chunks with finishReason (one for the finish, one carrying - // usage data). Without this guard TEXT_MESSAGE_END and RUN_FINISHED - // would be emitted twice. - if (!aguiState.hasEmittedRunFinished) { - aguiState.hasEmittedRunFinished = true - - // Emit all completed tool calls when finish reason indicates tool usage - if (finishReason === 'tool_calls' || toolCallBuffers.size > 0) { - for (const [, tc] of toolCallBuffers.entries()) { - // Parse arguments for TOOL_CALL_END - let parsedInput: unknown = {} + // Emit a single terminal RUN_FINISHED after the iterator is exhausted. + if (aguiState.hasEmittedRunStarted) { + // Close any started tool calls that never got finishReason. + for (const [, toolCall] of toolCallsInProgress) { + if (!toolCall.started) continue + let parsedInput: unknown = {} + if (toolCall.arguments) { try { - parsedInput = tc.arguments ? JSON.parse(tc.arguments) : {} - } catch { + const parsed: unknown = JSON.parse(toolCall.arguments) + parsedInput = parsed && typeof parsed === 'object' ? parsed : {} + } catch (parseError) { + options.logger.errors( + `${this.name}.processStreamChunks tool-args JSON parse failed (drain)`, + { + error: toRunErrorPayload( + parseError, + `tool ${toolCall.name} (${toolCall.id}) returned malformed JSON arguments`, + ), + source: `${this.name}.processStreamChunks`, + toolCallId: toolCall.id, + toolName: toolCall.name, + rawArguments: toolCall.arguments, + }, + ) parsedInput = {} } - - // Emit AG-UI TOOL_CALL_END - yield asChunk({ - type: 'TOOL_CALL_END', - toolCallId: tc.id, - toolCallName: tc.name, - toolName: tc.name, - model: meta.model, - timestamp: meta.timestamp, - input: parsedInput, - }) } + yield { + type: EventType.TOOL_CALL_END, + toolCallId: toolCall.id, + toolCallName: toolCall.name, + toolName: toolCall.name, + model: lastModel || options.model, + timestamp: Date.now(), + input: parsedInput, + } satisfies StreamChunk + emittedAnyToolCallEnd = true + } + toolCallsInProgress.clear() - toolCallBuffers.clear() + // Make sure the text message lifecycle is closed even on early + // termination paths where finishReason never arrives. + if (hasEmittedTextMessageStart) { + yield { + type: EventType.TEXT_MESSAGE_END, + messageId: aguiState.messageId, + model: lastModel || options.model, + timestamp: Date.now(), + } satisfies StreamChunk } - aguiState.computedFinishReason = - finishReason === 'tool_calls' - ? 'tool_calls' - : finishReason === 'length' - ? 'length' - : 'stop' - - // Close reasoning events if still open - if (aguiState.reasoningMessageId && !aguiState.hasClosedReasoning) { - aguiState.hasClosedReasoning = true - yield asChunk({ - type: 'REASONING_MESSAGE_END', - messageId: aguiState.reasoningMessageId, - model: meta.model, - timestamp: meta.timestamp, - }) - yield asChunk({ - type: 'REASONING_END', - messageId: aguiState.reasoningMessageId, - model: meta.model, - timestamp: meta.timestamp, - }) - - // Legacy: single STEP_FINISHED to close the STEP_STARTED - if (aguiState.stepId) { - yield asChunk({ - type: 'STEP_FINISHED', - stepName: aguiState.stepId, - stepId: aguiState.stepId, - model: meta.model, - timestamp: meta.timestamp, - content: accumulated.reasoning, - }) + // Close any reasoning lifecycle that text never closed (no text + // content arrived, or the stream cut off before text started). + if (reasoningMessageId && !hasClosedReasoning) { + hasClosedReasoning = true + yield { + type: EventType.REASONING_MESSAGE_END, + messageId: reasoningMessageId, + model: lastModel || options.model, + timestamp: Date.now(), + } satisfies StreamChunk + yield { + type: EventType.REASONING_END, + messageId: reasoningMessageId, + model: lastModel || options.model, + timestamp: Date.now(), + } satisfies StreamChunk + if (stepId) { + yield { + type: EventType.STEP_FINISHED, + stepName: stepId, + stepId, + model: lastModel || options.model, + timestamp: Date.now(), + content: accumulatedReasoning, + } satisfies StreamChunk } } - // Emit TEXT_MESSAGE_END if we had text content - if (aguiState.hasEmittedTextMessageStart) { - aguiState.hasEmittedTextMessageEnd = true - yield asChunk({ - type: 'TEXT_MESSAGE_END', - messageId: aguiState.messageId, - model: meta.model, - timestamp: meta.timestamp, - }) - } + // Map upstream finishReason to AG-UI's narrower vocabulary while + // preserving the upstream value when it falls outside the AG-UI set. + // Use `tool_calls` only when a TOOL_CALL_END was actually emitted. + // OpenRouter emits 'error' as a finish reason for upstream errors; + // collapse to 'content_filter' (the closest AG-UI equivalent). + const finishReason: + | 'tool_calls' + | 'length' + | 'content_filter' + | 'stop' = emittedAnyToolCallEnd + ? 'tool_calls' + : pendingFinishReason === 'tool_calls' + ? 'stop' + : pendingFinishReason === 'length' + ? 'length' + : pendingFinishReason === 'content_filter' || + pendingFinishReason === 'error' + ? 'content_filter' + : 'stop' + + yield { + type: EventType.RUN_FINISHED, + runId: aguiState.runId, + threadId: aguiState.threadId, + model: lastModel || options.model, + timestamp: Date.now(), + usage: lastUsage + ? { + promptTokens: lastUsage.promptTokens || 0, + completionTokens: lastUsage.completionTokens || 0, + totalTokens: lastUsage.totalTokens || 0, + } + : undefined, + finishReason, + } satisfies StreamChunk } + } catch (error: unknown) { + // Narrow before logging: raw SDK errors can carry request metadata + // (including auth headers) which we must never surface to user loggers. + const errorPayload = toRunErrorPayload( + error, + `${this.name}.processStreamChunks failed`, + ) + options.logger.errors(`${this.name}.processStreamChunks fatal`, { + error: errorPayload, + source: `${this.name}.processStreamChunks`, + }) + + // Emit AG-UI RUN_ERROR + yield { + type: EventType.RUN_ERROR, + model: options.model, + timestamp: Date.now(), + message: errorPayload.message, + code: errorPayload.code, + error: errorPayload, + } satisfies StreamChunk } } - private mapTextOptionsToSDK( + /** + * Build an OpenRouter `ChatRequest` (camelCase) from `TextOptions`. Applies + * `:variant` model suffixing and routes tools through OpenRouter's + * converter (function tools + branded web_search tool). + */ + protected mapOptionsToRequest( options: TextOptions>, - ): ChatRequest { - const modelOptions = options.modelOptions - - const messages = this.convertMessages(options.messages) - + ): Omit { + const modelOptions = options.modelOptions as + | (Record & { variant?: string }) + | undefined + const variantSuffix = modelOptions?.variant + ? `:${modelOptions.variant}` + : '' + + const messages: Array = [] if (options.systemPrompts?.length) { - messages.unshift({ + messages.push({ role: 'system', content: options.systemPrompts.join('\n'), - }) + } as ChatMessages) } + for (const m of options.messages) { + messages.push(this.convertMessage(m)) + } + + const tools = options.tools + ? convertToolsToProviderFormat(options.tools) + : undefined - // Spread modelOptions first, then conditionally override with explicit - // top-level options so undefined values don't clobber modelOptions. Fixes - // #310, where the reverse order silently dropped user-set values. - const request: ChatRequest = { - ...modelOptions, - model: - options.model + - (modelOptions?.variant ? `:${modelOptions.variant}` : ''), + // Spread modelOptions first so explicit top-level options (set below) win + // when defined but `undefined` doesn't clobber values the caller set in + // modelOptions. + return { + ...(modelOptions as Record), + model: options.model + variantSuffix, messages, ...(options.temperature !== undefined && { temperature: options.temperature, @@ -687,107 +818,238 @@ export class OpenRouterTextAdapter< maxCompletionTokens: options.maxTokens, }), ...(options.topP !== undefined && { topP: options.topP }), - tools: options.tools - ? convertToolsToProviderFormat(options.tools) - : undefined, + ...(tools && + tools.length > 0 && { tools: tools as ChatRequest['tools'] }), + } as Omit + } + + /** + * Convert a ModelMessage to OpenRouter's ChatMessages discriminated union + * (camelCase: `toolCallId`, `toolCalls`). + */ + protected convertMessage(message: ModelMessage): ChatMessages { + if (message.role === 'tool') { + // For structured (Array) tool results, extract the text + // content rather than JSON-stringifying the parts — sending the raw + // ContentPart shape (e.g. `[{"type":"text","content":"…"}]`) into the + // tool message's `content` field would feed the literal JSON of the + // parts back to the model instead of the tool's textual result. + return { + role: 'tool', + content: + typeof message.content === 'string' + ? message.content + : this.extractTextContent(message.content), + toolCallId: message.toolCallId || '', + } as ChatMessages + } + + if (message.role === 'assistant') { + // Stringify object-shaped tool-call arguments to match the SDK's + // `ChatToolCall.function.arguments: string` contract. Without this an + // assistant message that carries already-parsed args (common after a + // multi-turn run) would either serialise as `[object Object]` or be + // rejected by the SDK's Zod schema with an opaque validation error. + const toolCalls = message.toolCalls?.map((tc) => ({ + ...tc, + function: { + name: tc.function.name, + arguments: + typeof tc.function.arguments === 'string' + ? tc.function.arguments + : JSON.stringify(tc.function.arguments), + }, + })) + // Per the OpenAI-compatible Chat Completions contract, an assistant + // message that only carries tool_calls should have `content: null` + // rather than `content: ''` or `content: undefined`. For multi-part + // assistant content (Array) we extract the text rather + // than JSON-stringifying the parts, which would otherwise leak the + // literal part shape into the next-turn prompt. + const textContent = this.extractTextContent(message.content) + const hasToolCalls = !!toolCalls && toolCalls.length > 0 + return { + role: 'assistant', + content: hasToolCalls && !textContent ? null : textContent, + toolCalls, + } as ChatMessages } - return request + // user — fail loud on empty and unsupported content. Silently sending an + // empty string would mask a real caller bug and produce a paid request + // with no input. + const contentParts = this.normalizeContent(message.content) + if (contentParts.length === 1 && contentParts[0]?.type === 'text') { + const text = contentParts[0].content + if (text.length === 0) { + throw new Error( + `User message for ${this.name} has empty text content. ` + + `Empty user messages would produce a paid request with no input; ` + + `provide non-empty content or omit the message.`, + ) + } + return { + role: 'user', + content: text, + } as ChatMessages + } + + const parts: Array = [] + for (const part of contentParts) { + const converted = this.convertContentPart(part) + if (!converted) { + throw new Error( + `Unsupported content part type for ${this.name}: ${part.type}. ` + + `Override convertContentPart to handle this type, ` + + `or remove it from the message.`, + ) + } + parts.push(converted) + } + if (parts.length === 0) { + throw new Error( + `User message for ${this.name} has no content parts. ` + + `Empty user messages would produce a paid request with no input; ` + + `provide at least one text/image/audio part or omit the message.`, + ) + } + return { + role: 'user', + content: parts, + } as ChatMessages } - private convertMessages(messages: Array): Array { - return messages.map((msg) => { - if (msg.role === 'tool') { + /** OpenRouter content-part converter (camelCase imageUrl/inputAudio/videoUrl). */ + protected convertContentPart(part: ContentPart): ChatContentItems | null { + switch (part.type) { + case 'text': + return { type: 'text', text: part.content } as ChatContentItems + case 'image': { + const meta = part.metadata as OpenRouterImageMetadata | undefined + const value = part.source.value + // Default to `application/octet-stream` when the source didn't + // provide a MIME type — interpolating `undefined` into the URI + // ("data:undefined;base64,...") produces an invalid data URI the + // API rejects. + const imageMime = part.source.mimeType || 'application/octet-stream' + const url = + part.source.type === 'data' && !value.startsWith('data:') + ? `data:${imageMime};base64,${value}` + : value return { - role: 'tool' as const, - content: - typeof msg.content === 'string' - ? msg.content - : JSON.stringify(msg.content), - toolCallId: msg.toolCallId || '', - } + type: 'image_url', + imageUrl: { url, detail: meta?.detail || 'auto' }, + } as ChatContentItems } - - if (msg.role === 'user') { - const content = this.convertContentParts(msg.content) + case 'audio': + // OpenRouter's chat-completions `input_audio` shape carries + // `{ data, format }` where `data` is base64 — there's no URL + // variant on this wire. For URL-sourced audio, fall back to a + // text reference rather than feeding the literal URL into the + // base64 slot. The Responses adapter does have an `input_file` + // URL variant and routes URLs there directly — see + // `responses-text.ts`. + if (part.source.type === 'url') { + return { + type: 'text', + text: `[Audio: ${part.source.value}]`, + } as ChatContentItems + } + return { + type: 'input_audio', + inputAudio: { data: part.source.value, format: 'mp3' }, + } as ChatContentItems + case 'video': return { - role: 'user' as const, - content: - content.length === 1 && content[0]?.type === 'text' - ? (content[0] as { type: 'text'; text: string }).text - : content, + type: 'video_url', + videoUrl: { url: part.source.value }, + } as ChatContentItems + case 'document': + // The chat-completions SDK has no document_url type. For URL + // sources, surface a text reference so the model at least sees + // the link. For data sources, `part.source.value` is the raw + // base64 payload — inlining it into the prompt would blow the + // context window with megabytes of binary and leak the document + // content verbatim. Throw instead so the caller can either + // switch to the Responses adapter (which has proper input_file + // support for data documents) or strip the document before + // sending. + if (part.source.type === 'data') { + throw new Error( + `${this.name} chat-completions does not support inline (data) document content parts. ` + + `Use the Responses adapter (openRouterResponsesText) for document data, ` + + `or pass the document as a URL.`, + ) } - } + return { + type: 'text', + text: `[Document: ${part.source.value}]`, + } as ChatContentItems + default: + return null + } + } - // assistant role - return { - role: 'assistant' as const, - content: - typeof msg.content === 'string' - ? msg.content - : msg.content - ? JSON.stringify(msg.content) - : undefined, - toolCalls: msg.toolCalls, - } - }) + /** + * 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 } - private convertContentParts( + /** + * Extracts text content from a content value that may be string, null, or ContentPart array. + */ + protected extractTextContent( content: string | null | Array, - ): Array { - if (!content) return [{ type: 'text', text: '' }] - if (typeof content === 'string') return [{ type: 'text', text: content }] + ): string { + if (content === null) { + return '' + } + if (typeof content === 'string') { + return content + } + return content + .filter((p) => p.type === 'text') + .map((p) => p.content) + .join('') + } +} - const parts: Array = [] - for (const part of content) { - switch (part.type) { - case 'text': - parts.push({ type: 'text', text: part.content }) - break - case 'image': { - const meta = part.metadata as OpenRouterImageMetadata | 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', - imageUrl: { - url: imageUrl, - detail: meta?.detail || 'auto', - }, - }) - break - } - case 'audio': - parts.push({ - type: 'input_audio', - inputAudio: { - data: part.source.value, - format: 'mp3', - }, - }) - break - case 'video': - parts.push({ - type: 'video_url', - videoUrl: { url: part.source.value }, - }) - break - case 'document': - // SDK doesn't have document_url type, pass as custom - parts.push({ - type: 'text', - text: `[Document: ${part.source.value}]`, - }) - break +/** + * Flatten any reasoning deltas in a stream chunk into a single string. + * OpenRouter emits reasoning content via `delta.reasoningDetails`, a union of + * variants including `{ type: 'reasoning.text', text }` and + * `{ type: 'reasoning.summary', summary }`. + */ +function extractReasoningText(chunk: ChatStreamChunk): string { + let text = '' + for (const choice of chunk.choices) { + const details = (choice.delta as { reasoningDetails?: Array }) + .reasoningDetails + if (!Array.isArray(details)) continue + for (const detail of details) { + const d = detail as { type?: string; text?: unknown; summary?: unknown } + if (d.type === 'reasoning.text' && typeof d.text === 'string') { + text += d.text + } else if ( + d.type === 'reasoning.summary' && + typeof d.summary === 'string' + ) { + text += d.summary } } - return parts.length ? parts : [{ type: 'text', text: '' }] } + return text } export function createOpenRouterText( diff --git a/packages/typescript/ai-openrouter/src/index.ts b/packages/typescript/ai-openrouter/src/index.ts index e17844743..e883b4323 100644 --- a/packages/typescript/ai-openrouter/src/index.ts +++ b/packages/typescript/ai-openrouter/src/index.ts @@ -11,13 +11,21 @@ export { type OpenRouterTextModelOptions, } from './adapters/text' -// Summarize adapter - for text summarization +// Responses (beta) adapter - for the OpenRouter beta Responses API +export { + OpenRouterResponsesTextAdapter, + createOpenRouterResponsesText, + openRouterResponsesText, + type OpenRouterResponsesConfig, + type OpenRouterResponsesTextProviderOptions, +} from './adapters/responses-text' + +// Summarize - thin factory functions over @tanstack/ai's ChatStreamSummarizeAdapter export { - OpenRouterSummarizeAdapter, createOpenRouterSummarize, openRouterSummarize, type OpenRouterSummarizeConfig, - type OpenRouterSummarizeProviderOptions, + type OpenRouterTextModels as OpenRouterSummarizeModel, } from './adapters/summarize' // Image adapter - for image generation diff --git a/packages/typescript/ai-openrouter/src/internal/request-options.ts b/packages/typescript/ai-openrouter/src/internal/request-options.ts new file mode 100644 index 000000000..72cb5c0b1 --- /dev/null +++ b/packages/typescript/ai-openrouter/src/internal/request-options.ts @@ -0,0 +1,17 @@ +/** + * Extract `headers` and `signal` from a `Request | RequestInit` for the + * OpenRouter SDK's per-call request-options. `Request` exposes `headers` as a + * `Headers` instance (HeadersInit-compatible) while `RequestInit` exposes + * `HeadersInit` directly — this helper accepts either shape so callers don't + * need to cast. + * + * Always returns an object (possibly empty) rather than `undefined` so test + * assertions that match the second argument shape via `expect.anything()` / + * `expect.objectContaining()` keep working when no request override was set. + */ +export function extractRequestOptions( + request: Request | RequestInit | undefined, +): { headers?: HeadersInit; signal?: AbortSignal | null } { + if (!request) return {} + return { headers: request.headers, signal: request.signal ?? undefined } +} diff --git a/packages/typescript/ai-openrouter/src/internal/responses-tool-converter.ts b/packages/typescript/ai-openrouter/src/internal/responses-tool-converter.ts new file mode 100644 index 000000000..5df88fa41 --- /dev/null +++ b/packages/typescript/ai-openrouter/src/internal/responses-tool-converter.ts @@ -0,0 +1,57 @@ +import { makeStructuredOutputCompatible } from './schema-converter' +import type { JSONSchema, Tool } from '@tanstack/ai' + +/** + * Responses API function tool format. + * + * Matches OpenRouter's `ResponsesRequestToolFunction` shape exactly: + * { type: 'function', name: string, description?: string, parameters: object, strict?: boolean } + */ +export interface ResponsesFunctionTool { + type: 'function' + name: string + description?: string | null + parameters: Record | null + strict: boolean | null +} + +/** + * Converts a standard Tool to the Responses API FunctionTool format. + * + * Tool schemas are already converted to JSON Schema in the ai layer. + * We apply OpenAI-compatible transformations for strict mode: + * - All properties in required array + * - Optional fields made nullable + * - additionalProperties: false + * + * This enables strict mode for all tools automatically. + */ +export function convertFunctionToolToResponsesFormat( + tool: Tool, + schemaConverter: ( + schema: Record, + required: Array, + ) => Record = makeStructuredOutputCompatible, +): ResponsesFunctionTool { + const inputSchema = (tool.inputSchema ?? { + type: 'object', + properties: {}, + required: [], + }) as JSONSchema + + // Shallow-copy the converter's result before mutating — a subclass-supplied + // schemaConverter has no contract requirement to return a fresh object; + // mutating in place could corrupt the caller's tool definition. + const jsonSchema = { + ...schemaConverter(inputSchema, inputSchema.required || []), + } + jsonSchema.additionalProperties = false + + return { + type: 'function', + name: tool.name, + description: tool.description, + parameters: jsonSchema, + strict: true, + } +} diff --git a/packages/typescript/ai-openrouter/src/internal/schema-converter.ts b/packages/typescript/ai-openrouter/src/internal/schema-converter.ts new file mode 100644 index 000000000..3338770e2 --- /dev/null +++ b/packages/typescript/ai-openrouter/src/internal/schema-converter.ts @@ -0,0 +1,90 @@ +/** + * Transform a JSON schema to be compatible with OpenAI-style structured output requirements. + * The base requirements (which OpenRouter inherits because it routes to upstream OpenAI-compatible + * structured-output backends) are: + * - 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 strict 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/ai-openrouter/src/text/responses-provider-options.ts b/packages/typescript/ai-openrouter/src/text/responses-provider-options.ts new file mode 100644 index 000000000..af4f9f872 --- /dev/null +++ b/packages/typescript/ai-openrouter/src/text/responses-provider-options.ts @@ -0,0 +1,58 @@ +import type { ResponsesRequest } from '@openrouter/sdk/models' +import type { OPENROUTER_CHAT_MODELS } from '../model-meta' + +type OpenRouterResponsesModel = (typeof OPENROUTER_CHAT_MODELS)[number] + +// --------------------------------------------------------------------------- +// Composite option types for the OpenRouter Responses adapter. +// Derived from the SDK's `ResponsesRequest` so future SDK additions surface +// here without manual fan-out (mirrors `text-provider-options.ts`). +// --------------------------------------------------------------------------- + +export type OpenRouterResponsesCommonOptions = Pick< + ResponsesRequest, + | 'provider' + | 'plugins' + | 'user' + | 'sessionId' + | 'metadata' + | 'trace' + | 'modalities' + | 'serviceTier' + | 'safetyIdentifier' + | 'promptCacheKey' + | 'previousResponseId' + | 'imageConfig' + | 'include' + | 'maxToolCalls' + | 'truncation' +> & { + /** A list of model IDs to use as fallbacks if the primary model is unavailable. */ + models?: Array + /** The model variant to use, if supported by the model. Appended to the model ID. */ + variant?: 'free' | 'nitro' | 'online' | 'exacto' | 'extended' | 'thinking' +} + +// `parallelToolCalls` lives in BaseOptions alongside `toolChoice` (the other +// tool-related knob). Listing it in both picks would let an SDK rename of +// either pick still type-check through the survivor, defeating the static +// gate the picks exist for. +export type OpenRouterResponsesBaseOptions = Pick< + ResponsesRequest, + | 'maxOutputTokens' + | 'temperature' + | 'topP' + | 'topK' + | 'topLogprobs' + | 'frequencyPenalty' + | 'presencePenalty' + | 'reasoning' + | 'toolChoice' + | 'parallelToolCalls' + | 'text' + | 'background' + | 'prompt' +> + +export type ExternalResponsesProviderOptions = + OpenRouterResponsesCommonOptions & OpenRouterResponsesBaseOptions diff --git a/packages/typescript/ai-openrouter/tests/openrouter-adapter.test.ts b/packages/typescript/ai-openrouter/tests/openrouter-adapter.test.ts index 206d16525..3fc2ba243 100644 --- a/packages/typescript/ai-openrouter/tests/openrouter-adapter.test.ts +++ b/packages/typescript/ai-openrouter/tests/openrouter-adapter.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { chat } from '@tanstack/ai' +import { EventType, chat } from '@tanstack/ai' import { resolveDebugOption } from '@tanstack/ai/adapter-internals' import { ChatRequest$outboundSchema } from '@openrouter/sdk/models' import { createOpenRouterText } from '../src/adapters/text' @@ -10,12 +10,19 @@ import type { StreamChunk, Tool } from '@tanstack/ai' const testLogger = resolveDebugOption(false) // Declare mockSend at module level let mockSend: any +// Captures the most recent OpenRouter SDK constructor config so tests can +// assert that app-attribution headers (httpReferer, appTitle, etc.) actually +// reach the SDK rather than being silently dropped by the adapter. +let lastOpenRouterConfig: any // Mock the SDK with a class defined inline // eslint-disable-next-line @typescript-eslint/require-await vi.mock('@openrouter/sdk', async () => { return { OpenRouter: class { + constructor(config?: unknown) { + lastOpenRouterConfig = config + } chat = { send: (...args: Array) => mockSend(...args), } @@ -374,6 +381,52 @@ describe('OpenRouter adapter option mapping', () => { }) }) + it('defaults base64 image data URIs to application/octet-stream when mimeType is missing', async () => { + setupMockSdkClient([ + { + id: 'x', + model: 'openai/gpt-4o-mini', + choices: [{ delta: { content: 'ok' }, finishReason: 'stop' }], + }, + ]) + const adapter = createAdapter() + for await (const _ of adapter.chatStream({ + model: 'openai/gpt-4o-mini', + messages: [ + { + role: 'user', + content: [ + { type: 'text', content: 'see image' }, + { + type: 'image', + // The TS type requires `mimeType` on data sources, but at + // runtime a JS caller (or a cast) can still elide it. Cast + // to bypass the type check so the adapter's defensive + // default — `application/octet-stream` — is exercised; the + // alternative is a literal `data:undefined;base64,...` URI + // that the upstream rejects. + source: { type: 'data', value: 'aGVsbG8=' } as any, + }, + ], + }, + ], + logger: testLogger, + })) { + // consume + } + + const [rawParams] = mockSend.mock.calls[0]! + const params = rawParams.chatRequest + const imagePart = params.messages[0].content.find( + (p: any) => p.type === 'image_url', + ) + expect(imagePart).toBeDefined() + expect(imagePart.imageUrl.url).toBe( + 'data:application/octet-stream;base64,aGVsbG8=', + ) + expect(imagePart.imageUrl.url).not.toContain('undefined') + }) + it('yields error chunk on SDK error', async () => { mockSend = vi.fn().mockRejectedValueOnce(new Error('Invalid API key')) @@ -789,6 +842,41 @@ describe('OpenRouter AG-UI event emission', () => { expect(runErrorChunk).toBeDefined() if (runErrorChunk?.type === 'RUN_ERROR') { expect(runErrorChunk.error?.message).toBe('Rate limit exceeded') + // Provider error codes arrive as numbers (429, 500, etc.). The chunk + // adapter passes the raw value through and `toRunErrorPayload` coerces + // finite numbers via `String(...)`. + expect(runErrorChunk.error?.code).toBe('429') + } + }) + + it('drops object-shaped error.code rather than shipping "[object Object]"', async () => { + // A misbehaving upstream sending an object as `error.code` previously + // surfaced as `code: "[object Object]"` in RUN_ERROR because the chunk + // adapter pre-stringified anything non-null. The current code path passes + // the raw value through; `toRunErrorPayload`'s typeof gate drops it. + const streamChunks = [ + { + id: 'chatcmpl-bad', + model: 'openai/gpt-4o-mini', + choices: [] as Array, + error: { message: 'weird', code: { nested: 'oops' } }, + }, + ] + setupMockSdkClient(streamChunks) + const adapter = createAdapter() + const chunks: Array = [] + for await (const chunk of adapter.chatStream({ + model: 'openai/gpt-4o-mini', + messages: [{ role: 'user', content: 'hi' }], + logger: testLogger, + })) { + chunks.push(chunk) + } + const runErr = chunks.find((c) => c.type === 'RUN_ERROR') + expect(runErr).toBeDefined() + if (runErr?.type === 'RUN_ERROR') { + expect(runErr.error?.message).toBe('weird') + expect(runErr.error?.code).toBeUndefined() } }) @@ -1151,6 +1239,9 @@ describe('OpenRouter structured output', () => { const adapter = createAdapter() + // The shared base re-throws the underlying error rather than wrapping it + // with a "Structured output generation failed:" prefix — the prefix only + // existed in the pre-migration OpenRouter adapter. await expect( adapter.structuredOutput({ chatOptions: { @@ -1160,10 +1251,10 @@ describe('OpenRouter structured output', () => { }, outputSchema: { type: 'object' }, }), - ).rejects.toThrow('Structured output generation failed: Server error') + ).rejects.toThrow('Server error') }) - it('handles empty content gracefully', async () => { + it('throws a clear "no content" error when the response is empty', async () => { const nonStreamResponse = { choices: [ { @@ -1177,6 +1268,9 @@ describe('OpenRouter structured output', () => { setupMockSdkClient([], nonStreamResponse) const adapter = createAdapter() + // Empty content must surface as a distinct error so the actual failure + // mode (the model returned no content) is visible in logs rather than + // being masked by a misleading JSON-parse error on an empty string. await expect( adapter.structuredOutput({ chatOptions: { @@ -1186,7 +1280,7 @@ describe('OpenRouter structured output', () => { }, outputSchema: { type: 'object' }, }), - ).rejects.toThrow('Structured output response contained no content') + ).rejects.toThrow('response contained no content') }) }) @@ -1663,4 +1757,478 @@ describe('OpenRouter STEP event consistency', () => { expect(stepStarted).toHaveLength(1) expect(stepFinished).toHaveLength(1) }) + + it('emits the spec REASONING_* lifecycle alongside the legacy STEP_* events', async () => { + // The base now exposes both the legacy STEP_STARTED/STEP_FINISHED pair + // (kept for backwards compatibility with consumers built against the + // pre-spec stream) AND the spec REASONING_START / REASONING_MESSAGE_* / + // REASONING_END events. Dropping any of the REASONING_* events would + // silently break consumers that migrated to the new shape. + const streamChunks = [ + { + id: 'r-1', + model: 'openai/o1-preview', + choices: [ + { + delta: { + reasoningDetails: [ + { type: 'reasoning.text', text: 'Thinking...' }, + ], + }, + finishReason: null, + }, + ], + }, + { + id: 'r-1', + model: 'openai/o1-preview', + choices: [ + { + delta: { + reasoningDetails: [{ type: 'reasoning.text', text: ' done.' }], + }, + finishReason: null, + }, + ], + }, + { + id: 'r-1', + model: 'openai/o1-preview', + choices: [{ delta: { content: 'Final answer.' }, finishReason: null }], + }, + { + id: 'r-1', + model: 'openai/o1-preview', + choices: [{ delta: {}, finishReason: 'stop' }], + }, + ] + + setupMockSdkClient(streamChunks) + const adapter = createAdapter() + const chunks: Array = [] + for await (const chunk of adapter.chatStream({ + model: 'openai/o1-preview', + messages: [{ role: 'user', content: 'q' }], + logger: testLogger, + })) { + chunks.push(chunk) + } + const types = chunks.map((c) => c.type) + const reasoningStart = types.indexOf(EventType.REASONING_START) + const reasoningMessageStart = types.indexOf( + EventType.REASONING_MESSAGE_START, + ) + const reasoningMessageContent = types.indexOf( + EventType.REASONING_MESSAGE_CONTENT, + ) + const reasoningMessageEnd = types.indexOf(EventType.REASONING_MESSAGE_END) + const reasoningEnd = types.indexOf(EventType.REASONING_END) + expect(reasoningStart).toBeGreaterThanOrEqual(0) + expect(reasoningMessageStart).toBeGreaterThan(reasoningStart) + expect(reasoningMessageContent).toBeGreaterThan(reasoningMessageStart) + expect(reasoningMessageEnd).toBeGreaterThan(reasoningMessageContent) + expect(reasoningEnd).toBeGreaterThan(reasoningMessageEnd) + + // Joining REASONING_MESSAGE_CONTENT deltas reproduces the full reasoning + // text — the migration leaves the new-spec event shape semantically + // equivalent to the legacy STEP_FINISHED accumulator without losing data. + const reasoningDeltas = chunks + .filter( + (c): c is Extract => + c.type === 'REASONING_MESSAGE_CONTENT', + ) + .map((c) => c.delta) + .join('') + expect(reasoningDeltas).toBe('Thinking... done.') + }) +}) + +describe('OpenRouter SDK constructor wiring', () => { + beforeEach(() => { + vi.clearAllMocks() + lastOpenRouterConfig = undefined + }) + + it('forwards app-attribution headers (httpReferer, appTitle) to the SDK constructor', () => { + void createOpenRouterText('openai/gpt-4o-mini', 'test-key', { + httpReferer: 'https://app.example.com', + appTitle: 'TestApp', + } as any) + expect(lastOpenRouterConfig).toBeDefined() + expect(lastOpenRouterConfig.apiKey).toBe('test-key') + expect(lastOpenRouterConfig.httpReferer).toBe('https://app.example.com') + expect(lastOpenRouterConfig.appTitle).toBe('TestApp') + }) + + it('forwards serverURL overrides to the SDK constructor', () => { + void createOpenRouterText('openai/gpt-4o-mini', 'test-key', { + serverURL: 'https://custom.example.com/api/v1', + } as any) + expect(lastOpenRouterConfig.serverURL).toBe( + 'https://custom.example.com/api/v1', + ) + }) +}) + +describe('OpenRouter stream_options conversion', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('converts include_usage to includeUsage so the SDK preserves it', async () => { + const streamChunks = [ + { + id: 'x', + model: 'openai/gpt-4o-mini', + choices: [{ delta: { content: 'hi' }, finishReason: 'stop' }], + usage: { promptTokens: 1, completionTokens: 1, totalTokens: 2 }, + }, + ] + setupMockSdkClient(streamChunks) + const adapter = createAdapter() + + for await (const _ of adapter.chatStream({ + model: 'openai/gpt-4o-mini', + messages: [{ role: 'user', content: 'hi' }], + logger: testLogger, + })) { + // consume + } + + const [rawParams] = mockSend.mock.calls[0]! + const params = rawParams.chatRequest + // The SDK's outbound Zod schema strips unknown keys. Without the + // include_usage → includeUsage rename, the camelCase key would survive + // here but the wire-format serialisation would drop it entirely. + expect(params.streamOptions).toBeDefined() + expect(params.streamOptions.includeUsage).toBe(true) + expect(params.streamOptions).not.toHaveProperty('include_usage') + + const serialized = ChatRequest$outboundSchema.parse(params) + expect((serialized as any).stream_options).toEqual({ include_usage: true }) + }) + + it('propagates the abort signal to the SDK call', async () => { + setupMockSdkClient([ + { + id: 'x', + model: 'openai/gpt-4o-mini', + choices: [{ delta: { content: 'hi' }, finishReason: 'stop' }], + }, + ]) + const adapter = createAdapter() + const controller = new AbortController() + + for await (const _ of adapter.chatStream({ + model: 'openai/gpt-4o-mini', + messages: [{ role: 'user', content: 'hi' }], + logger: testLogger, + request: { signal: controller.signal } as any, + })) { + // consume + } + + // The second argument to the SDK call must carry the signal so + // user-initiated aborts actually reach the SDK rather than letting the + // request continue burning tokens silently. + const [, options] = mockSend.mock.calls[0]! + expect(options.signal).toBe(controller.signal) + }) + + it('forwards caller-supplied request headers to the SDK call', async () => { + setupMockSdkClient([ + { + id: 'x', + model: 'openai/gpt-4o-mini', + choices: [{ delta: { content: 'hi' }, finishReason: 'stop' }], + }, + ]) + const adapter = createAdapter() + const headers = { + 'X-Trace-Id': 'trace-123', + 'X-End-User': 'user-abc', + } + + for await (const _ of adapter.chatStream({ + model: 'openai/gpt-4o-mini', + messages: [{ role: 'user', content: 'hi' }], + logger: testLogger, + request: { headers } as any, + })) { + // consume + } + + // Custom tracing / end-user identifiers passed via options.request.headers + // must reach the SDK — otherwise observability tags are silently dropped + // only for OpenRouter while other providers preserve them. + const [, options] = mockSend.mock.calls[0]! + expect(options.headers).toEqual(headers) + }) + + it('maps RequestAbortedError from the SDK to RUN_ERROR with code: aborted', async () => { + const abortErr = Object.assign(new Error('Request aborted by client'), { + name: 'RequestAbortedError', + }) + mockSend = vi.fn().mockRejectedValueOnce(abortErr) + const adapter = createAdapter() + const chunks: Array = [] + + for await (const chunk of adapter.chatStream({ + model: 'openai/gpt-4o-mini', + messages: [{ role: 'user', content: 'hi' }], + logger: testLogger, + })) { + chunks.push(chunk) + } + + const runErr = chunks.find((c) => c.type === 'RUN_ERROR') + expect(runErr).toBeDefined() + if (runErr?.type === 'RUN_ERROR') { + expect(runErr.error?.code).toBe('aborted') + expect(runErr.error?.message).toBe('Request aborted') + } + }) +}) + +describe('OpenRouter convertMessage fail-loud guards', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('surfaces empty user-message guard as RUN_ERROR (no paid request)', async () => { + setupMockSdkClient([]) + const adapter = createAdapter() + + // mapOptionsToRequest runs inside chatStream's try block, so the + // fail-loud guard surfaces as a RUN_ERROR event instead of an iterator + // throw — uniform error contract for callers, and we still never make a + // paid request with an empty user message. + const events: Array = [] + for await (const evt of adapter.chatStream({ + model: 'openai/gpt-4o-mini', + messages: [{ role: 'user', content: '' }], + logger: testLogger, + })) { + events.push(evt) + } + const runError = events.find( + (e): e is Extract => + e.type === EventType.RUN_ERROR, + ) + expect(runError).toBeDefined() + expect(runError!.message).toMatch(/empty text content/i) + expect(mockSend).not.toHaveBeenCalled() + }) + + it('surfaces unsupported content-part guard as RUN_ERROR (no paid request)', async () => { + setupMockSdkClient([]) + const adapter = createAdapter() + + const events: Array = [] + for await (const evt of adapter.chatStream({ + model: 'openai/gpt-4o-mini', + messages: [ + { + role: 'user', + content: [{ type: 'mystery-type' as any, content: 'x' } as any], + }, + ], + logger: testLogger, + })) { + events.push(evt) + } + const runError = events.find( + (e): e is Extract => + e.type === EventType.RUN_ERROR, + ) + expect(runError).toBeDefined() + expect(runError!.message).toMatch(/unsupported content part/i) + expect(mockSend).not.toHaveBeenCalled() + }) + + it('stringifies object-shaped assistant toolCalls.function.arguments', async () => { + setupMockSdkClient([ + { + id: 'x', + model: 'openai/gpt-4o-mini', + choices: [{ delta: { content: 'ok' }, finishReason: 'stop' }], + }, + ]) + const adapter = createAdapter() + + for await (const _ of adapter.chatStream({ + model: 'openai/gpt-4o-mini', + messages: [ + { role: 'user', content: 'hi' }, + { + role: 'assistant', + content: null, + toolCalls: [ + { + id: 'call_1', + type: 'function', + function: { + name: 'lookup_weather', + // Object args from a prior parsed turn — SDK expects string. + arguments: { location: 'Berlin' } as any, + }, + }, + ], + }, + { role: 'tool', toolCallId: 'call_1', content: '{"temp":72}' }, + ], + logger: testLogger, + })) { + // consume + } + + const [rawParams] = mockSend.mock.calls[0]! + const assistantMsg = rawParams.chatRequest.messages.find( + (m: any) => m.role === 'assistant', + ) + expect(assistantMsg).toBeDefined() + const args = assistantMsg.toolCalls[0].function.arguments + expect(typeof args).toBe('string') + expect(JSON.parse(args)).toEqual({ location: 'Berlin' }) + }) + + it('extracts text from array-shaped assistant content instead of JSON-stringifying parts', async () => { + setupMockSdkClient([ + { + id: 'x', + model: 'openai/gpt-4o-mini', + choices: [{ delta: { content: 'ok' }, finishReason: 'stop' }], + }, + ]) + const adapter = createAdapter() + + for await (const _ of adapter.chatStream({ + model: 'openai/gpt-4o-mini', + messages: [ + { role: 'user', content: 'first' }, + { + role: 'assistant', + // Multi-part assistant content from a prior turn. The base extracts + // joined text; the OpenRouter override must do the same instead of + // JSON-stringifying the parts into the next-turn prompt. + content: [ + { type: 'text', content: 'hello ' }, + { type: 'text', content: 'world' }, + ], + }, + { role: 'user', content: 'second' }, + ], + logger: testLogger, + })) { + // consume + } + + const [rawParams] = mockSend.mock.calls[0]! + const assistantMsg = rawParams.chatRequest.messages.find( + (m: any) => m.role === 'assistant', + ) + expect(assistantMsg).toBeDefined() + expect(assistantMsg.content).toBe('hello world') + }) + + it('extracts text from array-shaped tool message content instead of JSON-stringifying parts', async () => { + setupMockSdkClient([ + { + id: 'x', + model: 'openai/gpt-4o-mini', + choices: [{ delta: { content: 'ok' }, finishReason: 'stop' }], + }, + ]) + const adapter = createAdapter() + + for await (const _ of adapter.chatStream({ + model: 'openai/gpt-4o-mini', + messages: [ + { role: 'user', content: 'hi' }, + { + role: 'assistant', + content: null, + toolCalls: [ + { + id: 'call_1', + type: 'function', + function: { + name: 'lookup_weather', + arguments: '{"location":"Berlin"}', + }, + }, + ], + }, + { + role: 'tool', + toolCallId: 'call_1', + // Structured tool result content. The adapter must extract the + // text rather than JSON-stringifying the parts; otherwise the + // model would see the literal `[{"type":"text","content":"..."}]` + // shape on its next turn instead of the actual tool output. + content: [ + { type: 'text', content: '{"temp":' }, + { type: 'text', content: '72}' }, + ], + }, + ], + logger: testLogger, + })) { + // consume + } + + const [rawParams] = mockSend.mock.calls[0]! + const toolMsg = rawParams.chatRequest.messages.find( + (m: any) => m.role === 'tool', + ) + expect(toolMsg).toBeDefined() + expect(toolMsg.content).toBe('{"temp":72}') + expect(toolMsg.content).not.toContain('"type":"text"') + }) + + it('emits content: null (not undefined) for assistant messages with only tool calls', async () => { + setupMockSdkClient([ + { + id: 'x', + model: 'openai/gpt-4o-mini', + choices: [{ delta: { content: 'ok' }, finishReason: 'stop' }], + }, + ]) + const adapter = createAdapter() + + for await (const _ of adapter.chatStream({ + model: 'openai/gpt-4o-mini', + messages: [ + { role: 'user', content: 'hi' }, + { + role: 'assistant', + content: null, + toolCalls: [ + { + id: 'call_1', + type: 'function', + function: { + name: 'lookup_weather', + arguments: '{"location":"Berlin"}', + }, + }, + ], + }, + { role: 'tool', toolCallId: 'call_1', content: '{"temp":72}' }, + ], + logger: testLogger, + })) { + // consume + } + + const [rawParams] = mockSend.mock.calls[0]! + const assistantMsg = rawParams.chatRequest.messages.find( + (m: any) => m.role === 'assistant', + ) + expect(assistantMsg).toBeDefined() + // Strictly null — the OpenAI Chat Completions contract documents `null` + // for tool-call-only assistant messages, and the SDK's Zod schema may + // strip `undefined` entirely. + expect(assistantMsg.content).toBeNull() + }) }) diff --git a/packages/typescript/ai-openrouter/tests/openrouter-responses-adapter.test.ts b/packages/typescript/ai-openrouter/tests/openrouter-responses-adapter.test.ts new file mode 100644 index 000000000..b30e83c3c --- /dev/null +++ b/packages/typescript/ai-openrouter/tests/openrouter-responses-adapter.test.ts @@ -0,0 +1,976 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { EventType, chat } from '@tanstack/ai' +import { resolveDebugOption } from '@tanstack/ai/adapter-internals' +import { ResponsesRequest$outboundSchema } from '@openrouter/sdk/models' +import { createOpenRouterResponsesText } from '../src/adapters/responses-text' +import { webSearchTool } from '../src/tools/web-search-tool' +import type { StreamChunk, Tool } from '@tanstack/ai' + +const testLogger = resolveDebugOption(false) +let mockSend: any +let lastOpenRouterConfig: any + +vi.mock('@openrouter/sdk', async () => { + return { + OpenRouter: class { + constructor(config?: unknown) { + lastOpenRouterConfig = config + } + beta = { + responses: { + send: (...args: Array) => mockSend(...args), + }, + } + }, + } +}) + +const createAdapter = () => + createOpenRouterResponsesText('openai/gpt-4o-mini', 'test-key') + +const weatherTool: Tool = { + name: 'lookup_weather', + description: 'Return the forecast for a location', +} + +function createAsyncIterable(chunks: Array): AsyncIterable { + return { + [Symbol.asyncIterator]() { + let index = 0 + return { + // eslint-disable-next-line @typescript-eslint/require-await + async next() { + if (index < chunks.length) { + return { value: chunks[index++]!, done: false } + } + return { value: undefined as T, done: true } + }, + } + }, + } +} + +function setupMockSdkClient( + streamEvents: Array>, + nonStreamResult?: Record, +) { + mockSend = vi.fn().mockImplementation((params) => { + if (params.responsesRequest?.stream) { + return Promise.resolve(createAsyncIterable(streamEvents)) + } + return Promise.resolve(nonStreamResult) + }) +} + +describe('OpenRouter responses adapter — request shape', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('maps options into the Responses API payload (snake → camel)', async () => { + setupMockSdkClient([ + { + type: 'response.completed', + sequenceNumber: 1, + response: { + model: 'openai/gpt-4o-mini', + output: [], + usage: { inputTokens: 5, outputTokens: 2, totalTokens: 7 }, + }, + }, + ]) + + const adapter = createAdapter() + + for await (const _ of chat({ + adapter, + systemPrompts: ['Stay concise'], + messages: [{ role: 'user', content: 'How is the weather?' }], + tools: [weatherTool], + temperature: 0.25, + topP: 0.6, + maxTokens: 1024, + modelOptions: { toolChoice: 'auto' as any }, + })) { + // consume + } + + expect(mockSend).toHaveBeenCalledTimes(1) + const [rawParams] = mockSend.mock.calls[0]! + const params = rawParams.responsesRequest + + // Top-level camelCase keys reach the SDK. + expect(params.model).toBe('openai/gpt-4o-mini') + expect(params.temperature).toBe(0.25) + expect(params.topP).toBe(0.6) + expect(params.maxOutputTokens).toBe(1024) + expect(params.toolChoice).toBe('auto') + expect(params.instructions).toBe('Stay concise') + expect(params.stream).toBe(true) + + // Tools land in OpenRouter's flat Responses function-tool shape. + expect(Array.isArray(params.tools)).toBe(true) + expect(params.tools[0]).toMatchObject({ + type: 'function', + name: 'lookup_weather', + }) + + // The wire-format outboundSchema must accept the params — if camelCase + // keys are still snake_case (silently stripped by Zod), this throws. + const serialized = ResponsesRequest$outboundSchema.parse(params) + expect(serialized).toHaveProperty('model', 'openai/gpt-4o-mini') + expect(serialized).toHaveProperty('temperature', 0.25) + expect(serialized).toHaveProperty('top_p', 0.6) + expect(serialized).toHaveProperty('max_output_tokens', 1024) + expect(serialized).toHaveProperty('tool_choice', 'auto') + }) + + it('walks input[] camel-casing call_id and image_url so Zod does not strip them', async () => { + setupMockSdkClient([ + { + type: 'response.completed', + sequenceNumber: 1, + response: { + model: 'openai/gpt-4o-mini', + output: [], + usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 }, + }, + }, + ]) + + const adapter = createAdapter() + for await (const _ of chat({ + adapter, + messages: [ + { + role: 'assistant', + content: null, + toolCalls: [ + { + id: 'call_abc', + type: 'function', + function: { name: 'lookup_weather', arguments: '{"x":1}' }, + }, + ], + }, + { role: 'tool', toolCallId: 'call_abc', content: '{"temp":72}' }, + ], + })) { + // consume + } + + const params = mockSend.mock.calls[0]![0].responsesRequest + const fcOutput = params.input.find( + (i: any) => i.type === 'function_call_output', + ) + // call_id was snake_case from the base; we must hand the SDK camelCase + // or Zod silently strips it and the tool result detaches from its call. + expect(fcOutput).toBeDefined() + expect(fcOutput.callId).toBe('call_abc') + expect(fcOutput).not.toHaveProperty('call_id') + }) + + it('applies modelOptions.variant as a `:suffix` to the model id', async () => { + setupMockSdkClient([ + { + type: 'response.completed', + sequenceNumber: 1, + response: { + model: 'openai/gpt-4o-mini:thinking', + output: [], + usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 }, + }, + }, + ]) + const adapter = createAdapter() + for await (const _ of chat({ + adapter, + messages: [{ role: 'user', content: 'hi' }], + modelOptions: { variant: 'thinking' as any }, + })) { + // consume + } + const params = mockSend.mock.calls[0]![0].responsesRequest + expect(params.model).toBe('openai/gpt-4o-mini:thinking') + }) + + it('rejects webSearchTool() as RUN_ERROR pointing at the chat adapter', async () => { + const adapter = createAdapter() + const ws = webSearchTool() as Tool + const events: Array = [] + for await (const evt of adapter.chatStream({ + model: 'openai/gpt-4o-mini' as any, + messages: [{ role: 'user', content: 'hi' }], + tools: [ws], + logger: testLogger, + })) { + events.push(evt) + } + const runError = events.find( + (e): e is Extract => + e.type === EventType.RUN_ERROR, + ) + expect(runError).toBeDefined() + expect(runError!.message).toMatch(/openRouterText/) + }) + + it('falls back audio URL → input_file (chat-completions audio input is base64-only)', async () => { + setupMockSdkClient([ + { + type: 'response.completed', + sequenceNumber: 1, + response: { + model: 'm', + output: [], + usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 }, + }, + }, + ]) + const adapter = createAdapter() + for await (const _ of chat({ + adapter, + messages: [ + { + role: 'user', + content: [ + { + type: 'audio', + source: { + type: 'url', + value: 'https://example.com/clip.mp3', + } as any, + } as any, + ], + }, + ], + })) { + // consume + } + const params = mockSend.mock.calls[0]![0].responsesRequest + const userMsg = params.input.find((i: any) => i.role === 'user') + expect(userMsg).toBeDefined() + const audioPart = userMsg.content.find((p: any) => p.type === 'input_file') + expect(audioPart).toBeDefined() + expect(audioPart.fileUrl).toBe('https://example.com/clip.mp3') + }) + + it('builds fileData data URI for inline document parts', async () => { + setupMockSdkClient([ + { + type: 'response.completed', + sequenceNumber: 1, + response: { + model: 'm', + output: [], + usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 }, + }, + }, + ]) + const adapter = createAdapter() + for await (const _ of chat({ + adapter, + messages: [ + { + role: 'user', + content: [ + { + type: 'document', + source: { + type: 'data', + value: 'aGVsbG8=', + mimeType: 'application/pdf', + } as any, + } as any, + ], + }, + ], + })) { + // consume + } + const params = mockSend.mock.calls[0]![0].responsesRequest + const userMsg = params.input.find((i: any) => i.role === 'user') + const docPart = userMsg.content.find((p: any) => p.type === 'input_file') + expect(docPart).toBeDefined() + expect(docPart.fileData).toBe('data:application/pdf;base64,aGVsbG8=') + // Survives the SDK's outbound Zod schema (key strip would drop fileData) + const serialized = ResponsesRequest$outboundSchema.parse(params) + expect(JSON.stringify(serialized)).toContain( + 'data:application/pdf;base64,aGVsbG8=', + ) + }) + + it('defaults image data-URI mimeType to application/octet-stream when omitted', async () => { + setupMockSdkClient([ + { + type: 'response.completed', + sequenceNumber: 1, + response: { + model: 'm', + output: [], + usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 }, + }, + }, + ]) + const adapter = createAdapter() + for await (const _ of chat({ + adapter, + messages: [ + { + role: 'user', + content: [ + { + type: 'image', + source: { type: 'data', value: 'aGVsbG8=' } as any, + } as any, + ], + }, + ], + })) { + // consume + } + const params = mockSend.mock.calls[0]![0].responsesRequest + const userMsg = params.input.find((i: any) => i.role === 'user') + const imgPart = userMsg.content.find((p: any) => p.type === 'input_image') + expect(imgPart).toBeDefined() + expect(imgPart.imageUrl).toBe( + 'data:application/octet-stream;base64,aGVsbG8=', + ) + }) + + it('routes video parts as input_video with camelCase videoUrl that survives Zod', async () => { + setupMockSdkClient([ + { + type: 'response.completed', + sequenceNumber: 1, + response: { + model: 'm', + output: [], + usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 }, + }, + }, + ]) + const adapter = createAdapter() + for await (const _ of chat({ + adapter, + messages: [ + { + role: 'user', + content: [ + { + type: 'video', + source: { value: 'https://example.com/v.mp4' } as any, + } as any, + ], + }, + ], + })) { + // consume + } + const params = mockSend.mock.calls[0]![0].responsesRequest + const userMsg = params.input.find((i: any) => i.role === 'user') + const videoPart = userMsg.content.find((p: any) => p.type === 'input_video') + expect(videoPart).toBeDefined() + expect(videoPart.videoUrl).toBe('https://example.com/v.mp4') + // The outbound schema would strip the camelCase videoUrl if the converter + // emitted snake_case (or any other key shape). + const serialized = ResponsesRequest$outboundSchema.parse(params) + expect(JSON.stringify(serialized)).toContain('https://example.com/v.mp4') + }) + + it('stringifies object-shaped assistant tool-call arguments for the SDK', async () => { + setupMockSdkClient([ + { + type: 'response.completed', + sequenceNumber: 1, + response: { + model: 'm', + output: [], + usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 }, + }, + }, + ]) + const adapter = createAdapter() + for await (const _ of chat({ + adapter, + messages: [ + { + role: 'assistant', + content: null, + toolCalls: [ + { + id: 'call_obj', + type: 'function', + function: { + name: 'lookup_weather', + arguments: { location: 'Berlin' } as any, + }, + }, + ], + }, + { role: 'tool', toolCallId: 'call_obj', content: '{"temp":72}' }, + ], + })) { + // consume + } + const params = mockSend.mock.calls[0]![0].responsesRequest + const fnCall = params.input.find( + (i: any) => i.type === 'function_call' && i.callId === 'call_obj', + ) + expect(fnCall).toBeDefined() + expect(typeof fnCall.arguments).toBe('string') + expect(JSON.parse(fnCall.arguments)).toEqual({ location: 'Berlin' }) + }) + + it('extracts text from array-shaped tool message content rather than JSON-stringifying parts', async () => { + setupMockSdkClient([ + { + type: 'response.completed', + sequenceNumber: 1, + response: { + model: 'm', + output: [], + usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 }, + }, + }, + ]) + const adapter = createAdapter() + for await (const _ of chat({ + adapter, + messages: [ + { + role: 'assistant', + content: null, + toolCalls: [ + { + id: 'call_arr', + type: 'function', + function: { name: 'lookup_weather', arguments: '{}' }, + }, + ], + }, + { + role: 'tool', + toolCallId: 'call_arr', + content: [ + { type: 'text', content: '{"temp":' } as any, + { type: 'text', content: '72}' } as any, + ] as any, + }, + ], + })) { + // consume + } + const params = mockSend.mock.calls[0]![0].responsesRequest + const fcOutput = params.input.find( + (i: any) => i.type === 'function_call_output', + ) + expect(fcOutput).toBeDefined() + expect(fcOutput.output).toBe('{"temp":72}') + expect(fcOutput.output).not.toContain('"type"') + }) + + it('throws on inline document data via chat-completions adapter (rejects base64 PDF inline)', async () => { + // Cross-adapter assertion: the chat-completions sibling must throw on + // inline document data so callers know to use the Responses adapter. + const { createOpenRouterText } = await import('../src/adapters/text') + const chatAdapter = createOpenRouterText('openai/gpt-4o-mini' as any, 'k') + const events: Array = [] + for await (const evt of chatAdapter.chatStream({ + model: 'openai/gpt-4o-mini', + messages: [ + { + role: 'user', + content: [ + { + type: 'document', + source: { type: 'data', value: 'aGVsbG8=' } as any, + } as any, + ], + }, + ], + logger: testLogger, + })) { + events.push(evt) + } + const runError = events.find( + (e): e is Extract => + e.type === EventType.RUN_ERROR, + ) + expect(runError).toBeDefined() + expect(runError!.message.toLowerCase()).toMatch( + /inline.*document|document.*inline|responses adapter/, + ) + }) +}) + +describe('OpenRouter responses adapter — stream event bridge', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('routes text deltas through TEXT_MESSAGE_* lifecycle', async () => { + setupMockSdkClient([ + { + type: 'response.created', + sequenceNumber: 0, + response: { model: 'm', output: [] }, + }, + { + type: 'response.output_text.delta', + sequenceNumber: 1, + itemId: 'msg_1', + outputIndex: 0, + contentIndex: 0, + delta: 'Hello ', + }, + { + type: 'response.output_text.delta', + sequenceNumber: 2, + itemId: 'msg_1', + outputIndex: 0, + contentIndex: 0, + delta: 'world', + }, + { + type: 'response.completed', + sequenceNumber: 3, + response: { + model: 'm', + output: [], + usage: { inputTokens: 1, outputTokens: 2, totalTokens: 3 }, + }, + }, + ]) + const adapter = createAdapter() + const chunks: Array = [] + for await (const c of chat({ + adapter, + messages: [{ role: 'user', content: 'hi' }], + })) { + chunks.push(c) + } + + const text = chunks.filter((c) => c.type === 'TEXT_MESSAGE_CONTENT') + expect(text.map((c: any) => c.delta)).toEqual(['Hello ', 'world']) + + const finished = chunks.find((c) => c.type === 'RUN_FINISHED') as any + expect(finished).toBeDefined() + // Usage shape is mapped from camel to snake before the base reads it. + expect(finished.usage).toEqual({ + promptTokens: 1, + completionTokens: 2, + totalTokens: 3, + }) + }) + + it('routes function-call args through TOOL_CALL_START/ARGS/END', async () => { + setupMockSdkClient([ + { + type: 'response.created', + sequenceNumber: 0, + response: { model: 'm', output: [] }, + }, + { + type: 'response.output_item.added', + sequenceNumber: 1, + outputIndex: 0, + item: { + type: 'function_call', + id: 'item_1', + callId: 'call_abc', + name: 'lookup_weather', + arguments: '', + }, + }, + { + type: 'response.function_call_arguments.delta', + sequenceNumber: 2, + itemId: 'item_1', + outputIndex: 0, + delta: '{"location":"Berlin"}', + }, + { + type: 'response.function_call_arguments.done', + sequenceNumber: 3, + itemId: 'item_1', + outputIndex: 0, + arguments: '{"location":"Berlin"}', + }, + { + type: 'response.completed', + sequenceNumber: 4, + response: { + model: 'm', + output: [{ type: 'function_call' }], + usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 }, + }, + }, + ]) + + const adapter = createAdapter() + const chunks: Array = [] + for await (const c of chat({ + adapter, + messages: [{ role: 'user', content: 'hi' }], + tools: [weatherTool], + })) { + chunks.push(c) + } + + const start = chunks.find((c) => c.type === 'TOOL_CALL_START') as any + expect(start).toMatchObject({ + type: 'TOOL_CALL_START', + toolCallId: 'item_1', + toolCallName: 'lookup_weather', + }) + + const args = chunks.filter((c) => c.type === 'TOOL_CALL_ARGS') as any[] + expect(args.length).toBe(1) + expect(args[0]!.delta).toBe('{"location":"Berlin"}') + + const end = chunks.find((c) => c.type === 'TOOL_CALL_END') as any + expect(end.input).toEqual({ location: 'Berlin' }) + + const finished = chunks.find((c) => c.type === 'RUN_FINISHED') as any + expect(finished.finishReason).toBe('tool_calls') + }) + + it('surfaces response.failed with a RUN_ERROR carrying the error message + code', async () => { + setupMockSdkClient([ + { + type: 'response.created', + sequenceNumber: 0, + response: { model: 'm', output: [] }, + }, + { + type: 'response.failed', + sequenceNumber: 1, + response: { + model: 'm', + output: [], + error: { message: 'kaboom', code: 'server_error' }, + }, + }, + ]) + const adapter = createAdapter() + const chunks: Array = [] + for await (const c of adapter.chatStream({ + model: 'openai/gpt-4o-mini' as any, + messages: [{ role: 'user', content: 'hi' }], + logger: testLogger, + })) { + chunks.push(c) + } + const err = chunks.find((c) => c.type === 'RUN_ERROR') as any + expect(err).toBeDefined() + expect(err.error.message).toBe('kaboom') + expect(err.error.code).toBe('server_error') + // RUN_ERROR is terminal — no synthetic RUN_FINISHED should follow. + expect(chunks.find((c) => c.type === 'RUN_FINISHED')).toBeUndefined() + }) + + it('stringifies non-string error.code on top-level error events', async () => { + setupMockSdkClient([ + { + type: 'response.created', + sequenceNumber: 0, + response: { model: 'm', output: [] }, + }, + { + type: 'error', + sequenceNumber: 1, + message: 'rate limit', + code: 429, + param: null, + }, + ]) + const adapter = createAdapter() + const chunks: Array = [] + for await (const c of adapter.chatStream({ + model: 'openai/gpt-4o-mini' as any, + messages: [{ role: 'user', content: 'hi' }], + logger: testLogger, + })) { + chunks.push(c) + } + const err = chunks.find((c) => c.type === 'RUN_ERROR') as any + expect(err).toBeDefined() + expect(err.error.code).toBe('429') + }) + + it('drops object-shaped error.code rather than shipping "[object Object]"', async () => { + setupMockSdkClient([ + { + type: 'response.created', + sequenceNumber: 0, + response: { model: 'm', output: [] }, + }, + { + type: 'response.failed', + sequenceNumber: 1, + response: { + model: 'm', + output: [], + error: { message: 'malformed', code: { nested: 'oops' } as any }, + }, + }, + ]) + const adapter = createAdapter() + const chunks: Array = [] + for await (const c of adapter.chatStream({ + model: 'openai/gpt-4o-mini' as any, + messages: [{ role: 'user', content: 'hi' }], + logger: testLogger, + })) { + chunks.push(c) + } + const err = chunks.find((c) => c.type === 'RUN_ERROR') as any + expect(err).toBeDefined() + expect(err.message).toBe('malformed') + // Object-shaped code must fall through to undefined rather than being + // stringified as "[object Object]" — the typeof narrowing matches + // normalizeCode's contract in toRunErrorPayload. + expect(err.code).toBeUndefined() + expect(err.error.code).toBeUndefined() + }) + + it('stringifies non-string error.code on response.failed events', async () => { + setupMockSdkClient([ + { + type: 'response.created', + sequenceNumber: 0, + response: { model: 'm', output: [] }, + }, + { + type: 'response.failed', + sequenceNumber: 1, + response: { + model: 'm', + output: [], + error: { message: 'upstream auth failed', code: 401 }, + }, + }, + ]) + const adapter = createAdapter() + const chunks: Array = [] + for await (const c of adapter.chatStream({ + model: 'openai/gpt-4o-mini' as any, + messages: [{ role: 'user', content: 'hi' }], + logger: testLogger, + })) { + chunks.push(c) + } + const err = chunks.find((c) => c.type === 'RUN_ERROR') as any + expect(err).toBeDefined() + expect(err.message).toBe('upstream auth failed') + // Provider code must survive as a string so `toRunErrorPayload`'s + // string-only `code` filter doesn't drop it on the way through. + expect(err.code).toBe('401') + expect(err.error.code).toBe('401') + }) + + it('does not emit further lifecycle events after a top-level error event', async () => { + setupMockSdkClient([ + { + type: 'response.created', + sequenceNumber: 0, + response: { model: 'm', output: [] }, + }, + { + type: 'response.output_item.added', + sequenceNumber: 1, + outputIndex: 0, + item: { type: 'message', id: 'msg_1', role: 'assistant' }, + }, + { + type: 'response.output_text.delta', + sequenceNumber: 2, + itemId: 'msg_1', + outputIndex: 0, + contentIndex: 0, + delta: 'partial ', + }, + // Top-level error mid-stream — terminal. + { + type: 'error', + sequenceNumber: 3, + message: 'rate limit', + code: 429, + param: null, + }, + // The adapter MUST NOT process anything after the error event; + // these chunks would otherwise yield TEXT_MESSAGE_CONTENT / END + // events past the terminal RUN_ERROR. + { + type: 'response.output_text.delta', + sequenceNumber: 4, + itemId: 'msg_1', + outputIndex: 0, + contentIndex: 0, + delta: 'after-error', + }, + { + type: 'response.output_text.done', + sequenceNumber: 5, + itemId: 'msg_1', + outputIndex: 0, + contentIndex: 0, + text: 'partial after-error', + }, + ]) + const adapter = createAdapter() + const chunks: Array = [] + for await (const c of adapter.chatStream({ + model: 'openai/gpt-4o-mini' as any, + messages: [{ role: 'user', content: 'hi' }], + logger: testLogger, + })) { + chunks.push(c) + } + + const errIndex = chunks.findIndex((c) => c.type === 'RUN_ERROR') + expect(errIndex).toBeGreaterThanOrEqual(0) + // No content/lifecycle events emitted after RUN_ERROR. + const post = chunks.slice(errIndex + 1) + expect(post).toEqual([]) + // The first delta's content reached the consumer; the second did not. + const allContent = chunks + .filter((c) => c.type === 'TEXT_MESSAGE_CONTENT') + .map((c: any) => c.delta) + .join('') + expect(allContent).toBe('partial ') + expect(allContent).not.toContain('after-error') + }) +}) + +describe('OpenRouter responses adapter — structured output', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('preserves null values in structured output (does not strip nulls)', async () => { + // Non-streaming Responses API result with a `null` field in the parsed + // JSON. The base default `transformStructuredOutput` would convert + // nulls to undefined; the OpenRouter override must keep them intact + // so consumers that discriminate "field present but null" from + // "field absent" see the null sentinel the upstream returned. + setupMockSdkClient([], { + id: 'resp_1', + model: 'openai/gpt-4o-mini', + output: [ + { + type: 'message', + id: 'msg_1', + role: 'assistant', + content: [ + { + type: 'output_text', + text: JSON.stringify({ + name: 'Alice', + age: 30, + nickname: null, + }), + }, + ], + }, + ], + usage: { inputTokens: 5, outputTokens: 2, totalTokens: 7 }, + }) + + const adapter = createAdapter() + const result = await adapter.structuredOutput({ + chatOptions: { + model: 'openai/gpt-4o-mini' as any, + messages: [{ role: 'user', content: 'profile?' }], + logger: testLogger, + }, + outputSchema: { + type: 'object', + properties: { + name: { type: 'string' }, + age: { type: 'number' }, + nickname: { type: ['string', 'null'] }, + }, + required: ['name', 'age', 'nickname'], + }, + }) + + expect(result.data).toEqual({ + name: 'Alice', + age: 30, + nickname: null, + }) + // Critical: nickname should be `null`, not `undefined`. + expect((result.data as any).nickname).toBeNull() + }) +}) + +describe('OpenRouter responses adapter — SDK constructor wiring', () => { + beforeEach(() => { + vi.clearAllMocks() + lastOpenRouterConfig = undefined + }) + + it('forwards app-attribution headers (httpReferer, appTitle) to the SDK constructor', () => { + void createOpenRouterResponsesText('openai/gpt-4o-mini', 'test-key', { + httpReferer: 'https://app.example.com', + appTitle: 'TestApp', + } as any) + expect(lastOpenRouterConfig).toBeDefined() + expect(lastOpenRouterConfig.apiKey).toBe('test-key') + expect(lastOpenRouterConfig.httpReferer).toBe('https://app.example.com') + expect(lastOpenRouterConfig.appTitle).toBe('TestApp') + }) + + it('propagates the abort signal to the SDK call', async () => { + setupMockSdkClient([ + { + type: 'response.completed', + sequenceNumber: 1, + response: { + model: 'm', + output: [], + usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 }, + }, + }, + ]) + const adapter = createAdapter() + const controller = new AbortController() + for await (const _ of adapter.chatStream({ + model: 'openai/gpt-4o-mini' as any, + messages: [{ role: 'user', content: 'hi' }], + logger: testLogger, + request: { signal: controller.signal } as any, + })) { + // consume + } + const [, options] = mockSend.mock.calls[0]! + expect(options.signal).toBe(controller.signal) + }) + + it('forwards caller-supplied request headers to the SDK call', async () => { + setupMockSdkClient([ + { + type: 'response.completed', + sequenceNumber: 1, + response: { + model: 'm', + output: [], + usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 }, + }, + }, + ]) + const adapter = createAdapter() + const headers = { 'X-Trace-Id': 'trace-r1' } + for await (const _ of adapter.chatStream({ + model: 'openai/gpt-4o-mini' as any, + messages: [{ role: 'user', content: 'hi' }], + logger: testLogger, + request: { headers } as any, + })) { + // consume + } + const [, options] = mockSend.mock.calls[0]! + expect(options.headers).toEqual(headers) + }) +}) diff --git a/packages/typescript/ai/src/activities/error-payload.ts b/packages/typescript/ai/src/activities/error-payload.ts index 396c5573d..3ab7d101d 100644 --- a/packages/typescript/ai/src/activities/error-payload.ts +++ b/packages/typescript/ai/src/activities/error-payload.ts @@ -5,16 +5,45 @@ * Accepts Error instances, objects with string-ish `message`/`code`, or bare * strings; always returns a shape safe to serialize. Never leaks the full * error object (which may carry request/response state from an SDK). + * + * Abort-shaped errors (DOM `AbortError`, OpenAI `APIUserAbortError`, + * OpenRouter `RequestAbortedError`) are normalized to a stable + * `{ message: 'Request aborted', code: 'aborted' }` shape so callers can + * discriminate user-initiated cancellation from other failures without + * matching on provider-specific message strings. */ +const ABORT_ERROR_NAMES = new Set([ + 'AbortError', + 'APIUserAbortError', + 'RequestAbortedError', +]) + +// HTTP status codes carried as numbers (e.g. `error.status = 429`) are a +// common variant on SDK error classes; coerce so the resulting `code` field +// is stable as a string for downstream consumers. +function normalizeCode(codeField: unknown): string | undefined { + if (typeof codeField === 'string') return codeField + if (typeof codeField === 'number' && Number.isFinite(codeField)) { + return String(codeField) + } + return undefined +} + export function toRunErrorPayload( error: unknown, fallbackMessage = 'Unknown error occurred', ): { message: string; code: string | undefined } { + if (error && typeof error === 'object') { + const name = (error as { name?: unknown }).name + if (typeof name === 'string' && ABORT_ERROR_NAMES.has(name)) { + return { message: 'Request aborted', code: 'aborted' } + } + } if (error instanceof Error) { const codeField = (error as Error & { code?: unknown }).code return { message: error.message || fallbackMessage, - code: typeof codeField === 'string' ? codeField : undefined, + code: normalizeCode(codeField), } } if (typeof error === 'object' && error !== null) { @@ -25,7 +54,7 @@ export function toRunErrorPayload( typeof messageField === 'string' && messageField.length > 0 ? messageField : fallbackMessage, - code: typeof codeField === 'string' ? codeField : undefined, + code: normalizeCode(codeField), } } if (typeof error === 'string' && error.length > 0) { diff --git a/packages/typescript/ai/src/activities/index.ts b/packages/typescript/ai/src/activities/index.ts index b69e8f423..69d06be22 100644 --- a/packages/typescript/ai/src/activities/index.ts +++ b/packages/typescript/ai/src/activities/index.ts @@ -60,6 +60,11 @@ export { type SummarizeAdapterConfig, type AnySummarizeAdapter, } from './summarize/adapter' +export { + ChatStreamSummarizeAdapter, + type ChatStreamCapable, + type InferTextProviderOptions, +} from './summarize/chat-stream-summarize' // =========================== // Image Activity diff --git a/packages/typescript/ai/src/activities/stream-generation-result.ts b/packages/typescript/ai/src/activities/stream-generation-result.ts index 2a2274cbd..deeacda8f 100644 --- a/packages/typescript/ai/src/activities/stream-generation-result.ts +++ b/packages/typescript/ai/src/activities/stream-generation-result.ts @@ -34,7 +34,7 @@ export async function* streamGenerationResult( runId, threadId, timestamp: Date.now(), - } as StreamChunk + } satisfies StreamChunk try { const result = await generator() @@ -44,7 +44,7 @@ export async function* streamGenerationResult( name: 'generation:result', value: result as unknown, timestamp: Date.now(), - } as StreamChunk + } satisfies StreamChunk yield { type: EventType.RUN_FINISHED, @@ -52,18 +52,16 @@ export async function* streamGenerationResult( threadId, finishReason: 'stop', timestamp: Date.now(), - } as StreamChunk + } satisfies StreamChunk } catch (error: unknown) { const payload = toRunErrorPayload(error, 'Generation failed') yield { type: EventType.RUN_ERROR, - runId, - threadId, message: payload.message, code: payload.code, // Deprecated nested form for backward compatibility error: payload, timestamp: Date.now(), - } as StreamChunk + } satisfies StreamChunk } } diff --git a/packages/typescript/ai/src/activities/summarize/adapter.ts b/packages/typescript/ai/src/activities/summarize/adapter.ts index 0c9beed91..2f7cc34f6 100644 --- a/packages/typescript/ai/src/activities/summarize/adapter.ts +++ b/packages/typescript/ai/src/activities/summarize/adapter.ts @@ -46,7 +46,9 @@ export interface SummarizeAdapter< /** * Summarize the given text */ - summarize: (options: SummarizationOptions) => Promise + summarize: ( + options: SummarizationOptions, + ) => Promise /** * Stream summarization of the given text. @@ -54,7 +56,7 @@ export interface SummarizeAdapter< * non-streaming summarize and yield the result as a single chunk. */ summarizeStream?: ( - options: SummarizationOptions, + options: SummarizationOptions, ) => AsyncIterable } @@ -91,7 +93,7 @@ export abstract class BaseSummarizeAdapter< } abstract summarize( - options: SummarizationOptions, + options: SummarizationOptions, ): Promise /** @@ -99,7 +101,9 @@ export abstract class BaseSummarizeAdapter< * Override this method in concrete implementations to enable streaming. * If not overridden, the activity layer will fall back to non-streaming. */ - summarizeStream?(options: SummarizationOptions): AsyncIterable + summarizeStream?( + options: SummarizationOptions, + ): AsyncIterable protected generateId(): string { return `${this.name}-${Date.now()}-${Math.random().toString(36).substring(7)}` diff --git a/packages/typescript/openai-base/src/adapters/summarize.ts b/packages/typescript/ai/src/activities/summarize/chat-stream-summarize.ts similarity index 59% rename from packages/typescript/openai-base/src/adapters/summarize.ts rename to packages/typescript/ai/src/activities/summarize/chat-stream-summarize.ts index fed92b296..534851824 100644 --- a/packages/typescript/openai-base/src/adapters/summarize.ts +++ b/packages/typescript/ai/src/activities/summarize/chat-stream-summarize.ts @@ -1,57 +1,69 @@ -import { BaseSummarizeAdapter } from '@tanstack/ai/adapters' -import { toRunErrorPayload } from '@tanstack/ai/adapter-internals' -import { generateId } from '@tanstack/ai-utils' +import { toRunErrorPayload } from '../error-payload' +import { BaseSummarizeAdapter } from './adapter' import type { StreamChunk, SummarizationOptions, SummarizationResult, TextOptions, -} from '@tanstack/ai' +} from '../../types' /** - * 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. + * Minimal contract for a text adapter that supports `chatStream`. Lets + * `ChatStreamSummarizeAdapter` work with any text adapter without coupling + * to a specific implementation. + * + * The provider-options shape is intentionally `any` here — the wrapper only + * forwards `modelOptions` straight through, so a text adapter with a richer + * per-model options type (e.g. `ResolveProviderOptions`) is still + * acceptable. Summarize-level type safety is enforced via + * `SummarizationOptions` on the wrapper itself. */ -export interface ChatStreamCapable { - chatStream: ( - options: TextOptions, - ) => AsyncIterable +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. + * Extract the per-model `modelOptions` type a text adapter accepts. Used by + * provider summarize factories so their `modelOptions` IntelliSense matches + * what the underlying text adapter actually understands. + */ +export type InferTextProviderOptions = TAdapter extends { + '~types': { providerOptions: infer P } +} + ? P extends object + ? P + : object + : object + +/** + * Summarize adapter that wraps any `ChatStreamCapable` text adapter and + * prompts it for summarization. Not tied to any wire format. */ -export class OpenAICompatibleSummarizeAdapter< +export class ChatStreamSummarizeAdapter< TModel extends string, TProviderOptions extends object = Record, > extends BaseSummarizeAdapter { readonly name: string - private textAdapter: ChatStreamCapable + private textAdapter: ChatStreamCapable constructor( - textAdapter: ChatStreamCapable, + textAdapter: ChatStreamCapable, model: TModel, - name: string = 'openai-compatible', + name: string = 'chat-stream-summarize', ) { super({}, model) this.name = name this.textAdapter = textAdapter } - async summarize(options: SummarizationOptions): Promise { + async summarize( + options: SummarizationOptions, + ): Promise { const systemPrompt = this.buildSummarizationPrompt(options) let summary = '' - const id = generateId(this.name) + const id = this.generateId() let model = options.model let usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 } @@ -61,14 +73,9 @@ export class OpenAICompatibleSummarizeAdapter< ) try { - for await (const chunk of this.textAdapter.chatStream({ - model: options.model, - messages: [{ role: 'user', content: options.text }], - systemPrompts: [systemPrompt], - maxTokens: options.maxLength, - temperature: 0.3, - logger: options.logger, - } satisfies TextOptions)) { + for await (const chunk of this.textAdapter.chatStream( + this.buildTextOptions(options, systemPrompt), + )) { if (chunk.type === 'TEXT_MESSAGE_CONTENT') { if (chunk.content) { summary = chunk.content @@ -117,7 +124,7 @@ export class OpenAICompatibleSummarizeAdapter< } async *summarizeStream( - options: SummarizationOptions, + options: SummarizationOptions, ): AsyncIterable { const systemPrompt = this.buildSummarizationPrompt(options) @@ -127,14 +134,9 @@ export class OpenAICompatibleSummarizeAdapter< ) try { - yield* this.textAdapter.chatStream({ - model: options.model, - messages: [{ role: 'user', content: options.text }], - systemPrompts: [systemPrompt], - maxTokens: options.maxLength, - temperature: 0.3, - logger: options.logger, - } satisfies TextOptions) + yield* this.textAdapter.chatStream( + this.buildTextOptions(options, systemPrompt), + ) } catch (error: unknown) { options.logger.errors(`${this.name}.summarizeStream fatal`, { error: toRunErrorPayload(error, `${this.name}.summarizeStream failed`), @@ -144,7 +146,30 @@ export class OpenAICompatibleSummarizeAdapter< } } - protected buildSummarizationPrompt(options: SummarizationOptions): string { + /** + * Build the TextOptions passed to the underlying chatStream. Provider + * `modelOptions` from the summarize call are forwarded as-is so knobs like + * Anthropic cache headers, Gemini safety settings, or Ollama tuning params + * still reach the wire layer. + */ + protected buildTextOptions( + options: SummarizationOptions, + systemPrompt: string, + ): TextOptions { + return { + model: options.model, + messages: [{ role: 'user', content: options.text }], + systemPrompts: [systemPrompt], + maxTokens: options.maxLength, + temperature: 0.3, + modelOptions: options.modelOptions, + logger: options.logger, + } + } + + protected buildSummarizationPrompt( + options: SummarizationOptions, + ): string { let prompt = 'You are a professional summarizer. ' switch (options.style) { diff --git a/packages/typescript/ai/src/activities/summarize/index.ts b/packages/typescript/ai/src/activities/summarize/index.ts index e73bd7532..f454ab6af 100644 --- a/packages/typescript/ai/src/activities/summarize/index.ts +++ b/packages/typescript/ai/src/activities/summarize/index.ts @@ -184,7 +184,7 @@ export function summarize< async function runSummarize( options: SummarizeActivityOptions, false>, ): Promise { - const { adapter, text, maxLength, style, focus } = options + const { adapter, text, maxLength, style, focus, modelOptions } = options const model = adapter.model const requestId = createId('summarize') const inputLength = text.length @@ -211,6 +211,7 @@ async function runSummarize( maxLength, style, focus, + modelOptions, logger, } @@ -253,7 +254,7 @@ async function runSummarize( async function* runStreamingSummarize( options: SummarizeActivityOptions, true>, ): AsyncIterable { - const { adapter, text, maxLength, style, focus } = options + const { adapter, text, maxLength, style, focus, modelOptions } = options const model = adapter.model const logger: InternalLogger = resolveDebugOption(options.debug) @@ -269,6 +270,7 @@ async function* runStreamingSummarize( maxLength, style, focus, + modelOptions, logger, } @@ -313,3 +315,8 @@ export type { AnySummarizeAdapter, } from './adapter' export { BaseSummarizeAdapter } from './adapter' +export { + ChatStreamSummarizeAdapter, + type ChatStreamCapable, + type InferTextProviderOptions, +} from './chat-stream-summarize' diff --git a/packages/typescript/ai/src/types.ts b/packages/typescript/ai/src/types.ts index ec87c3259..f66522b3a 100644 --- a/packages/typescript/ai/src/types.ts +++ b/packages/typescript/ai/src/types.ts @@ -830,7 +830,13 @@ export interface RunFinishedEvent extends AGUIRunFinishedEvent { /** Model identifier for multi-model support */ model?: string /** Why the generation stopped */ - finishReason?: 'stop' | 'length' | 'content_filter' | 'tool_calls' | null + finishReason?: + | 'stop' + | 'length' + | 'content_filter' + | 'tool_calls' + | 'function_call' + | null /** Token usage statistics */ usage?: { promptTokens: number @@ -1179,12 +1185,16 @@ export interface TextCompletionChunk { } } -export interface SummarizationOptions { +export interface SummarizationOptions< + TProviderOptions extends object = Record, +> { model: string text: string maxLength?: number style?: 'bullet-points' | 'paragraph' | 'concise' focus?: Array + /** Provider-specific options forwarded by the summarize() activity. */ + modelOptions?: TProviderOptions /** * Internal logger threaded from the summarize() entry point. Adapters must * call logger.request() before the SDK call and logger.errors() in catch blocks. diff --git a/packages/typescript/ai/tests/error-payload.test.ts b/packages/typescript/ai/tests/error-payload.test.ts index 1add82fc7..75d6bab96 100644 --- a/packages/typescript/ai/tests/error-payload.test.ts +++ b/packages/typescript/ai/tests/error-payload.test.ts @@ -34,17 +34,28 @@ describe('toRunErrorPayload', () => { ) }) - it('ignores non-string code fields (returns undefined)', () => { + it('coerces numeric code fields to strings', () => { expect(toRunErrorPayload({ message: 'x', code: 500 })).toEqual({ message: 'x', - code: undefined, + code: '500', }) }) - it('ignores non-string code fields on Error instances too', () => { - const err = Object.assign(new Error('numeric code'), { code: 500 }) + it('coerces numeric code fields on Error instances too', () => { + const err = Object.assign(new Error('http 429'), { code: 429 }) expect(toRunErrorPayload(err)).toEqual({ - message: 'numeric code', + message: 'http 429', + code: '429', + }) + }) + + it('ignores non-finite or otherwise non-string/non-number codes', () => { + expect(toRunErrorPayload({ message: 'nan', code: Number.NaN })).toEqual({ + message: 'nan', + code: undefined, + }) + expect(toRunErrorPayload({ message: 'sym', code: Symbol('x') })).toEqual({ + message: 'sym', code: undefined, }) }) @@ -73,4 +84,49 @@ describe('toRunErrorPayload', () => { expect(payload).toEqual({ message: 'leaky', code: undefined }) expect(payload).not.toHaveProperty('request') }) + + describe('abort normalization', () => { + it('normalizes DOM AbortError to code: aborted', () => { + const err = new Error('The operation was aborted') + err.name = 'AbortError' + expect(toRunErrorPayload(err)).toEqual({ + message: 'Request aborted', + code: 'aborted', + }) + }) + + it('normalizes OpenAI APIUserAbortError', () => { + const err = new Error('Request was aborted.') + err.name = 'APIUserAbortError' + expect(toRunErrorPayload(err)).toEqual({ + message: 'Request aborted', + code: 'aborted', + }) + }) + + it('normalizes OpenRouter RequestAbortedError', () => { + const err = new Error('Request aborted by client: AbortError: ...') + err.name = 'RequestAbortedError' + expect(toRunErrorPayload(err)).toEqual({ + message: 'Request aborted', + code: 'aborted', + }) + }) + + it('normalizes abort-named plain objects (non-Error throws)', () => { + const obj = { name: 'AbortError', message: 'whatever' } + expect(toRunErrorPayload(obj)).toEqual({ + message: 'Request aborted', + code: 'aborted', + }) + }) + + it('does not normalize errors with similar-looking names', () => { + const err = Object.assign(new Error('hi'), { name: 'NotAbortError' }) + expect(toRunErrorPayload(err)).toEqual({ + message: 'hi', + code: undefined, + }) + }) + }) }) diff --git a/packages/typescript/ai/tests/test-utils.ts b/packages/typescript/ai/tests/test-utils.ts index 73b239b93..365d4f80c 100644 --- a/packages/typescript/ai/tests/test-utils.ts +++ b/packages/typescript/ai/tests/test-utils.ts @@ -1,3 +1,4 @@ +import { EventType } from '../src/types' import type { AnyTextAdapter } from '../src/activities/chat/adapter' import type { StreamChunk, TextMessageContentEvent, Tool } from '../src/types' @@ -5,7 +6,9 @@ import type { StreamChunk, TextMessageContentEvent, Tool } from '../src/types' // Chunk factory // ============================================================================ -/** Create a typed StreamChunk with minimal boilerplate. */ +/** Escape hatch for tests that deliberately construct off-spec chunks (e.g. + * to exercise deprecated-field handling or malformed input). Prefer the + * strictly-typed `ev.*` builders below for normal cases. */ export function chunk( type: string, fields: Record = {}, @@ -20,32 +23,61 @@ export function chunk( /** Shorthand chunk factories for common AG-UI events. */ export const ev = { runStarted: (runId = 'run-1', threadId = 'thread-1') => - chunk('RUN_STARTED', { runId, threadId }), + ({ + type: EventType.RUN_STARTED, + runId, + threadId, + timestamp: Date.now(), + }) satisfies StreamChunk, textStart: (messageId = 'msg-1') => - chunk('TEXT_MESSAGE_START', { messageId, role: 'assistant' as const }), + ({ + type: EventType.TEXT_MESSAGE_START, + messageId, + role: 'assistant', + timestamp: Date.now(), + }) satisfies StreamChunk, textContent: (delta: string, messageId = 'msg-1') => - chunk('TEXT_MESSAGE_CONTENT', { messageId, delta }), - textEnd: (messageId = 'msg-1') => chunk('TEXT_MESSAGE_END', { messageId }), + ({ + type: EventType.TEXT_MESSAGE_CONTENT, + messageId, + delta, + timestamp: Date.now(), + }) satisfies StreamChunk, + textEnd: (messageId = 'msg-1') => + ({ + type: EventType.TEXT_MESSAGE_END, + messageId, + timestamp: Date.now(), + }) satisfies StreamChunk, toolStart: (toolCallId: string, toolCallName: string, index?: number) => - chunk('TOOL_CALL_START', { + ({ + type: EventType.TOOL_CALL_START, toolCallId, toolCallName, toolName: toolCallName, + timestamp: Date.now(), ...(index !== undefined ? { index } : {}), - }), + }) satisfies StreamChunk, toolArgs: (toolCallId: string, delta: string) => - chunk('TOOL_CALL_ARGS', { toolCallId, delta }), + ({ + type: EventType.TOOL_CALL_ARGS, + toolCallId, + delta, + timestamp: Date.now(), + }) satisfies StreamChunk, toolEnd: ( toolCallId: string, toolCallName: string, opts?: { input?: unknown; result?: string }, ) => - chunk('TOOL_CALL_END', { + ({ + type: EventType.TOOL_CALL_END, toolCallId, toolCallName, toolName: toolCallName, + timestamp: Date.now(), ...opts, - }), + }) satisfies StreamChunk, runFinished: ( finishReason: | 'stop' @@ -61,17 +93,35 @@ export const ev = { }, threadId = 'thread-1', ) => - chunk('RUN_FINISHED', { + ({ + type: EventType.RUN_FINISHED, runId, threadId, finishReason, + timestamp: Date.now(), ...(usage ? { usage } : {}), - }), - runError: (message: string, runId = 'run-1') => - chunk('RUN_ERROR', { message, runId, error: { message } }), - stepStarted: (stepName = 'step-1') => chunk('STEP_STARTED', { stepName }), + }) satisfies StreamChunk, + runError: (message: string) => + ({ + type: EventType.RUN_ERROR, + message, + timestamp: Date.now(), + error: { message }, + }) satisfies StreamChunk, + stepStarted: (stepName = 'step-1') => + ({ + type: EventType.STEP_STARTED, + stepName, + timestamp: Date.now(), + }) satisfies StreamChunk, stepFinished: (delta: string, stepName = 'step-1') => - chunk('STEP_FINISHED', { stepName, stepId: stepName, delta }), + ({ + type: EventType.STEP_FINISHED, + stepName, + stepId: stepName, + delta, + timestamp: Date.now(), + }) satisfies StreamChunk, } // ============================================================================ diff --git a/packages/typescript/openai-base/CHANGELOG.md b/packages/typescript/openai-base/CHANGELOG.md index dc37dc0f0..e6aaa3e75 100644 --- a/packages/typescript/openai-base/CHANGELOG.md +++ b/packages/typescript/openai-base/CHANGELOG.md @@ -1,5 +1,7 @@ # @tanstack/openai-base +> Renamed from `@tanstack/openai-base` in 0.3.0. See the [README](./README.md) for context. + ## 0.2.1 ### Patch Changes diff --git a/packages/typescript/openai-base/README.md b/packages/typescript/openai-base/README.md new file mode 100644 index 000000000..897be381a --- /dev/null +++ b/packages/typescript/openai-base/README.md @@ -0,0 +1,125 @@ +# @tanstack/openai-base + +Shared adapters for providers that implement OpenAI's wire-format protocols. + +> Renamed from `@tanstack/openai-base` in 0.3.0. The "base" name was misleading. +> See [Why this package exists](#why-this-package-exists). + +## TL;DR + +OpenAI authored two wire formats — `/v1/chat/completions` and `/v1/responses` — +that other vendors have implemented to varying degrees. This package contains +the shared logic for talking to **any** server that speaks one of those wire +formats. OpenAI is one such server. OpenRouter, Groq, Grok, vLLM, SGLang, +Together, Ollama's compat layer, Fireworks, and others are too. + +The package holds two shared base classes: + +- `OpenAIBaseChatCompletionsTextAdapter` +- `OpenAIBaseResponsesTextAdapter` + +Provider packages (`@tanstack/ai-openai`, `@tanstack/ai-openrouter`, +`@tanstack/ai-groq`, `@tanstack/ai-grok`) subclass these and override a small +set of protected hooks for SDK-shape variance. + +## Why this package exists + +The old name, `@tanstack/openai-base`, implied that OpenAI's evolving API +_was_ the contract — that everyone else inherits from OpenAI. That framing +broke down in two ways: + +1. **OpenAI doesn't define the protocol; the ecosystem does.** Many providers + ship `/v1/chat/completions` as their native API (Groq, Together, vLLM, + SGLang, Fireworks, Ollama's compat layer). When OpenAI ships a new field + that no other provider supports, that field belongs to _OpenAI's product_, + not to the protocol. +2. **The Responses API has the same shape.** OpenRouter's beta Responses + endpoint routes requests with OpenAI's Responses wire format to Claude, + Gemini, and other underlying models. So Responses is also a multi-vendor + protocol, not an OpenAI-only product surface. + +Calling the protocol "OpenAI-compatible" matches the actual industry term — +Vercel publishes `@ai-sdk/openai-compatible`, BentoML and Lightning AI docs +use the same phrase, LiteLLM calls them "OpenAI-compatible endpoints." There +is no neutral standard name; the protocol is named after the vendor who +originally shipped it. + +## What goes here vs. in `@tanstack/ai-openai` + +| Belongs in `@tanstack/openai-base` | Belongs in `@tanstack/ai-openai` | +| --------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | +| Logic for the Chat Completions wire format | OpenAI-specific tool types (`web_search_preview`, `code_interpreter`, `local_shell`, `apply_patch`, `computer_use`, `mcp`, …) | +| Logic for the Responses wire format | OpenAI model metadata, model lists, capability matrices | +| Streaming chunk assembly, AG-UI lifecycle, partial-JSON tool-arg buffering, tool-call deduplication | OpenAI-only request/response fields that no other vendor supports | +| Schema converters and structured-output coercion that all OpenAI-compatible servers accept | OpenAI's media adapters (image/TTS/video/transcription) that other providers don't implement | + +**Rule of thumb**: if you'd add a field here, it should be supported by at +least two OpenAI-compatible providers. Otherwise it belongs in the +provider's own package, plumbed in via a subclass override or a hook. + +## How providers extend the bases + +Subclasses customize SDK-shape variance via a small set of protected hook +methods: + +- `callChatCompletion`, `callChatCompletionStream` — substitute a different + SDK or HTTP client (OpenRouter uses `@openrouter/sdk` here; OpenAI and + Groq use the OpenAI SDK with a `baseURL` override). +- `convertMessage`, `mapOptionsToRequest` — bridge request-shape differences + (camelCase vs snake_case, additional provider fields). +- `extractReasoning` — surface a provider's reasoning channel into the + shared `REASONING_*` lifecycle. +- `transformStructuredOutput`, `makeStructuredOutputCompatible` — + adjust structured-output handling for provider quirks. +- `processStreamChunks` — wrap the shared chunk processor for last-mile + fixups (e.g. Groq's `x_groq.usage` → `chunk.usage`). + +Each provider typically overrides 2–6 hooks and inherits everything else. + +## Architecture context + +Every text adapter in TanStack AI — regardless of provider — emits +[AG-UI](https://github.com/CopilotKit/ag-ui) events (`RUN_STARTED`, +`TEXT_MESSAGE_*`, `TOOL_CALL_*`, `RUN_FINISHED`, …) as its output stream. +That is the _universal_ unification. + +Input protocols are different. The OpenAI-compatible family (this package) +has many implementers and warrants shared classes. Anthropic, Google Gemini, +and Ollama have single-provider input protocols, so their adapters extend +`BaseTextAdapter` from `@tanstack/ai` directly — no compatible family exists +because no compatible family exists. + +``` +@tanstack/ai +└── BaseTextAdapter (abstract — emits AG-UI events) + │ + ├── @tanstack/openai-base::OpenAIBaseChatCompletionsTextAdapter + │ ├── ai-openrouter + │ ├── ai-groq + │ └── ai-grok + │ + ├── @tanstack/openai-base::OpenAIBaseResponsesTextAdapter + │ ├── ai-openai (primary text adapter — Responses is OpenAI's preferred API) + │ └── ai-openrouter (beta — routes to any underlying model) + │ + ├── ai-anthropic::AnthropicTextAdapter extends BaseTextAdapter directly + ├── ai-gemini::GeminiTextAdapter extends BaseTextAdapter directly + └── ai-ollama::OllamaTextAdapter extends BaseTextAdapter directly +``` + +Note: `ai-openai` ships only the Responses-based adapter. For pure Chat +Completions use cases without OpenAI-specific behaviour, use `ai-grok` +(xAI's API is a direct OpenAI Chat Completions clone) or build a new +provider package extending `OpenAIBaseChatCompletionsTextAdapter`. + +## Direct use + +Most users don't import from this package directly; they install a provider +package and the adapter from there does the work. + +If you're building an adapter for a new OpenAI-compatible provider (vLLM, +Together, Fireworks, a self-hosted gateway, …), import the abstract +adapters from this package and subclass them. The existing providers are +worked examples — `@tanstack/ai-grok` is the simplest (xAI's API is a +direct OpenAI clone), `@tanstack/ai-openrouter` is the most heavily +overridden (different SDK, camelCase fields, multi-provider routing). diff --git a/packages/typescript/openai-base/package.json b/packages/typescript/openai-base/package.json index 95c91b039..e60b54b07 100644 --- a/packages/typescript/openai-base/package.json +++ b/packages/typescript/openai-base/package.json @@ -1,7 +1,7 @@ { "name": "@tanstack/openai-base", "version": "0.2.1", - "description": "Shared base adapters and utilities for OpenAI-compatible providers in TanStack AI", + "description": "Shared base adapters for OpenAI-SDK-backed providers in TanStack AI (Chat Completions and Responses)", "author": "", "license": "MIT", "repository": { @@ -36,8 +36,7 @@ "ai", "openai", "tanstack", - "adapter", - "base" + "adapter" ], "dependencies": { "@tanstack/ai-utils": "workspace:*", @@ -49,6 +48,7 @@ "devDependencies": { "@tanstack/ai": "workspace:*", "@vitest/coverage-v8": "4.0.14", - "vite": "^7.2.7" + "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 index ac014f619..83b73b191 100644 --- a/packages/typescript/openai-base/src/adapters/chat-completions-text.ts +++ b/packages/typescript/openai-base/src/adapters/chat-completions-text.ts @@ -1,15 +1,21 @@ +import { EventType } from '@tanstack/ai' import { BaseTextAdapter } from '@tanstack/ai/adapters' import { toRunErrorPayload } from '@tanstack/ai/adapter-internals' import { generateId, transformNullsToUndefined } from '@tanstack/ai-utils' -import { createOpenAICompatibleClient } from '../utils/client' import { extractRequestOptions } from '../utils/request-options' import { makeStructuredOutputCompatible } from '../utils/schema-converter' import { convertToolsToChatCompletionsFormat } from './chat-completions-tool-converter' +import type OpenAI from 'openai' import type { StructuredOutputOptions, StructuredOutputResult, } from '@tanstack/ai/adapters' -import type OpenAI_SDK from 'openai' +import type { + ChatCompletionChunk, + ChatCompletionContentPart, + ChatCompletionCreateParamsStreaming, + ChatCompletionMessageParam, +} from 'openai/resources/chat/completions/completions' import type { ContentPart, DefaultMessageMetadataByModality, @@ -18,29 +24,16 @@ import type { 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. + * Shared implementation of the OpenAI Chat Completions API. Holds the + * stream-accumulator + AG-UI lifecycle logic and calls the OpenAI SDK + * directly. Subclasses (ai-openai, ai-grok, ai-groq) construct an OpenAI + * client with their provider-specific `baseURL` / headers and pass it in. */ -export class OpenAICompatibleChatCompletionsTextAdapter< +export abstract class OpenAIBaseChatCompletionsTextAdapter< TModel extends string, - TProviderOptions extends Record = Record, + TProviderOptions extends Record = Record, TInputModalities extends ReadonlyArray = ReadonlyArray, TMessageMetadata extends DefaultMessageMetadataByModality = DefaultMessageMetadataByModality, @@ -54,34 +47,33 @@ export class OpenAICompatibleChatCompletionsTextAdapter< > { readonly kind = 'text' as const readonly name: string + protected client: OpenAI - protected client: OpenAI_SDK - - constructor( - config: OpenAICompatibleClientConfig, - model: TModel, - name: string = 'openai-compatible', - ) { + constructor(model: TModel, name: string, client: OpenAI) { super({}, model) this.name = name - this.client = createOpenAICompatibleClient(config) + this.client = client } 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), + threadId: options.threadId ?? generateId(this.name), messageId: generateId(this.name), - timestamp, hasEmittedRunStarted: false, } try { + // mapOptionsToRequest can throw (e.g. fail-loud guards in convertMessage + // for empty content or unsupported parts). Keep it inside the try so + // those failures surface as a single RUN_ERROR event, matching every + // other failure mode here — callers iterating chatStream then only need + // one error-handling path instead of both a try/catch around iteration + // and a RUN_ERROR handler. + const requestParams = this.mapOptionsToRequest(options) options.logger.request( `activity=chat provider=${this.name} model=${this.model} messages=${options.messages.length} tools=${options.tools?.length ?? 0} stream=true`, { provider: this.name, model: this.model }, @@ -107,22 +99,24 @@ export class OpenAICompatibleChatCompletionsTextAdapter< // Emit RUN_STARTED if not yet emitted if (!aguiState.hasEmittedRunStarted) { aguiState.hasEmittedRunStarted = true - yield asChunk({ - type: 'RUN_STARTED', + yield { + type: EventType.RUN_STARTED, runId: aguiState.runId, + threadId: aguiState.threadId, model: options.model, - timestamp, - }) + timestamp: Date.now(), + } satisfies StreamChunk } // Emit AG-UI RUN_ERROR - yield asChunk({ - type: 'RUN_ERROR', - runId: aguiState.runId, + yield { + type: EventType.RUN_ERROR, model: options.model, - timestamp, + timestamp: Date.now(), + message: errorPayload.message, + code: errorPayload.code, error: errorPayload, - }) + } satisfies StreamChunk options.logger.errors(`${this.name}.chatStream fatal`, { error: errorPayload, @@ -181,8 +175,16 @@ export class OpenAICompatibleChatCompletionsTextAdapter< extractRequestOptions(chatOptions.request), ) - // Extract text content from the response - const rawText = response.choices[0]?.message.content || '' + // Extract text content from the response. Fail loud on empty content + // rather than letting it cascade into a JSON-parse error on '' — the + // root cause (the model returned no content for the structured request) + // is then visible in logs. + const rawText = response.choices[0]?.message.content + if (typeof rawText !== 'string' || rawText.length === 0) { + throw new Error( + `${this.name}.structuredOutput: response contained no content`, + ) + } // Parse the JSON response let parsed: unknown @@ -195,8 +197,10 @@ export class OpenAICompatibleChatCompletionsTextAdapter< } // 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) + // Provider returns null for optional fields we made nullable in the schema. + // Subclasses can override `transformStructuredOutput` to skip this — e.g. + // OpenRouter historically passed nulls through unchanged. + const transformed = this.transformStructuredOutput(parsed) return { data: transformed, @@ -224,22 +228,43 @@ export class OpenAICompatibleChatCompletionsTextAdapter< return makeStructuredOutputCompatible(schema, originalRequired) } + /** + * Extract reasoning content from a stream chunk. Default returns + * `undefined` because the OpenAI Chat Completions chunk shape doesn't + * carry reasoning. The chunk param is typed `unknown` so an override can + * narrow to its own SDK chunk type without an `as` dance — the base only + * passes through `processStreamChunks`'s structurally-iterated chunk. + */ + protected extractReasoning(_chunk: unknown): { text: string } | undefined { + return undefined + } + + /** + * Final shaping pass applied to parsed structured-output JSON before it is + * returned to the caller. Default converts `null` values to `undefined` so + * the result aligns with the original Zod schema's optional-field + * semantics. Subclasses with different conventions (OpenRouter historically + * preserves nulls) can override. + */ + protected transformStructuredOutput(parsed: unknown): unknown { + return transformNullsToUndefined(parsed) + } + /** * 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, + stream: AsyncIterable, options: TextOptions, aguiState: { runId: string + threadId: string messageId: string - timestamp: number hasEmittedRunStarted: boolean }, ): AsyncIterable { let accumulatedContent = '' - const timestamp = aguiState.timestamp let hasEmittedTextMessageStart = false let lastModel: string | undefined // Track usage from any chunk that carries it. With @@ -248,11 +273,9 @@ export class OpenAICompatibleChatCompletionsTextAdapter< // earlier `finish_reason` chunk does NOT include token counts. We must // therefore defer RUN_FINISHED until the iterator is exhausted so we can // pick up usage from the trailing chunk regardless of arrival order. - let lastUsage: - | OpenAI_SDK.Chat.Completions.ChatCompletionChunk['usage'] - | undefined + let lastUsage: ChatCompletionChunk['usage'] | undefined let pendingFinishReason: - | OpenAI_SDK.Chat.Completions.ChatCompletionChunk.Choice['finish_reason'] + | ChatCompletionChunk['choices'][number]['finish_reason'] | undefined // Track tool calls being streamed (arguments come in chunks) @@ -265,6 +288,17 @@ export class OpenAICompatibleChatCompletionsTextAdapter< started: boolean // Track if TOOL_CALL_START has been emitted } >() + + // Reasoning lifecycle (driven by extractReasoning() hook — see method + // docs). The base wire format (OpenAI Chat Completions) has no reasoning, + // so these stay unused for openai/grok/groq. OpenRouter etc. opt in. + let reasoningMessageId: string | undefined + let hasClosedReasoning = false + // Legacy STEP_STARTED/STEP_FINISHED pair emitted alongside REASONING_* + // for back-compat with consumers (UI, devtools) that haven't migrated + // to the spec REASONING_* events yet. + let stepId: string | undefined + let accumulatedReasoning = '' // Track whether ANY tool call lifecycle was actually completed across the // entire stream. Lets us downgrade a `tool_calls` finish_reason to `stop` // when the upstream signalled tool calls but never produced a complete @@ -298,12 +332,56 @@ export class OpenAICompatibleChatCompletionsTextAdapter< // `hasEmittedRunStarted`). if (!aguiState.hasEmittedRunStarted) { aguiState.hasEmittedRunStarted = true - yield asChunk({ - type: 'RUN_STARTED', + yield { + type: EventType.RUN_STARTED, runId: aguiState.runId, + threadId: aguiState.threadId, + model: chunk.model || options.model, + timestamp: Date.now(), + } satisfies StreamChunk + } + + // Reasoning content (extractReasoning() hook). Run before reading + // choice/delta so reasoning-only chunks (no `choices`) still drive + // the REASONING_* lifecycle on providers that send reasoning out of + // band. The base default returns undefined. + const reasoning = this.extractReasoning(chunk) + if (reasoning && reasoning.text) { + if (!reasoningMessageId) { + reasoningMessageId = generateId(this.name) + stepId = generateId(this.name) + yield { + type: EventType.REASONING_START, + messageId: reasoningMessageId, + model: chunk.model || options.model, + timestamp: Date.now(), + } satisfies StreamChunk + yield { + type: EventType.REASONING_MESSAGE_START, + messageId: reasoningMessageId, + role: 'reasoning' as const, + model: chunk.model || options.model, + timestamp: Date.now(), + } satisfies StreamChunk + // Legacy STEP_STARTED (single emission, paired with the + // STEP_FINISHED below when reasoning closes). + yield { + type: EventType.STEP_STARTED, + stepName: stepId, + stepId, + model: chunk.model || options.model, + timestamp: Date.now(), + stepType: 'thinking', + } satisfies StreamChunk + } + accumulatedReasoning += reasoning.text + yield { + type: EventType.REASONING_MESSAGE_CONTENT, + messageId: reasoningMessageId, + delta: reasoning.text, model: chunk.model || options.model, - timestamp, - }) + timestamp: Date.now(), + } satisfies StreamChunk } const choice = chunk.choices[0] @@ -316,29 +394,57 @@ export class OpenAICompatibleChatCompletionsTextAdapter< // Handle content delta if (deltaContent) { + // Close reasoning before text starts so consumers see a clean + // REASONING_END before any TEXT_MESSAGE_START. + if (reasoningMessageId && !hasClosedReasoning) { + hasClosedReasoning = true + yield { + type: EventType.REASONING_MESSAGE_END, + messageId: reasoningMessageId, + model: chunk.model || options.model, + timestamp: Date.now(), + } satisfies StreamChunk + yield { + type: EventType.REASONING_END, + messageId: reasoningMessageId, + model: chunk.model || options.model, + timestamp: Date.now(), + } satisfies StreamChunk + if (stepId) { + yield { + type: EventType.STEP_FINISHED, + stepName: stepId, + stepId, + model: chunk.model || options.model, + timestamp: Date.now(), + content: accumulatedReasoning, + } satisfies StreamChunk + } + } + // Emit TEXT_MESSAGE_START on first text content if (!hasEmittedTextMessageStart) { hasEmittedTextMessageStart = true - yield asChunk({ - type: 'TEXT_MESSAGE_START', + yield { + type: EventType.TEXT_MESSAGE_START, messageId: aguiState.messageId, model: chunk.model || options.model, - timestamp, + timestamp: Date.now(), role: 'assistant', - }) + } satisfies StreamChunk } accumulatedContent += deltaContent // Emit AG-UI TEXT_MESSAGE_CONTENT - yield asChunk({ - type: 'TEXT_MESSAGE_CONTENT', + yield { + type: EventType.TEXT_MESSAGE_CONTENT, messageId: aguiState.messageId, model: chunk.model || options.model, - timestamp, + timestamp: Date.now(), delta: deltaContent, content: accumulatedContent, - }) + } satisfies StreamChunk } // Handle tool calls - they come in as deltas @@ -372,26 +478,26 @@ export class OpenAICompatibleChatCompletionsTextAdapter< // 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', + yield { + type: EventType.TOOL_CALL_START, toolCallId: toolCall.id, toolCallName: toolCall.name, toolName: toolCall.name, model: chunk.model || options.model, - timestamp, + timestamp: Date.now(), index, - }) + } satisfies StreamChunk } // Emit TOOL_CALL_ARGS for argument deltas if (toolCallDelta.function?.arguments && toolCall.started) { - yield asChunk({ - type: 'TOOL_CALL_ARGS', + yield { + type: EventType.TOOL_CALL_ARGS, toolCallId: toolCall.id, model: chunk.model || options.model, - timestamp, + timestamp: Date.now(), delta: toolCallDelta.function.arguments, - }) + } satisfies StreamChunk } } } @@ -445,15 +551,15 @@ export class OpenAICompatibleChatCompletionsTextAdapter< } // Emit AG-UI TOOL_CALL_END - yield asChunk({ - type: 'TOOL_CALL_END', + yield { + type: EventType.TOOL_CALL_END, toolCallId: toolCall.id, toolCallName: toolCall.name, toolName: toolCall.name, model: chunk.model || options.model, - timestamp, + timestamp: Date.now(), input: parsedInput, - }) + } satisfies StreamChunk emittedAnyToolCallEnd = true } // Clear tool-call state after emission so a subsequent @@ -464,12 +570,12 @@ export class OpenAICompatibleChatCompletionsTextAdapter< // Emit TEXT_MESSAGE_END if we had text content if (hasEmittedTextMessageStart) { - yield asChunk({ - type: 'TEXT_MESSAGE_END', + yield { + type: EventType.TEXT_MESSAGE_END, messageId: aguiState.messageId, model: chunk.model || options.model, - timestamp, - }) + timestamp: Date.now(), + } satisfies StreamChunk hasEmittedTextMessageStart = false } @@ -497,19 +603,36 @@ export class OpenAICompatibleChatCompletionsTextAdapter< try { const parsed: unknown = JSON.parse(toolCall.arguments) parsedInput = parsed && typeof parsed === 'object' ? parsed : {} - } catch { + } catch (parseError) { + // Mirror the finish_reason path's logger call — a truncated + // stream emitting malformed tool-call JSON would otherwise + // silently invoke the tool with `{}`, the exact failure the + // finish_reason logger was added to prevent. + options.logger.errors( + `${this.name}.processStreamChunks tool-args JSON parse failed (drain)`, + { + error: toRunErrorPayload( + parseError, + `tool ${toolCall.name} (${toolCall.id}) returned malformed JSON arguments`, + ), + source: `${this.name}.processStreamChunks`, + toolCallId: toolCall.id, + toolName: toolCall.name, + rawArguments: toolCall.arguments, + }, + ) parsedInput = {} } } - yield asChunk({ - type: 'TOOL_CALL_END', + yield { + type: EventType.TOOL_CALL_END, toolCallId: toolCall.id, toolCallName: toolCall.name, toolName: toolCall.name, model: lastModel || options.model, - timestamp, + timestamp: Date.now(), input: parsedInput, - }) + } satisfies StreamChunk pendingToolCount += 1 emittedAnyToolCallEnd = true } @@ -518,12 +641,40 @@ export class OpenAICompatibleChatCompletionsTextAdapter< // Make sure the text message lifecycle is closed even on early // termination paths where finish_reason never arrives. if (hasEmittedTextMessageStart) { - yield asChunk({ - type: 'TEXT_MESSAGE_END', + yield { + type: EventType.TEXT_MESSAGE_END, messageId: aguiState.messageId, model: lastModel || options.model, - timestamp, - }) + timestamp: Date.now(), + } satisfies StreamChunk + } + + // Close any reasoning lifecycle that text never closed (no text + // content arrived, or the stream cut off before text started). + if (reasoningMessageId && !hasClosedReasoning) { + hasClosedReasoning = true + yield { + type: EventType.REASONING_MESSAGE_END, + messageId: reasoningMessageId, + model: lastModel || options.model, + timestamp: Date.now(), + } satisfies StreamChunk + yield { + type: EventType.REASONING_END, + messageId: reasoningMessageId, + model: lastModel || options.model, + timestamp: Date.now(), + } satisfies StreamChunk + if (stepId) { + yield { + type: EventType.STEP_FINISHED, + stepName: stepId, + stepId, + model: lastModel || options.model, + timestamp: Date.now(), + content: accumulatedReasoning, + } satisfies StreamChunk + } } // Map upstream finish_reason to AG-UI's narrower vocabulary while @@ -534,17 +685,18 @@ export class OpenAICompatibleChatCompletionsTextAdapter< // `tool_calls` but never produced a started/ended pair must NOT // surface `tool_calls` here, since downstream consumers wait for // tool results that would never arrive. - const finishReason: string = emittedAnyToolCallEnd + const finishReason = emittedAnyToolCallEnd ? 'tool_calls' : pendingFinishReason === 'tool_calls' ? 'stop' : (pendingFinishReason ?? 'stop') - yield asChunk({ - type: 'RUN_FINISHED', + yield { + type: EventType.RUN_FINISHED, runId: aguiState.runId, + threadId: aguiState.threadId, model: lastModel || options.model, - timestamp, + timestamp: Date.now(), usage: lastUsage ? { promptTokens: lastUsage.prompt_tokens || 0, @@ -553,7 +705,7 @@ export class OpenAICompatibleChatCompletionsTextAdapter< } : undefined, finishReason, - }) + } satisfies StreamChunk } } catch (error: unknown) { // Narrow before logging: raw SDK errors can carry request metadata @@ -568,13 +720,14 @@ export class OpenAICompatibleChatCompletionsTextAdapter< }) // Emit AG-UI RUN_ERROR - yield asChunk({ - type: 'RUN_ERROR', - runId: aguiState.runId, + yield { + type: EventType.RUN_ERROR, model: options.model, - timestamp, + timestamp: Date.now(), + message: errorPayload.message, + code: errorPayload.code, error: errorPayload, - }) + } satisfies StreamChunk } } @@ -584,7 +737,7 @@ export class OpenAICompatibleChatCompletionsTextAdapter< */ protected mapOptionsToRequest( options: TextOptions, - ): OpenAI_SDK.Chat.Completions.ChatCompletionCreateParamsStreaming { + ): ChatCompletionCreateParamsStreaming { const tools = options.tools ? convertToolsToChatCompletionsFormat( options.tools, @@ -593,8 +746,7 @@ export class OpenAICompatibleChatCompletionsTextAdapter< : undefined // Build messages array with system prompts - const messages: Array = - [] + const messages: Array = [] // Add system prompts first if (options.systemPrompts && options.systemPrompts.length > 0) { @@ -641,9 +793,7 @@ export class OpenAICompatibleChatCompletionsTextAdapter< * 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 { + protected convertMessage(message: ModelMessage): ChatCompletionMessageParam { // Handle tool messages if (message.role === 'tool') { return { @@ -709,8 +859,7 @@ export class OpenAICompatibleChatCompletionsTextAdapter< // content parts rather than silently dropping them — a message of all // unsupported parts would otherwise turn into an empty user prompt and // mask a real capability mismatch. - const parts: Array = - [] + const parts: Array = [] for (const part of contentParts) { const converted = this.convertContentPart(part) if (!converted) { @@ -746,7 +895,7 @@ export class OpenAICompatibleChatCompletionsTextAdapter< */ protected convertContentPart( part: ContentPart, - ): OpenAI_SDK.Chat.Completions.ChatCompletionContentPart | null { + ): ChatCompletionContentPart | null { if (part.type === 'text') { return { type: 'text', text: part.content } } 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 index 2a83eaae3..bb691443d 100644 --- a/packages/typescript/openai-base/src/adapters/chat-completions-tool-converter.ts +++ b/packages/typescript/openai-base/src/adapters/chat-completions-tool-converter.ts @@ -1,13 +1,17 @@ import { makeStructuredOutputCompatible } from '../utils/schema-converter' +import type { ChatCompletionTool } from 'openai/resources/chat/completions/completions' 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. + * Chat Completions API tool format. The SDK's `ChatCompletionTool` is the + * union `ChatCompletionFunctionTool | ChatCompletionCustomTool`; we only + * emit the function variant here. Re-exported as our own alias so consumers + * importing the converter's output don't have to reach into the SDK. */ -export type ChatCompletionFunctionTool = - OpenAI.Chat.Completions.ChatCompletionTool +export type ChatCompletionFunctionTool = Extract< + ChatCompletionTool, + { type: 'function' } +> /** * Converts a standard Tool to OpenAI Chat Completions ChatCompletionTool format. diff --git a/packages/typescript/openai-base/src/adapters/image.ts b/packages/typescript/openai-base/src/adapters/image.ts deleted file mode 100644 index 89b8f283f..000000000 --- a/packages/typescript/openai-base/src/adapters/image.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { BaseImageAdapter } from '@tanstack/ai/adapters' -import { toRunErrorPayload } from '@tanstack/ai/adapter-internals' -import { generateId } from '@tanstack/ai-utils' -import { createOpenAICompatibleClient } from '../utils/client' -import type { - GeneratedImage, - ImageGenerationOptions, - ImageGenerationResult, -} from '@tanstack/ai' -import type OpenAI_SDK from 'openai' -import type { OpenAICompatibleClientConfig } from '../types/config' - -/** - * OpenAI-Compatible Image Generation Adapter - * - * A generalized base class for providers that implement OpenAI-compatible image - * generation APIs. Providers like OpenAI, Grok, and others can extend this class - * and only need to: - * - Set `baseURL` in the config - * - Lock the generic type parameters to provider-specific types - * - Override validation or request building methods for provider-specific constraints - * - * All methods that validate inputs, build requests, or transform responses are - * `protected` so subclasses can override them. - */ -export class OpenAICompatibleImageAdapter< - TModel extends string, - TProviderOptions extends object = Record, - TModelProviderOptionsByName extends Record = Record, - TModelSizeByName extends Record = Record, -> extends BaseImageAdapter< - TModel, - TProviderOptions, - TModelProviderOptionsByName, - TModelSizeByName -> { - readonly kind = 'image' as const - readonly name: string - - protected client: OpenAI_SDK - - constructor( - config: OpenAICompatibleClientConfig, - model: TModel, - name: string = 'openai-compatible', - ) { - super(model, {}) - this.name = name - this.client = createOpenAICompatibleClient(config) - } - - async generateImages( - options: ImageGenerationOptions, - ): Promise { - const { model, prompt, numberOfImages, size } = options - - // Validate inputs - this.validatePrompt({ prompt, model }) - this.validateImageSize(model, size) - this.validateNumberOfImages(model, numberOfImages) - - // Build request based on model type - const request = this.buildRequest(options) - - try { - options.logger.request( - `activity=image provider=${this.name} model=${model} n=${request.n ?? 1} size=${request.size ?? 'default'}`, - { provider: this.name, model }, - ) - const response = await this.client.images.generate({ - ...request, - stream: false, - }) - - return this.transformResponse(model, response) - } catch (error: unknown) { - // Narrow before logging: raw SDK errors can carry request metadata - // (including auth headers) which we must never surface to user loggers. - options.logger.errors(`${this.name}.generateImages fatal`, { - error: toRunErrorPayload(error, `${this.name}.generateImages failed`), - source: `${this.name}.generateImages`, - }) - throw error - } - } - - protected buildRequest( - options: ImageGenerationOptions, - ): OpenAI_SDK.Images.ImageGenerateParams { - const { model, prompt, numberOfImages, size, modelOptions } = options - - return { - model, - prompt, - n: numberOfImages ?? 1, - size: size as OpenAI_SDK.Images.ImageGenerateParams['size'], - ...modelOptions, - } - } - - protected transformResponse( - model: string, - response: OpenAI_SDK.Images.ImagesResponse, - ): ImageGenerationResult { - const images: Array = (response.data ?? []).flatMap( - (item): Array => { - const revisedPrompt = item.revised_prompt - if (item.b64_json) { - return [{ b64Json: item.b64_json, revisedPrompt }] - } - if (item.url) { - return [{ url: item.url, revisedPrompt }] - } - return [] - }, - ) - - return { - id: generateId(this.name), - model, - images, - usage: response.usage - ? { - inputTokens: response.usage.input_tokens, - outputTokens: response.usage.output_tokens, - totalTokens: response.usage.total_tokens, - } - : undefined, - } - } - - protected validatePrompt(options: { prompt: string; model: string }): void { - if (options.prompt.length === 0) { - throw new Error('Prompt cannot be empty.') - } - } - - protected validateImageSize(_model: string, _size: string | undefined): void { - // Default: no size validation — subclasses can override - } - - protected validateNumberOfImages( - _model: string, - numberOfImages: number | undefined, - ): void { - if (numberOfImages === undefined) return - - // The base adapter only enforces "must be at least 1". Per-provider / - // per-model upper bounds vary widely (some support 4, some 10, some - // unlimited), so concrete adapter subclasses are expected to override - // this method with a model-specific cap. - if (numberOfImages < 1) { - throw new Error( - `Number of images must be at least 1. Requested: ${numberOfImages}`, - ) - } - } -} diff --git a/packages/typescript/openai-base/src/adapters/responses-text.ts b/packages/typescript/openai-base/src/adapters/responses-text.ts index 48faadd21..609cf8087 100644 --- a/packages/typescript/openai-base/src/adapters/responses-text.ts +++ b/packages/typescript/openai-base/src/adapters/responses-text.ts @@ -1,16 +1,22 @@ +import { EventType } from '@tanstack/ai' import { BaseTextAdapter } from '@tanstack/ai/adapters' import { toRunErrorPayload } from '@tanstack/ai/adapter-internals' import { generateId, transformNullsToUndefined } from '@tanstack/ai-utils' -import { createOpenAICompatibleClient } from '../utils/client' import { extractRequestOptions } from '../utils/request-options' import { makeStructuredOutputCompatible } from '../utils/schema-converter' import { convertToolsToResponsesFormat } from './responses-tool-converter' +import type OpenAI from 'openai' import type { StructuredOutputOptions, StructuredOutputResult, } from '@tanstack/ai/adapters' -import type OpenAI_SDK from 'openai' -import type { Responses } from 'openai/resources' +import type { + Response, + ResponseCreateParams, + ResponseInput, + ResponseInputContent, + ResponseStreamEvent, +} from 'openai/resources/responses/responses' import type { ContentPart, DefaultMessageMetadataByModality, @@ -19,39 +25,16 @@ import type { 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. + * Shared implementation of the OpenAI Responses API. Holds the stream-event + * accumulator + AG-UI lifecycle and calls the OpenAI SDK directly. Subclasses + * (today: ai-openai) construct an OpenAI client with their provider-specific + * `baseURL` / headers and pass it in. */ -export class OpenAICompatibleResponsesTextAdapter< +export abstract class OpenAIBaseResponsesTextAdapter< TModel extends string, - TProviderOptions extends Record = Record, + TProviderOptions extends Record = Record, TInputModalities extends ReadonlyArray = ReadonlyArray, TMessageMetadata extends DefaultMessageMetadataByModality = DefaultMessageMetadataByModality, @@ -65,17 +48,12 @@ export class OpenAICompatibleResponsesTextAdapter< > { readonly kind = 'text' as const readonly name: string + protected client: OpenAI - protected client: OpenAI_SDK - - constructor( - config: OpenAICompatibleClientConfig, - model: TModel, - name: string = 'openai-compatible-responses', - ) { + constructor(model: TModel, name: string, client: OpenAI) { super({}, model) this.name = name - this.client = createOpenAICompatibleClient(config) + this.client = client } async *chatStream( @@ -87,20 +65,34 @@ export class OpenAICompatibleResponsesTextAdapter< // We assign our own indices as we encounter unique tool call IDs. const toolCallMetadata = new Map< string, - { index: number; name: string; started: boolean } + { + index: number + name: string + started: boolean + // Set once TOOL_CALL_END has been emitted (via args.done or the + // output_item.done backfill) so the two paths don't double-emit. + ended?: boolean + // Set when args.done arrives before TOOL_CALL_START could fire + // (output_item.added lacked a name). output_item.done picks these + // up to emit the missing END. + pendingArguments?: string + } >() - const requestParams = this.mapOptionsToRequest(options) - const timestamp = Date.now() // AG-UI lifecycle tracking const aguiState = { runId: generateId(this.name), + threadId: options.threadId ?? generateId(this.name), messageId: generateId(this.name), - timestamp, hasEmittedRunStarted: false, } try { + // mapOptionsToRequest can throw on caller-side validation failures + // (empty user content, unsupported parts, webSearchTool() rejection in + // the OpenRouter override). Keep it inside the try so those failures + // surface as RUN_ERROR events instead of iterator throws. + const requestParams = this.mapOptionsToRequest(options) options.logger.request( `activity=chat provider=${this.name} model=${this.model} messages=${options.messages.length} tools=${options.tools?.length ?? 0} stream=true`, { provider: this.name, model: this.model }, @@ -130,22 +122,24 @@ export class OpenAICompatibleResponsesTextAdapter< // Emit RUN_STARTED if not yet emitted if (!aguiState.hasEmittedRunStarted) { aguiState.hasEmittedRunStarted = true - yield asChunk({ - type: 'RUN_STARTED', + yield { + type: EventType.RUN_STARTED, runId: aguiState.runId, + threadId: aguiState.threadId, model: options.model, - timestamp, - }) + timestamp: Date.now(), + } satisfies StreamChunk } // Emit AG-UI RUN_ERROR - yield asChunk({ - type: 'RUN_ERROR', - runId: aguiState.runId, + yield { + type: EventType.RUN_ERROR, model: options.model, - timestamp, + timestamp: Date.now(), + message: errorPayload.message, + code: errorPayload.code, error: errorPayload, - }) + } satisfies StreamChunk options.logger.errors(`${this.name}.chatStream fatal`, { error: errorPayload, @@ -195,10 +189,7 @@ export class OpenAICompatibleResponsesTextAdapter< ) const response = await this.client.responses.create( { - ...(cleanParams as Omit< - OpenAI_SDK.Responses.ResponseCreateParams, - 'stream' - >), + ...(cleanParams as Omit), stream: false, // Configure structured output via text.format text: { @@ -217,9 +208,17 @@ export class OpenAICompatibleResponsesTextAdapter< // SDK return type to `Response`, but the explicit annotation makes // that contract local rather than relying on inference through the // overloaded `client.responses.create` signature. - const rawText = this.extractTextFromResponse( - response satisfies OpenAI_SDK.Responses.Response, - ) + const rawText = this.extractTextFromResponse(response satisfies Response) + + // Fail loud on empty content rather than letting it cascade into a + // confusing "Failed to parse JSON. Content: " error — the root cause + // (the model returned no text content for the structured request) is + // then visible in logs. Mirrors the chat-completions sibling. + if (rawText.length === 0) { + throw new Error( + `${this.name}.structuredOutput: response contained no content`, + ) + } // Parse the JSON response let parsed: unknown @@ -231,9 +230,13 @@ export class OpenAICompatibleResponsesTextAdapter< ) } - // 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) + // Apply the provider-specific post-parse shaping (default: null → + // undefined to align with the original Zod schema's optional-field + // semantics; subclasses with different conventions can override + // `transformStructuredOutput`, mirroring the chat-completions base's + // hook so OpenRouter and other providers that preserve nulls in + // structured output can opt out without forking `structuredOutput`). + const transformed = this.transformStructuredOutput(parsed) return { data: transformed, @@ -261,26 +264,49 @@ export class OpenAICompatibleResponsesTextAdapter< return makeStructuredOutputCompatible(schema, originalRequired) } + /** + * Final shaping pass applied to parsed structured-output JSON before it is + * returned to the caller. Default converts `null` values to `undefined` so + * the result aligns with the original Zod schema's optional-field + * semantics. Subclasses with different conventions (OpenRouter historically + * preserves nulls) can override — mirrors the chat-completions base's hook + * so a subclass that opts out of null-stripping doesn't have to fork the + * whole `structuredOutput` method. + */ + protected transformStructuredOutput(parsed: unknown): unknown { + return transformNullsToUndefined(parsed) + } + /** * 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 { + protected extractTextFromResponse(response: Response): string { let textContent = '' let refusal: string | undefined + let sawMessageItem = false + const observedItemTypes = new Set() for (const item of response.output) { + observedItemTypes.add(item.type) if (item.type === 'message') { + sawMessageItem = true for (const part of item.content) { - if (part.type === 'output_text') { - textContent += part.text + // Cast off the discriminated union before the type discrimination + // so future SDK variants (e.g. `output_audio`, `output_image`) hit + // the explicit error path rather than being misreported as refusals + // when they get added to the union. Mirrors the streaming side's + // handleContentPart. + const partType = (part as { type: string }).type + if (partType === 'output_text') { + textContent += (part as { text?: string }).text ?? '' + } else if (partType === 'refusal') { + const refusalText = (part as { refusal?: string }).refusal + refusal = refusalText || refusal || 'Refused without explanation' } else { - // The Responses SDK currently models message content as - // `output_text | refusal`, so the only non-text branch is a - // refusal. Capture it so we can surface a distinct error below. - refusal = part.refusal || refusal || 'Refused without explanation' + throw new Error( + `${this.name}.extractTextFromResponse: unsupported message content part type "${partType}"`, + ) } } } @@ -295,6 +321,16 @@ export class OpenAICompatibleResponsesTextAdapter< throw err } + // Response had items but none carried message text (e.g. only + // function_call or reasoning items). Surface that explicitly so a + // downstream structured-output caller doesn't see a misleading + // "Failed to parse JSON. Content: " from an empty string. + if (!textContent && response.output.length > 0 && !sawMessageItem) { + throw new Error( + `${this.name}.extractTextFromResponse: response.output contained items of type(s) [${[...observedItemTypes].sort().join(', ')}] but no message text — the model returned a non-text response`, + ) + } + return textContent } @@ -314,22 +350,27 @@ export class OpenAICompatibleResponsesTextAdapter< * - error */ protected async *processStreamChunks( - stream: AsyncIterable, + stream: AsyncIterable, toolCallMetadata: Map< string, - { index: number; name: string; started: boolean } + { + index: number + name: string + started: boolean + ended?: boolean + pendingArguments?: string + } >, options: TextOptions, aguiState: { runId: string + threadId: string messageId: string - timestamp: number hasEmittedRunStarted: boolean }, ): AsyncIterable { let accumulatedContent = '' let accumulatedReasoning = '' - const timestamp = aguiState.timestamp // Track if we've been streaming deltas to avoid duplicating content from done events let hasStreamedContentDeltas = false @@ -357,12 +398,13 @@ export class OpenAICompatibleResponsesTextAdapter< // Emit RUN_STARTED on first chunk if (!aguiState.hasEmittedRunStarted) { aguiState.hasEmittedRunStarted = true - yield asChunk({ - type: 'RUN_STARTED', + yield { + type: EventType.RUN_STARTED, runId: aguiState.runId, + threadId: aguiState.threadId, model: model || options.model, - timestamp, - }) + timestamp: Date.now(), + } satisfies StreamChunk } const handleContentPart = (contentPart: { @@ -372,14 +414,14 @@ export class OpenAICompatibleResponsesTextAdapter< }): StreamChunk => { if (contentPart.type === 'output_text') { accumulatedContent += contentPart.text || '' - return asChunk({ - type: 'TEXT_MESSAGE_CONTENT', + return { + type: EventType.TEXT_MESSAGE_CONTENT, messageId: aguiState.messageId, model: model || options.model, - timestamp, + timestamp: Date.now(), delta: contentPart.text || '', content: accumulatedContent, - }) + } satisfies StreamChunk } if (contentPart.type === 'reasoning_text') { @@ -391,14 +433,15 @@ export class OpenAICompatibleResponsesTextAdapter< if (!stepId) { stepId = generateId(this.name) } - return asChunk({ - type: 'STEP_FINISHED', + return { + type: EventType.STEP_FINISHED, + stepName: stepId, stepId, model: model || options.model, - timestamp, + timestamp: Date.now(), delta: contentPart.text || '', content: accumulatedReasoning, - }) + } satisfies StreamChunk } // Either a real refusal or an unknown content_part type. Surface // the part type in the error so unknown parts are debuggable @@ -407,16 +450,15 @@ export class OpenAICompatibleResponsesTextAdapter< const message = isRefusal ? contentPart.refusal || 'Refused without explanation' : `Unsupported response content_part type: ${contentPart.type}` - return asChunk({ - type: 'RUN_ERROR', - runId: aguiState.runId, + const code = isRefusal ? 'refusal' : contentPart.type + return { + type: EventType.RUN_ERROR, model: model || options.model, - timestamp, - error: { - message, - code: isRefusal ? 'refusal' : contentPart.type, - }, - }) + timestamp: Date.now(), + message, + code, + error: { message, code }, + } satisfies StreamChunk } // Capture model metadata from any of these events (created starts @@ -451,12 +493,12 @@ export class OpenAICompatibleResponsesTextAdapter< chunk.type === 'response.incomplete' ) { if (hasEmittedTextMessageStart) { - yield asChunk({ - type: 'TEXT_MESSAGE_END', + yield { + type: EventType.TEXT_MESSAGE_END, messageId: aguiState.messageId, model: chunk.response.model, - timestamp, - }) + timestamp: Date.now(), + } satisfies StreamChunk hasEmittedTextMessageStart = false } // Coalesce error + incomplete_details into a single RUN_ERROR @@ -469,23 +511,25 @@ export class OpenAICompatibleResponsesTextAdapter< ? 'Response failed' : 'Response ended incomplete') const errorCode = - chunk.response.error?.code || - (chunk.response.incomplete_details ? 'incomplete' : undefined) + chunk.response.error?.code ?? + (chunk.response.incomplete_details ? 'incomplete' : undefined) ?? + undefined // Always emit RUN_ERROR for terminal failure events, even when the // upstream omitted both `error` and `incomplete_details`. Skipping // emission on a `response.incomplete` with no detail would let the // post-loop synthetic block silently coerce the run to a clean // `RUN_FINISHED { finishReason: 'stop' }` — masking the failure. - yield asChunk({ - type: 'RUN_ERROR', - runId: aguiState.runId, + yield { + type: EventType.RUN_ERROR, model: chunk.response.model, - timestamp, + timestamp: Date.now(), + message: errorMessage, + ...(errorCode !== undefined && { code: errorCode }), error: { message: errorMessage, ...(errorCode !== undefined && { code: errorCode }), }, - }) + } satisfies StreamChunk // RUN_ERROR is the terminal event for this run; stop processing // any further chunks the iterator might still deliver. runFinishedEmitted = true @@ -506,25 +550,25 @@ export class OpenAICompatibleResponsesTextAdapter< // Emit TEXT_MESSAGE_START on first text content if (!hasEmittedTextMessageStart) { hasEmittedTextMessageStart = true - yield asChunk({ - type: 'TEXT_MESSAGE_START', + yield { + type: EventType.TEXT_MESSAGE_START, messageId: aguiState.messageId, model: model || options.model, - timestamp, + timestamp: Date.now(), role: 'assistant', - }) + } satisfies StreamChunk } accumulatedContent += textDelta hasStreamedContentDeltas = true - yield asChunk({ - type: 'TEXT_MESSAGE_CONTENT', + yield { + type: EventType.TEXT_MESSAGE_CONTENT, messageId: aguiState.messageId, model: model || options.model, - timestamp, + timestamp: Date.now(), delta: textDelta, content: accumulatedContent, - }) + } satisfies StreamChunk } } @@ -543,25 +587,28 @@ export class OpenAICompatibleResponsesTextAdapter< if (!hasEmittedStepStarted) { hasEmittedStepStarted = true stepId = generateId(this.name) - yield asChunk({ - type: 'STEP_STARTED', + yield { + type: EventType.STEP_STARTED, + stepName: stepId, stepId, model: model || options.model, - timestamp, + timestamp: Date.now(), stepType: 'thinking', - }) + } satisfies StreamChunk } accumulatedReasoning += reasoningDelta hasStreamedReasoningDeltas = true - yield asChunk({ - type: 'STEP_FINISHED', - stepId: stepId || generateId(this.name), + const fallbackStepId = stepId || generateId(this.name) + yield { + type: EventType.STEP_FINISHED, + stepName: fallbackStepId, + stepId: fallbackStepId, model: model || options.model, - timestamp, + timestamp: Date.now(), delta: reasoningDelta, content: accumulatedReasoning, - }) + } satisfies StreamChunk } } @@ -579,25 +626,28 @@ export class OpenAICompatibleResponsesTextAdapter< if (!hasEmittedStepStarted) { hasEmittedStepStarted = true stepId = generateId(this.name) - yield asChunk({ - type: 'STEP_STARTED', + yield { + type: EventType.STEP_STARTED, + stepName: stepId, stepId, model: model || options.model, - timestamp, + timestamp: Date.now(), stepType: 'thinking', - }) + } satisfies StreamChunk } accumulatedReasoning += summaryDelta hasStreamedReasoningDeltas = true - yield asChunk({ - type: 'STEP_FINISHED', - stepId: stepId || generateId(this.name), + const fallbackStepId = stepId || generateId(this.name) + yield { + type: EventType.STEP_FINISHED, + stepName: fallbackStepId, + stepId: fallbackStepId, model: model || options.model, - timestamp, + timestamp: Date.now(), delta: summaryDelta, content: accumulatedReasoning, - }) + } satisfies StreamChunk } } @@ -610,25 +660,26 @@ export class OpenAICompatibleResponsesTextAdapter< !hasEmittedTextMessageStart ) { hasEmittedTextMessageStart = true - yield asChunk({ - type: 'TEXT_MESSAGE_START', + yield { + type: EventType.TEXT_MESSAGE_START, messageId: aguiState.messageId, model: model || options.model, - timestamp, + timestamp: Date.now(), role: 'assistant', - }) + } satisfies StreamChunk } // 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', + yield { + type: EventType.STEP_STARTED, + stepName: stepId, stepId, model: model || options.model, - timestamp, + timestamp: Date.now(), stepType: 'thinking', - }) + } satisfies StreamChunk } // Mark whichever stream we just emitted into so a subsequent // `content_part.done` doesn't duplicate the same text. Without @@ -668,6 +719,40 @@ export class OpenAICompatibleResponsesTextAdapter< continue } + // Upstreams that emit `content_part.done` without any preceding + // deltas (or `content_part.added`) still need a START event before + // CONTENT — otherwise consumers tracking start/end pairs see content + // without a start and never see an end. Emit the lifecycle opener + // for whichever stream this content_part belongs to before yielding + // the CONTENT chunk; the post-loop block emits the matching END. + if ( + contentPart.type === 'output_text' && + !hasEmittedTextMessageStart + ) { + hasEmittedTextMessageStart = true + yield { + type: EventType.TEXT_MESSAGE_START, + messageId: aguiState.messageId, + model: model || options.model, + timestamp: Date.now(), + role: 'assistant', + } satisfies StreamChunk + } else if ( + contentPart.type === 'reasoning_text' && + !hasEmittedStepStarted + ) { + hasEmittedStepStarted = true + stepId = generateId(this.name) + yield { + type: EventType.STEP_STARTED, + stepName: stepId, + stepId, + model: model || options.model, + timestamp: Date.now(), + stepType: 'thinking', + } satisfies StreamChunk + } + // Only emit if we haven't been streaming deltas (e.g., for non-streaming responses) const doneChunk = handleContentPart(contentPart) yield doneChunk @@ -682,27 +767,35 @@ export class OpenAICompatibleResponsesTextAdapter< const item = chunk.item if (item.type === 'function_call' && item.id) { const existing = toolCallMetadata.get(item.id) - // Only emit TOOL_CALL_START on the FIRST output_item.added for - // an item id. A duplicate emission (which can happen on retried - // streams or replay) would violate AG-UI's start-once contract. - if (!existing?.started) { - if (!existing) { - toolCallMetadata.set(item.id, { - index: chunk.output_index, - name: item.name || '', - started: false, - }) - } - yield asChunk({ - type: 'TOOL_CALL_START', + // Track the item as soon as we see it so subsequent arg deltas + // aren't logged as orphans, but only emit TOOL_CALL_START when + // both id AND name are populated. Emitting START with an empty + // name would propagate into TOOL_CALL_END (which reads the same + // metadata) and route the tool call to whatever name happens to + // match `''` downstream — a silent misroute. + if (!existing) { + toolCallMetadata.set(item.id, { + index: chunk.output_index, + name: item.name || '', + started: false, + }) + } else if (!existing.name && item.name) { + // A later output_item.added for the same id finally carries + // the name. Update so the gated emission below can fire. + existing.name = item.name + } + const metadata = toolCallMetadata.get(item.id)! + if (!metadata.started && metadata.name) { + yield { + type: EventType.TOOL_CALL_START, toolCallId: item.id, - toolCallName: item.name || '', - toolName: item.name || '', + toolCallName: metadata.name, + toolName: metadata.name, model: model || options.model, - timestamp, + timestamp: Date.now(), index: chunk.output_index, - }) - toolCallMetadata.get(item.id)!.started = true + } satisfies StreamChunk + metadata.started = true } } } @@ -735,13 +828,13 @@ export class OpenAICompatibleResponsesTextAdapter< ) continue } - yield asChunk({ - type: 'TOOL_CALL_ARGS', + yield { + type: EventType.TOOL_CALL_ARGS, toolCallId: chunk.item_id, model: model || options.model, - timestamp, + timestamp: Date.now(), delta: chunk.delta, - }) + } satisfies StreamChunk } if (chunk.type === 'response.function_call_arguments.done') { @@ -749,13 +842,19 @@ export class OpenAICompatibleResponsesTextAdapter< // Get the function name from metadata (captured in output_item.added) const metadata = toolCallMetadata.get(item_id) - // Skip TOOL_CALL_END for items whose start was never emitted (no - // matching `output_item.added`). Emitting END without START would - // produce an unbalanced AG-UI lifecycle event downstream consumers - // can't pair. + // If the matching START was never emitted (the upstream sent an + // `output_item.added` without a name and no later event has filled + // it in yet), defer END until `output_item.done` or + // `response.completed` can backfill the name. We stash the raw + // arguments so the late emission has them. Emitting END without + // START would produce an unbalanced AG-UI lifecycle event + // downstream consumers can't pair. if (!metadata?.started) { + if (metadata) { + metadata.pendingArguments = chunk.arguments + } options.logger.errors( - `${this.name}.processStreamChunks orphan function_call_arguments.done`, + `${this.name}.processStreamChunks deferring function_call_arguments.done — TOOL_CALL_START not yet emitted (waiting for name)`, { source: `${this.name}.processStreamChunks`, toolCallId: item_id, @@ -764,7 +863,12 @@ export class OpenAICompatibleResponsesTextAdapter< ) continue } + // The output_item.done backstop may have already emitted END (when + // it arrived before args.done with a populated item.arguments). + // Skip so we never produce a duplicate close for the same id. + if (metadata.ended) continue const name = metadata.name || '' + metadata.ended = true // Parse arguments. Surface parse failures via the logger so a // model emitting malformed JSON is debuggable instead of silently @@ -792,26 +896,177 @@ export class OpenAICompatibleResponsesTextAdapter< } } - yield asChunk({ - type: 'TOOL_CALL_END', + yield { + type: EventType.TOOL_CALL_END, toolCallId: item_id, toolCallName: name, toolName: name, model: model || options.model, - timestamp, + timestamp: Date.now(), input: parsedInput, - }) + } satisfies StreamChunk + } + + // `output_item.done` is the last point at which a function_call's + // name is guaranteed to be on the wire — it carries the fully-formed + // ResponseFunctionToolCall. Use it as a backstop to recover any + // tool call whose name was missing from `output_item.added` (and + // whose START + END therefore never fired). + if (chunk.type === 'response.output_item.done') { + const item = chunk.item + if (item.type === 'function_call' && item.id) { + const metadata = toolCallMetadata.get(item.id) ?? { + index: chunk.output_index, + name: item.name || '', + started: false, + } + if (!toolCallMetadata.has(item.id)) { + toolCallMetadata.set(item.id, metadata) + } else if (!metadata.name && item.name) { + metadata.name = item.name + } + // Emit gated START if we now have a name and never started. + if (!metadata.started && metadata.name) { + yield { + type: EventType.TOOL_CALL_START, + toolCallId: item.id, + toolCallName: metadata.name, + toolName: metadata.name, + model: model || options.model, + timestamp: Date.now(), + index: metadata.index, + } satisfies StreamChunk + metadata.started = true + } + // Emit END if we have args (either from a previously-deferred + // args.done OR from item.arguments) and haven't already ended. + const rawArgs = + typeof item.arguments === 'string' && item.arguments.length > 0 + ? item.arguments + : metadata.pendingArguments + if (metadata.started && !metadata.ended && rawArgs !== undefined) { + const name = metadata.name || '' + let parsedInput: unknown = {} + if (rawArgs) { + try { + const parsed = JSON.parse(rawArgs) + parsedInput = + parsed && typeof parsed === 'object' ? parsed : {} + } catch (parseError) { + options.logger.errors( + `${this.name}.processStreamChunks tool-args JSON parse failed (output_item.done backfill)`, + { + error: toRunErrorPayload( + parseError, + `tool ${name} (${item.id}) returned malformed JSON arguments`, + ), + source: `${this.name}.processStreamChunks`, + toolCallId: item.id, + toolName: name, + rawArguments: rawArgs, + }, + ) + parsedInput = {} + } + } + yield { + type: EventType.TOOL_CALL_END, + toolCallId: item.id, + toolCallName: name, + toolName: name, + model: model || options.model, + timestamp: Date.now(), + input: parsedInput, + } satisfies StreamChunk + metadata.ended = true + metadata.pendingArguments = undefined + } + } } if (chunk.type === 'response.completed') { + // Final backstop for function_call lifecycle: if a function_call + // appears in `response.output[]` but was never matched by an + // output_item.added/done with a name, recover the missing START + // (and END if args were pending). Without this, a tool call could + // be silently dropped from the AG-UI stream while `hasFunctionCalls` + // below still routes the run's finishReason to 'tool_calls' — + // leaving consumers waiting for tool results they never saw start. + for (const item of chunk.response.output) { + if (item.type !== 'function_call' || !item.id) continue + const metadata = toolCallMetadata.get(item.id) ?? { + index: 0, + name: item.name || '', + started: false, + } + if (!toolCallMetadata.has(item.id)) { + toolCallMetadata.set(item.id, metadata) + } else if (!metadata.name && item.name) { + metadata.name = item.name + } + if (!metadata.started && metadata.name) { + yield { + type: EventType.TOOL_CALL_START, + toolCallId: item.id, + toolCallName: metadata.name, + toolName: metadata.name, + model: model || options.model, + timestamp: Date.now(), + index: metadata.index, + } satisfies StreamChunk + metadata.started = true + } + const rawArgs = + typeof item.arguments === 'string' && item.arguments.length > 0 + ? item.arguments + : metadata.pendingArguments + if (metadata.started && !metadata.ended) { + const name = metadata.name || '' + let parsedInput: unknown = {} + if (rawArgs) { + try { + const parsed = JSON.parse(rawArgs) + parsedInput = + parsed && typeof parsed === 'object' ? parsed : {} + } catch (parseError) { + options.logger.errors( + `${this.name}.processStreamChunks tool-args JSON parse failed (response.completed backfill)`, + { + error: toRunErrorPayload( + parseError, + `tool ${name} (${item.id}) returned malformed JSON arguments`, + ), + source: `${this.name}.processStreamChunks`, + toolCallId: item.id, + toolName: name, + rawArguments: rawArgs, + }, + ) + parsedInput = {} + } + } + yield { + type: EventType.TOOL_CALL_END, + toolCallId: item.id, + toolCallName: name, + toolName: name, + model: model || options.model, + timestamp: Date.now(), + input: parsedInput, + } satisfies StreamChunk + metadata.ended = true + metadata.pendingArguments = undefined + } + } + // Emit TEXT_MESSAGE_END if we had text content if (hasEmittedTextMessageStart) { - yield asChunk({ - type: 'TEXT_MESSAGE_END', + yield { + type: EventType.TEXT_MESSAGE_END, messageId: aguiState.messageId, model: model || options.model, - timestamp, - }) + timestamp: Date.now(), + } satisfies StreamChunk hasEmittedTextMessageStart = false } @@ -819,43 +1074,62 @@ export class OpenAICompatibleResponsesTextAdapter< // Otherwise surface incomplete_details.reason when present so // callers can distinguish length-limit / content-filter cutoffs // from a clean stop, mirroring the chat-completions adapter. + // The Responses API's incomplete_details.reason ('max_output_tokens' + // | 'content_filter') maps to the AG-UI finishReason vocabulary: + // max_output_tokens → 'length', content_filter → 'content_filter'. const hasFunctionCalls = chunk.response.output.some( (item: unknown) => (item as { type: string }).type === 'function_call', ) - const finishReason: string = hasFunctionCalls + const incompleteReason = chunk.response.incomplete_details?.reason + const finishReason: + | 'tool_calls' + | 'length' + | 'content_filter' + | 'stop' = hasFunctionCalls ? 'tool_calls' - : (chunk.response.incomplete_details?.reason ?? 'stop') - - yield asChunk({ - type: 'RUN_FINISHED', + : incompleteReason === 'max_output_tokens' + ? 'length' + : incompleteReason === 'content_filter' + ? 'content_filter' + : 'stop' + + yield { + type: EventType.RUN_FINISHED, runId: aguiState.runId, + threadId: aguiState.threadId, model: model || options.model, - timestamp, + timestamp: Date.now(), usage: { promptTokens: chunk.response.usage?.input_tokens || 0, completionTokens: chunk.response.usage?.output_tokens || 0, totalTokens: chunk.response.usage?.total_tokens || 0, }, finishReason, - }) + } satisfies StreamChunk runFinishedEmitted = true } if (chunk.type === 'error') { - yield asChunk({ - type: 'RUN_ERROR', - runId: aguiState.runId, + yield { + type: EventType.RUN_ERROR, model: model || options.model, - timestamp, + timestamp: Date.now(), + message: chunk.message, + code: chunk.code ?? undefined, error: { message: chunk.message, code: chunk.code ?? undefined, }, - }) + } satisfies StreamChunk // RUN_ERROR is terminal — don't let the synthetic RUN_FINISHED - // block fire after a top-level stream error event. + // block fire after a top-level stream error event, and stop + // processing further chunks so no in-flight lifecycle events + // (TEXT_MESSAGE_CONTENT, TOOL_CALL_*) leak past the terminal + // error. Mirrors the `response.failed` / `response.incomplete` + // branches above which return after their RUN_ERROR emission. runFinishedEmitted = true + return } } @@ -865,21 +1139,22 @@ export class OpenAICompatibleResponsesTextAdapter< // see a terminal event for every started run. if (!runFinishedEmitted && aguiState.hasEmittedRunStarted) { if (hasEmittedTextMessageStart) { - yield asChunk({ - type: 'TEXT_MESSAGE_END', + yield { + type: EventType.TEXT_MESSAGE_END, messageId: aguiState.messageId, model: model || options.model, - timestamp, - }) + timestamp: Date.now(), + } satisfies StreamChunk } - yield asChunk({ - type: 'RUN_FINISHED', + yield { + type: EventType.RUN_FINISHED, runId: aguiState.runId, + threadId: aguiState.threadId, model: model || options.model, - timestamp, + timestamp: Date.now(), usage: undefined, finishReason: toolCallMetadata.size > 0 ? 'tool_calls' : 'stop', - }) + } satisfies StreamChunk } } catch (error: unknown) { // Narrow before logging: raw SDK errors can carry request metadata @@ -892,13 +1167,14 @@ export class OpenAICompatibleResponsesTextAdapter< error: errorPayload, source: `${this.name}.processStreamChunks`, }) - yield asChunk({ - type: 'RUN_ERROR', - runId: aguiState.runId, + yield { + type: EventType.RUN_ERROR, model: options.model, - timestamp, + timestamp: Date.now(), + message: errorPayload.message, + code: errorPayload.code, error: errorPayload, - }) + } satisfies StreamChunk } } @@ -908,7 +1184,7 @@ export class OpenAICompatibleResponsesTextAdapter< */ protected mapOptionsToRequest( options: TextOptions, - ): Omit { + ): Omit { const input = this.convertMessagesToInput(options.messages) const tools = options.tools @@ -961,8 +1237,8 @@ export class OpenAICompatibleResponsesTextAdapter< */ protected convertMessagesToInput( messages: Array, - ): Responses.ResponseInput { - const result: Responses.ResponseInput = [] + ): ResponseInput { + const result: ResponseInput = [] for (const message of messages) { // Handle tool messages - convert to FunctionToolCallOutput @@ -1016,7 +1292,7 @@ export class OpenAICompatibleResponsesTextAdapter< // Handle user messages (default case) — support multimodal content const contentParts = this.normalizeContent(message.content) - const inputContent: Array = [] + const inputContent: Array = [] for (const part of contentParts) { inputContent.push(this.convertContentPartToInput(part)) @@ -1049,9 +1325,7 @@ export class OpenAICompatibleResponsesTextAdapter< * 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 { + protected convertContentPartToInput(part: ContentPart): ResponseInputContent { switch (part.type) { case 'text': return { diff --git a/packages/typescript/openai-base/src/adapters/transcription.ts b/packages/typescript/openai-base/src/adapters/transcription.ts deleted file mode 100644 index 702dc6479..000000000 --- a/packages/typescript/openai-base/src/adapters/transcription.ts +++ /dev/null @@ -1,194 +0,0 @@ -import { BaseTranscriptionAdapter } from '@tanstack/ai/adapters' -import { toRunErrorPayload } from '@tanstack/ai/adapter-internals' -import { base64ToArrayBuffer, generateId } from '@tanstack/ai-utils' -import { createOpenAICompatibleClient } from '../utils/client' -import type { - TranscriptionOptions, - TranscriptionResult, - TranscriptionSegment, -} from '@tanstack/ai' -import type OpenAI_SDK from 'openai' -import type { OpenAICompatibleClientConfig } from '../types/config' - -/** - * OpenAI-Compatible Transcription (Speech-to-Text) Adapter - * - * A generalized base class for providers that implement OpenAI-compatible audio - * transcription APIs. Providers can extend this class and only need to: - * - Set `baseURL` in the config - * - Lock the generic type parameters to provider-specific types - * - Override audio handling or response mapping methods as needed - * - * All methods that handle audio input or map response formats are `protected` - * so subclasses can override them. - */ -export class OpenAICompatibleTranscriptionAdapter< - TModel extends string, - TProviderOptions extends object = Record, -> extends BaseTranscriptionAdapter { - readonly name: string - - protected client: OpenAI_SDK - - constructor( - config: OpenAICompatibleClientConfig, - model: TModel, - name: string = 'openai-compatible', - ) { - super(model, {}) - this.name = name - this.client = createOpenAICompatibleClient(config) - } - - async transcribe( - options: TranscriptionOptions, - ): Promise { - const { model, audio, language, prompt, responseFormat, modelOptions } = - options - - // Convert audio input to File object - const file = this.prepareAudioFile(audio) - - // Build request - const request: OpenAI_SDK.Audio.TranscriptionCreateParams = { - model, - file, - language, - prompt, - response_format: this.mapResponseFormat(responseFormat), - ...modelOptions, - } - - // Call API - use verbose_json to get timestamps when available - const useVerbose = - responseFormat === 'verbose_json' || - (!responseFormat && this.shouldDefaultToVerbose(model)) - - try { - options.logger.request( - `activity=transcription provider=${this.name} model=${model} verbose=${useVerbose}`, - { provider: this.name, model }, - ) - if (useVerbose) { - const response = await this.client.audio.transcriptions.create({ - ...request, - response_format: 'verbose_json', - }) - - return { - id: generateId(this.name), - model, - text: response.text, - language: response.language, - duration: response.duration, - segments: response.segments?.map( - (seg): TranscriptionSegment => ({ - id: seg.id, - start: seg.start, - end: seg.end, - text: seg.text, - // The OpenAI SDK types `avg_logprob` as `number`, so call Math.exp - // directly. Previously this was guarded with `seg.avg_logprob ?` - // which treated `0` (perfect-confidence) as missing. - confidence: Math.exp(seg.avg_logprob), - }), - ), - words: response.words?.map((w) => ({ - word: w.word, - start: w.start, - end: w.end, - })), - } - } else { - const response = await this.client.audio.transcriptions.create(request) - - return { - id: generateId(this.name), - model, - text: typeof response === 'string' ? response : response.text, - language, - } - } - } catch (error: unknown) { - // Narrow before logging: raw SDK errors can carry request metadata - // (including auth headers) which we must never surface to user loggers. - options.logger.errors(`${this.name}.transcribe fatal`, { - error: toRunErrorPayload(error, `${this.name}.transcribe failed`), - source: `${this.name}.transcribe`, - }) - throw error - } - } - - protected prepareAudioFile(audio: string | File | Blob | ArrayBuffer): File { - // If already a File, return it - if (typeof File !== 'undefined' && audio instanceof File) { - return audio - } - - // If Blob, convert to File - if (typeof Blob !== 'undefined' && audio instanceof Blob) { - this.ensureFileSupport() - return new File([audio], 'audio.mp3', { - type: audio.type || 'audio/mpeg', - }) - } - - // If ArrayBuffer, convert to File - if (typeof ArrayBuffer !== 'undefined' && audio instanceof ArrayBuffer) { - this.ensureFileSupport() - return new File([audio], 'audio.mp3', { type: 'audio/mpeg' }) - } - - // If base64 string, decode and convert to File - if (typeof audio === 'string') { - this.ensureFileSupport() - - // Check if it's a data URL - if (audio.startsWith('data:')) { - const parts = audio.split(',') - const header = parts[0] - const base64Data = parts[1] || '' - const mimeMatch = header?.match(/data:([^;]+)/) - const mimeType = mimeMatch?.[1] || 'audio/mpeg' - const bytes = base64ToArrayBuffer(base64Data) - const extension = mimeType.split('/')[1] || 'mp3' - return new File([bytes], `audio.${extension}`, { type: mimeType }) - } - - // Assume raw base64 - const bytes = base64ToArrayBuffer(audio) - return new File([bytes], 'audio.mp3', { type: 'audio/mpeg' }) - } - - throw new Error('Invalid audio input type') - } - - /** - * Checks that the global `File` constructor is available. - * Throws a descriptive error in environments that lack it (e.g. Node < 20). - */ - private ensureFileSupport(): void { - if (typeof File === 'undefined') { - throw new Error( - '`File` is not available in this environment. ' + - 'Use Node.js 20 or newer, or pass a File object directly.', - ) - } - } - - /** - * Whether the adapter should default to verbose_json when no response format is specified. - * Override in provider-specific subclasses for model-specific behavior. - */ - protected shouldDefaultToVerbose(_model: string): boolean { - return false - } - - protected mapResponseFormat( - format?: 'json' | 'text' | 'srt' | 'verbose_json' | 'vtt', - ): OpenAI_SDK.Audio.TranscriptionCreateParams['response_format'] { - if (!format) return 'json' - return format as OpenAI_SDK.Audio.TranscriptionCreateParams['response_format'] - } -} diff --git a/packages/typescript/openai-base/src/adapters/tts.ts b/packages/typescript/openai-base/src/adapters/tts.ts deleted file mode 100644 index b61c9c095..000000000 --- a/packages/typescript/openai-base/src/adapters/tts.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { BaseTTSAdapter } from '@tanstack/ai/adapters' -import { toRunErrorPayload } from '@tanstack/ai/adapter-internals' -import { arrayBufferToBase64, generateId } from '@tanstack/ai-utils' -import { createOpenAICompatibleClient } from '../utils/client' -import type { TTSOptions, TTSResult } from '@tanstack/ai' -import type OpenAI_SDK from 'openai' -import type { OpenAICompatibleClientConfig } from '../types/config' - -/** - * OpenAI-Compatible Text-to-Speech Adapter - * - * A generalized base class for providers that implement OpenAI-compatible TTS APIs. - * Providers can extend this class and only need to: - * - Set `baseURL` in the config - * - Lock the generic type parameters to provider-specific types - * - Override validation methods or request building for provider-specific constraints - * - * All methods that validate inputs or build requests are `protected` so subclasses - * can override them. - */ -export class OpenAICompatibleTTSAdapter< - TModel extends string, - TProviderOptions extends object = Record, -> extends BaseTTSAdapter { - readonly name: string - - protected client: OpenAI_SDK - - constructor( - config: OpenAICompatibleClientConfig, - model: TModel, - name: string = 'openai-compatible', - ) { - super(model, {}) - this.name = name - this.client = createOpenAICompatibleClient(config) - } - - async generateSpeech( - options: TTSOptions, - ): Promise { - const { model, text, voice, format, speed, modelOptions } = options - - // Validate inputs - this.validateAudioInput(text) - this.validateSpeed(speed) - this.validateInstructions(model, modelOptions) - - // Build request - const request: OpenAI_SDK.Audio.SpeechCreateParams = { - model, - input: text, - voice: (voice || 'alloy') as OpenAI_SDK.Audio.SpeechCreateParams['voice'], - response_format: format, - speed, - ...modelOptions, - } - - try { - options.logger.request( - `activity=tts provider=${this.name} model=${model} format=${request.response_format ?? 'default'} voice=${request.voice}`, - { provider: this.name, model }, - ) - const response = await this.client.audio.speech.create(request) - - // Convert response to base64. Buffer is Node-only; use atob fallback in - // browser/edge runtimes where the SDK can run. - const arrayBuffer = await response.arrayBuffer() - const base64 = arrayBufferToBase64(arrayBuffer) - - const outputFormat = (request.response_format as string) || 'mp3' - const contentType = this.getContentType(outputFormat) - - return { - id: generateId(this.name), - model, - audio: base64, - format: outputFormat, - contentType, - } - } catch (error: unknown) { - // Narrow before logging: raw SDK errors can carry request metadata - // (including auth headers) which we must never surface to user loggers. - options.logger.errors(`${this.name}.generateSpeech fatal`, { - error: toRunErrorPayload(error, `${this.name}.generateSpeech failed`), - source: `${this.name}.generateSpeech`, - }) - throw error - } - } - - protected validateAudioInput(text: string): void { - if (text.length > 4096) { - throw new Error('Input text exceeds maximum length of 4096 characters.') - } - } - - protected validateSpeed(speed?: number): void { - if (speed !== undefined) { - if (speed < 0.25 || speed > 4.0) { - throw new Error('Speed must be between 0.25 and 4.0.') - } - } - } - - protected validateInstructions( - _model: string, - _modelOptions?: TProviderOptions, - ): void { - // Default: no instructions validation — subclasses can override - } - - protected getContentType(format: string): string { - const contentTypes: Record = { - mp3: 'audio/mpeg', - opus: 'audio/opus', - aac: 'audio/aac', - flac: 'audio/flac', - wav: 'audio/wav', - pcm: 'audio/pcm', - } - return contentTypes[format] || 'audio/mpeg' - } -} diff --git a/packages/typescript/openai-base/src/adapters/video.ts b/packages/typescript/openai-base/src/adapters/video.ts deleted file mode 100644 index 8aaf1ad77..000000000 --- a/packages/typescript/openai-base/src/adapters/video.ts +++ /dev/null @@ -1,385 +0,0 @@ -import { BaseVideoAdapter } from '@tanstack/ai/adapters' -import { toRunErrorPayload } from '@tanstack/ai/adapter-internals' -import { arrayBufferToBase64 } from '@tanstack/ai-utils' -import { createOpenAICompatibleClient } from '../utils/client' -import type { - VideoGenerationOptions, - VideoJobResult, - VideoStatusResult, - VideoUrlResult, -} from '@tanstack/ai' -import type OpenAI_SDK from 'openai' -import type { OpenAICompatibleClientConfig } from '../types/config' - -/** - * Threshold for emitting a "this download will probably OOM serverless - * runtimes" warning. Anything larger than this (in bytes) gets surfaced via - * console.warn — workers and small isolates routinely run out of memory once - * a downloaded video is base64-encoded (the encoded form is ~33% larger and - * resides in V8 heap rather than streaming through the runtime's network - * layer). - */ -const LARGE_MEDIA_BUFFER_BYTES = 10 * 1024 * 1024 - -function warnIfLargeMediaBuffer( - byteLength: number, - source: string, - providerName: string, -): void { - if (byteLength <= LARGE_MEDIA_BUFFER_BYTES) return - // No InternalLogger plumbed through to these download paths yet; surface - // via console.warn so Workers / Lambda dashboards still capture it. - console.warn( - `[${providerName}.${source}] downloaded ${(byteLength / 1024 / 1024).toFixed(1)} MiB into memory before base64 encoding. ` + - `Workers/serverless runtimes commonly run out of memory above ~10 MiB. ` + - `Consider streaming the video through a CDN or your own storage layer instead.`, - ) -} - -/** - * OpenAI-Compatible Video Generation Adapter - * - * A generalized base class for providers that implement OpenAI-compatible video - * generation APIs. Uses a job/polling architecture for async video generation. - * - * Providers can extend this class and only need to: - * - Set `baseURL` in the config - * - Lock the generic type parameters to provider-specific types - * - Override validation or request building methods as needed - * - * All methods that validate inputs, build requests, or map responses are `protected` - * so subclasses can override them. - * - * @experimental Video generation is an experimental feature and may change. - */ -export class OpenAICompatibleVideoAdapter< - TModel extends string, - TProviderOptions extends object = Record, - TModelProviderOptionsByName extends Record = Record, - TModelSizeByName extends Record = Record, -> extends BaseVideoAdapter< - TModel, - TProviderOptions, - TModelProviderOptionsByName, - TModelSizeByName -> { - readonly name: string - - protected client: OpenAI_SDK - protected clientConfig: OpenAICompatibleClientConfig - - constructor( - config: OpenAICompatibleClientConfig, - model: TModel, - name: string = 'openai-compatible', - ) { - super(config, model) - this.name = name - this.clientConfig = config - this.client = createOpenAICompatibleClient(config) - } - - /** - * Create a new video generation job. - * - * @experimental Video generation is an experimental feature and may change. - */ - async createVideoJob( - options: VideoGenerationOptions, - ): Promise { - const { model, size, duration, modelOptions } = options - - // Validate inputs - this.validateVideoSize(model, size) - const seconds = duration ?? (modelOptions as any)?.seconds - this.validateVideoSeconds(model, seconds) - - // Build request - const request = this.buildRequest(options) - - try { - options.logger.request( - `activity=video.create provider=${this.name} model=${model} size=${request.size ?? 'default'} seconds=${request.seconds ?? 'default'}`, - { provider: this.name, model }, - ) - // The video API on the OpenAI SDK is still experimental and shipped on - // some SDK versions but not others; access through `videosClient` lets - // subclasses override the entry point or supply a polyfill without - // forcing every call site through `as any`. - const videosClient = this.getVideosClient() - const response = await videosClient.create(request) - - return { - jobId: response.id, - model, - } - } catch (error: any) { - options.logger.errors(`${this.name}.createVideoJob fatal`, { - error: toRunErrorPayload(error, `${this.name}.createVideoJob failed`), - source: `${this.name}.createVideoJob`, - }) - if (error?.message?.includes('videos') || error?.code === 'invalid_api') { - throw new Error( - `Video generation API is not available. The API may require special access. ` + - `Original error: ${error.message}`, - ) - } - throw error - } - } - - /** - * Returns the underlying OpenAI Videos resource. Pulled out as a protected - * accessor so subclasses targeting forks of the SDK can swap the access - * path without forcing each call site to cast through `any`. - */ - protected getVideosClient(): { - create: (req: Record) => Promise<{ id: string }> - retrieve: (id: string) => Promise<{ - id: string - status: string - progress?: number - url?: string - expires_at?: number - error?: { message?: string } - }> - downloadContent?: (id: string) => Promise - content?: (id: string) => Promise - getContent?: (id: string) => Promise - download?: (id: string) => Promise - } { - return (this.client as unknown as { videos: any }).videos - } - - /** - * Get the current status of a video generation job. - * - * @experimental Video generation is an experimental feature and may change. - */ - async getVideoStatus(jobId: string): Promise { - try { - const videosClient = this.getVideosClient() - const response = await videosClient.retrieve(jobId) - - return { - jobId, - status: this.mapStatus(response.status), - progress: response.progress, - error: response.error?.message, - } - } catch (error: any) { - if (error.status === 404) { - return { - jobId, - status: 'failed', - error: 'Job not found', - } - } - throw error - } - } - - /** - * Get the URL to download/view the generated video. - * - * @experimental Video generation is an experimental feature and may change. - */ - async getVideoUrl(jobId: string): Promise { - try { - const videosClient = this.getVideosClient() - - // Prefer retrieve() because many openai-compatible backends (and the - // aimock test harness) return the URL directly on the video resource - // and do not implement a separate /content endpoint. Subclasses can - // override this method if they need to download raw bytes via - // downloadContent()/content(). - const videoInfo = await videosClient.retrieve(jobId) - if (videoInfo.url) { - return { - jobId, - url: videoInfo.url, - expiresAt: videoInfo.expires_at - ? new Date(videoInfo.expires_at) - : undefined, - } - } - - // SDK download fall-through: try the various possible method names in - // decreasing order of modernity. - if (typeof videosClient.downloadContent === 'function') { - const contentResponse = await videosClient.downloadContent(jobId) - const videoBlob = await contentResponse.blob() - const buffer = await videoBlob.arrayBuffer() - warnIfLargeMediaBuffer( - buffer.byteLength, - 'video.downloadContent', - this.name, - ) - const base64 = arrayBufferToBase64(buffer) - const mimeType = - contentResponse.headers.get('content-type') || 'video/mp4' - return { - jobId, - url: `data:${mimeType};base64,${base64}`, - expiresAt: undefined, - } - } - - // The remaining SDK fall-throughs all return a binary payload - // (Blob/Response/ArrayBuffer-shaped), NOT an `{ url, expires_at }` - // object the way the bottom return assumed. Convert to a data URL - // here so the caller actually receives a usable URL. - let response: any - if (typeof videosClient.content === 'function') { - response = await videosClient.content(jobId) - } else if (typeof videosClient.getContent === 'function') { - response = await videosClient.getContent(jobId) - } else if (typeof videosClient.download === 'function') { - response = await videosClient.download(jobId) - } else { - // Last resort: raw fetch with auth header. - const baseUrl = this.clientConfig.baseURL || 'https://api.openai.com/v1' - const apiKey = this.clientConfig.apiKey - - const contentResponse = await fetch( - `${baseUrl}/videos/${jobId}/content`, - { - method: 'GET', - headers: { - Authorization: `Bearer ${apiKey}`, - }, - }, - ) - - if (!contentResponse.ok) { - const contentType = contentResponse.headers.get('content-type') - if (contentType?.includes('application/json')) { - const errorData = await contentResponse.json().catch(() => ({})) - throw new Error( - errorData.error?.message || - `Failed to get video content: ${contentResponse.status}`, - ) - } - throw new Error( - `Failed to get video content: ${contentResponse.status}`, - ) - } - - const videoBlob = await contentResponse.blob() - const buffer = await videoBlob.arrayBuffer() - warnIfLargeMediaBuffer(buffer.byteLength, 'video.fetch', this.name) - const base64 = arrayBufferToBase64(buffer) - const mimeType = - contentResponse.headers.get('content-type') || 'video/mp4' - - return { - jobId, - url: `data:${mimeType};base64,${base64}`, - expiresAt: undefined, - } - } - - // The fall-through SDK methods produce a Blob-ish or fetch-`Response`-ish - // object. Read it as bytes and wrap in a data URL so callers see an - // actual playable URL instead of the API endpoint URL (which is what - // `response.url` would be on a fetch Response). - const fallthroughBlob = - typeof response?.blob === 'function' - ? await response.blob() - : response instanceof Blob - ? response - : null - if (!fallthroughBlob) { - throw new Error( - `Video content download via SDK fall-through returned an unexpected shape (no blob()). ` + - `Override getVideoUrl() in your subclass to handle this provider.`, - ) - } - const fallthroughBuffer = await fallthroughBlob.arrayBuffer() - warnIfLargeMediaBuffer( - fallthroughBuffer.byteLength, - 'video.sdkFallthrough', - this.name, - ) - const fallthroughBase64 = arrayBufferToBase64(fallthroughBuffer) - const fallthroughMime = - (typeof response?.headers?.get === 'function' - ? response.headers.get('content-type') - : undefined) || - fallthroughBlob.type || - 'video/mp4' - return { - jobId, - url: `data:${fallthroughMime};base64,${fallthroughBase64}`, - expiresAt: undefined, - } - } catch (error: any) { - if (error.status === 404) { - throw new Error(`Video job not found: ${jobId}`) - } - if (error.status === 400) { - throw new Error( - `Video is not ready for download. Check status first. Job ID: ${jobId}`, - ) - } - throw error - } - } - - protected buildRequest( - options: VideoGenerationOptions, - ): Record { - const { model, prompt, size, duration, modelOptions } = options - - const request: Record = { - model, - prompt, - } - - if (size) { - request['size'] = size - } else if ((modelOptions as any)?.size) { - request['size'] = (modelOptions as any).size - } - - const seconds = duration ?? (modelOptions as any)?.seconds - if (seconds !== undefined) { - request['seconds'] = String(seconds) - } - - return request - } - - protected validateVideoSize(_model: string, _size?: string): void { - // Default: no size validation — subclasses can override - } - - protected validateVideoSeconds( - _model: string, - _seconds?: number | string, - ): void { - // Default: no duration validation — subclasses can override - } - - protected mapStatus( - apiStatus: string, - ): 'pending' | 'processing' | 'completed' | 'failed' { - switch (apiStatus) { - case 'queued': - case 'pending': - return 'pending' - case 'processing': - case 'in_progress': - return 'processing' - case 'completed': - case 'succeeded': - return 'completed' - case 'failed': - case 'error': - case 'cancelled': - return 'failed' - default: - return 'processing' - } - } -} diff --git a/packages/typescript/openai-base/src/index.ts b/packages/typescript/openai-base/src/index.ts index ab15140ea..43e149148 100644 --- a/packages/typescript/openai-base/src/index.ts +++ b/packages/typescript/openai-base/src/index.ts @@ -1,24 +1,14 @@ export { makeStructuredOutputCompatible } from './utils/schema-converter' -export { createOpenAICompatibleClient } from './utils/client' -export type { OpenAICompatibleClientConfig } from './types/config' export * from './tools/index' -export { OpenAICompatibleChatCompletionsTextAdapter } from './adapters/chat-completions-text' +export { OpenAIBaseChatCompletionsTextAdapter } from './adapters/chat-completions-text' export { convertFunctionToolToChatCompletionsFormat, convertToolsToChatCompletionsFormat, type ChatCompletionFunctionTool, } from './adapters/chat-completions-tool-converter' -export { OpenAICompatibleResponsesTextAdapter } from './adapters/responses-text' +export { OpenAIBaseResponsesTextAdapter } 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 index 6bc157aa4..d97594d6a 100644 --- a/packages/typescript/openai-base/src/tools/apply-patch-tool.ts +++ b/packages/typescript/openai-base/src/tools/apply-patch-tool.ts @@ -1,7 +1,7 @@ -import type OpenAI from 'openai' +import type { ApplyPatchTool as ApplyPatchToolConfig } from 'openai/resources/responses/responses' import type { Tool } from '@tanstack/ai' -export type ApplyPatchToolConfig = OpenAI.Responses.ApplyPatchTool +export type { ApplyPatchToolConfig } /** @deprecated Renamed to `ApplyPatchToolConfig`. Will be removed in a future release. */ export type ApplyPatchTool = ApplyPatchToolConfig diff --git a/packages/typescript/openai-base/src/tools/code-interpreter-tool.ts b/packages/typescript/openai-base/src/tools/code-interpreter-tool.ts index 53f130588..448680da7 100644 --- a/packages/typescript/openai-base/src/tools/code-interpreter-tool.ts +++ b/packages/typescript/openai-base/src/tools/code-interpreter-tool.ts @@ -1,7 +1,9 @@ +import type { Tool as SDKTool } from 'openai/resources/responses/responses' import type { Tool } from '@tanstack/ai' -import type OpenAI from 'openai' -export type CodeInterpreterToolConfig = OpenAI.Responses.Tool.CodeInterpreter +type CodeInterpreterToolConfig = SDKTool.CodeInterpreter + +export type { CodeInterpreterToolConfig } /** @deprecated Renamed to `CodeInterpreterToolConfig`. Will be removed in a future release. */ export type CodeInterpreterTool = CodeInterpreterToolConfig diff --git a/packages/typescript/openai-base/src/tools/computer-use-tool.ts b/packages/typescript/openai-base/src/tools/computer-use-tool.ts index 487e6486c..c79481a03 100644 --- a/packages/typescript/openai-base/src/tools/computer-use-tool.ts +++ b/packages/typescript/openai-base/src/tools/computer-use-tool.ts @@ -1,7 +1,7 @@ -import type OpenAI from 'openai' +import type { ComputerTool as ComputerUseToolConfig } from 'openai/resources/responses/responses' import type { Tool } from '@tanstack/ai' -export type ComputerUseToolConfig = OpenAI.Responses.ComputerTool +export type { ComputerUseToolConfig } /** @deprecated Renamed to `ComputerUseToolConfig`. Will be removed in a future release. */ export type ComputerUseTool = ComputerUseToolConfig diff --git a/packages/typescript/openai-base/src/tools/custom-tool.ts b/packages/typescript/openai-base/src/tools/custom-tool.ts index 6e0cb8e5f..dc6ce2732 100644 --- a/packages/typescript/openai-base/src/tools/custom-tool.ts +++ b/packages/typescript/openai-base/src/tools/custom-tool.ts @@ -1,7 +1,7 @@ -import type OpenAI from 'openai' +import type { CustomTool as CustomToolConfig } from 'openai/resources/responses/responses' import type { Tool } from '@tanstack/ai' -export type CustomToolConfig = OpenAI.Responses.CustomTool +export type { CustomToolConfig } /** @deprecated Renamed to `CustomToolConfig`. Will be removed in a future release. */ export type CustomTool = CustomToolConfig diff --git a/packages/typescript/openai-base/src/tools/file-search-tool.ts b/packages/typescript/openai-base/src/tools/file-search-tool.ts index 82eb472aa..804b99dac 100644 --- a/packages/typescript/openai-base/src/tools/file-search-tool.ts +++ b/packages/typescript/openai-base/src/tools/file-search-tool.ts @@ -1,6 +1,8 @@ -import type OpenAI from 'openai' +import type { FileSearchTool as FileSearchToolConfig } from 'openai/resources/responses/responses' import type { Tool } from '@tanstack/ai' +export type { FileSearchToolConfig } + const validateMaxNumResults = (maxNumResults: number | undefined) => { if ( maxNumResults !== undefined && @@ -10,8 +12,6 @@ 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 diff --git a/packages/typescript/openai-base/src/tools/function-tool.ts b/packages/typescript/openai-base/src/tools/function-tool.ts index bf06804c6..1f58d229f 100644 --- a/packages/typescript/openai-base/src/tools/function-tool.ts +++ b/packages/typescript/openai-base/src/tools/function-tool.ts @@ -1,8 +1,8 @@ import { makeStructuredOutputCompatible } from '../utils/schema-converter' +import type { FunctionTool as FunctionToolConfig } from 'openai/resources/responses/responses' import type { JSONSchema, Tool } from '@tanstack/ai' -import type OpenAI from 'openai' -export type FunctionToolConfig = OpenAI.Responses.FunctionTool +export type { FunctionToolConfig } /** @deprecated Renamed to `FunctionToolConfig`. Will be removed in a future release. */ export type FunctionTool = 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 index f81fee40f..b5150e25d 100644 --- a/packages/typescript/openai-base/src/tools/image-generation-tool.ts +++ b/packages/typescript/openai-base/src/tools/image-generation-tool.ts @@ -1,7 +1,9 @@ -import type OpenAI from 'openai' +import type { Tool as SDKTool } from 'openai/resources/responses/responses' import type { Tool } from '@tanstack/ai' -export type ImageGenerationToolConfig = OpenAI.Responses.Tool.ImageGeneration +type ImageGenerationToolConfig = SDKTool.ImageGeneration + +export type { ImageGenerationToolConfig } /** @deprecated Renamed to `ImageGenerationToolConfig`. Will be removed in a future release. */ export type ImageGenerationTool = ImageGenerationToolConfig diff --git a/packages/typescript/openai-base/src/tools/local-shell-tool.ts b/packages/typescript/openai-base/src/tools/local-shell-tool.ts index dc15f46c5..359c9611f 100644 --- a/packages/typescript/openai-base/src/tools/local-shell-tool.ts +++ b/packages/typescript/openai-base/src/tools/local-shell-tool.ts @@ -1,7 +1,9 @@ -import type OpenAI from 'openai' +import type { Tool as SDKTool } from 'openai/resources/responses/responses' import type { Tool } from '@tanstack/ai' -export type LocalShellToolConfig = OpenAI.Responses.Tool.LocalShell +type LocalShellToolConfig = SDKTool.LocalShell + +export type { LocalShellToolConfig } /** @deprecated Renamed to `LocalShellToolConfig`. Will be removed in a future release. */ export type LocalShellTool = LocalShellToolConfig diff --git a/packages/typescript/openai-base/src/tools/mcp-tool.ts b/packages/typescript/openai-base/src/tools/mcp-tool.ts index 6693a466b..26d9a59f7 100644 --- a/packages/typescript/openai-base/src/tools/mcp-tool.ts +++ b/packages/typescript/openai-base/src/tools/mcp-tool.ts @@ -1,7 +1,9 @@ -import type OpenAI from 'openai' +import type { Tool as SDKTool } from 'openai/resources/responses/responses' import type { Tool } from '@tanstack/ai' -export type MCPToolConfig = OpenAI.Responses.Tool.Mcp +type MCPToolConfig = SDKTool.Mcp + +export type { MCPToolConfig } /** @deprecated Renamed to `MCPToolConfig`. Will be removed in a future release. */ export type MCPTool = MCPToolConfig diff --git a/packages/typescript/openai-base/src/tools/shell-tool.ts b/packages/typescript/openai-base/src/tools/shell-tool.ts index 4912a33c6..6c797d8b7 100644 --- a/packages/typescript/openai-base/src/tools/shell-tool.ts +++ b/packages/typescript/openai-base/src/tools/shell-tool.ts @@ -1,7 +1,7 @@ -import type OpenAI from 'openai' +import type { FunctionShellTool as ShellToolConfig } from 'openai/resources/responses/responses' import type { Tool } from '@tanstack/ai' -export type ShellToolConfig = OpenAI.Responses.FunctionShellTool +export type { ShellToolConfig } /** @deprecated Renamed to `ShellToolConfig`. Will be removed in a future release. */ export type ShellTool = ShellToolConfig 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 index 0f020fde4..18c71525f 100644 --- a/packages/typescript/openai-base/src/tools/web-search-preview-tool.ts +++ b/packages/typescript/openai-base/src/tools/web-search-preview-tool.ts @@ -1,7 +1,7 @@ -import type OpenAI from 'openai' +import type { WebSearchPreviewTool as WebSearchPreviewToolConfig } from 'openai/resources/responses/responses' import type { Tool } from '@tanstack/ai' -export type WebSearchPreviewToolConfig = OpenAI.Responses.WebSearchPreviewTool +export type { WebSearchPreviewToolConfig } /** @deprecated Renamed to `WebSearchPreviewToolConfig`. Will be removed in a future release. */ export type WebSearchPreviewTool = WebSearchPreviewToolConfig diff --git a/packages/typescript/openai-base/src/tools/web-search-tool.ts b/packages/typescript/openai-base/src/tools/web-search-tool.ts index ac5bfdfc9..eb84f31d1 100644 --- a/packages/typescript/openai-base/src/tools/web-search-tool.ts +++ b/packages/typescript/openai-base/src/tools/web-search-tool.ts @@ -1,7 +1,7 @@ -import type OpenAI from 'openai' +import type { WebSearchTool as WebSearchToolConfig } from 'openai/resources/responses/responses' import type { Tool } from '@tanstack/ai' -export type WebSearchToolConfig = OpenAI.Responses.WebSearchTool +export type { WebSearchToolConfig } /** @deprecated Renamed to `WebSearchToolConfig`. Will be removed in a future release. */ export type WebSearchTool = WebSearchToolConfig diff --git a/packages/typescript/openai-base/src/types/config.ts b/packages/typescript/openai-base/src/types/config.ts deleted file mode 100644 index 976336b42..000000000 --- a/packages/typescript/openai-base/src/types/config.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { ClientOptions } from 'openai' - -export interface OpenAICompatibleClientConfig extends ClientOptions { - apiKey: string -} diff --git a/packages/typescript/openai-base/src/utils/client.ts b/packages/typescript/openai-base/src/utils/client.ts deleted file mode 100644 index 8dd54b2fc..000000000 --- a/packages/typescript/openai-base/src/utils/client.ts +++ /dev/null @@ -1,8 +0,0 @@ -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/tests/chat-completions-text.test.ts b/packages/typescript/openai-base/tests/chat-completions-text.test.ts index a4bca2114..00f77bf5a 100644 --- a/packages/typescript/openai-base/tests/chat-completions-text.test.ts +++ b/packages/typescript/openai-base/tests/chat-completions-text.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest' -import { OpenAICompatibleChatCompletionsTextAdapter } from '../src/adapters/chat-completions-text' +import { OpenAIBaseChatCompletionsTextAdapter } from '../src/adapters/chat-completions-text' +import type OpenAI from 'openai' import type { StreamChunk, Tool } from '@tanstack/ai' import { resolveDebugOption } from '@tanstack/ai/adapter-internals' @@ -8,18 +9,32 @@ const testLogger = resolveDebugOption(false) // Declare mockCreate at module level let mockCreate: ReturnType -// Mock the OpenAI SDK -vi.mock('openai', () => { +/** Build a stub OpenAI client whose `chat.completions.create` defers to the + * module-level `mockCreate`. Reassigning `mockCreate` inside a test still + * takes effect because the stub looks it up at call time. */ +function makeStubClient(): OpenAI { return { - default: class { - chat = { - completions: { - create: (...args: Array) => mockCreate(...args), - }, - } + chat: { + completions: { + create: (params: unknown, options: unknown) => + mockCreate(params, options), + }, }, + } as unknown as OpenAI +} + +/** + * Concrete test subclass. The base now calls the OpenAI SDK directly, so the + * subclass just supplies a stub client whose `chat.completions.create` routes + * into `mockCreate` for per-test setup. Constructor signature mirrors the + * pre-refactor `(config, model, name)` shape so existing call sites read + * naturally; `config` is ignored. + */ +class TestChatCompletionsAdapter extends OpenAIBaseChatCompletionsTextAdapter { + constructor(_config: unknown, model: string, name = 'openai-compatible') { + super(model, name, makeStubClient()) } -}) +} // Helper to create async iterable from chunks function createAsyncIterable(chunks: Array): AsyncIterable { @@ -61,7 +76,7 @@ const weatherTool: Tool = { description: 'Return the forecast for a location', } -describe('OpenAICompatibleChatCompletionsTextAdapter', () => { +describe('OpenAIBaseChatCompletionsTextAdapter', () => { beforeEach(() => { vi.clearAllMocks() }) @@ -72,10 +87,7 @@ describe('OpenAICompatibleChatCompletionsTextAdapter', () => { describe('instantiation', () => { it('creates an adapter with default name', () => { - const adapter = new OpenAICompatibleChatCompletionsTextAdapter( - testConfig, - 'test-model', - ) + const adapter = new TestChatCompletionsAdapter(testConfig, 'test-model') expect(adapter).toBeDefined() expect(adapter.kind).toBe('text') @@ -84,7 +96,7 @@ describe('OpenAICompatibleChatCompletionsTextAdapter', () => { }) it('creates an adapter with custom name', () => { - const adapter = new OpenAICompatibleChatCompletionsTextAdapter( + const adapter = new TestChatCompletionsAdapter( testConfig, 'test-model', 'my-provider', @@ -95,7 +107,7 @@ describe('OpenAICompatibleChatCompletionsTextAdapter', () => { }) it('creates an adapter with custom baseURL', () => { - const adapter = new OpenAICompatibleChatCompletionsTextAdapter( + const adapter = new TestChatCompletionsAdapter( { apiKey: 'test-key', baseURL: 'https://custom.api.example.com/v1', @@ -139,10 +151,7 @@ describe('OpenAICompatibleChatCompletionsTextAdapter', () => { ] setupMockSdkClient(streamChunks) - const adapter = new OpenAICompatibleChatCompletionsTextAdapter( - testConfig, - 'test-model', - ) + const adapter = new TestChatCompletionsAdapter(testConfig, 'test-model') const chunks: Array = [] for await (const chunk of adapter.chatStream({ @@ -190,10 +199,7 @@ describe('OpenAICompatibleChatCompletionsTextAdapter', () => { ] setupMockSdkClient(streamChunks) - const adapter = new OpenAICompatibleChatCompletionsTextAdapter( - testConfig, - 'test-model', - ) + const adapter = new TestChatCompletionsAdapter(testConfig, 'test-model') const chunks: Array = [] for await (const chunk of adapter.chatStream({ @@ -252,10 +258,7 @@ describe('OpenAICompatibleChatCompletionsTextAdapter', () => { ] setupMockSdkClient(streamChunks) - const adapter = new OpenAICompatibleChatCompletionsTextAdapter( - testConfig, - 'test-model', - ) + const adapter = new TestChatCompletionsAdapter(testConfig, 'test-model') const chunks: Array = [] for await (const chunk of adapter.chatStream({ @@ -322,10 +325,7 @@ describe('OpenAICompatibleChatCompletionsTextAdapter', () => { ] setupMockSdkClient(streamChunks) - const adapter = new OpenAICompatibleChatCompletionsTextAdapter( - testConfig, - 'test-model', - ) + const adapter = new TestChatCompletionsAdapter(testConfig, 'test-model') const chunks: Array = [] for await (const chunk of adapter.chatStream({ @@ -395,10 +395,7 @@ describe('OpenAICompatibleChatCompletionsTextAdapter', () => { ] setupMockSdkClient(streamChunks) - const adapter = new OpenAICompatibleChatCompletionsTextAdapter( - testConfig, - 'test-model', - ) + const adapter = new TestChatCompletionsAdapter(testConfig, 'test-model') const chunks: Array = [] for await (const chunk of adapter.chatStream({ @@ -491,10 +488,7 @@ describe('OpenAICompatibleChatCompletionsTextAdapter', () => { ] setupMockSdkClient(streamChunks) - const adapter = new OpenAICompatibleChatCompletionsTextAdapter( - testConfig, - 'test-model', - ) + const adapter = new TestChatCompletionsAdapter(testConfig, 'test-model') const chunks: Array = [] for await (const chunk of adapter.chatStream({ @@ -565,10 +559,7 @@ describe('OpenAICompatibleChatCompletionsTextAdapter', () => { mockCreate = vi.fn().mockResolvedValue(errorIterable) - const adapter = new OpenAICompatibleChatCompletionsTextAdapter( - testConfig, - 'test-model', - ) + const adapter = new TestChatCompletionsAdapter(testConfig, 'test-model') const chunks: Array = [] for await (const chunk of adapter.chatStream({ @@ -590,10 +581,7 @@ describe('OpenAICompatibleChatCompletionsTextAdapter', () => { 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 adapter = new TestChatCompletionsAdapter(testConfig, 'test-model') const chunks: Array = [] for await (const chunk of adapter.chatStream({ @@ -628,10 +616,7 @@ describe('OpenAICompatibleChatCompletionsTextAdapter', () => { setupMockSdkClient([], nonStreamResponse) - const adapter = new OpenAICompatibleChatCompletionsTextAdapter( - testConfig, - 'test-model', - ) + const adapter = new TestChatCompletionsAdapter(testConfig, 'test-model') const result = await adapter.structuredOutput({ chatOptions: { @@ -677,10 +662,7 @@ describe('OpenAICompatibleChatCompletionsTextAdapter', () => { setupMockSdkClient([], nonStreamResponse) - const adapter = new OpenAICompatibleChatCompletionsTextAdapter( - testConfig, - 'test-model', - ) + const adapter = new TestChatCompletionsAdapter(testConfig, 'test-model') const result = await adapter.structuredOutput({ chatOptions: { @@ -716,10 +698,7 @@ describe('OpenAICompatibleChatCompletionsTextAdapter', () => { setupMockSdkClient([], nonStreamResponse) - const adapter = new OpenAICompatibleChatCompletionsTextAdapter( - testConfig, - 'test-model', - ) + const adapter = new TestChatCompletionsAdapter(testConfig, 'test-model') await expect( adapter.structuredOutput({ @@ -738,17 +717,113 @@ describe('OpenAICompatibleChatCompletionsTextAdapter', () => { }), ).rejects.toThrow('Failed to parse structured output as JSON') }) + + it('throws a clear "no content" error when content is empty', async () => { + const nonStreamResponse = { + choices: [{ message: { content: '' } }], + } + setupMockSdkClient([], nonStreamResponse) + + const adapter = new TestChatCompletionsAdapter(testConfig, 'test-model') + + // Empty content must surface as a distinct error rather than masquerade + // as a JSON-parse failure on an empty string. + await expect( + adapter.structuredOutput({ + chatOptions: { + logger: testLogger, + model: 'test-model', + messages: [{ role: 'user', content: 'Give me data' }], + }, + outputSchema: { type: 'object' }, + }), + ).rejects.toThrow('response contained no content') + }) + + it('throws a clear "no content" error when content is missing', async () => { + const nonStreamResponse = { + choices: [{ message: {} }], + } + setupMockSdkClient([], nonStreamResponse) + + const adapter = new TestChatCompletionsAdapter(testConfig, 'test-model') + + await expect( + adapter.structuredOutput({ + chatOptions: { + logger: testLogger, + model: 'test-model', + messages: [{ role: 'user', content: 'Give me data' }], + }, + outputSchema: { type: 'object' }, + }), + ).rejects.toThrow('response contained no content') + }) + }) + + describe('drain-path tool args error handling', () => { + it('logs malformed JSON tool args via the logger when the stream ends without finish_reason', async () => { + // Simulates a truncated stream: tool call starts and accumulates + // malformed JSON, but no finish_reason chunk ever arrives. The drain + // block must still surface the parse failure rather than swallowing it. + const streamChunks = [ + { + id: 'chatcmpl-drain', + model: 'test-model', + choices: [ + { + delta: { + tool_calls: [ + { + index: 0, + id: 'call_drain', + type: 'function', + function: { + name: 'lookup_weather', + arguments: '{"location":', // truncated — invalid JSON + }, + }, + ], + }, + finish_reason: null, + }, + ], + }, + ] + + setupMockSdkClient(streamChunks) + const errorsSpy = vi.spyOn(testLogger, 'errors') + const adapter = new TestChatCompletionsAdapter(testConfig, 'test-model') + + try { + for await (const _ of adapter.chatStream({ + logger: testLogger, + model: 'test-model', + messages: [{ role: 'user', content: 'Weather?' }], + tools: [weatherTool], + })) { + // consume + } + + const drainCall = errorsSpy.mock.calls.find((c) => + String(c[0]).includes('(drain)'), + ) + expect(drainCall).toBeDefined() + const ctx = drainCall![1] as Record + expect(ctx.toolCallId).toBe('call_drain') + expect(ctx.toolName).toBe('lookup_weather') + expect(ctx.rawArguments).toBe('{"location":') + } finally { + errorsSpy.mockRestore() + } + }) }) 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', - ) + class MyProviderAdapter extends OpenAIBaseChatCompletionsTextAdapter { + constructor(_apiKey: string, model: string) { + super(model, 'my-provider', makeStubClient()) } } @@ -776,10 +851,7 @@ describe('OpenAICompatibleChatCompletionsTextAdapter', () => { ] setupMockSdkClient(streamChunks) - const adapter = new OpenAICompatibleChatCompletionsTextAdapter( - testConfig, - 'test-model', - ) + const adapter = new TestChatCompletionsAdapter(testConfig, 'test-model') const chunks: Array = [] for await (const chunk of adapter.chatStream({ @@ -817,10 +889,7 @@ describe('OpenAICompatibleChatCompletionsTextAdapter', () => { ] setupMockSdkClient(streamChunks) - const adapter = new OpenAICompatibleChatCompletionsTextAdapter( - testConfig, - 'test-model', - ) + const adapter = new TestChatCompletionsAdapter(testConfig, 'test-model') const chunks: Array = [] for await (const chunk of adapter.chatStream({ @@ -848,10 +917,7 @@ describe('OpenAICompatibleChatCompletionsTextAdapter', () => { setupMockSdkClient([], nonStreamResponse) - const adapter = new OpenAICompatibleChatCompletionsTextAdapter( - testConfig, - 'test-model', - ) + const adapter = new TestChatCompletionsAdapter(testConfig, 'test-model') await adapter.structuredOutput({ chatOptions: { @@ -888,10 +954,7 @@ describe('OpenAICompatibleChatCompletionsTextAdapter', () => { ] setupMockSdkClient(streamChunks) - const adapter = new OpenAICompatibleChatCompletionsTextAdapter( - testConfig, - 'test-model', - ) + const adapter = new TestChatCompletionsAdapter(testConfig, 'test-model') const controller = new AbortController() const chunks: Array = [] diff --git a/packages/typescript/openai-base/tests/media-adapters.test.ts b/packages/typescript/openai-base/tests/media-adapters.test.ts deleted file mode 100644 index d49dbf222..000000000 --- a/packages/typescript/openai-base/tests/media-adapters.test.ts +++ /dev/null @@ -1,367 +0,0 @@ -/** - * Smoke tests for the OpenAI-compatible media adapters (image, summarize, - * transcription, TTS, video). Each test verifies the adapter instantiates, - * forwards arguments to the OpenAI SDK shape we expect, and surfaces errors - * via `logger.errors` / `RUN_ERROR` rather than swallowing them. The mocks - * stand in for the OpenAI SDK; the real SDK is exercised in the e2e suite. - */ -import { describe, expect, it, beforeEach, vi } from 'vitest' -import { resolveDebugOption } from '@tanstack/ai/adapter-internals' -import { OpenAICompatibleImageAdapter } from '../src/adapters/image' -import { OpenAICompatibleSummarizeAdapter } from '../src/adapters/summarize' -import { OpenAICompatibleTranscriptionAdapter } from '../src/adapters/transcription' -import { OpenAICompatibleTTSAdapter } from '../src/adapters/tts' -import { OpenAICompatibleVideoAdapter } from '../src/adapters/video' -import type { ChatStreamCapable } from '../src/adapters/summarize' -import type { StreamChunk } from '@tanstack/ai' - -const testLogger = resolveDebugOption(false) - -let mockImagesGenerate: ReturnType -let mockTranscriptionsCreate: ReturnType -let mockSpeechCreate: ReturnType -let mockVideosCreate: ReturnType -let mockVideosRetrieve: ReturnType - -vi.mock('openai', () => { - return { - default: class { - images = { - generate: (...args: Array) => mockImagesGenerate(...args), - } - audio = { - transcriptions: { - create: (...args: Array) => - mockTranscriptionsCreate(...args), - }, - speech: { - create: (...args: Array) => mockSpeechCreate(...args), - }, - } - videos = { - create: (...args: Array) => mockVideosCreate(...args), - retrieve: (...args: Array) => mockVideosRetrieve(...args), - } - }, - } -}) - -const config = { - apiKey: 'test-key', - baseURL: 'https://api.test-provider.com/v1', -} - -beforeEach(() => { - vi.clearAllMocks() - mockImagesGenerate = vi.fn() - mockTranscriptionsCreate = vi.fn() - mockSpeechCreate = vi.fn() - mockVideosCreate = vi.fn() - mockVideosRetrieve = vi.fn() -}) - -describe('OpenAICompatibleImageAdapter', () => { - it('forwards model, prompt, n, and size to images.generate', async () => { - mockImagesGenerate.mockResolvedValue({ - data: [{ url: 'https://example.com/img.png' }], - }) - - const adapter = new OpenAICompatibleImageAdapter(config, 'test-model') - const result = await adapter.generateImages({ - logger: testLogger, - model: 'test-model', - prompt: 'a cat', - numberOfImages: 2, - size: '1024x1024', - }) - - expect(mockImagesGenerate).toHaveBeenCalledWith( - expect.objectContaining({ - model: 'test-model', - prompt: 'a cat', - n: 2, - size: '1024x1024', - stream: false, - }), - ) - expect(result.images).toHaveLength(1) - expect(result.images[0]).toMatchObject({ - url: 'https://example.com/img.png', - }) - }) - - it('rejects invalid number of images via base validator', async () => { - const adapter = new OpenAICompatibleImageAdapter(config, 'test-model') - await expect( - adapter.generateImages({ - logger: testLogger, - model: 'test-model', - prompt: 'a cat', - numberOfImages: 0, - }), - ).rejects.toThrow('at least 1') - }) - - it('logs to errors and rethrows on SDK failure', async () => { - const errors = vi.fn() - // testLogger is a class instance — spreading drops prototype methods, so - // wrap with a Proxy that overrides `errors` and forwards everything else. - const logger = new Proxy(testLogger, { - get(target, key) { - if (key === 'errors') return errors - return Reflect.get(target, key) - }, - }) - mockImagesGenerate.mockRejectedValue(new Error('boom')) - - const adapter = new OpenAICompatibleImageAdapter(config, 'test-model') - await expect( - adapter.generateImages({ - logger, - model: 'test-model', - prompt: 'a cat', - }), - ).rejects.toThrow('boom') - expect(errors).toHaveBeenCalled() - }) -}) - -describe('OpenAICompatibleSummarizeAdapter', () => { - function fakeTextAdapter( - chunks: Array, - ): ChatStreamCapable> { - return { - async *chatStream() { - for (const c of chunks) { - yield c - } - }, - } - } - - it('accumulates content from TEXT_MESSAGE_CONTENT chunks', async () => { - const adapter = new OpenAICompatibleSummarizeAdapter( - fakeTextAdapter([ - { - type: 'TEXT_MESSAGE_CONTENT', - delta: 'Hello ', - messageId: 'm1', - model: 'test-model', - timestamp: 1, - } as unknown as StreamChunk, - { - type: 'TEXT_MESSAGE_CONTENT', - delta: 'world', - messageId: 'm1', - model: 'test-model', - timestamp: 2, - } as unknown as StreamChunk, - { - type: 'RUN_FINISHED', - runId: 'r1', - model: 'test-model', - timestamp: 3, - usage: { promptTokens: 10, completionTokens: 5, totalTokens: 15 }, - finishReason: 'stop', - } as unknown as StreamChunk, - ]), - 'test-model', - 'test-provider', - ) - - const result = await adapter.summarize({ - logger: testLogger, - model: 'test-model', - text: 'Long text to summarise.', - }) - - expect(result.summary).toBe('Hello world') - expect(result.usage).toEqual({ - promptTokens: 10, - completionTokens: 5, - totalTokens: 15, - }) - }) - - it('throws and logs when the underlying chatStream emits RUN_ERROR', async () => { - const errors = vi.fn() - // testLogger is a class instance — spreading drops prototype methods, so - // wrap with a Proxy that overrides `errors` and forwards everything else. - const logger = new Proxy(testLogger, { - get(target, key) { - if (key === 'errors') return errors - return Reflect.get(target, key) - }, - }) - - const adapter = new OpenAICompatibleSummarizeAdapter( - { - async *chatStream() { - yield { - type: 'RUN_ERROR', - runId: 'r1', - model: 'test-model', - timestamp: 1, - error: { message: 'upstream rate limit', code: 'rate_limited' }, - } as unknown as StreamChunk - }, - }, - 'test-model', - 'test-provider', - ) - - await expect( - adapter.summarize({ - logger, - model: 'test-model', - text: 'irrelevant', - }), - ).rejects.toThrow('upstream rate limit') - expect(errors).toHaveBeenCalled() - }) -}) - -describe('OpenAICompatibleTranscriptionAdapter', () => { - it('forwards model and language and returns text-only result for non-verbose formats', async () => { - mockTranscriptionsCreate.mockResolvedValue({ text: 'hello world' }) - - const adapter = new OpenAICompatibleTranscriptionAdapter( - config, - 'whisper-1', - ) - const result = await adapter.transcribe({ - logger: testLogger, - model: 'whisper-1', - audio: new Blob([new Uint8Array([1, 2, 3])], { type: 'audio/mpeg' }), - language: 'en', - responseFormat: 'json', - }) - - expect(mockTranscriptionsCreate).toHaveBeenCalledWith( - expect.objectContaining({ - model: 'whisper-1', - language: 'en', - }), - ) - expect(result.text).toBe('hello world') - expect(result.segments).toBeUndefined() - }) - - it('decodes a base64 audio string to a File on the request path', async () => { - mockTranscriptionsCreate.mockResolvedValue({ text: 'decoded' }) - - const adapter = new OpenAICompatibleTranscriptionAdapter( - config, - 'whisper-1', - ) - // 3 raw bytes encoded as base64 - const base64 = 'AQID' - await adapter.transcribe({ - logger: testLogger, - model: 'whisper-1', - audio: base64, - responseFormat: 'json', - }) - - const callArgs = mockTranscriptionsCreate.mock.calls[0]?.[0] - expect(callArgs?.file).toBeDefined() - expect(callArgs?.file).toBeInstanceOf(File) - }) -}) - -describe('OpenAICompatibleTTSAdapter', () => { - it('forwards model/voice/format/speed and returns base64 audio', async () => { - const fakeBuffer = new Uint8Array([1, 2, 3, 4]).buffer - mockSpeechCreate.mockResolvedValue({ - arrayBuffer: () => Promise.resolve(fakeBuffer), - }) - - const adapter = new OpenAICompatibleTTSAdapter(config, 'tts-1') - const result = await adapter.generateSpeech({ - logger: testLogger, - model: 'tts-1', - text: 'Hello', - voice: 'alloy', - format: 'mp3', - speed: 1.0, - }) - - expect(mockSpeechCreate).toHaveBeenCalledWith( - expect.objectContaining({ - model: 'tts-1', - input: 'Hello', - voice: 'alloy', - response_format: 'mp3', - speed: 1.0, - }), - ) - expect(result.audio).toBeTruthy() - expect(result.contentType).toBe('audio/mpeg') - expect(result.format).toBe('mp3') - }) - - it('rejects out-of-range speed via base validator', async () => { - const adapter = new OpenAICompatibleTTSAdapter(config, 'tts-1') - await expect( - adapter.generateSpeech({ - logger: testLogger, - model: 'tts-1', - text: 'Hello', - speed: 5.0, - }), - ).rejects.toThrow('Speed') - }) -}) - -describe('OpenAICompatibleVideoAdapter', () => { - it('createVideoJob forwards model/prompt/size/duration and returns jobId', async () => { - mockVideosCreate.mockResolvedValue({ id: 'job-123' }) - - const adapter = new OpenAICompatibleVideoAdapter(config, 'sora-2') - const result = await adapter.createVideoJob({ - logger: testLogger, - model: 'sora-2', - prompt: 'a sunset', - size: '1080x1920', - duration: 4, - }) - - expect(mockVideosCreate).toHaveBeenCalledWith( - expect.objectContaining({ - model: 'sora-2', - prompt: 'a sunset', - size: '1080x1920', - seconds: '4', - }), - ) - expect(result.jobId).toBe('job-123') - }) - - it('getVideoStatus maps SDK status strings to the AG-UI vocabulary', async () => { - mockVideosRetrieve.mockResolvedValue({ - id: 'job-123', - status: 'queued', - progress: 5, - }) - - const adapter = new OpenAICompatibleVideoAdapter(config, 'sora-2') - const status = await adapter.getVideoStatus('job-123') - - expect(status.status).toBe('pending') - expect(status.progress).toBe(5) - }) - - it('getVideoUrl returns the URL directly when retrieve() exposes one', async () => { - mockVideosRetrieve.mockResolvedValue({ - id: 'job-123', - url: 'https://cdn.example.com/job-123.mp4', - expires_at: 1700000000, - }) - - const adapter = new OpenAICompatibleVideoAdapter(config, 'sora-2') - const result = await adapter.getVideoUrl('job-123') - - expect(result.url).toBe('https://cdn.example.com/job-123.mp4') - expect(result.expiresAt).toBeInstanceOf(Date) - }) -}) diff --git a/packages/typescript/openai-base/tests/responses-text.test.ts b/packages/typescript/openai-base/tests/responses-text.test.ts index abf9729f2..16613a662 100644 --- a/packages/typescript/openai-base/tests/responses-text.test.ts +++ b/packages/typescript/openai-base/tests/responses-text.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest' -import { OpenAICompatibleResponsesTextAdapter } from '../src/adapters/responses-text' +import { OpenAIBaseResponsesTextAdapter } from '../src/adapters/responses-text' +import type OpenAI from 'openai' import type { StreamChunk, Tool } from '@tanstack/ai' import { resolveDebugOption } from '@tanstack/ai/adapter-internals' @@ -8,16 +9,32 @@ const testLogger = resolveDebugOption(false) // Declare mockCreate at module level let mockResponsesCreate: ReturnType -// Mock the OpenAI SDK -vi.mock('openai', () => { +/** Build a stub OpenAI client whose `responses.create` defers to the + * module-level `mockResponsesCreate`. Reassigning the mock inside a test + * still takes effect because the stub looks it up at call time. */ +function makeStubClient(): OpenAI { return { - default: class { - responses = { - create: (...args: Array) => mockResponsesCreate(...args), - } + responses: { + create: (params: unknown, options: unknown) => + mockResponsesCreate(params, options), }, + } as unknown as OpenAI +} + +/** + * Concrete test subclass. The base now calls the OpenAI SDK directly, so the + * subclass just supplies a stub client whose `responses.create` routes into + * `mockResponsesCreate` for per-test setup. + */ +class TestResponsesAdapter extends OpenAIBaseResponsesTextAdapter { + constructor( + _config: unknown, + model: string, + name = 'openai-compatible-responses', + ) { + super(model, name, makeStubClient()) } -}) +} // Helper to create async iterable from chunks function createAsyncIterable(chunks: Array): AsyncIterable { @@ -59,7 +76,7 @@ const weatherTool: Tool = { description: 'Return the forecast for a location', } -describe('OpenAICompatibleResponsesTextAdapter', () => { +describe('OpenAIBaseResponsesTextAdapter', () => { beforeEach(() => { vi.clearAllMocks() }) @@ -70,10 +87,7 @@ describe('OpenAICompatibleResponsesTextAdapter', () => { describe('instantiation', () => { it('creates an adapter with default name', () => { - const adapter = new OpenAICompatibleResponsesTextAdapter( - testConfig, - 'test-model', - ) + const adapter = new TestResponsesAdapter(testConfig, 'test-model') expect(adapter).toBeDefined() expect(adapter.kind).toBe('text') @@ -82,7 +96,7 @@ describe('OpenAICompatibleResponsesTextAdapter', () => { }) it('creates an adapter with custom name', () => { - const adapter = new OpenAICompatibleResponsesTextAdapter( + const adapter = new TestResponsesAdapter( testConfig, 'test-model', 'my-provider', @@ -93,7 +107,7 @@ describe('OpenAICompatibleResponsesTextAdapter', () => { }) it('creates an adapter with custom baseURL', () => { - const adapter = new OpenAICompatibleResponsesTextAdapter( + const adapter = new TestResponsesAdapter( { apiKey: 'test-key', baseURL: 'https://custom.api.example.com/v1', @@ -138,10 +152,7 @@ describe('OpenAICompatibleResponsesTextAdapter', () => { ] setupMockResponsesClient(streamChunks) - const adapter = new OpenAICompatibleResponsesTextAdapter( - testConfig, - 'test-model', - ) + const adapter = new TestResponsesAdapter(testConfig, 'test-model') const chunks: Array = [] for await (const chunk of adapter.chatStream({ @@ -190,10 +201,7 @@ describe('OpenAICompatibleResponsesTextAdapter', () => { ] setupMockResponsesClient(streamChunks) - const adapter = new OpenAICompatibleResponsesTextAdapter( - testConfig, - 'test-model', - ) + const adapter = new TestResponsesAdapter(testConfig, 'test-model') const chunks: Array = [] for await (const chunk of adapter.chatStream({ @@ -253,10 +261,7 @@ describe('OpenAICompatibleResponsesTextAdapter', () => { ] setupMockResponsesClient(streamChunks) - const adapter = new OpenAICompatibleResponsesTextAdapter( - testConfig, - 'test-model', - ) + const adapter = new TestResponsesAdapter(testConfig, 'test-model') const chunks: Array = [] for await (const chunk of adapter.chatStream({ @@ -324,10 +329,7 @@ describe('OpenAICompatibleResponsesTextAdapter', () => { ] setupMockResponsesClient(streamChunks) - const adapter = new OpenAICompatibleResponsesTextAdapter( - testConfig, - 'test-model', - ) + const adapter = new TestResponsesAdapter(testConfig, 'test-model') const chunks: Array = [] for await (const chunk of adapter.chatStream({ @@ -392,10 +394,7 @@ describe('OpenAICompatibleResponsesTextAdapter', () => { ] setupMockResponsesClient(streamChunks) - const adapter = new OpenAICompatibleResponsesTextAdapter( - testConfig, - 'test-model', - ) + const adapter = new TestResponsesAdapter(testConfig, 'test-model') const chunks: Array = [] for await (const chunk of adapter.chatStream({ @@ -466,10 +465,7 @@ describe('OpenAICompatibleResponsesTextAdapter', () => { ] setupMockResponsesClient(streamChunks) - const adapter = new OpenAICompatibleResponsesTextAdapter( - testConfig, - 'test-model', - ) + const adapter = new TestResponsesAdapter(testConfig, 'test-model') const chunks: Array = [] for await (const chunk of adapter.chatStream({ @@ -550,10 +546,7 @@ describe('OpenAICompatibleResponsesTextAdapter', () => { ] setupMockResponsesClient(streamChunks) - const adapter = new OpenAICompatibleResponsesTextAdapter( - testConfig, - 'test-model', - ) + const adapter = new TestResponsesAdapter(testConfig, 'test-model') const chunks: Array = [] for await (const chunk of adapter.chatStream({ @@ -637,10 +630,7 @@ describe('OpenAICompatibleResponsesTextAdapter', () => { ] setupMockResponsesClient(streamChunks) - const adapter = new OpenAICompatibleResponsesTextAdapter( - testConfig, - 'test-model', - ) + const adapter = new TestResponsesAdapter(testConfig, 'test-model') const chunks: Array = [] for await (const chunk of adapter.chatStream({ @@ -763,10 +753,7 @@ describe('OpenAICompatibleResponsesTextAdapter', () => { ] setupMockResponsesClient(streamChunks) - const adapter = new OpenAICompatibleResponsesTextAdapter( - testConfig, - 'test-model', - ) + const adapter = new TestResponsesAdapter(testConfig, 'test-model') const chunks: Array = [] for await (const chunk of adapter.chatStream({ @@ -852,10 +839,7 @@ describe('OpenAICompatibleResponsesTextAdapter', () => { ] setupMockResponsesClient(streamChunks) - const adapter = new OpenAICompatibleResponsesTextAdapter( - testConfig, - 'test-model', - ) + const adapter = new TestResponsesAdapter(testConfig, 'test-model') const chunks: Array = [] for await (const chunk of adapter.chatStream({ @@ -889,6 +873,297 @@ describe('OpenAICompatibleResponsesTextAdapter', () => { expect(toolEnd.toolCallId).toBe('fc_internal_001') } }) + + it('does not emit TOOL_CALL_START until the item carries a name (no empty-name misroute)', async () => { + const streamChunks = [ + { + type: 'response.created', + response: { + id: 'resp-x', + model: 'test-model', + status: 'in_progress', + }, + }, + // First added event lacks a name — should NOT emit TOOL_CALL_START + { + type: 'response.output_item.added', + output_index: 0, + item: { type: 'function_call', id: 'call_late_name' }, + }, + // Second added event for the same id finally carries the name + { + type: 'response.output_item.added', + output_index: 0, + item: { + type: 'function_call', + id: 'call_late_name', + name: 'lookup_weather', + }, + }, + { + type: 'response.function_call_arguments.done', + item_id: 'call_late_name', + arguments: '{"location":"NYC"}', + }, + { + type: 'response.completed', + response: { + id: 'resp-x', + model: 'test-model', + status: 'completed', + output: [ + { + type: 'function_call', + id: 'call_late_name', + name: 'lookup_weather', + arguments: '{"location":"NYC"}', + }, + ], + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, + }, + }, + ] + setupMockResponsesClient(streamChunks) + const adapter = new TestResponsesAdapter(testConfig, 'test-model') + const chunks: Array = [] + for await (const chunk of adapter.chatStream({ + logger: testLogger, + model: 'test-model', + messages: [{ role: 'user', content: 'q' }], + tools: [weatherTool], + })) { + chunks.push(chunk) + } + const starts = chunks.filter((c) => c.type === 'TOOL_CALL_START') + expect(starts.length).toBe(1) + if (starts[0]?.type === 'TOOL_CALL_START') { + expect(starts[0].toolName).toBe('lookup_weather') + expect(starts[0].toolCallName).toBe('lookup_weather') + } + const end = chunks.find((c) => c.type === 'TOOL_CALL_END') + expect(end).toBeDefined() + if (end?.type === 'TOOL_CALL_END') { + expect(end.toolName).toBe('lookup_weather') + } + }) + + it('backfills tool call via output_item.done when name was missing from output_item.added', async () => { + // Some upstreams send `output_item.added` with no `name`, then carry + // the full function_call item (including name and arguments) in + // `output_item.done`. Without a backfill, the tool call would be + // silently dropped from the AG-UI stream. + const streamChunks = [ + { + type: 'response.created', + response: { + id: 'resp-bf', + model: 'test-model', + status: 'in_progress', + }, + }, + { + type: 'response.output_item.added', + output_index: 0, + item: { type: 'function_call', id: 'fc_bf' }, + }, + // Orphan args deltas + done arrive before name is known. + { + type: 'response.function_call_arguments.delta', + item_id: 'fc_bf', + delta: '{"location":', + }, + { + type: 'response.function_call_arguments.done', + item_id: 'fc_bf', + arguments: '{"location":"NYC"}', + }, + // output_item.done finally carries the name + full arguments. + { + type: 'response.output_item.done', + output_index: 0, + item: { + type: 'function_call', + id: 'fc_bf', + name: 'lookup_weather', + arguments: '{"location":"NYC"}', + }, + }, + { + type: 'response.completed', + response: { + id: 'resp-bf', + model: 'test-model', + status: 'completed', + output: [ + { + type: 'function_call', + id: 'fc_bf', + name: 'lookup_weather', + arguments: '{"location":"NYC"}', + }, + ], + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, + }, + }, + ] + setupMockResponsesClient(streamChunks) + const adapter = new TestResponsesAdapter(testConfig, 'test-model') + const chunks: Array = [] + for await (const chunk of adapter.chatStream({ + logger: testLogger, + model: 'test-model', + messages: [{ role: 'user', content: 'q' }], + tools: [weatherTool], + })) { + chunks.push(chunk) + } + const starts = chunks.filter((c) => c.type === 'TOOL_CALL_START') + const ends = chunks.filter((c) => c.type === 'TOOL_CALL_END') + expect(starts).toHaveLength(1) + expect(ends).toHaveLength(1) + if (starts[0]?.type === 'TOOL_CALL_START') { + expect(starts[0].toolName).toBe('lookup_weather') + } + if (ends[0]?.type === 'TOOL_CALL_END') { + expect(ends[0].toolName).toBe('lookup_weather') + expect(ends[0].input).toEqual({ location: 'NYC' }) + } + }) + + it('backfills tool call from response.completed.output[] when name never arrived mid-stream', async () => { + // Defense-in-depth backstop: if neither output_item.added nor + // output_item.done carried the name (very off-spec upstream), the + // function_call item in response.completed.output[] still has it. + const streamChunks = [ + { + type: 'response.created', + response: { + id: 'resp-final', + model: 'test-model', + status: 'in_progress', + }, + }, + { + type: 'response.output_item.added', + output_index: 0, + item: { type: 'function_call', id: 'fc_final' }, + }, + { + type: 'response.function_call_arguments.done', + item_id: 'fc_final', + arguments: '{"location":"Berlin"}', + }, + { + type: 'response.completed', + response: { + id: 'resp-final', + model: 'test-model', + status: 'completed', + output: [ + { + type: 'function_call', + id: 'fc_final', + name: 'lookup_weather', + arguments: '{"location":"Berlin"}', + }, + ], + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, + }, + }, + ] + setupMockResponsesClient(streamChunks) + const adapter = new TestResponsesAdapter(testConfig, 'test-model') + const chunks: Array = [] + for await (const chunk of adapter.chatStream({ + logger: testLogger, + model: 'test-model', + messages: [{ role: 'user', content: 'q' }], + tools: [weatherTool], + })) { + chunks.push(chunk) + } + const starts = chunks.filter((c) => c.type === 'TOOL_CALL_START') + const ends = chunks.filter((c) => c.type === 'TOOL_CALL_END') + expect(starts).toHaveLength(1) + expect(ends).toHaveLength(1) + if (ends[0]?.type === 'TOOL_CALL_END') { + expect(ends[0].toolName).toBe('lookup_weather') + expect(ends[0].input).toEqual({ location: 'Berlin' }) + } + }) + + it('does not emit duplicate TOOL_CALL_END when output_item.done precedes args.done', async () => { + // Reverse ordering: output_item.done arrives with full arguments and + // emits START + END (sets ended=true), then a late args.done arrives + // for the same id. Without the metadata.ended guard, args.done would + // emit a second TOOL_CALL_END. + const streamChunks = [ + { + type: 'response.created', + response: { + id: 'resp-rev', + model: 'test-model', + status: 'in_progress', + }, + }, + { + type: 'response.output_item.added', + output_index: 0, + item: { + type: 'function_call', + id: 'fc_rev', + name: 'lookup_weather', + }, + }, + { + type: 'response.output_item.done', + output_index: 0, + item: { + type: 'function_call', + id: 'fc_rev', + name: 'lookup_weather', + arguments: '{"location":"Tokyo"}', + }, + }, + { + type: 'response.function_call_arguments.done', + item_id: 'fc_rev', + arguments: '{"location":"Tokyo"}', + }, + { + type: 'response.completed', + response: { + id: 'resp-rev', + model: 'test-model', + status: 'completed', + output: [ + { + type: 'function_call', + id: 'fc_rev', + name: 'lookup_weather', + arguments: '{"location":"Tokyo"}', + }, + ], + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, + }, + }, + ] + setupMockResponsesClient(streamChunks) + const adapter = new TestResponsesAdapter(testConfig, 'test-model') + const chunks: Array = [] + for await (const chunk of adapter.chatStream({ + logger: testLogger, + model: 'test-model', + messages: [{ role: 'user', content: 'q' }], + tools: [weatherTool], + })) { + chunks.push(chunk) + } + const starts = chunks.filter((c) => c.type === 'TOOL_CALL_START') + const ends = chunks.filter((c) => c.type === 'TOOL_CALL_END') + expect(starts).toHaveLength(1) + expect(ends).toHaveLength(1) + }) }) describe('content_part events', () => { @@ -926,10 +1201,7 @@ describe('OpenAICompatibleResponsesTextAdapter', () => { ] setupMockResponsesClient(streamChunks) - const adapter = new OpenAICompatibleResponsesTextAdapter( - testConfig, - 'test-model', - ) + const adapter = new TestResponsesAdapter(testConfig, 'test-model') const chunks: Array = [] for await (const chunk of adapter.chatStream({ @@ -992,10 +1264,7 @@ describe('OpenAICompatibleResponsesTextAdapter', () => { ] setupMockResponsesClient(streamChunks) - const adapter = new OpenAICompatibleResponsesTextAdapter( - testConfig, - 'test-model', - ) + const adapter = new TestResponsesAdapter(testConfig, 'test-model') const chunks: Array = [] for await (const chunk of adapter.chatStream({ @@ -1012,6 +1281,51 @@ describe('OpenAICompatibleResponsesTextAdapter', () => { ) expect(contentChunks.length).toBe(2) }) + + it('emits TEXT_MESSAGE_START before content when only content_part.done fires (no preceding deltas or added)', async () => { + const streamChunks = [ + { + type: 'response.created', + response: { + id: 'resp-d', + model: 'test-model', + status: 'in_progress', + }, + }, + // No deltas, no content_part.added — only the done event. + { + type: 'response.content_part.done', + part: { type: 'output_text', text: 'whole message at once' }, + }, + { + type: 'response.completed', + response: { + id: 'resp-d', + model: 'test-model', + status: 'completed', + output: [], + usage: { input_tokens: 5, output_tokens: 3, total_tokens: 8 }, + }, + }, + ] + setupMockResponsesClient(streamChunks) + const adapter = new TestResponsesAdapter(testConfig, 'test-model') + const chunks: Array = [] + for await (const chunk of adapter.chatStream({ + logger: testLogger, + model: 'test-model', + messages: [{ role: 'user', content: 'hi' }], + })) { + chunks.push(chunk) + } + const types = chunks.map((c) => c.type) + const startIdx = types.indexOf('TEXT_MESSAGE_START') + const contentIdx = types.indexOf('TEXT_MESSAGE_CONTENT') + const endIdx = types.indexOf('TEXT_MESSAGE_END') + expect(startIdx).toBeGreaterThanOrEqual(0) + expect(contentIdx).toBeGreaterThan(startIdx) + expect(endIdx).toBeGreaterThan(contentIdx) + }) }) describe('error handling', () => { @@ -1048,10 +1362,7 @@ describe('OpenAICompatibleResponsesTextAdapter', () => { mockResponsesCreate = vi.fn().mockResolvedValue(errorIterable) - const adapter = new OpenAICompatibleResponsesTextAdapter( - testConfig, - 'test-model', - ) + const adapter = new TestResponsesAdapter(testConfig, 'test-model') const chunks: Array = [] for await (const chunk of adapter.chatStream({ @@ -1075,10 +1386,7 @@ describe('OpenAICompatibleResponsesTextAdapter', () => { .fn() .mockRejectedValue(new Error('API key invalid')) - const adapter = new OpenAICompatibleResponsesTextAdapter( - testConfig, - 'test-model', - ) + const adapter = new TestResponsesAdapter(testConfig, 'test-model') const chunks: Array = [] for await (const chunk of adapter.chatStream({ @@ -1115,10 +1423,7 @@ describe('OpenAICompatibleResponsesTextAdapter', () => { ] setupMockResponsesClient(streamChunks) - const adapter = new OpenAICompatibleResponsesTextAdapter( - testConfig, - 'test-model', - ) + const adapter = new TestResponsesAdapter(testConfig, 'test-model') const chunks: Array = [] for await (const chunk of adapter.chatStream({ @@ -1152,10 +1457,7 @@ describe('OpenAICompatibleResponsesTextAdapter', () => { ] setupMockResponsesClient(streamChunks) - const adapter = new OpenAICompatibleResponsesTextAdapter( - testConfig, - 'test-model', - ) + const adapter = new TestResponsesAdapter(testConfig, 'test-model') const chunks: Array = [] for await (const chunk of adapter.chatStream({ @@ -1193,10 +1495,7 @@ describe('OpenAICompatibleResponsesTextAdapter', () => { ] setupMockResponsesClient(streamChunks) - const adapter = new OpenAICompatibleResponsesTextAdapter( - testConfig, - 'test-model', - ) + const adapter = new TestResponsesAdapter(testConfig, 'test-model') const chunks: Array = [] for await (const chunk of adapter.chatStream({ @@ -1236,10 +1535,7 @@ describe('OpenAICompatibleResponsesTextAdapter', () => { setupMockResponsesClient([], nonStreamResponse) - const adapter = new OpenAICompatibleResponsesTextAdapter( - testConfig, - 'test-model', - ) + const adapter = new TestResponsesAdapter(testConfig, 'test-model') const result = await adapter.structuredOutput({ chatOptions: { @@ -1293,10 +1589,7 @@ describe('OpenAICompatibleResponsesTextAdapter', () => { setupMockResponsesClient([], nonStreamResponse) - const adapter = new OpenAICompatibleResponsesTextAdapter( - testConfig, - 'test-model', - ) + const adapter = new TestResponsesAdapter(testConfig, 'test-model') const result = await adapter.structuredOutput({ chatOptions: { @@ -1336,10 +1629,7 @@ describe('OpenAICompatibleResponsesTextAdapter', () => { setupMockResponsesClient([], nonStreamResponse) - const adapter = new OpenAICompatibleResponsesTextAdapter( - testConfig, - 'test-model', - ) + const adapter = new TestResponsesAdapter(testConfig, 'test-model') await expect( adapter.structuredOutput({ @@ -1358,6 +1648,90 @@ describe('OpenAICompatibleResponsesTextAdapter', () => { }), ).rejects.toThrow('Failed to parse structured output as JSON') }) + + it('fails loud when response has only message items with empty text', async () => { + const nonStreamResponse = { + output: [ + { + type: 'message', + content: [{ type: 'output_text', text: '' }], + }, + ], + } + setupMockResponsesClient([], nonStreamResponse) + const adapter = new TestResponsesAdapter(testConfig, 'test-model') + await expect( + adapter.structuredOutput({ + chatOptions: { + logger: testLogger, + model: 'test-model', + messages: [{ role: 'user', content: 'q' }], + }, + outputSchema: { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'], + }, + }), + ).rejects.toThrow('response contained no content') + }) + + it('fails loud when response.output has only non-message items (function_call, reasoning)', async () => { + const nonStreamResponse = { + output: [ + { + type: 'function_call', + id: 'fc_1', + call_id: 'call_1', + name: 'do_thing', + arguments: '{}', + }, + ], + } + setupMockResponsesClient([], nonStreamResponse) + const adapter = new TestResponsesAdapter(testConfig, 'test-model') + await expect( + adapter.structuredOutput({ + chatOptions: { + logger: testLogger, + model: 'test-model', + messages: [{ role: 'user', content: 'q' }], + }, + outputSchema: { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'], + }, + }), + ).rejects.toThrow(/function_call/) + }) + + it('throws on unknown message content_part type rather than misreporting as refusal', async () => { + const nonStreamResponse = { + output: [ + { + type: 'message', + content: [{ type: 'output_audio', audio: 'base64data' }], + }, + ], + } + setupMockResponsesClient([], nonStreamResponse) + const adapter = new TestResponsesAdapter(testConfig, 'test-model') + await expect( + adapter.structuredOutput({ + chatOptions: { + logger: testLogger, + model: 'test-model', + messages: [{ role: 'user', content: 'q' }], + }, + outputSchema: { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'], + }, + }), + ).rejects.toThrow(/unsupported message content part type "output_audio"/) + }) }) describe('request mapping', () => { @@ -1388,10 +1762,7 @@ describe('OpenAICompatibleResponsesTextAdapter', () => { ] setupMockResponsesClient(streamChunks) - const adapter = new OpenAICompatibleResponsesTextAdapter( - testConfig, - 'test-model', - ) + const adapter = new TestResponsesAdapter(testConfig, 'test-model') const chunks: Array = [] for await (const chunk of adapter.chatStream({ @@ -1459,10 +1830,7 @@ describe('OpenAICompatibleResponsesTextAdapter', () => { ] setupMockResponsesClient(streamChunks) - const adapter = new OpenAICompatibleResponsesTextAdapter( - testConfig, - 'test-model', - ) + const adapter = new TestResponsesAdapter(testConfig, 'test-model') const chunks: Array = [] for await (const chunk of adapter.chatStream({ @@ -1510,10 +1878,7 @@ describe('OpenAICompatibleResponsesTextAdapter', () => { ] setupMockResponsesClient(streamChunks) - const adapter = new OpenAICompatibleResponsesTextAdapter( - testConfig, - 'test-model', - ) + const adapter = new TestResponsesAdapter(testConfig, 'test-model') const chunks: Array = [] for await (const chunk of adapter.chatStream({ @@ -1571,13 +1936,9 @@ describe('OpenAICompatibleResponsesTextAdapter', () => { 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', - ) + class MyProviderAdapter extends OpenAIBaseResponsesTextAdapter { + constructor(_apiKey: string, model: string) { + super(model, 'my-provider', makeStubClient()) } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cc34515d4..241c2f9ca 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -642,7 +642,7 @@ importers: version: 7.3.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) vitest: specifier: ^4.0.14 - version: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.0.14)(happy-dom@20.0.11)(jsdom@27.3.0(postcss@8.5.9))(vite@7.3.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jsdom@27.3.0(postcss@8.5.9))(vite@7.3.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) web-vitals: specifier: ^5.1.0 version: 5.1.0 @@ -944,7 +944,7 @@ importers: version: 1.1.0 '@vitest/coverage-v8': specifier: 4.0.14 - version: 4.0.14(vitest@4.1.4) + version: 4.0.14(vitest@4.0.14(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) zod: specifier: ^4.2.0 version: 4.2.1 @@ -963,7 +963,7 @@ importers: version: link:../ai '@vitest/coverage-v8': specifier: 4.0.14 - version: 4.0.14(vitest@4.1.4) + version: 4.0.14(vitest@4.0.14(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) zod: specifier: ^4.2.0 version: 4.2.1 @@ -982,7 +982,7 @@ importers: version: 1.1.0 '@vitest/coverage-v8': specifier: 4.0.14 - version: 4.0.14(vitest@4.1.4) + version: 4.0.14(vitest@4.0.14(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) vite: 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) @@ -1001,7 +1001,7 @@ importers: version: link:../ai '@vitest/coverage-v8': specifier: 4.0.14 - version: 4.0.14(vitest@4.1.4) + version: 4.0.14(vitest@4.0.14(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(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 @@ -1029,7 +1029,7 @@ importers: version: link:../ai-openai '@vitest/coverage-v8': specifier: 4.0.14 - version: 4.0.14(vitest@4.1.4) + version: 4.0.14(vitest@4.0.14(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) commander: specifier: ^13.1.0 version: 13.1.0 @@ -1106,7 +1106,7 @@ importers: devDependencies: '@vitest/coverage-v8': specifier: 4.0.14 - version: 4.0.14(vitest@4.1.4) + version: 4.0.14(vitest@4.0.14(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) jsdom: specifier: ^27.2.0 version: 27.3.0(postcss@8.5.9) @@ -1143,7 +1143,7 @@ importers: version: link:../ai-client '@vitest/coverage-v8': specifier: 4.0.14 - version: 4.0.14(vitest@4.1.4) + version: 4.0.14(vitest@4.0.14(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) packages/typescript/ai-event-client: dependencies: @@ -1156,7 +1156,7 @@ importers: devDependencies: '@vitest/coverage-v8': specifier: 4.0.14 - version: 4.0.14(vitest@4.1.4) + version: 4.0.14(vitest@4.0.14(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) packages/typescript/ai-fal: dependencies: @@ -1172,7 +1172,7 @@ importers: version: link:../ai '@vitest/coverage-v8': specifier: 4.0.14 - version: 4.0.14(vitest@4.1.4) + version: 4.0.14(vitest@4.0.14(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) vite: specifier: ^7.2.7 version: 7.3.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) @@ -1191,7 +1191,7 @@ importers: version: link:../ai '@vitest/coverage-v8': specifier: 4.0.14 - version: 4.0.14(vitest@4.1.4) + version: 4.0.14(vitest@4.0.14(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) vite: 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) @@ -1207,6 +1207,9 @@ importers: '@tanstack/openai-base': specifier: workspace:* version: link:../openai-base + openai: + specifier: ^6.9.1 + version: 6.10.0(ws@8.19.0)(zod@4.3.6) zod: specifier: ^4.0.0 version: 4.3.6 @@ -1219,7 +1222,7 @@ importers: version: link:../ai-client '@vitest/coverage-v8': specifier: 4.0.14 - version: 4.0.14(vitest@4.1.4) + version: 4.0.14(vitest@4.0.14(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) vite: 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) @@ -1235,16 +1238,16 @@ importers: '@tanstack/openai-base': specifier: workspace:* version: link:../openai-base - groq-sdk: - specifier: ^0.37.0 - version: 0.37.0 + openai: + specifier: ^6.9.1 + version: 6.10.0(ws@8.19.0)(zod@4.3.6) zod: specifier: ^4.0.0 version: 4.3.6 devDependencies: '@vitest/coverage-v8': specifier: 4.0.14 - version: 4.0.14(vitest@4.1.4) + version: 4.0.14(vitest@4.0.14(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) vite: specifier: ^7.2.7 version: 7.3.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) @@ -1260,7 +1263,7 @@ importers: version: 4.20260317.1 '@vitest/coverage-v8': specifier: 4.0.14 - version: 4.0.14(vitest@4.1.4) + version: 4.0.14(vitest@4.0.14(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) wrangler: specifier: ^4.88.0 version: 4.88.0(@cloudflare/workers-types@4.20260317.1) @@ -1276,7 +1279,7 @@ importers: devDependencies: '@vitest/coverage-v8': specifier: 4.0.14 - version: 4.0.14(vitest@4.1.4) + version: 4.0.14(vitest@4.0.14(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) packages/typescript/ai-isolate-quickjs: dependencies: @@ -1289,7 +1292,7 @@ importers: devDependencies: '@vitest/coverage-v8': specifier: 4.0.14 - version: 4.0.14(vitest@4.1.4) + version: 4.0.14(vitest@4.0.14(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) packages/typescript/ai-ollama: dependencies: @@ -1305,7 +1308,7 @@ importers: version: link:../ai '@vitest/coverage-v8': specifier: 4.0.14 - version: 4.0.14(vitest@4.1.4) + version: 4.0.14(vitest@4.0.14(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) vite: 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) @@ -1320,7 +1323,7 @@ importers: version: link:../openai-base openai: specifier: ^6.9.1 - version: 6.10.0(ws@8.19.0)(zod@4.2.1) + version: 6.10.0(ws@8.19.0)(zod@4.3.6) devDependencies: '@tanstack/ai': specifier: workspace:* @@ -1330,13 +1333,13 @@ importers: version: link:../ai-client '@vitest/coverage-v8': specifier: 4.0.14 - version: 4.0.14(vitest@4.1.4) + version: 4.0.14(vitest@4.0.14(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) vite: 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) zod: specifier: ^4.2.0 - version: 4.2.1 + version: 4.3.6 packages/typescript/ai-openrouter: dependencies: @@ -1352,7 +1355,7 @@ importers: version: link:../ai '@vitest/coverage-v8': specifier: 4.0.14 - version: 4.0.14(vitest@4.1.4) + version: 4.0.14(vitest@4.0.14(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) vite: 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) @@ -1374,7 +1377,7 @@ importers: version: 3.2.4(preact@10.28.2) '@vitest/coverage-v8': specifier: 4.0.14 - version: 4.0.14(vitest@4.1.4) + version: 4.0.14(vitest@4.0.14(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) jsdom: specifier: ^27.2.0 version: 27.3.0(postcss@8.5.9) @@ -1402,7 +1405,7 @@ importers: version: 19.2.7 '@vitest/coverage-v8': specifier: 4.0.14 - version: 4.0.14(vitest@4.1.4) + version: 4.0.14(vitest@4.0.14(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) jsdom: specifier: ^27.2.0 version: 27.3.0(postcss@8.5.9) @@ -1442,7 +1445,7 @@ importers: version: 19.2.7 '@vitest/coverage-v8': specifier: 4.0.14 - version: 4.0.14(vitest@4.1.4) + version: 4.0.14(vitest@4.0.14(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) react: specifier: ^19.2.3 version: 19.2.3 @@ -1513,7 +1516,7 @@ importers: version: link:../ai-solid '@vitest/coverage-v8': specifier: 4.0.14 - version: 4.0.14(vitest@4.1.4) + version: 4.0.14(vitest@4.0.14(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) solid-js: specifier: ^1.9.10 version: 1.9.10 @@ -1541,7 +1544,7 @@ importers: version: 24.10.3 '@vitest/coverage-v8': specifier: 4.0.14 - version: 4.0.14(vitest@4.1.4) + version: 4.0.14(vitest@4.0.14(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) jsdom: specifier: ^27.2.0 version: 27.3.0(postcss@8.5.9) @@ -1565,7 +1568,7 @@ importers: version: 24.10.3 '@vitest/coverage-v8': specifier: 4.0.14 - version: 4.0.14(vitest@4.1.4) + version: 4.0.14(vitest@4.0.14(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) vite: specifier: ^7.2.7 version: 7.3.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) @@ -1630,7 +1633,7 @@ importers: version: 6.0.3(vite@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))(vue@3.5.25(typescript@5.9.3)) '@vitest/coverage-v8': specifier: 4.0.14 - version: 4.0.14(vitest@4.1.4) + version: 4.0.14(vitest@4.0.14(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) vite: 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) @@ -1655,10 +1658,13 @@ importers: version: link:../ai '@vitest/coverage-v8': specifier: 4.0.14 - version: 4.0.14(vitest@4.1.4) + version: 4.0.14(vitest@4.0.14(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) vite: specifier: ^7.2.7 version: 7.3.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + zod: + specifier: ^4.2.0 + version: 4.3.6 packages/typescript/preact-ai-devtools: dependencies: @@ -1674,7 +1680,7 @@ importers: devDependencies: '@vitest/coverage-v8': specifier: 4.0.14 - version: 4.0.14(vitest@4.1.4) + version: 4.0.14(vitest@4.0.14(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) vite: 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) @@ -1693,7 +1699,7 @@ importers: version: 19.2.7 '@vitest/coverage-v8': specifier: 4.0.14 - version: 4.0.14(vitest@4.1.4) + version: 4.0.14(vitest@4.0.14(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) react: specifier: ^19.2.3 version: 19.2.3 @@ -1712,7 +1718,7 @@ importers: devDependencies: '@vitest/coverage-v8': specifier: 4.0.14 - version: 4.0.14(vitest@4.1.4) + version: 4.0.14(vitest@4.0.14(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) solid-js: specifier: ^1.9.10 version: 1.9.10 @@ -6511,15 +6517,9 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} - '@types/node-fetch@2.6.13': - resolution: {integrity: sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==} - '@types/node@12.20.55': resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} - '@types/node@18.19.130': - resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==} - '@types/node@20.19.26': resolution: {integrity: sha512-0l6cjgF0XnihUpndDhk+nyD3exio3iKaYROSgvh/qSevPXax3L8p5DBRFjbvalnwatGgHEQn2R88y2fA3g4irg==} @@ -6763,12 +6763,26 @@ packages: '@vitest/browser': optional: true + '@vitest/expect@4.0.14': + resolution: {integrity: sha512-RHk63V3zvRiYOWAV0rGEBRO820ce17hz7cI2kDmEdfQsBjT2luEKB5tCOc91u1oSQoUOZkSv3ZyzkdkSLD7lKw==} + '@vitest/expect@4.0.15': resolution: {integrity: sha512-Gfyva9/GxPAWXIWjyGDli9O+waHDC0Q0jaLdFP1qPAUUfo1FEXPXUfUkp3eZA0sSq340vPycSyOlYUeM15Ft1w==} '@vitest/expect@4.1.4': resolution: {integrity: sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww==} + '@vitest/mocker@4.0.14': + resolution: {integrity: sha512-RzS5NujlCzeRPF1MK7MXLiEFpkIXeMdQ+rN3Kk3tDI9j0mtbr7Nmuq67tpkOJQpgyClbOltCXMjLZicJHsH5Cg==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + '@vitest/mocker@4.0.15': resolution: {integrity: sha512-CZ28GLfOEIFkvCFngN8Sfx5h+Se0zN+h4B7yOsPVCcgtiO7t5jt9xQh2E1UkFep+eb9fjyMfuC5gBypwb07fvQ==} peerDependencies: @@ -6800,18 +6814,27 @@ packages: '@vitest/pretty-format@4.1.4': resolution: {integrity: sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==} + '@vitest/runner@4.0.14': + resolution: {integrity: sha512-BsAIk3FAqxICqREbX8SetIteT8PiaUL/tgJjmhxJhCsigmzzH8xeadtp7LRnTpCVzvf0ib9BgAfKJHuhNllKLw==} + '@vitest/runner@4.0.15': resolution: {integrity: sha512-+A+yMY8dGixUhHmNdPUxOh0la6uVzun86vAbuMT3hIDxMrAOmn5ILBHm8ajrqHE0t8R9T1dGnde1A5DTnmi3qw==} '@vitest/runner@4.1.4': resolution: {integrity: sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ==} + '@vitest/snapshot@4.0.14': + resolution: {integrity: sha512-aQVBfT1PMzDSA16Y3Fp45a0q8nKexx6N5Amw3MX55BeTeZpoC08fGqEZqVmPcqN0ueZsuUQ9rriPMhZ3Mu19Ag==} + '@vitest/snapshot@4.0.15': resolution: {integrity: sha512-A7Ob8EdFZJIBjLjeO0DZF4lqR6U7Ydi5/5LIZ0xcI+23lYlsYJAfGn8PrIWTYdZQRNnSRlzhg0zyGu37mVdy5g==} '@vitest/snapshot@4.1.4': resolution: {integrity: sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw==} + '@vitest/spy@4.0.14': + resolution: {integrity: sha512-JmAZT1UtZooO0tpY3GRyiC/8W7dCs05UOq9rfsUUgEZEdq+DuHLmWhPsrTt0TiW7WYeL/hXpaE07AZ2RCk44hg==} + '@vitest/spy@4.0.15': resolution: {integrity: sha512-+EIjOJmnY6mIfdXtE/bnozKEvTC4Uczg19yeZ2vtCz5Yyb0QQ31QWVQ8hswJ3Ysx/K2EqaNsVanjr//2+P3FHw==} @@ -6945,10 +6968,6 @@ packages: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} - agentkeepalive@4.6.0: - resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==} - engines: {node: '>= 8.0.0'} - ajv-draft-04@1.0.0: resolution: {integrity: sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==} peerDependencies: @@ -8306,9 +8325,6 @@ packages: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} - form-data-encoder@1.7.2: - resolution: {integrity: sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==} - form-data@4.0.5: resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} engines: {node: '>= 6'} @@ -8318,10 +8334,6 @@ packages: engines: {node: '>=18.3.0'} hasBin: true - formdata-node@4.4.1: - resolution: {integrity: sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==} - engines: {node: '>= 12.20'} - formdata-polyfill@4.0.10: resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} engines: {node: '>=12.20.0'} @@ -8503,9 +8515,6 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - groq-sdk@0.37.0: - resolution: {integrity: sha512-lT72pcT8b/X5XrzdKf+rWVzUGW1OQSKESmL8fFN5cTbsf02gq6oFam4SVeNtzELt9cYE2Pt3pdGgSImuTbHFDg==} - gtoken@8.0.0: resolution: {integrity: sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==} engines: {node: '>=18'} @@ -8684,9 +8693,6 @@ packages: resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==} engines: {node: '>=16.17.0'} - humanize-ms@1.2.1: - resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==} - iconv-lite@0.6.3: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} @@ -11305,9 +11311,6 @@ packages: unctx@2.5.0: resolution: {integrity: sha512-p+Rz9x0R7X+CYDkT+Xg8/GhpcShTlU8n+cf9OtOEf7zEQsNcCZO1dPKNRDqvUTaq+P32PMMkxWHwfrxkqfqAYg==} - undici-types@5.26.5: - resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} - undici-types@5.28.4: resolution: {integrity: sha512-3OeMF5Lyowe8VW0skf5qaIE7Or3yS9LS7fvMUI0gg4YxpIBVg0L8BxCmROw2CcYhSkpR68Epz7CGc8MPj94Uww==} @@ -11855,6 +11858,40 @@ packages: vite: optional: true + vitest@4.0.14: + resolution: {integrity: sha512-d9B2J9Cm9dN9+6nxMnnNJKJCtcyKfnHj15N6YNJfaFHRLua/d3sRKU9RuKmO9mB0XdFtUizlxfz/VPbd3OxGhw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.0.14 + '@vitest/browser-preview': 4.0.14 + '@vitest/browser-webdriverio': 4.0.14 + '@vitest/ui': 4.0.14 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + vitest@4.0.15: resolution: {integrity: sha512-n1RxDp8UJm6N0IbJLQo+yzLZ2sQCDyl1o0LeugbPWf8+8Fttp29GghsQBjYJVmWq3gBFfe9Hs1spR44vovn2wA==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -11979,10 +12016,6 @@ packages: resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} engines: {node: '>= 8'} - web-streams-polyfill@4.0.0-beta.3: - resolution: {integrity: sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==} - engines: {node: '>= 14'} - web-vitals@5.1.0: resolution: {integrity: sha512-ArI3kx5jI0atlTtmV0fWU3fjpLmq/nD3Zr1iFFlJLaqa5wLBkUSzINwBPySCX/8jRyjlmy1Volw1kz1g9XE4Jg==} @@ -17022,17 +17055,8 @@ snapshots: '@types/ms@2.1.0': {} - '@types/node-fetch@2.6.13': - dependencies: - '@types/node': 24.10.3 - form-data: 4.0.5 - '@types/node@12.20.55': {} - '@types/node@18.19.130': - dependencies: - undici-types: 5.26.5 - '@types/node@20.19.26': dependencies: undici-types: 6.21.0 @@ -17331,7 +17355,7 @@ snapshots: vite: 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) vue: 3.5.25(typescript@5.9.3) - '@vitest/coverage-v8@4.0.14(vitest@4.0.15(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + '@vitest/coverage-v8@4.0.14(vitest@4.0.14(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@bcoe/v8-coverage': 1.0.2 '@vitest/utils': 4.0.14 @@ -17344,11 +17368,11 @@ snapshots: obug: 2.1.1 std-env: 3.10.0 tinyrainbow: 3.1.0 - vitest: 4.0.15(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vitest: 4.0.14(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - supports-color - '@vitest/coverage-v8@4.0.14(vitest@4.1.4)': + '@vitest/coverage-v8@4.0.14(vitest@4.0.15(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@bcoe/v8-coverage': 1.0.2 '@vitest/utils': 4.0.14 @@ -17361,10 +17385,19 @@ snapshots: obug: 2.1.1 std-env: 3.10.0 tinyrainbow: 3.1.0 - vitest: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.0.14)(happy-dom@20.0.11)(jsdom@27.3.0(postcss@8.5.9))(vite@7.3.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + vitest: 4.0.15(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - supports-color + '@vitest/expect@4.0.14': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.0.14 + '@vitest/utils': 4.0.14 + chai: 6.2.2 + tinyrainbow: 3.1.0 + '@vitest/expect@4.0.15': dependencies: '@standard-schema/spec': 1.1.0 @@ -17383,6 +17416,14 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 + '@vitest/mocker@4.0.14(vite@7.3.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + dependencies: + '@vitest/spy': 4.0.14 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + '@vitest/mocker@4.0.15(vite@7.3.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@vitest/spy': 4.0.15 @@ -17411,6 +17452,11 @@ snapshots: dependencies: tinyrainbow: 3.1.0 + '@vitest/runner@4.0.14': + dependencies: + '@vitest/utils': 4.0.14 + pathe: 2.0.3 + '@vitest/runner@4.0.15': dependencies: '@vitest/utils': 4.0.15 @@ -17421,6 +17467,12 @@ snapshots: '@vitest/utils': 4.1.4 pathe: 2.0.3 + '@vitest/snapshot@4.0.14': + dependencies: + '@vitest/pretty-format': 4.0.14 + magic-string: 0.30.21 + pathe: 2.0.3 + '@vitest/snapshot@4.0.15': dependencies: '@vitest/pretty-format': 4.0.15 @@ -17434,6 +17486,8 @@ snapshots: magic-string: 0.30.21 pathe: 2.0.3 + '@vitest/spy@4.0.14': {} + '@vitest/spy@4.0.15': {} '@vitest/spy@4.1.4': {} @@ -17606,10 +17660,6 @@ snapshots: agent-base@7.1.4: {} - agentkeepalive@4.6.0: - dependencies: - humanize-ms: 1.2.1 - ajv-draft-04@1.0.0(ajv@8.13.0): optionalDependencies: ajv: 8.13.0 @@ -19152,8 +19202,6 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 - form-data-encoder@1.7.2: {} - form-data@4.0.5: dependencies: asynckit: 0.4.0 @@ -19166,11 +19214,6 @@ snapshots: dependencies: fd-package-json: 2.0.0 - formdata-node@4.4.1: - dependencies: - node-domexception: 1.0.0 - web-streams-polyfill: 4.0.0-beta.3 - formdata-polyfill@4.0.10: dependencies: fetch-blob: 3.2.0 @@ -19372,18 +19415,6 @@ snapshots: graceful-fs@4.2.11: {} - groq-sdk@0.37.0: - dependencies: - '@types/node': 18.19.130 - '@types/node-fetch': 2.6.13 - abort-controller: 3.0.0 - agentkeepalive: 4.6.0 - form-data-encoder: 1.7.2 - formdata-node: 4.4.1 - node-fetch: 2.7.0 - transitivePeerDependencies: - - encoding - gtoken@8.0.0: dependencies: gaxios: 7.1.3 @@ -19645,10 +19676,6 @@ snapshots: human-signals@5.0.0: {} - humanize-ms@1.2.1: - dependencies: - ms: 2.1.3 - iconv-lite@0.6.3: dependencies: safer-buffer: 2.1.2 @@ -21264,11 +21291,6 @@ snapshots: is-docker: 2.2.1 is-wsl: 2.2.0 - openai@6.10.0(ws@8.19.0)(zod@4.2.1): - optionalDependencies: - 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 @@ -23048,8 +23070,6 @@ snapshots: magic-string: 0.30.21 unplugin: 2.3.11 - undici-types@5.26.5: {} - undici-types@5.28.4: {} undici-types@6.21.0: {} @@ -23562,26 +23582,26 @@ snapshots: optionalDependencies: vite: 7.3.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) - vitest@4.0.15(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): + vitest@4.0.14(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): dependencies: - '@vitest/expect': 4.0.15 - '@vitest/mocker': 4.0.15(vite@7.3.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) - '@vitest/pretty-format': 4.0.15 - '@vitest/runner': 4.0.15 - '@vitest/snapshot': 4.0.15 - '@vitest/spy': 4.0.15 - '@vitest/utils': 4.0.15 + '@vitest/expect': 4.0.14 + '@vitest/mocker': 4.0.14(vite@7.3.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/pretty-format': 4.0.14 + '@vitest/runner': 4.0.14 + '@vitest/snapshot': 4.0.14 + '@vitest/spy': 4.0.14 + '@vitest/utils': 4.0.14 es-module-lexer: 1.7.0 expect-type: 1.3.0 magic-string: 0.30.21 obug: 2.1.1 pathe: 2.0.3 - picomatch: 4.0.3 + picomatch: 4.0.4 std-env: 3.10.0 tinybench: 2.9.0 - tinyexec: 1.0.2 - tinyglobby: 0.2.15 - tinyrainbow: 3.0.3 + tinyexec: 0.3.2 + tinyglobby: 0.2.16 + tinyrainbow: 3.1.0 vite: 7.3.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) why-is-node-running: 2.3.0 optionalDependencies: @@ -23602,36 +23622,45 @@ snapshots: - tsx - yaml - vitest@4.1.4(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.0.14)(happy-dom@20.0.11)(jsdom@27.3.0(postcss@8.5.9))(vite@7.3.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)): + vitest@4.0.15(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): dependencies: - '@vitest/expect': 4.1.4 - '@vitest/mocker': 4.1.4(vite@7.3.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) - '@vitest/pretty-format': 4.1.4 - '@vitest/runner': 4.1.4 - '@vitest/snapshot': 4.1.4 - '@vitest/spy': 4.1.4 - '@vitest/utils': 4.1.4 - es-module-lexer: 2.0.0 + '@vitest/expect': 4.0.15 + '@vitest/mocker': 4.0.15(vite@7.3.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/pretty-format': 4.0.15 + '@vitest/runner': 4.0.15 + '@vitest/snapshot': 4.0.15 + '@vitest/spy': 4.0.15 + '@vitest/utils': 4.0.15 + es-module-lexer: 1.7.0 expect-type: 1.3.0 magic-string: 0.30.21 obug: 2.1.1 pathe: 2.0.3 - picomatch: 4.0.4 - std-env: 4.0.0 + picomatch: 4.0.3 + std-env: 3.10.0 tinybench: 2.9.0 - tinyexec: 1.1.1 - tinyglobby: 0.2.16 - tinyrainbow: 3.1.0 + tinyexec: 1.0.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.0.3 vite: 7.3.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.1 '@types/node': 24.10.3 - '@vitest/coverage-v8': 4.0.14(vitest@4.1.4) happy-dom: 20.0.11 jsdom: 27.3.0(postcss@8.5.9) transitivePeerDependencies: + - jiti + - less + - lightningcss - msw + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - yaml vitest@4.1.4(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jsdom@27.3.0(postcss@8.5.9))(vite@7.3.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)): dependencies: @@ -23662,7 +23691,6 @@ snapshots: jsdom: 27.3.0(postcss@8.5.9) transitivePeerDependencies: - msw - optional: true vscode-uri@3.1.0: {} @@ -23715,8 +23743,6 @@ snapshots: web-streams-polyfill@3.3.3: {} - web-streams-polyfill@4.0.0-beta.3: {} - web-vitals@5.1.0: {} webdriver-bidi-protocol@0.4.1: {} diff --git a/testing/e2e/src/lib/feature-support.ts b/testing/e2e/src/lib/feature-support.ts index 25a8b4d43..7a8dbb8c8 100644 --- a/testing/e2e/src/lib/feature-support.ts +++ b/testing/e2e/src/lib/feature-support.ts @@ -16,6 +16,7 @@ export const matrix: Record> = { 'groq', 'grok', 'openrouter', + 'openrouter-responses', ]), 'one-shot-text': new Set([ 'openai', @@ -25,6 +26,7 @@ export const matrix: Record> = { 'groq', 'grok', 'openrouter', + 'openrouter-responses', ]), reasoning: new Set(['openai', 'anthropic', 'gemini']), 'multi-turn-reasoning': new Set(['anthropic']), @@ -36,6 +38,7 @@ export const matrix: Record> = { 'groq', 'grok', 'openrouter', + 'openrouter-responses', ]), 'tool-calling': new Set([ 'openai', @@ -45,6 +48,7 @@ export const matrix: Record> = { 'groq', 'grok', 'openrouter', + 'openrouter-responses', ]), 'parallel-tool-calls': new Set([ 'openai', @@ -53,6 +57,7 @@ export const matrix: Record> = { 'groq', 'grok', 'openrouter', + 'openrouter-responses', ]), // Gemini excluded: approval flow timing issues with Gemini's streaming format 'tool-approval': new Set([ @@ -62,6 +67,7 @@ export const matrix: Record> = { 'groq', 'grok', 'openrouter', + 'openrouter-responses', ]), // Ollama excluded: aimock doesn't support content+toolCalls for /api/chat format 'text-tool-text': new Set([ @@ -71,6 +77,7 @@ export const matrix: Record> = { 'groq', 'grok', 'openrouter', + 'openrouter-responses', ]), 'structured-output': new Set([ 'openai', @@ -80,6 +87,7 @@ export const matrix: Record> = { 'groq', 'grok', 'openrouter', + 'openrouter-responses', ]), 'agentic-structured': new Set([ 'openai', @@ -89,6 +97,7 @@ export const matrix: Record> = { 'groq', 'grok', 'openrouter', + 'openrouter-responses', ]), 'multimodal-image': new Set([ 'openai', @@ -96,6 +105,7 @@ export const matrix: Record> = { 'gemini', 'grok', 'openrouter', + 'openrouter-responses', ]), 'multimodal-structured': new Set([ 'openai', @@ -103,6 +113,7 @@ export const matrix: Record> = { 'gemini', 'grok', 'openrouter', + 'openrouter-responses', ]), summarize: new Set([ 'openai', @@ -111,6 +122,7 @@ export const matrix: Record> = { 'ollama', 'grok', 'openrouter', + 'openrouter-responses', ]), 'summarize-stream': new Set([ 'openai', @@ -119,6 +131,7 @@ export const matrix: Record> = { 'ollama', 'grok', 'openrouter', + 'openrouter-responses', ]), // Gemini excluded: aimock doesn't mock Gemini's Imagen predict endpoint format 'image-gen': new Set(['openai', 'grok']), diff --git a/testing/e2e/src/lib/providers.ts b/testing/e2e/src/lib/providers.ts index ca2d00c4d..fd11a01eb 100644 --- a/testing/e2e/src/lib/providers.ts +++ b/testing/e2e/src/lib/providers.ts @@ -6,7 +6,10 @@ import { createGeminiChat } from '@tanstack/ai-gemini' import { createOllamaChat } from '@tanstack/ai-ollama' import { createGroqText } from '@tanstack/ai-groq' import { createGrokText } from '@tanstack/ai-grok' -import { createOpenRouterText } from '@tanstack/ai-openrouter' +import { + createOpenRouterResponsesText, + createOpenRouterText, +} from '@tanstack/ai-openrouter' import { HTTPClient } from '@openrouter/sdk' import type { Provider } from '@/lib/types' @@ -21,6 +24,7 @@ const defaultModels: Record = { groq: 'llama-3.3-70b-versatile', grok: 'grok-3', openrouter: 'openai/gpt-4o', + 'openrouter-responses': 'openai/gpt-4o', // ElevenLabs has no chat/text model — the support matrix already filters // it out of text features, but we still need an entry to satisfy the // Record constraint. @@ -110,6 +114,26 @@ export function createTextAdapter( }), }) }, + 'openrouter-responses': () => { + // Same X-Test-Id injection rationale as the chat-completions factory + // above. The beta Responses endpoint uses the same SDK base URL + + // HTTPClient surface. + const httpClient = new HTTPClient() + if (testId) { + httpClient.addHook('beforeRequest', (req) => { + const next = new Request(req) + next.headers.set('X-Test-Id', testId) + return next + }) + } + return createChatOptions({ + adapter: createOpenRouterResponsesText( + model as 'openai/gpt-4o', + DUMMY_KEY, + { serverURL: openaiUrl, httpClient }, + ), + }) + }, elevenlabs: () => { throw new Error( 'ElevenLabs has no text/chat adapter — use createTTSAdapter or createTranscriptionAdapter.', diff --git a/testing/e2e/src/lib/types.ts b/testing/e2e/src/lib/types.ts index eafe588fc..2f5cd634c 100644 --- a/testing/e2e/src/lib/types.ts +++ b/testing/e2e/src/lib/types.ts @@ -8,6 +8,7 @@ export type Provider = | 'grok' | 'groq' | 'openrouter' + | 'openrouter-responses' | 'elevenlabs' export type Feature = @@ -41,6 +42,7 @@ export const ALL_PROVIDERS: Provider[] = [ 'grok', 'groq', 'openrouter', + 'openrouter-responses', 'elevenlabs', ] diff --git a/testing/e2e/src/routes/api.summarize.ts b/testing/e2e/src/routes/api.summarize.ts index e5912edf9..5f65e9884 100644 --- a/testing/e2e/src/routes/api.summarize.ts +++ b/testing/e2e/src/routes/api.summarize.ts @@ -5,6 +5,7 @@ import { createAnthropicSummarize } from '@tanstack/ai-anthropic' import { createGeminiSummarize } from '@tanstack/ai-gemini' import { createOllamaSummarize } from '@tanstack/ai-ollama' import { createGrokSummarize } from '@tanstack/ai-grok' +import { createOpenRouterSummarize } from '@tanstack/ai-openrouter' import type { Provider } from '@/lib/types' const LLMOCK_BASE = process.env.LLMOCK_URL || 'http://127.0.0.1:4010' @@ -26,8 +27,19 @@ function createSummarizeAdapter(provider: Provider) { ollama: () => createOllamaSummarize('mistral', LLMOCK_BASE), grok: () => createGrokSummarize('grok-3', DUMMY_KEY, { baseURL: LLMOCK_OPENAI }), + // Both OpenRouter provider rows use the OpenRouter summarize adapter: + // `createOpenRouterSummarize` wraps the OpenRouter chat-completions + // text adapter regardless of whether the caller selected the Chat + // Completions or Responses surface, so a single factory backs both + // matrix entries. openrouter: () => - createOpenaiSummarize('gpt-4o', DUMMY_KEY, { baseURL: LLMOCK_OPENAI }), + createOpenRouterSummarize('openai/gpt-4o', DUMMY_KEY, { + serverURL: LLMOCK_OPENAI, + }), + 'openrouter-responses': () => + createOpenRouterSummarize('openai/gpt-4o', DUMMY_KEY, { + serverURL: LLMOCK_OPENAI, + }), } return factories[provider]?.() } diff --git a/testing/e2e/tests/test-matrix.ts b/testing/e2e/tests/test-matrix.ts index fea85dc59..f48dcebc0 100644 --- a/testing/e2e/tests/test-matrix.ts +++ b/testing/e2e/tests/test-matrix.ts @@ -21,6 +21,7 @@ export const providers: Provider[] = [ 'groq', 'grok', 'openrouter', + 'openrouter-responses', 'elevenlabs', ]