From 0e72363bf27d4c3d5378bd3150e50bf9f9baf819 Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Mon, 11 May 2026 22:24:57 +1000 Subject: [PATCH 1/6] refactor: migrate ai-groq + ai-openrouter onto @tanstack/openai-base (#543) Adds protected `callChatCompletion`, `callChatCompletionStream`, `extractReasoning`, and `transformStructuredOutput` hooks to `OpenAICompatibleChatCompletionsTextAdapter` so providers with non-OpenAI SDK shapes can reuse the shared stream accumulator, partial-JSON tool-call buffer, RUN_ERROR taxonomy, and lifecycle gates. ai-groq drops `groq-sdk` in favour of the OpenAI SDK pointed at api.groq.com/openai/v1; ai-openrouter keeps `@openrouter/sdk` via hook overrides. ai-ollama remains on BaseTextAdapter (native API has a different wire format). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../migrate-groq-openrouter-to-openai-base.md | 15 + packages/typescript/ai-groq/package.json | 3 +- .../typescript/ai-groq/src/adapters/text.ts | 607 +--------- .../typescript/ai-groq/src/utils/client.ts | 37 +- .../typescript/ai-groq/src/utils/index.ts | 4 +- .../ai-groq/tests/groq-adapter.test.ts | 49 +- .../typescript/ai-openrouter/package.json | 3 +- .../ai-openrouter/src/adapters/text.ts | 1011 ++++++----------- .../tests/openrouter-adapter.test.ts | 9 +- .../src/adapters/chat-completions-text.ts | 181 ++- packages/typescript/openai-base/src/index.ts | 9 + pnpm-lock.yaml | 76 +- 12 files changed, 644 insertions(+), 1360 deletions(-) create mode 100644 .changeset/migrate-groq-openrouter-to-openai-base.md diff --git a/.changeset/migrate-groq-openrouter-to-openai-base.md b/.changeset/migrate-groq-openrouter-to-openai-base.md new file mode 100644 index 000000000..ff5012565 --- /dev/null +++ b/.changeset/migrate-groq-openrouter-to-openai-base.md @@ -0,0 +1,15 @@ +--- +'@tanstack/openai-base': minor +'@tanstack/ai-groq': patch +'@tanstack/ai-openrouter': patch +--- + +Migrate `ai-groq` and `ai-openrouter` onto `OpenAICompatibleChatCompletionsTextAdapter` so they share the stream accumulator, partial-JSON tool-call buffer, RUN_ERROR taxonomy, and lifecycle gates with `ai-openai` / `ai-grok`. Removes ~1k LOC of duplicated stream processing. + +`@tanstack/openai-base` adds three protected hooks on `OpenAICompatibleChatCompletionsTextAdapter` so providers with non-OpenAI SDK shapes can reuse the base: `callChatCompletion` and `callChatCompletionStream` (SDK call sites for non-streaming and streaming Chat Completions), and `extractReasoning` (surface reasoning content from chunk shapes that carry it, e.g. OpenRouter's `delta.reasoningDetails`, into the base's REASONING_* + legacy STEP_STARTED/STEP_FINISHED lifecycle). Also adds `transformStructuredOutput` for subclasses (like OpenRouter) that preserve nulls in structured output instead of converting them to undefined. + +`@tanstack/ai-groq` drops the `groq-sdk` dependency in favour of the OpenAI SDK pointed at `https://api.groq.com/openai/v1` (the same pattern as `ai-grok` against xAI). The Groq-specific quirk where streaming usage arrives under `chunk.x_groq.usage` is preserved via a small `processStreamChunks` wrapper that promotes it to the standard `chunk.usage` slot. + +`@tanstack/ai-openrouter` keeps `@openrouter/sdk` (the source of truth for OpenRouter's typed provider routing, plugins, and metadata) but routes the SDK call through the base via overridden hooks. A small request shape converter (`max_tokens` → `maxCompletionTokens`, etc.) and chunk shape adapter (camelCase → snake_case for the base's reader) bridge the SDKs. No public API changes; provider routing, app attribution headers (`httpReferer`, `appTitle`), reasoning variants (`:thinking`), and `RequestAbortedError` handling are preserved. + +`ai-ollama` remains on `BaseTextAdapter` — its native API uses a different wire format from Chat Completions (different chunk shape, request shape, tool-call streaming, and reasoning surface) and doesn't fit the OpenAI base without rebuilding most of the processing it would otherwise inherit. Migrating it remains a separate effort. diff --git a/packages/typescript/ai-groq/package.json b/packages/typescript/ai-groq/package.json index 8ed6a98ba..82917b29a 100644 --- a/packages/typescript/ai-groq/package.json +++ b/packages/typescript/ai-groq/package.json @@ -52,7 +52,6 @@ }, "dependencies": { "@tanstack/ai-utils": "workspace:*", - "@tanstack/openai-base": "workspace:*", - "groq-sdk": "^0.37.0" + "@tanstack/openai-base": "workspace:*" } } diff --git a/packages/typescript/ai-groq/src/adapters/text.ts b/packages/typescript/ai-groq/src/adapters/text.ts index 34f44ba81..4d8ba131b 100644 --- a/packages/typescript/ai-groq/src/adapters/text.ts +++ b/packages/typescript/ai-groq/src/adapters/text.ts @@ -1,72 +1,44 @@ -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 { OpenAICompatibleChatCompletionsTextAdapter } 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 '@tanstack/openai-base' 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 +47,7 @@ export class GroqTextAdapter< ResolveInputModalities, TToolCapabilities extends ReadonlyArray = ResolveToolCapabilities, -> extends BaseTextAdapter< +> extends OpenAICompatibleChatCompletionsTextAdapter< TModel, TProviderOptions, TInputModalities, @@ -85,526 +57,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(withGroqDefaults(config), model, 'groq') } - 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 +125,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/utils/client.ts b/packages/typescript/ai-groq/src/utils/client.ts index 4e4f64580..082e347e0 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 { OpenAICompatibleClientConfig } from '@tanstack/openai-base' -export interface GroqClientConfig extends ClientOptions { - apiKey: string -} - -/** - * Creates a Groq SDK client instance - */ -export function createGroqClient(config: GroqClientConfig): Groq_SDK { - return new Groq_SDK(config) -} +export interface GroqClientConfig extends OpenAICompatibleClientConfig {} /** * 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 base adapter can drive it without a separate SDK. */ -export function generateId(prefix: string): string { - return _generateId(prefix) +export function withGroqDefaults( + config: GroqClientConfig, +): OpenAICompatibleClientConfig { + 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/tests/groq-adapter.test.ts b/packages/typescript/ai-groq/tests/groq-adapter.test.ts index a053aeea8..2f34fe368 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 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,41 @@ 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', @@ -422,7 +449,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-openrouter/package.json b/packages/typescript/ai-openrouter/package.json index 635193aed..d5486a627 100644 --- a/packages/typescript/ai-openrouter/package.json +++ b/packages/typescript/ai-openrouter/package.json @@ -44,7 +44,8 @@ ], "dependencies": { "@openrouter/sdk": "0.12.14", - "@tanstack/ai-utils": "workspace:*" + "@tanstack/ai-utils": "workspace:*", + "@tanstack/openai-base": "workspace:*" }, "devDependencies": { "@tanstack/ai": "workspace:*", diff --git a/packages/typescript/ai-openrouter/src/adapters/text.ts b/packages/typescript/ai-openrouter/src/adapters/text.ts index 29427171c..a43d60620 100644 --- a/packages/typescript/ai-openrouter/src/adapters/text.ts +++ b/packages/typescript/ai-openrouter/src/adapters/text.ts @@ -1,46 +1,33 @@ import { OpenRouter } from '@openrouter/sdk' -import { RequestAbortedError } from '@openrouter/sdk/models/errors' -import { convertSchemaToJsonSchema } from '@tanstack/ai' -import { BaseTextAdapter } from '@tanstack/ai/adapters' +import { OpenAICompatibleChatCompletionsTextAdapter } from '@tanstack/openai-base' import { convertToolsToProviderFormat } from '../tools' -import { - getOpenRouterApiKeyFromEnv, - generateId as utilGenerateId, -} from '../utils' +import { getOpenRouterApiKeyFromEnv } from '../utils' import type { SDKOptions } from '@openrouter/sdk' +import type { + ChatCompletion, + ChatCompletionChunk, + ChatCompletionCreateParamsNonStreaming, + ChatCompletionCreateParamsStreaming, +} from '@tanstack/openai-base' +import type { + ChatContentItems, + ChatMessages, + ChatRequest, + ChatStreamChoice, + ChatStreamChunk, +} from '@openrouter/sdk/models' import type { OPENROUTER_CHAT_MODELS, OpenRouterChatModelToolCapabilitiesByName, OpenRouterModelInputModalitiesByName, OpenRouterModelOptionsByName, } from '../model-meta' -import type { - StructuredOutputOptions, - StructuredOutputResult, -} from '@tanstack/ai/adapters' -import type { - ContentPart, - ModelMessage, - StreamChunk, - TextOptions, -} from '@tanstack/ai' +import type { ContentPart, ModelMessage, TextOptions } from '@tanstack/ai' 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,38 +49,37 @@ 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. + * + * Extends the OpenAI Chat Completions base so it shares the stream + * accumulator, partial-JSON tool-call buffer, RUN_ERROR taxonomy, and + * lifecycle gates with the rest of the OpenAI-compatible providers. + * + * The wire format is identical to OpenAI's Chat Completions, but the + * `@openrouter/sdk` SDK exposes a different call shape — `client.chat.send + * ({ chatRequest })` with camelCase fields. We override the two SDK-call + * hooks (`callChatCompletion` / `callChatCompletionStream`) to bridge that, + * plus a small chunk-shape adapter on the way back, and `extractReasoning` + * to surface OpenRouter's reasoning deltas through the base's REASONING_* + * lifecycle. + * + * Behaviour preserved from the pre-migration 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 — the base's + * `chatStream` wraps unknown errors into a single RUN_ERROR event via + * `toRunErrorPayload`, so the abort lifecycle is unchanged. + * - Model variant suffixing (e.g. `:thinking`, `:free`) via + * `modelOptions.variant`. + */ export class OpenRouterTextAdapter< TModel extends OpenRouterTextModels, TToolCapabilities extends ReadonlyArray = ResolveToolCapabilities, -> extends BaseTextAdapter< +> extends OpenAICompatibleChatCompletionsTextAdapter< TModel, ResolveProviderOptions, ResolveInputModalities, @@ -103,690 +89,335 @@ export class OpenRouterTextAdapter< readonly kind = 'text' as const readonly name = 'openrouter' as const - private client: OpenRouter + /** OpenRouter SDK client. The base's `this.client` (an OpenAI client) is + * unused because we override the SDK-call hooks below. */ + protected orClient: OpenRouter constructor(config: OpenRouterConfig, model: TModel) { - super({}, model) - this.client = new OpenRouter(config) + // The base needs an OpenAICompatibleClientConfig to construct an OpenAI + // client we never use. The OpenRouter SDK supports a Promise-returning + // apiKey getter; the OpenAI SDK's constructor here is a no-op for our + // purposes, so any string suffices. + const apiKey = + typeof config.apiKey === 'string' ? config.apiKey : 'unused' + super( + { apiKey, baseURL: 'https://openrouter.ai/api/v1' }, + model, + 'openrouter', + ) + 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, - 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 }, - ) - 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 - }, - chunk.usage, - aguiState, - ) - } - } - - // 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', { - error, - source: 'openrouter.chatStream', - }) - // Emit RUN_STARTED if not yet emitted (error on first call) - if (!aguiState.hasEmittedRunStarted) { - aguiState.hasEmittedRunStarted = true - yield asChunk({ - type: '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 - } - - // Emit AG-UI RUN_ERROR - yield asChunk({ - type: 'RUN_ERROR', - runId: aguiState.runId, - model: options.model, - timestamp, - message: (error as Error).message || 'Unknown error', - error: { - message: (error as Error).message || 'Unknown error', - }, - }) - } + // ──────────────────────────────────────────────────────────────────────── + // SDK call hooks — adapt OpenAI snake_case params to OpenRouter camelCase + // and adapt the returned shape back to the OpenAI structural contract the + // base's processStreamChunks reads. + // ──────────────────────────────────────────────────────────────────────── + + protected override async callChatCompletionStream( + params: ChatCompletionCreateParamsStreaming, + requestOptions: { signal?: AbortSignal | null; headers?: HeadersInit }, + ): Promise> { + const chatRequest = toOpenRouterRequest(params, true) + const stream = (await this.orClient.chat.send( + { chatRequest: { ...chatRequest, stream: true } }, + { signal: requestOptions.signal ?? undefined }, + )) as AsyncIterable + return adaptOpenRouterStreamChunks(stream) } - async structuredOutput( - options: StructuredOutputOptions>, - ): Promise> { - const { chatOptions, outputSchema } = options - const { logger } = chatOptions - - const requestParams = this.mapTextOptionsToSDK(chatOptions) + protected override async callChatCompletion( + params: ChatCompletionCreateParamsNonStreaming, + requestOptions: { signal?: AbortSignal | null; headers?: HeadersInit }, + ): Promise { + const chatRequest = toOpenRouterRequest(params, false) + const response = await this.orClient.chat.send( + { chatRequest: { ...chatRequest, stream: false } }, + { signal: requestOptions.signal ?? undefined }, + ) + // The base only reads `response.choices[0]?.message.content`. The SDK's + // non-streaming response carries that under the same path. + return response as unknown as ChatCompletion + } - // 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, - }) + // ──────────────────────────────────────────────────────────────────────── + // Reasoning hook — surface OpenRouter's `delta.reasoningDetails` through + // the base's REASONING_* lifecycle. + // ──────────────────────────────────────────────────────────────────────── - 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 }, - ) - const result = await this.client.chat.send( - { - chatRequest: { - ...requestParams, - stream: false, - responseFormat: { - type: 'json_schema', - jsonSchema: { - name: 'structured_output', - schema: strictSchema, - strict: true, - }, - }, - }, - }, - { signal: chatOptions.request?.signal }, - ) - 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') - } - if (error instanceof SyntaxError) { - throw new Error( - `Failed to parse structured output as JSON: ${error.message}`, - ) - } - const err = error as Error - throw new Error( - `Structured output generation failed: ${err.message || 'Unknown error occurred'}`, - ) - } + /** OpenRouter historically returns nulls in structured-output results as + * literal nulls rather than absent fields; preserve that behaviour. */ + protected override transformStructuredOutput(parsed: unknown): unknown { + return parsed } - protected override generateId(): string { - return utilGenerateId(this.name) + protected override extractReasoning( + chunk: ChatCompletionChunk, + ): { text: string } | undefined { + // The chunk-adapter stashes the raw reasoning deltas on a non-standard + // field so we don't need to round-trip them through camelCase ↔ + // snake_case on the OpenAI Chat Completions chunk schema. + const reasoning = (chunk as unknown as { _reasoningText?: string }) + ._reasoningText + return reasoning ? { text: reasoning } : undefined } - 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', - }) - } - - 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 - } - 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', - }) - } + // ──────────────────────────────────────────────────────────────────────── + // Message conversion — OpenRouter uses camelCase (`toolCallId`, + // `toolCalls`, `imageUrl`, `inputAudio`, `videoUrl`). We override + // `convertMessage` and `convertContentPart` so the base's + // `mapOptionsToRequest` flows through to the SDK without a second pass. + // ──────────────────────────────────────────────────────────────────────── - 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 - } - } + protected override convertMessage(message: ModelMessage): any { + if (message.role === 'tool') { + return { + role: 'tool', + content: + typeof message.content === 'string' + ? message.content + : JSON.stringify(message.content), + toolCallId: message.toolCallId || '', + } satisfies ChatMessages } - 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 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', - }) - } - - 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, - }) + if (message.role === 'assistant') { + return { + role: 'assistant', + content: + typeof message.content === 'string' + ? message.content + : message.content + ? JSON.stringify(message.content) + : undefined, + toolCalls: message.toolCalls, + } satisfies ChatMessages } - if (delta.toolCalls) { - for (const tc of delta.toolCalls) { - const existing = toolCallBuffers.get(tc.index) - if (!existing) { - if (!tc.id) { - continue - } - 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, - }) - } - - // 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, - }) - } - } + // user + const contentParts = this.normalizeContent(message.content) + if (contentParts.length === 1 && contentParts[0]?.type === 'text') { + return { + role: 'user', + content: contentParts[0].content, + } satisfies ChatMessages } - 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' }, - }) + const parts: Array = [] + for (const part of contentParts) { + const converted = this.convertContentPartToOpenRouter(part) + if (converted) parts.push(converted) } + return { + role: 'user', + content: parts.length ? parts : [{ type: 'text', text: '' }], + } satisfies ChatMessages + } - 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, + /** OpenRouter content-part converter (camelCase imageUrl/inputAudio/videoUrl). */ + private convertContentPartToOpenRouter( + part: ContentPart, + ): ChatContentItems | null { + switch (part.type) { + case 'text': + return { type: 'text', text: part.content } + case 'image': { + const meta = part.metadata as OpenRouterImageMetadata | undefined + const value = part.source.value + const url = + part.source.type === 'data' && !value.startsWith('data:') + ? `data:${part.source.mimeType};base64,${value}` + : value + return { + type: 'image_url', + imageUrl: { url, detail: meta?.detail || 'auto' }, } } - - // 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 = {} - try { - parsedInput = tc.arguments ? JSON.parse(tc.arguments) : {} - } catch { - 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, - }) - } - - toolCallBuffers.clear() - } - - 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, - }) - } - } - - // 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, - }) + case 'audio': + return { + type: 'input_audio', + inputAudio: { data: part.source.value, format: 'mp3' }, } - } + case 'video': + return { type: 'video_url', videoUrl: { url: part.source.value } } + case 'document': + // SDK doesn't have a document_url type — surface as text so the + // model at least sees the URL rather than dropping the part. + return { type: 'text', text: `[Document: ${part.source.value}]` } + default: + return null } } - private mapTextOptionsToSDK( - options: TextOptions>, - ): ChatRequest { - const modelOptions = options.modelOptions - - const messages = this.convertMessages(options.messages) - + /** Override request mapping to apply OpenRouter's `:variant` model suffix + * and route tools through OpenRouter's converter (function tools + + * branded web_search tool). The base writes snake_case fields here; the + * SDK-call hooks convert them just before sending. */ + protected override mapOptionsToRequest( + options: TextOptions, + ): ChatCompletionCreateParamsStreaming { + 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'), }) } + for (const m of options.messages) { + messages.push(this.convertMessage(m)) + } - // 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}` : ''), + const tools = options.tools + ? convertToolsToProviderFormat(options.tools) + : undefined + + // Keep modelOptions first so explicit top-level options (set below) win + // when defined but `undefined` doesn't clobber values the caller set in + // modelOptions. Fixes the same merge-order regression openai/grok handle. + return { + ...(modelOptions as Record), + model: options.model + variantSuffix, messages, ...(options.temperature !== undefined && { temperature: options.temperature, }), ...(options.maxTokens !== undefined && { - maxCompletionTokens: options.maxTokens, + max_tokens: options.maxTokens, }), - ...(options.topP !== undefined && { topP: options.topP }), - tools: options.tools - ? convertToolsToProviderFormat(options.tools) - : undefined, - } + ...(options.topP !== undefined && { top_p: options.topP }), + ...(tools && tools.length > 0 && { tools }), + stream: true, + } as ChatCompletionCreateParamsStreaming + } +} - return request +// ────────────────────────────────────────────────────────────────────────── +// Helpers: convert OpenAI Chat Completions params ↔ OpenRouter ChatRequest +// ────────────────────────────────────────────────────────────────────────── + +/** + * Convert the base's snake_case params shape to the OpenRouter SDK's + * camelCase ChatRequest. Only the fields the base actually writes need + * mapping — modelOptions already flows through in OpenRouter (camelCase) + * shape because the public option types derive from `ChatRequest`. + */ +function toOpenRouterRequest( + params: + | ChatCompletionCreateParamsStreaming + | ChatCompletionCreateParamsNonStreaming, + isStreaming: boolean, +): ChatRequest { + const p = params as Record + const out: Record = { ...p } + + // The base injects these snake_case fields. Rewrite to camelCase. + if ('max_tokens' in p) { + out.maxCompletionTokens = p.max_tokens + delete out.max_tokens + } + if ('top_p' in p) { + out.topP = p.top_p + delete out.top_p + } + if ('stream_options' in p) { + out.streamOptions = p.stream_options + delete out.stream_options + } + if ('response_format' in p && p.response_format) { + const rf = p.response_format + out.responseFormat = + rf.type === 'json_schema' && rf.json_schema + ? { + type: 'json_schema', + jsonSchema: rf.json_schema, + } + : rf + delete out.response_format } - private convertMessages(messages: Array): Array { - return messages.map((msg) => { - if (msg.role === 'tool') { - return { - role: 'tool' as const, - content: - typeof msg.content === 'string' - ? msg.content - : JSON.stringify(msg.content), - toolCallId: msg.toolCallId || '', - } - } + // Streaming flag is set per-call by the SDK call hook, not here. + delete out.stream + if (!isStreaming) delete out.streamOptions - if (msg.role === 'user') { - const content = this.convertContentParts(msg.content) - return { - role: 'user' as const, - content: - content.length === 1 && content[0]?.type === 'text' - ? (content[0] as { type: 'text'; text: string }).text - : content, + return out as ChatRequest +} + +/** + * Adapt OpenRouter's stream chunks (camelCase, with `reasoningDetails`) into + * the OpenAI Chat Completions chunk shape the base's `processStreamChunks` + * reads. Reasoning text is stashed on `_reasoningText` for the + * `extractReasoning` override to consume. + */ +async function* adaptOpenRouterStreamChunks( + stream: AsyncIterable, +): AsyncIterable { + for await (const chunk of stream) { + // Flatten any reasoning deltas in the chunk into a single string. + let reasoningText = '' + const adaptedChoices = chunk.choices.map((c: ChatStreamChoice) => { + const delta = c.delta as Record + if (Array.isArray(delta.reasoningDetails)) { + for (const d of delta.reasoningDetails) { + if (d?.type === 'reasoning.text' && typeof d.text === 'string') { + reasoningText += d.text + } else if ( + d?.type === 'reasoning.summary' && + typeof d.summary === 'string' + ) { + reasoningText += d.summary + } } } - - // assistant role return { - role: 'assistant' as const, - content: - typeof msg.content === 'string' - ? msg.content - : msg.content - ? JSON.stringify(msg.content) - : undefined, - toolCalls: msg.toolCalls, + index: (c as { index?: number }).index ?? 0, + delta: { + content: delta.content, + tool_calls: delta.toolCalls?.map((tc: any) => ({ + index: tc.index, + id: tc.id, + type: tc.type ?? 'function', + function: tc.function, + })), + refusal: delta.refusal, + role: delta.role, + }, + finish_reason: c.finishReason, } }) - } - private convertContentParts( - content: string | null | Array, - ): Array { - if (!content) return [{ type: 'text', text: '' }] - if (typeof content === 'string') return [{ type: 'text', text: content }] + const usage = (chunk as any).usage + const adapted: any = { + id: chunk.id || '', + object: 'chat.completion.chunk', + created: 0, + model: chunk.model || '', + choices: adaptedChoices, + ...(usage && { + usage: { + prompt_tokens: usage.promptTokens || 0, + completion_tokens: usage.completionTokens || 0, + total_tokens: usage.totalTokens || 0, + }, + }), + // Stash reasoning text for the extractReasoning hook. The base only + // reads documented Chat Completions fields, so an additional field is + // safe to pass alongside. + _reasoningText: reasoningText || undefined, + } - 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 - } + // Surface upstream errors so the base can route them to RUN_ERROR. + if ((chunk as any).error) { + throw Object.assign(new Error((chunk as any).error.message || 'OpenRouter stream error'), { + code: (chunk as any).error.code, + }) } - return parts.length ? parts : [{ type: 'text', text: '' }] + + yield adapted as ChatCompletionChunk } } diff --git a/packages/typescript/ai-openrouter/tests/openrouter-adapter.test.ts b/packages/typescript/ai-openrouter/tests/openrouter-adapter.test.ts index 206d16525..9ca4978f2 100644 --- a/packages/typescript/ai-openrouter/tests/openrouter-adapter.test.ts +++ b/packages/typescript/ai-openrouter/tests/openrouter-adapter.test.ts @@ -1151,6 +1151,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,7 +1163,7 @@ 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 () => { @@ -1177,6 +1180,8 @@ describe('OpenRouter structured output', () => { setupMockSdkClient([], nonStreamResponse) const adapter = createAdapter() + // The shared base surfaces the JSON parse failure rather than a separate + // "no content" error — empty content fails JSON.parse() in the base. await expect( adapter.structuredOutput({ chatOptions: { @@ -1186,7 +1191,7 @@ describe('OpenRouter structured output', () => { }, outputSchema: { type: 'object' }, }), - ).rejects.toThrow('Structured output response contained no content') + ).rejects.toThrow('Failed to parse structured output as JSON') }) }) 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..3da9bfd28 100644 --- a/packages/typescript/openai-base/src/adapters/chat-completions-text.ts +++ b/packages/typescript/openai-base/src/adapters/chat-completions-text.ts @@ -86,7 +86,7 @@ export class OpenAICompatibleChatCompletionsTextAdapter< `activity=chat provider=${this.name} model=${this.model} messages=${options.messages.length} tools=${options.tools?.length ?? 0} stream=true`, { provider: this.name, model: this.model }, ) - const stream = await this.client.chat.completions.create( + const stream = await this.callChatCompletionStream( { ...requestParams, stream: true, @@ -165,7 +165,7 @@ export class OpenAICompatibleChatCompletionsTextAdapter< `activity=structuredOutput provider=${this.name} model=${this.model} messages=${chatOptions.messages.length}`, { provider: this.name, model: this.model }, ) - const response = await this.client.chat.completions.create( + const response = await this.callChatCompletion( { ...cleanParams, stream: false, @@ -195,8 +195,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,6 +226,67 @@ export class OpenAICompatibleChatCompletionsTextAdapter< return makeStructuredOutputCompatible(schema, originalRequired) } + /** + * Performs the non-streaming Chat Completions network call. The default + * uses the OpenAI SDK (`client.chat.completions.create`), which covers any + * provider whose endpoint accepts the OpenAI SDK verbatim (e.g. xAI/Grok, + * Groq with a `baseURL` override, DeepSeek, Together, Fireworks). + * + * Override in subclasses whose SDK has a different call shape — for + * example `@openrouter/sdk` exposes `client.chat.send({ chatRequest })` + * with camelCase fields. The override is responsible for converting the + * params shape on the way in and returning an object structurally + * compatible with `ChatCompletion` (the base only reads documented fields + * like `response.choices[0].message.content`). + */ + protected async callChatCompletion( + params: OpenAI_SDK.Chat.Completions.ChatCompletionCreateParamsNonStreaming, + requestOptions: ReturnType, + ): Promise { + return this.client.chat.completions.create(params, requestOptions) + } + + /** + * Performs the streaming Chat Completions network call. Same pattern as + * {@link callChatCompletion} — default uses the OpenAI SDK; override for + * providers whose SDK exposes a different streaming entry point. Returns + * an `AsyncIterable` because the base's + * {@link processStreamChunks} only needs structural iteration over chunks. + */ + protected async callChatCompletionStream( + params: OpenAI_SDK.Chat.Completions.ChatCompletionCreateParamsStreaming, + requestOptions: ReturnType, + ): Promise< + AsyncIterable + > { + return this.client.chat.completions.create(params, requestOptions) + } + + /** + * Extract reasoning content from a stream chunk. Default returns + * `undefined` because OpenAI Chat Completions doesn't carry reasoning in + * the chunk format. Providers that DO carry reasoning on this wire (e.g. + * OpenRouter's `delta.reasoningDetails`) override this to yield reasoning + * text — the base then folds it into a single REASONING_* lifecycle + * without each subclass duplicating `processStreamChunks`. + */ + protected extractReasoning( + _chunk: OpenAI_SDK.Chat.Completions.ChatCompletionChunk, + ): { 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. @@ -265,6 +328,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 @@ -306,6 +380,49 @@ export class OpenAICompatibleChatCompletionsTextAdapter< }) } + // 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 asChunk({ + type: 'REASONING_START', + messageId: reasoningMessageId, + model: chunk.model || options.model, + timestamp, + }) + yield asChunk({ + type: 'REASONING_MESSAGE_START', + messageId: reasoningMessageId, + role: 'reasoning' as const, + model: chunk.model || options.model, + timestamp, + }) + // Legacy STEP_STARTED (single emission, paired with the + // STEP_FINISHED below when reasoning closes). + yield asChunk({ + type: 'STEP_STARTED', + stepName: stepId, + stepId, + model: chunk.model || options.model, + timestamp, + stepType: 'thinking', + }) + } + accumulatedReasoning += reasoning.text + yield asChunk({ + type: 'REASONING_MESSAGE_CONTENT', + messageId: reasoningMessageId, + delta: reasoning.text, + model: chunk.model || options.model, + timestamp, + }) + } + const choice = chunk.choices[0] if (!choice) continue @@ -316,6 +433,34 @@ 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 asChunk({ + type: 'REASONING_MESSAGE_END', + messageId: reasoningMessageId, + model: chunk.model || options.model, + timestamp, + }) + yield asChunk({ + type: 'REASONING_END', + messageId: reasoningMessageId, + model: chunk.model || options.model, + timestamp, + }) + if (stepId) { + yield asChunk({ + type: 'STEP_FINISHED', + stepName: stepId, + stepId, + model: chunk.model || options.model, + timestamp, + content: accumulatedReasoning, + }) + } + } + // Emit TEXT_MESSAGE_START on first text content if (!hasEmittedTextMessageStart) { hasEmittedTextMessageStart = true @@ -526,6 +671,34 @@ export class OpenAICompatibleChatCompletionsTextAdapter< }) } + // 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 asChunk({ + type: 'REASONING_MESSAGE_END', + messageId: reasoningMessageId, + model: lastModel || options.model, + timestamp, + }) + yield asChunk({ + type: 'REASONING_END', + messageId: reasoningMessageId, + model: lastModel || options.model, + timestamp, + }) + if (stepId) { + yield asChunk({ + type: 'STEP_FINISHED', + stepName: stepId, + stepId, + model: lastModel || options.model, + timestamp, + content: accumulatedReasoning, + }) + } + } + // Map upstream finish_reason to AG-UI's narrower vocabulary while // preserving the upstream value when it falls outside the AG-UI set. // Collapsing length / content_filter to 'stop' would hide why the diff --git a/packages/typescript/openai-base/src/index.ts b/packages/typescript/openai-base/src/index.ts index ab15140ea..df6491e18 100644 --- a/packages/typescript/openai-base/src/index.ts +++ b/packages/typescript/openai-base/src/index.ts @@ -3,6 +3,15 @@ export { createOpenAICompatibleClient } from './utils/client' export type { OpenAICompatibleClientConfig } from './types/config' export * from './tools/index' export { OpenAICompatibleChatCompletionsTextAdapter } from './adapters/chat-completions-text' +// Re-export the OpenAI SDK types subclasses need when overriding the +// `callChatCompletion*` / `processStreamChunks` hooks, so they don't need +// to declare `openai` as a direct dependency. +export type { + ChatCompletion, + ChatCompletionChunk, + ChatCompletionCreateParamsNonStreaming, + ChatCompletionCreateParamsStreaming, +} from 'openai/resources/chat/completions' export { convertFunctionToolToChatCompletionsFormat, convertToolsToChatCompletionsFormat, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7ab06cfa3..81017c749 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1243,9 +1243,6 @@ importers: '@tanstack/openai-base': specifier: workspace:* version: link:../openai-base - groq-sdk: - specifier: ^0.37.0 - version: 0.37.0 zod: specifier: ^4.0.0 version: 4.3.6 @@ -1354,6 +1351,9 @@ importers: '@tanstack/ai-utils': specifier: workspace:* version: link:../ai-utils + '@tanstack/openai-base': + specifier: workspace:* + version: link:../openai-base devDependencies: '@tanstack/ai': specifier: workspace:* @@ -6418,15 +6418,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==} @@ -6842,10 +6836,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: @@ -8203,9 +8193,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'} @@ -8215,10 +8202,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'} @@ -8400,9 +8383,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'} @@ -8581,9 +8561,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'} @@ -11198,9 +11175,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==} @@ -11872,10 +11846,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==} @@ -16975,17 +16945,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 @@ -17576,10 +17537,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 @@ -19122,8 +19079,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 @@ -19136,11 +19091,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 @@ -19342,18 +19292,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 @@ -19615,10 +19553,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 @@ -23018,8 +22952,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: {} @@ -23767,8 +23699,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: {} From 2c52bd18c8f9e881e420f0b5c86bb9d95f5b6816 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 11 May 2026 12:26:06 +0000 Subject: [PATCH 2/6] ci: apply automated fixes --- .../migrate-groq-openrouter-to-openai-base.md | 2 +- .../ai-groq/tests/groq-adapter.test.ts | 4 +--- .../ai-openrouter/src/adapters/text.ts | 16 ++++++++++------ .../src/adapters/chat-completions-text.ts | 4 +--- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/.changeset/migrate-groq-openrouter-to-openai-base.md b/.changeset/migrate-groq-openrouter-to-openai-base.md index ff5012565..0cbd390e4 100644 --- a/.changeset/migrate-groq-openrouter-to-openai-base.md +++ b/.changeset/migrate-groq-openrouter-to-openai-base.md @@ -6,7 +6,7 @@ Migrate `ai-groq` and `ai-openrouter` onto `OpenAICompatibleChatCompletionsTextAdapter` so they share the stream accumulator, partial-JSON tool-call buffer, RUN_ERROR taxonomy, and lifecycle gates with `ai-openai` / `ai-grok`. Removes ~1k LOC of duplicated stream processing. -`@tanstack/openai-base` adds three protected hooks on `OpenAICompatibleChatCompletionsTextAdapter` so providers with non-OpenAI SDK shapes can reuse the base: `callChatCompletion` and `callChatCompletionStream` (SDK call sites for non-streaming and streaming Chat Completions), and `extractReasoning` (surface reasoning content from chunk shapes that carry it, e.g. OpenRouter's `delta.reasoningDetails`, into the base's REASONING_* + legacy STEP_STARTED/STEP_FINISHED lifecycle). Also adds `transformStructuredOutput` for subclasses (like OpenRouter) that preserve nulls in structured output instead of converting them to undefined. +`@tanstack/openai-base` adds three protected hooks on `OpenAICompatibleChatCompletionsTextAdapter` so providers with non-OpenAI SDK shapes can reuse the base: `callChatCompletion` and `callChatCompletionStream` (SDK call sites for non-streaming and streaming Chat Completions), and `extractReasoning` (surface reasoning content from chunk shapes that carry it, e.g. OpenRouter's `delta.reasoningDetails`, into the base's REASONING\_\* + legacy STEP_STARTED/STEP_FINISHED lifecycle). Also adds `transformStructuredOutput` for subclasses (like OpenRouter) that preserve nulls in structured output instead of converting them to undefined. `@tanstack/ai-groq` drops the `groq-sdk` dependency in favour of the OpenAI SDK pointed at `https://api.groq.com/openai/v1` (the same pattern as `ai-grok` against xAI). The Groq-specific quirk where streaming usage arrives under `chunk.x_groq.usage` is preserved via a small `processStreamChunks` wrapper that promotes it to the standard `chunk.usage` slot. diff --git a/packages/typescript/ai-groq/tests/groq-adapter.test.ts b/packages/typescript/ai-groq/tests/groq-adapter.test.ts index 2f34fe368..def98d8da 100644 --- a/packages/typescript/ai-groq/tests/groq-adapter.test.ts +++ b/packages/typescript/ai-groq/tests/groq-adapter.test.ts @@ -56,9 +56,7 @@ function createAsyncIterable(chunks: Array): AsyncIterable { // = 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 +let pendingMockCreate: Mock<(...args: Array) => unknown> | undefined function setupMockSdkClient( streamChunks: Array>, diff --git a/packages/typescript/ai-openrouter/src/adapters/text.ts b/packages/typescript/ai-openrouter/src/adapters/text.ts index a43d60620..bb34aeb00 100644 --- a/packages/typescript/ai-openrouter/src/adapters/text.ts +++ b/packages/typescript/ai-openrouter/src/adapters/text.ts @@ -98,8 +98,7 @@ export class OpenRouterTextAdapter< // client we never use. The OpenRouter SDK supports a Promise-returning // apiKey getter; the OpenAI SDK's constructor here is a no-op for our // purposes, so any string suffices. - const apiKey = - typeof config.apiKey === 'string' ? config.apiKey : 'unused' + const apiKey = typeof config.apiKey === 'string' ? config.apiKey : 'unused' super( { apiKey, baseURL: 'https://openrouter.ai/api/v1' }, model, @@ -259,7 +258,9 @@ export class OpenRouterTextAdapter< const modelOptions = options.modelOptions as | (Record & { variant?: string }) | undefined - const variantSuffix = modelOptions?.variant ? `:${modelOptions.variant}` : '' + const variantSuffix = modelOptions?.variant + ? `:${modelOptions.variant}` + : '' const messages: Array = [] if (options.systemPrompts?.length) { @@ -412,9 +413,12 @@ async function* adaptOpenRouterStreamChunks( // Surface upstream errors so the base can route them to RUN_ERROR. if ((chunk as any).error) { - throw Object.assign(new Error((chunk as any).error.message || 'OpenRouter stream error'), { - code: (chunk as any).error.code, - }) + throw Object.assign( + new Error((chunk as any).error.message || 'OpenRouter stream error'), + { + code: (chunk as any).error.code, + }, + ) } yield adapted as ChatCompletionChunk 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 3da9bfd28..02a93c64b 100644 --- a/packages/typescript/openai-base/src/adapters/chat-completions-text.ts +++ b/packages/typescript/openai-base/src/adapters/chat-completions-text.ts @@ -256,9 +256,7 @@ export class OpenAICompatibleChatCompletionsTextAdapter< protected async callChatCompletionStream( params: OpenAI_SDK.Chat.Completions.ChatCompletionCreateParamsStreaming, requestOptions: ReturnType, - ): Promise< - AsyncIterable - > { + ): Promise> { return this.client.chat.completions.create(params, requestOptions) } From 0171b18e742abd7d6b10745c04359f42ea8463df Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Tue, 12 May 2026 09:21:40 +1000 Subject: [PATCH 3/6] fix(openai-base, ai-openrouter, ai): silent failures in chat-completions migration Addresses regressions and pre-existing silent failures surfaced by reviewing #545: - `@tanstack/ai`: `toRunErrorPayload` normalizes `AbortError` / `APIUserAbortError` / `RequestAbortedError` to `{ code: 'aborted' }` so consumers can discriminate user-initiated cancellation without matching provider-specific message strings. - `@tanstack/openai-base`: `structuredOutput` throws a distinct "response contained no content" error instead of cascading into a misleading JSON-parse error on an empty string; the post-loop tool-args drain now logs malformed JSON via `logger.errors` so truncated streams don't silently invoke tools with `{}`. - `@tanstack/ai-openrouter`: `stream_options.include_usage` is camelCased to `includeUsage` (Zod was silently stripping it, leaving `RUN_FINISHED.usage` always undefined on streaming); mid-stream `chunk.error.code` is stringified so provider codes (401/429/500) survive `toRunErrorPayload`; assistant `toolCalls[].function.arguments` is stringified to match the SDK's `string` contract; `convertMessage` now mirrors the base's fail-loud guards (empty user content, unsupported content parts). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../migrate-groq-openrouter-to-openai-base.md | 7 +- .../ai-openrouter/src/adapters/text.ts | 72 +++++- .../tests/openrouter-adapter.test.ts | 229 +++++++++++++++++- .../ai/src/activities/error-payload.ts | 18 ++ .../typescript/ai/tests/error-payload.test.ts | 45 ++++ .../src/adapters/chat-completions-text.ts | 31 ++- .../tests/chat-completions-text.test.ts | 109 +++++++++ 7 files changed, 495 insertions(+), 16 deletions(-) diff --git a/.changeset/migrate-groq-openrouter-to-openai-base.md b/.changeset/migrate-groq-openrouter-to-openai-base.md index 0cbd390e4..dd48aba33 100644 --- a/.changeset/migrate-groq-openrouter-to-openai-base.md +++ b/.changeset/migrate-groq-openrouter-to-openai-base.md @@ -2,14 +2,19 @@ '@tanstack/openai-base': minor '@tanstack/ai-groq': patch '@tanstack/ai-openrouter': patch +'@tanstack/ai': patch --- Migrate `ai-groq` and `ai-openrouter` onto `OpenAICompatibleChatCompletionsTextAdapter` so they share the stream accumulator, partial-JSON tool-call buffer, RUN_ERROR taxonomy, and lifecycle gates with `ai-openai` / `ai-grok`. Removes ~1k LOC of duplicated stream processing. `@tanstack/openai-base` adds three protected hooks on `OpenAICompatibleChatCompletionsTextAdapter` so providers with non-OpenAI SDK shapes can reuse the base: `callChatCompletion` and `callChatCompletionStream` (SDK call sites for non-streaming and streaming Chat Completions), and `extractReasoning` (surface reasoning content from chunk shapes that carry it, e.g. OpenRouter's `delta.reasoningDetails`, into the base's REASONING\_\* + legacy STEP_STARTED/STEP_FINISHED lifecycle). Also adds `transformStructuredOutput` for subclasses (like OpenRouter) that preserve nulls in structured output instead of converting them to undefined. +`@tanstack/openai-base` fixes two error-handling regressions in the shared base: `structuredOutput` now throws a distinct `"response contained no content"` error rather than letting empty content cascade into a misleading JSON-parse error, and the post-loop tool-args drain block now logs malformed JSON via `logger.errors` (matching the in-loop finish_reason path) so truncated streams emitting partial tool args are debuggable instead of silently invoking the tool with `{}`. + +`@tanstack/ai` normalizes abort-shaped errors (`AbortError`, `APIUserAbortError`, `RequestAbortedError`) to a stable `{ message: 'Request aborted', code: 'aborted' }` payload in `toRunErrorPayload`, so consumers can discriminate user-initiated cancellation from other failures without matching on provider-specific message strings. + `@tanstack/ai-groq` drops the `groq-sdk` dependency in favour of the OpenAI SDK pointed at `https://api.groq.com/openai/v1` (the same pattern as `ai-grok` against xAI). The Groq-specific quirk where streaming usage arrives under `chunk.x_groq.usage` is preserved via a small `processStreamChunks` wrapper that promotes it to the standard `chunk.usage` slot. -`@tanstack/ai-openrouter` keeps `@openrouter/sdk` (the source of truth for OpenRouter's typed provider routing, plugins, and metadata) but routes the SDK call through the base via overridden hooks. A small request shape converter (`max_tokens` → `maxCompletionTokens`, etc.) and chunk shape adapter (camelCase → snake_case for the base's reader) bridge the SDKs. No public API changes; provider routing, app attribution headers (`httpReferer`, `appTitle`), reasoning variants (`:thinking`), and `RequestAbortedError` handling are preserved. +`@tanstack/ai-openrouter` keeps `@openrouter/sdk` (the source of truth for OpenRouter's typed provider routing, plugins, and metadata) but routes the SDK call through the base via overridden hooks. A small request shape converter (`max_tokens` → `maxCompletionTokens`, etc.) and chunk shape adapter (camelCase → snake_case for the base's reader) bridge the SDKs. No public API changes; provider routing, app attribution headers (`httpReferer`, `appTitle`), reasoning variants (`:thinking`), and `RequestAbortedError` handling are preserved. Fixes: `stream_options.include_usage` is now correctly camelCased to `includeUsage` so streaming `RUN_FINISHED.usage` is populated (previously silently dropped by the SDK Zod schema); mid-stream `chunk.error.code` is stringified so provider error codes (401, 429, 500, …) survive the `toRunErrorPayload` narrow; assistant `toolCalls[].function.arguments` is stringified to match the SDK's `string` contract; and `convertMessage` now mirrors the base's fail-loud guards (throws on empty user content and unsupported content parts) instead of silently sending empty paid requests. `ai-ollama` remains on `BaseTextAdapter` — its native API uses a different wire format from Chat Completions (different chunk shape, request shape, tool-call streaming, and reasoning surface) and doesn't fit the OpenAI base without rebuilding most of the processing it would otherwise inherit. Migrating it remains a separate effort. diff --git a/packages/typescript/ai-openrouter/src/adapters/text.ts b/packages/typescript/ai-openrouter/src/adapters/text.ts index bb34aeb00..db7677cab 100644 --- a/packages/typescript/ai-openrouter/src/adapters/text.ts +++ b/packages/typescript/ai-openrouter/src/adapters/text.ts @@ -181,6 +181,21 @@ export class OpenRouterTextAdapter< } 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), + }, + })) return { role: 'assistant', content: @@ -189,27 +204,51 @@ export class OpenRouterTextAdapter< : message.content ? JSON.stringify(message.content) : undefined, - toolCalls: message.toolCalls, + toolCalls, } satisfies ChatMessages } - // user + // user — mirror the base's fail-loud behaviour 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: contentParts[0].content, + content: text, } satisfies ChatMessages } const parts: Array = [] for (const part of contentParts) { const converted = this.convertContentPartToOpenRouter(part) - if (converted) parts.push(converted) + if (!converted) { + throw new Error( + `Unsupported content part type for ${this.name}: ${part.type}. ` + + `Override convertContentPartToOpenRouter 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.length ? parts : [{ type: 'text', text: '' }], + content: parts, } satisfies ChatMessages } @@ -326,7 +365,20 @@ function toOpenRouterRequest( delete out.top_p } if ('stream_options' in p) { - out.streamOptions = p.stream_options + const so = p.stream_options as Record | undefined + if (so && typeof so === 'object') { + // The SDK's ChatStreamOptions schema uses camelCase keys and Zod + // strips unknowns at parse time — without this rename the base's + // include_usage flag would be silently dropped and RUN_FINISHED.usage + // would always be undefined for streaming OpenRouter calls. + const { include_usage, ...rest } = so + out.streamOptions = { + ...rest, + ...(include_usage !== undefined && { includeUsage: include_usage }), + } + } else { + out.streamOptions = so + } delete out.stream_options } if ('response_format' in p && p.response_format) { @@ -412,11 +464,15 @@ async function* adaptOpenRouterStreamChunks( } // Surface upstream errors so the base can route them to RUN_ERROR. + // Stringify code: OpenRouter's chunk error.code is numeric (401, 429, + // 500, …) but `toRunErrorPayload` drops non-string codes, which would + // silently lose provider error codes from the RUN_ERROR payload. if ((chunk as any).error) { + const errObj = (chunk as any).error throw Object.assign( - new Error((chunk as any).error.message || 'OpenRouter stream error'), + new Error(errObj.message || 'OpenRouter stream error'), { - code: (chunk as any).error.code, + code: errObj.code != null ? String(errObj.code) : undefined, }, ) } diff --git a/packages/typescript/ai-openrouter/tests/openrouter-adapter.test.ts b/packages/typescript/ai-openrouter/tests/openrouter-adapter.test.ts index 9ca4978f2..d4dc528de 100644 --- a/packages/typescript/ai-openrouter/tests/openrouter-adapter.test.ts +++ b/packages/typescript/ai-openrouter/tests/openrouter-adapter.test.ts @@ -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), } @@ -789,6 +796,10 @@ 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.) but + // toRunErrorPayload only retains string codes — the chunk adapter + // must stringify before throwing. + expect(runErrorChunk.error?.code).toBe('429') } }) @@ -1166,7 +1177,7 @@ describe('OpenRouter structured output', () => { ).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: [ { @@ -1180,8 +1191,9 @@ describe('OpenRouter structured output', () => { setupMockSdkClient([], nonStreamResponse) const adapter = createAdapter() - // The shared base surfaces the JSON parse failure rather than a separate - // "no content" error — empty content fails JSON.parse() in the base. + // 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: { @@ -1191,7 +1203,7 @@ describe('OpenRouter structured output', () => { }, outputSchema: { type: 'object' }, }), - ).rejects.toThrow('Failed to parse structured output as JSON') + ).rejects.toThrow('response contained no content') }) }) @@ -1669,3 +1681,212 @@ describe('OpenRouter STEP event consistency', () => { expect(stepFinished).toHaveLength(1) }) }) + +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).toEqual({ signal: controller.signal }) + }) + + 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('throws when a user message has empty text content', async () => { + setupMockSdkClient([]) + const adapter = createAdapter() + + // mapOptionsToRequest runs before chatStream's try block, so the + // fail-loud guard surfaces as a synchronous iterator throw — verifies + // we never made a paid request with an empty user message. + await expect(async () => { + for await (const _ of adapter.chatStream({ + model: 'openai/gpt-4o-mini', + messages: [{ role: 'user', content: '' }], + logger: testLogger, + })) { + // consume + } + }).rejects.toThrow(/empty text content/i) + expect(mockSend).not.toHaveBeenCalled() + }) + + it('throws on unsupported content-part types instead of dropping them', async () => { + setupMockSdkClient([]) + const adapter = createAdapter() + + await expect(async () => { + for await (const _ of adapter.chatStream({ + model: 'openai/gpt-4o-mini', + messages: [ + { + role: 'user', + content: [{ type: 'mystery-type' as any, content: 'x' } as any], + }, + ], + logger: testLogger, + })) { + // consume + } + }).rejects.toThrow(/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' }) + }) +}) diff --git a/packages/typescript/ai/src/activities/error-payload.ts b/packages/typescript/ai/src/activities/error-payload.ts index 396c5573d..33a6b8157 100644 --- a/packages/typescript/ai/src/activities/error-payload.ts +++ b/packages/typescript/ai/src/activities/error-payload.ts @@ -5,11 +5,29 @@ * 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', +]) + 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 { diff --git a/packages/typescript/ai/tests/error-payload.test.ts b/packages/typescript/ai/tests/error-payload.test.ts index 1add82fc7..784a4901c 100644 --- a/packages/typescript/ai/tests/error-payload.test.ts +++ b/packages/typescript/ai/tests/error-payload.test.ts @@ -73,4 +73,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/openai-base/src/adapters/chat-completions-text.ts b/packages/typescript/openai-base/src/adapters/chat-completions-text.ts index 02a93c64b..b15b2d8df 100644 --- a/packages/typescript/openai-base/src/adapters/chat-completions-text.ts +++ b/packages/typescript/openai-base/src/adapters/chat-completions-text.ts @@ -181,8 +181,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 @@ -640,7 +648,24 @@ 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 = {} } } 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..7eac9c634 100644 --- a/packages/typescript/openai-base/tests/chat-completions-text.test.ts +++ b/packages/typescript/openai-base/tests/chat-completions-text.test.ts @@ -738,6 +738,115 @@ 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 OpenAICompatibleChatCompletionsTextAdapter( + 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 OpenAICompatibleChatCompletionsTextAdapter( + 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 OpenAICompatibleChatCompletionsTextAdapter( + 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', () => { From d2fea9d1e41e6edb48ad3fb5343257d48ab5bbe5 Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Tue, 12 May 2026 12:40:52 +1000 Subject: [PATCH 4/6] feat(ai-openrouter, openai-base): OpenRouter Responses (beta) adapter Adds OpenRouterResponsesTextAdapter on top of @tanstack/openai-base's responses-text base, mirroring the chat-completions migration in #543. - openai-base: protected `callResponse` / `callResponseStream` hooks on OpenAICompatibleResponsesTextAdapter parallel to the existing `callChatCompletion*` hooks, so providers whose SDK has a different call shape can override without forking processStreamChunks. Re-exports the OpenAI Responses SDK types subclasses need. - ai-openrouter: new OpenRouterResponsesTextAdapter routing through `client.beta.responses.send({ responsesRequest })`. Emits the SDK's camelCase TS shape directly via overrides of convertMessagesToInput / convertContentPartToInput / mapOptionsToRequest, annotated with `Pick` so future SDK field renames break the build instead of silently producing Zod-stripped wire payloads. Bridges inbound stream events camel -> snake so the base's processStreamChunks reads documented fields unchanged. - Function tools only in v1; webSearchTool() throws with a clear error pointing at the chat-completions adapter. - Folds in the silent-failure lessons from 0171b18e (stringified error codes, stringified tool-call arguments, fail-loud on empty user content). - E2E: new `openrouter-responses` provider slot in feature-support / test-matrix / providers / types / api.summarize, reusing aimock's native `/v1/responses` handler. - 10 new unit tests covering request mapping (snake -> camel for top-level fields, function-call camelCasing in input[], variant suffix), stream-event bridge (text deltas, function-call lifecycle, response.failed, top-level error code stringification), webSearchTool() rejection, and SDK constructor wiring. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/adapters/responses-text.ts | 596 ++++++++++++++++++ .../typescript/ai-openrouter/src/index.ts | 9 + .../src/text/responses-provider-options.ts | 55 ++ .../openrouter-responses-adapter.test.ts | 451 +++++++++++++ .../src/adapters/responses-text.ts | 37 +- packages/typescript/openai-base/src/index.ts | 12 + testing/e2e/src/lib/feature-support.ts | 13 + testing/e2e/src/lib/providers.ts | 26 +- testing/e2e/src/lib/types.ts | 2 + testing/e2e/src/routes/api.summarize.ts | 2 + testing/e2e/tests/test-matrix.ts | 1 + 11 files changed, 1201 insertions(+), 3 deletions(-) create mode 100644 packages/typescript/ai-openrouter/src/adapters/responses-text.ts create mode 100644 packages/typescript/ai-openrouter/src/text/responses-provider-options.ts create mode 100644 packages/typescript/ai-openrouter/tests/openrouter-responses-adapter.test.ts 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..900a909fa --- /dev/null +++ b/packages/typescript/ai-openrouter/src/adapters/responses-text.ts @@ -0,0 +1,596 @@ +import { OpenRouter } from '@openrouter/sdk' +import { + OpenAICompatibleResponsesTextAdapter, + convertFunctionToolToResponsesFormat, +} from '@tanstack/openai-base' +import { isWebSearchTool } from '../tools/web-search-tool' +import { getOpenRouterApiKeyFromEnv } from '../utils' +import type { SDKOptions } from '@openrouter/sdk' +import type { + InputsUnion, + ResponsesRequest, + StreamEvents, +} from '@openrouter/sdk/models' +import type { + ResponseCreateParams, + ResponseCreateParamsNonStreaming, + ResponseCreateParamsStreaming, + ResponseInputContent, + ResponseStreamEvent, + ResponsesFunctionTool, + ResponsesResponse, +} from '@tanstack/openai-base' +import type { ContentPart, ModelMessage, 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 override narrow to the per-item discriminated + * union so a TS rename surfaces here. */ +type InputsItem = Extract>[number] + +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. + * + * Extends the OpenAI Responses base so the streaming event lifecycle, + * structured-output flow, tool-call accumulator, and RUN_ERROR taxonomy are + * shared with the rest of the OpenAI-Responses-compatible providers (OpenAI, + * Azure, …). + * + * The wire format is OpenAI-Responses-compatible, but the `@openrouter/sdk` + * SDK exposes a different call shape — `client.beta.responses.send + * ({ responsesRequest })` with camelCase fields. We override the two + * SDK-call hooks (`callResponse` / `callResponseStream`) to bridge that, + * plus chunk and result shape adapters on the way back. + * + * Behaviour preserved from the chat-completions migration: + * - Provider routing surface (`provider`, `models`, `plugins`, + * `variant`) passes through `modelOptions`. + * - App attribution headers (`httpReferer`, `appTitle`) and base URL + * overrides flow through the SDK `SDKOptions` constructor. + * - Model variant suffixing (e.g. `:thinking`, `:free`) via + * `modelOptions.variant`. + * + * 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 OpenAICompatibleResponsesTextAdapter< + TModel, + OpenRouterResponsesTextProviderOptions, + ResolveInputModalities, + OpenRouterMessageMetadataByModality, + TToolCapabilities +> { + readonly kind = 'text' as const + readonly name = 'openrouter-responses' as const + + /** OpenRouter SDK client. The base's `this.client` (an OpenAI client) is + * unused because we override the SDK-call hooks below. */ + protected orClient: OpenRouter + + constructor(config: OpenRouterResponsesConfig, model: TModel) { + // The base needs an OpenAICompatibleClientConfig to construct an OpenAI + // client we never use. The OpenRouter SDK supports a Promise-returning + // apiKey getter; the OpenAI SDK's constructor here is a no-op for our + // purposes, so any string suffices. + const apiKey = typeof config.apiKey === 'string' ? config.apiKey : 'unused' + super( + { apiKey, baseURL: 'https://openrouter.ai/api/v1' }, + model, + 'openrouter-responses', + ) + this.orClient = new OpenRouter(config) + } + + // ──────────────────────────────────────────────────────────────────────── + // SDK call hooks — the params we get here were built by our overridden + // mapOptionsToRequest / convertMessagesToInput / convertContentPartToInput + // already in OpenRouter's camelCase TS shape, so only a type cast bridges + // the base's static snake_case signature. The inbound result/stream still + // needs camel → snake reshaping because the base's processStreamChunks / + // extractTextFromResponse read documented snake_case fields like + // `response.usage.input_tokens` and `chunk.item_id`. + // ──────────────────────────────────────────────────────────────────────── + + protected override async callResponseStream( + params: ResponseCreateParamsStreaming, + requestOptions: { signal?: AbortSignal | null; headers?: HeadersInit }, + ): Promise> { + const responsesRequest = params as unknown as Omit< + ResponsesRequest, + 'stream' + > + // The SDK's EventStream is an AsyncIterable; treat it + // structurally so we don't need to depend on the SDK's class export. + const stream = (await this.orClient.beta.responses.send( + { responsesRequest: { ...responsesRequest, stream: true } }, + { signal: requestOptions.signal ?? undefined }, + )) as unknown as AsyncIterable + return adaptOpenRouterResponsesStreamEvents(stream) + } + + protected override async callResponse( + params: ResponseCreateParamsNonStreaming, + requestOptions: { signal?: AbortSignal | null; headers?: HeadersInit }, + ): Promise { + const responsesRequest = params as unknown as Omit< + ResponsesRequest, + 'stream' + > + const result = await this.orClient.beta.responses.send( + { responsesRequest: { ...responsesRequest, stream: false } }, + { signal: requestOptions.signal ?? undefined }, + ) + return adaptOpenRouterResponsesResult(result) + } + + // ──────────────────────────────────────────────────────────────────────── + // Request construction — emit OpenRouter's camelCase TS shape directly so + // a `Pick` annotation catches any field-name drift at + // compile time. Returned via `unknown as Omit` + // because the base's signature is the OpenAI snake_case type; the SDK call + // hooks above just pass the value through. + // ──────────────────────────────────────────────────────────────────────── + + protected override 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.`, + ) + } + } + } + + // Apply the same modelOptions/variant precedence as the chat adapter. + const modelOptions = options.modelOptions as + | (Partial & { variant?: string }) + | undefined + const variantSuffix = modelOptions?.variant + ? `:${modelOptions.variant}` + : '' + + // The override below returns Array — re-cast through the + // base's documented shape so this local has the type a Pick<…> expects. + const input = this.convertMessagesToInput(options.messages) as unknown as + | ResponsesRequest['input'] + | undefined + + // Reuse the openai-base function-tool converter. 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 + + // `Pick` is the static gate — if the SDK renames any + // of these keys in a future version this annotation breaks the build + // instead of silently producing a request the wire schema drops. + 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, + ...(tools && + tools.length > 0 && { + tools: tools as unknown as ResponsesRequest['tools'], + }), + } + + return built as unknown as Omit + } + + // ──────────────────────────────────────────────────────────────────────── + // Message + content converters — emit OpenRouter's camelCase TS shape + // (`callId`, `imageUrl`, `inputAudio`, `videoUrl`, `fileData`, `fileUrl`) + // directly. The return-type cast through `unknown` bridges to the base's + // signature without giving up the OpenRouter-shape return inside. + // ──────────────────────────────────────────────────────────────────────── + + protected override convertMessagesToInput( + messages: Array, + ): ReturnType< + OpenAICompatibleResponsesTextAdapter['convertMessagesToInput'] + > { + 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 + : JSON.stringify(message.content), + } as unknown as InputsItem) + continue + } + + if (message.role === 'assistant') { + if (message.toolCalls && message.toolCalls.length > 0) { + for (const toolCall of message.toolCalls) { + // Stringify object-shaped args to match the SDK's `arguments: + // string` contract — mirrors the chat adapter's fix (see + // commit 0171b18e). + 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 unknown as InputsItem) + } + } + + if (message.content) { + const contentStr = this.extractTextContent(message.content) + if (contentStr) { + result.push({ + type: 'message', + role: 'assistant', + content: contentStr, + } as unknown as InputsItem) + } + } + continue + } + + // user — fail loud on empty / unsupported content (mirrors the base). + 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 unknown as InputsItem) + } + + return result as unknown as ReturnType< + OpenAICompatibleResponsesTextAdapter['convertMessagesToInput'] + > + } + + protected override convertContentPartToInput( + part: ContentPart, + ): ResponseInputContent { + switch (part.type) { + case 'text': + return { + type: 'input_text', + text: part.content, + } as ResponseInputContent + 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', + } as unknown as ResponseInputContent + } + 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, + } as unknown as ResponseInputContent + } + return { + type: 'input_audio', + inputAudio: { data: part.source.value, format: 'mp3' }, + } as unknown as ResponseInputContent + } + case 'video': + return { + type: 'input_video', + videoUrl: part.source.value, + } as unknown as ResponseInputContent + case 'document': { + if (part.source.type === 'url') { + return { + type: 'input_file', + fileUrl: part.source.value, + } as unknown as ResponseInputContent + } + 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, + } as unknown as ResponseInputContent + } + default: + throw new Error( + `Unsupported content part type for ${this.name}: ${(part as { type: string }).type}`, + ) + } + } +} + +// ────────────────────────────────────────────────────────────────────────── +// Inbound stream-event bridge: OpenRouter SDK camelCase → OpenAI snake_case +// so the base's `processStreamChunks` reads documented fields unchanged. +// (Outbound conversion is no longer needed — the adapter overrides above +// emit OpenRouter camelCase directly.) +// ────────────────────────────────────────────────────────────────────────── + +/** + * Adapt OpenRouter's streaming events (camelCase, with extended event types) + * into the OpenAI Responses event shape the base's `processStreamChunks` + * reads. Reshapes the nested `response` payload for terminal events + * (`response.completed`, `response.failed`, `response.incomplete`, + * `response.created`) into snake_case so reads like + * `chunk.response.incomplete_details?.reason` and + * `chunk.response.usage.input_tokens` work unchanged. + */ +async function* adaptOpenRouterResponsesStreamEvents( + stream: AsyncIterable, +): AsyncIterable { + for await (const event of stream) { + const e = event as Record + switch (e.type) { + case 'response.created': + case 'response.in_progress': + case 'response.completed': + case 'response.failed': + case 'response.incomplete': { + yield { + type: e.type, + response: toSnakeResponseResult(e.response), + sequence_number: e.sequenceNumber, + } as unknown as ResponseStreamEvent + break + } + case 'response.output_text.delta': + case 'response.output_text.done': + case 'response.reasoning_text.delta': + case 'response.reasoning_text.done': + case 'response.reasoning_summary_text.delta': + case 'response.reasoning_summary_text.done': { + yield { + type: e.type, + item_id: e.itemId, + output_index: e.outputIndex, + content_index: e.contentIndex, + delta: e.delta, + text: e.text, + sequence_number: e.sequenceNumber, + } as unknown as ResponseStreamEvent + break + } + case 'response.content_part.added': + case 'response.content_part.done': { + yield { + type: e.type, + item_id: e.itemId, + output_index: e.outputIndex, + content_index: e.contentIndex, + part: toSnakeContentPart(e.part), + sequence_number: e.sequenceNumber, + } as unknown as ResponseStreamEvent + break + } + case 'response.output_item.added': + case 'response.output_item.done': { + yield { + type: e.type, + item: toSnakeOutputItem(e.item), + output_index: e.outputIndex, + sequence_number: e.sequenceNumber, + } as unknown as ResponseStreamEvent + break + } + case 'response.function_call_arguments.delta': + case 'response.function_call_arguments.done': { + yield { + type: e.type, + item_id: e.itemId, + output_index: e.outputIndex, + delta: e.delta, + arguments: e.arguments, + sequence_number: e.sequenceNumber, + } as unknown as ResponseStreamEvent + break + } + case 'error': { + // Stringify code so provider codes (401/429/500/…) survive + // `toRunErrorPayload`, mirroring the chat-completions fix in + // commit 0171b18e. + yield { + type: 'error', + message: e.message, + code: e.code != null ? String(e.code) : undefined, + param: e.param, + sequence_number: e.sequenceNumber, + } as unknown as ResponseStreamEvent + break + } + default: { + // Pass through unknown event types with sequenceNumber renamed so + // the base's debug logging still sees a usable `type`. Forwarding + // verbatim is safer than dropping silently — a new event type + // OpenRouter ships shouldn't be discarded by us. + const { sequenceNumber, ...rest } = e + yield { + ...rest, + ...(sequenceNumber !== undefined && { + sequence_number: sequenceNumber, + }), + } as unknown as ResponseStreamEvent + } + } + } +} + +/** Convert a non-streaming `OpenResponsesResult` so the base's + * `extractTextFromResponse` (which iterates `response.output[].content` for + * `type === 'output_text'`) reads it unchanged. */ +function adaptOpenRouterResponsesResult(result: unknown): ResponsesResponse { + return toSnakeResponseResult(result) as ResponsesResponse +} + +function toSnakeResponseResult(r: any): Record { + if (!r || typeof r !== 'object') return r + return { + ...r, + model: r.model, + incomplete_details: r.incompleteDetails ?? null, + ...(r.usage && { + usage: { + input_tokens: r.usage.inputTokens ?? 0, + output_tokens: r.usage.outputTokens ?? 0, + total_tokens: r.usage.totalTokens ?? 0, + ...(r.usage.inputTokensDetails && { + input_tokens_details: r.usage.inputTokensDetails, + }), + ...(r.usage.outputTokensDetails && { + output_tokens_details: r.usage.outputTokensDetails, + }), + }, + }), + output: Array.isArray(r.output) + ? r.output.map((it: any) => toSnakeOutputItem(it)) + : r.output, + ...(r.error && { + error: { message: r.error.message, code: r.error.code }, + }), + } +} + +function toSnakeOutputItem(item: any): any { + if (!item || typeof item !== 'object') return item + switch (item.type) { + case 'function_call': + return { + type: 'function_call', + id: item.id, + call_id: item.callId, + name: item.name, + arguments: item.arguments, + ...(item.status !== undefined && { status: item.status }), + } + case 'message': + return { + ...item, + // content parts already use { type:'output_text', text } — no rename + // needed; refusal has `refusal` either way. + } + default: + return item + } +} + +function toSnakeContentPart(part: any): any { + if (!part || typeof part !== 'object') return part + // Both output_text and refusal already share the same key names across + // SDKs (`text`, `refusal`, `type`). Pass through. + return part +} + +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/index.ts b/packages/typescript/ai-openrouter/src/index.ts index e17844743..0ff7e1432 100644 --- a/packages/typescript/ai-openrouter/src/index.ts +++ b/packages/typescript/ai-openrouter/src/index.ts @@ -11,6 +11,15 @@ export { type OpenRouterTextModelOptions, } from './adapters/text' +// Responses (beta) adapter - for the OpenRouter beta Responses API +export { + OpenRouterResponsesTextAdapter, + createOpenRouterResponsesText, + openRouterResponsesText, + type OpenRouterResponsesConfig, + type OpenRouterResponsesTextProviderOptions, +} from './adapters/responses-text' + // Summarize adapter - for text summarization export { OpenRouterSummarizeAdapter, 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..685233f78 --- /dev/null +++ b/packages/typescript/ai-openrouter/src/text/responses-provider-options.ts @@ -0,0 +1,55 @@ +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' + | 'parallelToolCalls' + | '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' +} + +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-responses-adapter.test.ts b/packages/typescript/ai-openrouter/tests/openrouter-responses-adapter.test.ts new file mode 100644 index 000000000..33b4fedc0 --- /dev/null +++ b/packages/typescript/ai-openrouter/tests/openrouter-responses-adapter.test.ts @@ -0,0 +1,451 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { 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() with a clear error pointing at the chat adapter', async () => { + const adapter = createAdapter() + const ws = webSearchTool() as unknown as Tool + await expect(async () => { + for await (const _ of adapter.chatStream({ + model: 'openai/gpt-4o-mini' as any, + messages: [{ role: 'user', content: 'hi' }], + tools: [ws], + logger: testLogger, + })) { + // consume + } + }).rejects.toThrow(/openRouterText/) + }) +}) + +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') + }) +}) + +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).toEqual({ signal: controller.signal }) + }) +}) diff --git a/packages/typescript/openai-base/src/adapters/responses-text.ts b/packages/typescript/openai-base/src/adapters/responses-text.ts index 48faadd21..8c7ca283d 100644 --- a/packages/typescript/openai-base/src/adapters/responses-text.ts +++ b/packages/typescript/openai-base/src/adapters/responses-text.ts @@ -105,7 +105,7 @@ export class OpenAICompatibleResponsesTextAdapter< `activity=chat provider=${this.name} model=${this.model} messages=${options.messages.length} tools=${options.tools?.length ?? 0} stream=true`, { provider: this.name, model: this.model }, ) - const response = await this.client.responses.create( + const response = await this.callResponseStream( { ...requestParams, stream: true, @@ -193,7 +193,7 @@ export class OpenAICompatibleResponsesTextAdapter< `activity=structuredOutput provider=${this.name} model=${this.model} messages=${chatOptions.messages.length}`, { provider: this.name, model: this.model }, ) - const response = await this.client.responses.create( + const response = await this.callResponse( { ...(cleanParams as Omit< OpenAI_SDK.Responses.ResponseCreateParams, @@ -261,6 +261,39 @@ export class OpenAICompatibleResponsesTextAdapter< return makeStructuredOutputCompatible(schema, originalRequired) } + /** + * Performs the non-streaming Responses API network call. The default uses + * the OpenAI SDK (`client.responses.create`), which covers any provider + * whose endpoint accepts the OpenAI SDK verbatim. + * + * Override in subclasses whose SDK has a different call shape — for + * example `@openrouter/sdk` exposes `client.beta.responses.send + * ({ responsesRequest })` with camelCase fields. The override is + * responsible for converting the params shape on the way in and returning + * an object structurally compatible with `OpenAI_SDK.Responses.Response` + * (the base only reads documented fields like `response.output[…]`). + */ + protected async callResponse( + params: OpenAI_SDK.Responses.ResponseCreateParamsNonStreaming, + requestOptions: ReturnType, + ): Promise { + return this.client.responses.create(params, requestOptions) + } + + /** + * Performs the streaming Responses API network call. Same pattern as + * {@link callResponse} — default uses the OpenAI SDK; override for + * providers whose SDK exposes a different streaming entry point. Returns + * an `AsyncIterable` because the base's + * {@link processStreamChunks} only needs structural iteration over events. + */ + protected async callResponseStream( + params: OpenAI_SDK.Responses.ResponseCreateParamsStreaming, + requestOptions: ReturnType, + ): Promise> { + return this.client.responses.create(params, requestOptions) + } + /** * Extract text content from a non-streaming Responses API response. * Override this in subclasses for provider-specific response shapes. diff --git a/packages/typescript/openai-base/src/index.ts b/packages/typescript/openai-base/src/index.ts index df6491e18..22b3f429a 100644 --- a/packages/typescript/openai-base/src/index.ts +++ b/packages/typescript/openai-base/src/index.ts @@ -18,6 +18,18 @@ export { type ChatCompletionFunctionTool, } from './adapters/chat-completions-tool-converter' export { OpenAICompatibleResponsesTextAdapter } from './adapters/responses-text' +// Re-export the OpenAI Responses SDK types subclasses need when overriding +// the `callResponse*` / `processStreamChunks` / `extractTextFromResponse` +// hooks, so subclass packages don't need to declare `openai` as a direct +// dependency. +export type { + Response as ResponsesResponse, + ResponseCreateParams, + ResponseCreateParamsNonStreaming, + ResponseCreateParamsStreaming, + ResponseInputContent, + ResponseStreamEvent, +} from 'openai/resources/responses/responses' export { convertFunctionToolToResponsesFormat, convertToolsToResponsesFormat, 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..2c34c99d9 100644 --- a/testing/e2e/src/routes/api.summarize.ts +++ b/testing/e2e/src/routes/api.summarize.ts @@ -28,6 +28,8 @@ function createSummarizeAdapter(provider: Provider) { createGrokSummarize('grok-3', DUMMY_KEY, { baseURL: LLMOCK_OPENAI }), openrouter: () => createOpenaiSummarize('gpt-4o', DUMMY_KEY, { baseURL: LLMOCK_OPENAI }), + 'openrouter-responses': () => + createOpenaiSummarize('gpt-4o', DUMMY_KEY, { baseURL: 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', ] From fb294643895aacaf684c93088d2468ee28e66d60 Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Tue, 12 May 2026 13:09:34 +1000 Subject: [PATCH 5/6] chore(ai-groq): remove dead unused message-param types Removes `validateTextProviderOptions` (no-op stub never called) and the chain of `ChatCompletion*MessageParam` / `ChatCompletionContentPart*` / `ChatCompletionMessageToolCall` types that were only referenced by it. Unblocks the root `test:knip` CI check. None of the removed exports are re-exported from the package's public `src/index.ts`, so this is internal-only cleanup. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../typescript/ai-groq/src/message-types.ts | 126 ------------------ .../ai-groq/src/text/text-provider-options.ts | 36 ----- 2 files changed, 162 deletions(-) diff --git a/packages/typescript/ai-groq/src/message-types.ts b/packages/typescript/ai-groq/src/message-types.ts index 42c218189..dfe55126b 100644 --- a/packages/typescript/ai-groq/src/message-types.ts +++ b/packages/typescript/ai-groq/src/message-types.ts @@ -7,62 +7,6 @@ * @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 { @@ -113,34 +57,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 +67,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 -} From 62c610b453fa49f894a4ffc8b541bb79ba1d4e59 Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Tue, 12 May 2026 13:14:14 +1000 Subject: [PATCH 6/6] fix(ai-openrouter): pass UNKNOWN-fallback events through verbatim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The OpenRouter SDK's stream-event schema is built with Speakeasy's discriminated-union helper, which on a per-variant parse failure falls back to `{ raw, type: 'UNKNOWN', isUnknown: true }` rather than throwing. This happens whenever an upstream omits an "optional-looking" required field — notably `sequence_number` and `logprobs` on text/reasoning delta events, which aimock-served fixtures don't include. Before this fix the adapter's switch hit the default branch for UNKNOWN events and emitted them with no usable `type`, so the base's processStreamChunks ignored them silently — the run terminated as `RUN_FINISHED { finishReason: 'stop' }` with no content. The `raw` payload preserved on the fallback is the original wire-shape event in snake_case, which is exactly what processStreamChunks reads. Re-emit it verbatim. Real-OpenRouter responses still flow through the existing camel -> snake bridge because their events include the required fields and parse cleanly. Unblocks the openrouter-responses E2E suite: 11 affected tests now pass locally against aimock; before this commit they all timed out empty. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ai-openrouter/src/adapters/responses-text.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/typescript/ai-openrouter/src/adapters/responses-text.ts b/packages/typescript/ai-openrouter/src/adapters/responses-text.ts index 900a909fa..3f23d8419 100644 --- a/packages/typescript/ai-openrouter/src/adapters/responses-text.ts +++ b/packages/typescript/ai-openrouter/src/adapters/responses-text.ts @@ -416,6 +416,18 @@ async function* adaptOpenRouterResponsesStreamEvents( ): AsyncIterable { for await (const event of stream) { const e = event as Record + + // Speakeasy'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 `sequence_number`/`logprobs` + // that some upstreams — including aimock — omit). The `raw` payload is + // the original wire-shape event in snake_case, which is exactly what the + // base's `processStreamChunks` reads. Re-emit it verbatim. + if (e.isUnknown && e.raw && typeof e.raw === 'object') { + yield e.raw as ResponseStreamEvent + continue + } + switch (e.type) { case 'response.created': case 'response.in_progress':