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..dd48aba33 --- /dev/null +++ b/.changeset/migrate-groq-openrouter-to-openai-base.md @@ -0,0 +1,20 @@ +--- +'@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. 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-anthropic/src/adapters/summarize.ts b/packages/typescript/ai-anthropic/src/adapters/summarize.ts index cdd9fe66f..60effa555 100644 --- a/packages/typescript/ai-anthropic/src/adapters/summarize.ts +++ b/packages/typescript/ai-anthropic/src/adapters/summarize.ts @@ -1,3 +1,4 @@ +import { EventType } from '@tanstack/ai' import { BaseSummarizeAdapter } from '@tanstack/ai/adapters' import { createAnthropicClient, @@ -12,10 +13,6 @@ import type { } from '@tanstack/ai' import type { AnthropicClientConfig } from '../utils' -/** Cast an event object to StreamChunk. */ -const asChunk = (chunk: Record) => - chunk as unknown as StreamChunk - /** * Configuration for Anthropic summarize adapter */ @@ -102,6 +99,7 @@ export class AnthropicSummarizeAdapter< const { logger } = options const systemPrompt = this.buildSummarizationPrompt(options) const id = generateId(this.name) + const threadId = generateId('thread') const model = options.model let accumulatedContent = '' let inputTokens = 0 @@ -114,6 +112,14 @@ export class AnthropicSummarizeAdapter< }) try { + yield { + type: EventType.RUN_STARTED, + runId: id, + threadId, + model, + timestamp: Date.now(), + } satisfies StreamChunk + const stream = await this.client.messages.create({ model: options.model, messages: [{ role: 'user', content: options.text }], @@ -134,20 +140,21 @@ export class AnthropicSummarizeAdapter< if (event.delta.type === 'text_delta') { const delta = event.delta.text accumulatedContent += delta - yield asChunk({ - type: 'TEXT_MESSAGE_CONTENT', + yield { + type: EventType.TEXT_MESSAGE_CONTENT, messageId: id, model, timestamp: Date.now(), delta, content: accumulatedContent, - }) + } satisfies StreamChunk } } else if (event.type === 'message_delta') { outputTokens = event.usage.output_tokens - yield asChunk({ - type: 'RUN_FINISHED', + yield { + type: EventType.RUN_FINISHED, runId: id, + threadId, model, timestamp: Date.now(), finishReason: event.delta.stop_reason as @@ -160,7 +167,7 @@ export class AnthropicSummarizeAdapter< completionTokens: outputTokens, totalTokens: inputTokens + outputTokens, }, - }) + } satisfies StreamChunk } } } catch (error) { diff --git a/packages/typescript/ai-anthropic/src/adapters/text.ts b/packages/typescript/ai-anthropic/src/adapters/text.ts index f057a18a4..92d19b8f0 100644 --- a/packages/typescript/ai-anthropic/src/adapters/text.ts +++ b/packages/typescript/ai-anthropic/src/adapters/text.ts @@ -1,3 +1,4 @@ +import { EventType } from '@tanstack/ai' import { BaseTextAdapter } from '@tanstack/ai/adapters' import { convertToolsToProviderFormat } from '../tools/tool-converter' import { validateTextProviderOptions } from '../text/text-provider-options' @@ -48,11 +49,6 @@ import type { } from '../message-types' import type { AnthropicClientConfig } from '../utils' -/** Cast an event object to StreamChunk. Adapters construct events with string - * literal types which are structurally compatible with the EventType enum. */ -const asChunk = (chunk: Record) => - chunk as unknown as StreamChunk - /** * Configuration for Anthropic text adapter */ @@ -177,8 +173,8 @@ export class AnthropicTextAdapter< error, source: 'anthropic.chatStream', }) - yield asChunk({ - type: 'RUN_ERROR', + yield { + type: EventType.RUN_ERROR, model: options.model, timestamp: Date.now(), message: err.message || 'Unknown error occurred', @@ -187,7 +183,7 @@ export class AnthropicTextAdapter< message: err.message || 'Unknown error occurred', code: err.code || String(err.status), }, - }) + } satisfies StreamChunk } } @@ -629,7 +625,6 @@ export class AnthropicTextAdapter< let accumulatedContent = '' let accumulatedThinking = '' let accumulatedSignature = '' - const timestamp = Date.now() const toolCallsMap = new Map< number, { id: string; name: string; input: string; started: boolean } @@ -657,13 +652,13 @@ export class AnthropicTextAdapter< // Emit RUN_STARTED on first event if (!hasEmittedRunStarted) { hasEmittedRunStarted = true - yield asChunk({ - type: 'RUN_STARTED', + yield { + type: EventType.RUN_STARTED, runId, threadId, model, - timestamp, - }) + timestamp: Date.now(), + } satisfies StreamChunk } if (event.type === 'content_block_start') { @@ -684,94 +679,94 @@ export class AnthropicTextAdapter< reasoningMessageId = genId() // Spec REASONING events - yield asChunk({ - type: 'REASONING_START', + yield { + type: EventType.REASONING_START, messageId: reasoningMessageId, model, - timestamp, - }) - yield asChunk({ - type: 'REASONING_MESSAGE_START', + timestamp: Date.now(), + } satisfies StreamChunk + yield { + type: EventType.REASONING_MESSAGE_START, messageId: reasoningMessageId, role: 'reasoning' as const, model, - timestamp, - }) + timestamp: Date.now(), + } satisfies StreamChunk // Legacy STEP events (kept during transition) - yield asChunk({ - type: 'STEP_STARTED', + yield { + type: EventType.STEP_STARTED, stepName: stepId, stepId, model, - timestamp, + timestamp: Date.now(), stepType: 'thinking', - }) + } satisfies StreamChunk } } else if (event.type === 'content_block_delta') { if (event.delta.type === 'text_delta') { // Close reasoning before text starts if (reasoningMessageId && !hasClosedReasoning) { hasClosedReasoning = true - yield asChunk({ - type: 'REASONING_MESSAGE_END', + yield { + type: EventType.REASONING_MESSAGE_END, messageId: reasoningMessageId, model, - timestamp, - }) - yield asChunk({ - type: 'REASONING_END', + timestamp: Date.now(), + } satisfies StreamChunk + yield { + type: EventType.REASONING_END, messageId: reasoningMessageId, model, - timestamp, - }) + timestamp: Date.now(), + } satisfies StreamChunk } // Emit TEXT_MESSAGE_START on first text content if (!hasEmittedTextMessageStart) { hasEmittedTextMessageStart = true - yield asChunk({ - type: 'TEXT_MESSAGE_START', + yield { + type: EventType.TEXT_MESSAGE_START, messageId, model, - timestamp, + timestamp: Date.now(), role: 'assistant', - }) + } satisfies StreamChunk } const delta = event.delta.text accumulatedContent += delta - yield asChunk({ - type: 'TEXT_MESSAGE_CONTENT', + yield { + type: EventType.TEXT_MESSAGE_CONTENT, messageId, model, - timestamp, + timestamp: Date.now(), delta, content: accumulatedContent, - }) + } satisfies StreamChunk } else if (event.delta.type === 'thinking_delta') { const delta = event.delta.thinking accumulatedThinking += delta // Spec REASONING content event - yield asChunk({ - type: 'REASONING_MESSAGE_CONTENT', + yield { + type: EventType.REASONING_MESSAGE_CONTENT, messageId: reasoningMessageId!, delta, model, - timestamp, - }) + timestamp: Date.now(), + } satisfies StreamChunk // Legacy STEP event - yield asChunk({ - type: 'STEP_FINISHED', + yield { + type: EventType.STEP_FINISHED, stepName: stepId || genId(), stepId: stepId || genId(), model, - timestamp, + timestamp: Date.now(), delta, content: accumulatedThinking, - }) + } satisfies StreamChunk } else if ( (event.delta as { type: string }).type === 'signature_delta' ) { @@ -783,43 +778,43 @@ export class AnthropicTextAdapter< // Emit TOOL_CALL_START on first args delta if (!existing.started) { existing.started = true - yield asChunk({ - type: 'TOOL_CALL_START', + yield { + type: EventType.TOOL_CALL_START, toolCallId: existing.id, toolCallName: existing.name, toolName: existing.name, model, - timestamp, + timestamp: Date.now(), index: currentToolIndex, - }) + } satisfies StreamChunk } existing.input += event.delta.partial_json - yield asChunk({ - type: 'TOOL_CALL_ARGS', + yield { + type: EventType.TOOL_CALL_ARGS, toolCallId: existing.id, model, - timestamp, + timestamp: Date.now(), delta: event.delta.partial_json, args: existing.input, - }) + } satisfies StreamChunk } } } else if (event.type === 'content_block_stop') { if (currentBlockType === 'thinking') { // Emit signature so it can be replayed in multi-turn context if (accumulatedSignature && stepId) { - yield asChunk({ - type: 'STEP_FINISHED', + yield { + type: EventType.STEP_FINISHED, stepName: stepId, stepId, model, - timestamp, + timestamp: Date.now(), delta: '', content: accumulatedThinking, signature: accumulatedSignature, - }) + } satisfies StreamChunk } } else if (currentBlockType === 'tool_use') { const existing = toolCallsMap.get(currentToolIndex) @@ -827,15 +822,15 @@ export class AnthropicTextAdapter< // If tool call wasn't started yet (no args), start it now if (!existing.started) { existing.started = true - yield asChunk({ - type: 'TOOL_CALL_START', + yield { + type: EventType.TOOL_CALL_START, toolCallId: existing.id, toolCallName: existing.name, toolName: existing.name, model, - timestamp, + timestamp: Date.now(), index: currentToolIndex, - }) + } satisfies StreamChunk } // Emit TOOL_CALL_END @@ -847,15 +842,15 @@ export class AnthropicTextAdapter< parsedInput = {} } - yield asChunk({ - type: 'TOOL_CALL_END', + yield { + type: EventType.TOOL_CALL_END, toolCallId: existing.id, toolCallName: existing.name, toolName: existing.name, model, - timestamp, + timestamp: Date.now(), input: parsedInput, - }) + } satisfies StreamChunk // Reset so a new TEXT_MESSAGE_START is emitted if text follows tool calls hasEmittedTextMessageStart = false @@ -863,12 +858,12 @@ export class AnthropicTextAdapter< } else { // Emit TEXT_MESSAGE_END only for text blocks (not tool_use blocks) if (hasEmittedTextMessageStart && accumulatedContent) { - yield asChunk({ - type: 'TEXT_MESSAGE_END', + yield { + type: EventType.TEXT_MESSAGE_END, messageId, model, - timestamp, - }) + timestamp: Date.now(), + } satisfies StreamChunk } } currentBlockType = null @@ -876,32 +871,32 @@ export class AnthropicTextAdapter< // Close reasoning events if still open if (reasoningMessageId && !hasClosedReasoning) { hasClosedReasoning = true - yield asChunk({ - type: 'REASONING_MESSAGE_END', + yield { + type: EventType.REASONING_MESSAGE_END, messageId: reasoningMessageId, model, - timestamp, - }) - yield asChunk({ - type: 'REASONING_END', + timestamp: Date.now(), + } satisfies StreamChunk + yield { + type: EventType.REASONING_END, messageId: reasoningMessageId, model, - timestamp, - }) + timestamp: Date.now(), + } satisfies StreamChunk } // Only emit RUN_FINISHED from message_stop if message_delta didn't already emit one. // message_delta carries the real stop_reason (tool_use, end_turn, etc.), // while message_stop is just a completion signal. if (!hasEmittedRunFinished) { - yield asChunk({ - type: 'RUN_FINISHED', + yield { + type: EventType.RUN_FINISHED, runId, threadId, model, - timestamp, + timestamp: Date.now(), finishReason: 'stop', - }) + } satisfies StreamChunk } } else if (event.type === 'message_delta') { if (event.delta.stop_reason) { @@ -910,28 +905,28 @@ export class AnthropicTextAdapter< // Close reasoning events if still open if (reasoningMessageId && !hasClosedReasoning) { hasClosedReasoning = true - yield asChunk({ - type: 'REASONING_MESSAGE_END', + yield { + type: EventType.REASONING_MESSAGE_END, messageId: reasoningMessageId, model, - timestamp, - }) - yield asChunk({ - type: 'REASONING_END', + timestamp: Date.now(), + } satisfies StreamChunk + yield { + type: EventType.REASONING_END, messageId: reasoningMessageId, model, - timestamp, - }) + timestamp: Date.now(), + } satisfies StreamChunk } switch (event.delta.stop_reason) { case 'tool_use': { - yield asChunk({ - type: 'RUN_FINISHED', + yield { + type: EventType.RUN_FINISHED, runId, threadId, model, - timestamp, + timestamp: Date.now(), finishReason: 'tool_calls', usage: { promptTokens: event.usage.input_tokens || 0, @@ -940,15 +935,14 @@ export class AnthropicTextAdapter< (event.usage.input_tokens || 0) + (event.usage.output_tokens || 0), }, - }) + } satisfies StreamChunk break } case 'max_tokens': { - yield asChunk({ - type: 'RUN_ERROR', - runId, + yield { + type: EventType.RUN_ERROR, model, - timestamp, + timestamp: Date.now(), message: 'The response was cut off because the maximum token limit was reached.', code: 'max_tokens', @@ -957,16 +951,16 @@ export class AnthropicTextAdapter< 'The response was cut off because the maximum token limit was reached.', code: 'max_tokens', }, - }) + } satisfies StreamChunk break } default: { - yield asChunk({ - type: 'RUN_FINISHED', + yield { + type: EventType.RUN_FINISHED, runId, threadId, model, - timestamp, + timestamp: Date.now(), finishReason: 'stop', usage: { promptTokens: event.usage.input_tokens || 0, @@ -975,7 +969,7 @@ export class AnthropicTextAdapter< (event.usage.input_tokens || 0) + (event.usage.output_tokens || 0), }, - }) + } satisfies StreamChunk } } } @@ -988,18 +982,17 @@ export class AnthropicTextAdapter< error, source: 'anthropic.processAnthropicStream', }) - yield asChunk({ - type: 'RUN_ERROR', - runId, + yield { + type: EventType.RUN_ERROR, model, - timestamp, + timestamp: Date.now(), message: err.message || 'Unknown error occurred', code: err.code || String(err.status), error: { message: err.message || 'Unknown error occurred', code: err.code || String(err.status), }, - }) + } satisfies StreamChunk } } } diff --git a/packages/typescript/ai-client/tests/chat-client-abort.test.ts b/packages/typescript/ai-client/tests/chat-client-abort.test.ts index 71bf71522..882d6d471 100644 --- a/packages/typescript/ai-client/tests/chat-client-abort.test.ts +++ b/packages/typescript/ai-client/tests/chat-client-abort.test.ts @@ -1,12 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { EventType } from '@tanstack/ai' import { ChatClient } from '../src/chat-client' import type { ConnectionAdapter } from '../src/connection-adapters' import type { StreamChunk } from '@tanstack/ai' -/** Cast an event object to StreamChunk for type compatibility with EventType enum. */ -const asChunk = (chunk: Record) => - chunk as unknown as StreamChunk - describe('ChatClient - Abort Signal Handling', () => { let mockAdapter: ConnectionAdapter let receivedAbortSignal: AbortSignal | undefined @@ -20,29 +17,30 @@ describe('ChatClient - Abort Signal Handling', () => { receivedAbortSignal = abortSignal // Simulate streaming chunks (AG-UI format) - yield asChunk({ - type: 'TEXT_MESSAGE_CONTENT', + yield { + type: EventType.TEXT_MESSAGE_CONTENT, messageId: '1', model: 'test', timestamp: Date.now(), delta: 'Hello', content: 'Hello', - }) - yield asChunk({ - type: 'TEXT_MESSAGE_CONTENT', + } satisfies StreamChunk + yield { + type: EventType.TEXT_MESSAGE_CONTENT, messageId: '1', model: 'test', timestamp: Date.now(), delta: ' World', content: 'Hello World', - }) - yield asChunk({ - type: 'RUN_FINISHED', + } satisfies StreamChunk + yield { + type: EventType.RUN_FINISHED, runId: 'run-1', + threadId: 'thread-1', model: 'test', timestamp: Date.now(), finishReason: 'stop', - }) + } satisfies StreamChunk }, } }) @@ -82,24 +80,24 @@ describe('ChatClient - Abort Signal Handling', () => { } try { - yield asChunk({ - type: 'TEXT_MESSAGE_CONTENT', + yield { + type: EventType.TEXT_MESSAGE_CONTENT, messageId: '1', model: 'test', timestamp: Date.now(), delta: 'Hello', content: 'Hello', - }) + } satisfies StreamChunk // Simulate long-running stream await new Promise((resolve) => setTimeout(resolve, 100)) - yield asChunk({ - type: 'TEXT_MESSAGE_CONTENT', + yield { + type: EventType.TEXT_MESSAGE_CONTENT, messageId: '1', model: 'test', timestamp: Date.now(), delta: ' World', content: 'Hello World', - }) + } satisfies StreamChunk } catch (err) { // Abort errors are expected if (err instanceof Error && err.name === 'AbortError') { @@ -137,28 +135,28 @@ describe('ChatClient - Abort Signal Handling', () => { const adapterWithPartial: ConnectionAdapter = { // eslint-disable-next-line @typescript-eslint/require-await async *connect(_messages, _data, abortSignal) { - yield asChunk({ - type: 'TEXT_MESSAGE_CONTENT', + yield { + type: EventType.TEXT_MESSAGE_CONTENT, messageId: '1', model: 'test', timestamp: Date.now(), delta: 'Hello', content: 'Hello', - }) + } satisfies StreamChunk yieldedChunks++ if (abortSignal?.aborted) { return } - yield asChunk({ - type: 'TEXT_MESSAGE_CONTENT', + yield { + type: EventType.TEXT_MESSAGE_CONTENT, messageId: '1', model: 'test', timestamp: Date.now(), delta: ' World', content: 'Hello World', - }) + } satisfies StreamChunk yieldedChunks++ }, } @@ -194,14 +192,14 @@ describe('ChatClient - Abort Signal Handling', () => { const adapterWithAbort: ConnectionAdapter = { // eslint-disable-next-line @typescript-eslint/require-await async *connect(_messages, _data, abortSignal) { - yield asChunk({ - type: 'TEXT_MESSAGE_CONTENT', + yield { + type: EventType.TEXT_MESSAGE_CONTENT, messageId: '1', model: 'test', timestamp: Date.now(), delta: 'Hello', content: 'Hello', - }) + } satisfies StreamChunk if (abortSignal?.aborted) { return @@ -234,14 +232,14 @@ describe('ChatClient - Abort Signal Handling', () => { it('should set isLoading to false after abort', async () => { const adapterWithAbort: ConnectionAdapter = { async *connect(_messages, _data, _abortSignal) { - yield asChunk({ - type: 'TEXT_MESSAGE_CONTENT', + yield { + type: EventType.TEXT_MESSAGE_CONTENT, messageId: '1', model: 'test', timestamp: Date.now(), delta: 'Hello', content: 'Hello', - }) + } satisfies StreamChunk await new Promise((resolve) => setTimeout(resolve, 50)) }, } @@ -276,13 +274,14 @@ describe('ChatClient - Abort Signal Handling', () => { if (abortSignal) { abortSignals.push(abortSignal) } - yield asChunk({ - type: 'RUN_FINISHED', + yield { + type: EventType.RUN_FINISHED, runId: 'run-1', + threadId: 'thread-1', model: 'test', timestamp: Date.now(), finishReason: 'stop', - }) + } satisfies StreamChunk }, } @@ -319,13 +318,14 @@ describe('ChatClient - Abort Signal Handling', () => { // eslint-disable-next-line @typescript-eslint/require-await async *connect(_messages, _data, _abortSignal) { connectCalled = true - yield asChunk({ - type: 'RUN_FINISHED', + yield { + type: EventType.RUN_FINISHED, runId: 'run-1', + threadId: 'thread-1', model: 'test', timestamp: Date.now(), finishReason: 'stop', - }) + } satisfies StreamChunk }, } @@ -367,13 +367,14 @@ describe('ChatClient - Abort Signal Handling', () => { if (abortSignal) { signalsPassedToConnect.push(abortSignal) } - yield asChunk({ - type: 'RUN_FINISHED', + yield { + type: EventType.RUN_FINISHED, runId: 'run-1', + threadId: 'thread-1', model: 'test', timestamp: Date.now(), finishReason: 'stop', - }) + } satisfies StreamChunk }, } diff --git a/packages/typescript/ai-client/tests/chat-client.test.ts b/packages/typescript/ai-client/tests/chat-client.test.ts index ec997c868..ff933bd01 100644 --- a/packages/typescript/ai-client/tests/chat-client.test.ts +++ b/packages/typescript/ai-client/tests/chat-client.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import { EventType } from '@tanstack/ai' import { ChatClient } from '../src/chat-client' import { createMockConnectionAdapter, @@ -15,10 +16,6 @@ import type { import type { StreamChunk } from '@tanstack/ai' import type { UIMessage } from '../src/types' -/** Cast an event object to StreamChunk for type compatibility with EventType enum. */ -const asChunk = (chunk: Record) => - chunk as unknown as StreamChunk - describe('ChatClient', () => { describe('constructor', () => { it('should create a client with default options', () => { @@ -154,8 +151,9 @@ describe('ChatClient', () => { it('stop should not unsubscribe an active subscription', async () => { const adapter = createSubscribeAdapter([ { - type: 'RUN_FINISHED', + type: EventType.RUN_FINISHED, runId: 'run-1', + threadId: 'thread-1', model: 'test', timestamp: Date.now(), finishReason: 'stop', @@ -259,7 +257,7 @@ describe('ChatClient', () => { it('unsubscribe should abort in-flight requests and disconnect', async () => { const adapter = createSubscribeAdapter([ { - type: 'TEXT_MESSAGE_CONTENT', + type: EventType.TEXT_MESSAGE_CONTENT, messageId: 'msg-1', model: 'test', timestamp: Date.now(), @@ -318,7 +316,7 @@ describe('ChatClient', () => { it('should remain pending without terminal run events', async () => { const adapter = createSubscribeAdapter([ { - type: 'TEXT_MESSAGE_CONTENT', + type: EventType.TEXT_MESSAGE_CONTENT, messageId: 'msg-1', model: 'test', timestamp: Date.now(), @@ -354,13 +352,14 @@ describe('ChatClient', () => { it('should flip to true on RUN_STARTED and false on RUN_FINISHED', async () => { const chunks: Array = [ { - type: 'RUN_STARTED', + type: EventType.RUN_STARTED, runId: 'run-1', + threadId: 'thread-1', model: 'test', timestamp: Date.now(), } as unknown as StreamChunk, { - type: 'TEXT_MESSAGE_CONTENT', + type: EventType.TEXT_MESSAGE_CONTENT, messageId: 'msg-1', model: 'test', timestamp: Date.now(), @@ -368,8 +367,9 @@ describe('ChatClient', () => { content: 'Hi', } as unknown as StreamChunk, { - type: 'RUN_FINISHED', + type: EventType.RUN_FINISHED, runId: 'run-1', + threadId: 'thread-1', model: 'test', timestamp: Date.now(), finishReason: 'stop', @@ -393,13 +393,15 @@ describe('ChatClient', () => { it('should flip to false on RUN_ERROR', async () => { const chunks: Array = [ { - type: 'RUN_STARTED', + type: EventType.RUN_STARTED, runId: 'run-1', + threadId: 'thread-1', model: 'test', timestamp: Date.now(), } as unknown as StreamChunk, { - type: 'RUN_ERROR', + type: EventType.RUN_ERROR, + message: 'something went wrong', runId: 'run-1', model: 'test', timestamp: Date.now(), @@ -424,13 +426,14 @@ describe('ChatClient', () => { it('should remain correct through subscribe/unsubscribe cycles', async () => { const chunks: Array = [ { - type: 'RUN_STARTED', + type: EventType.RUN_STARTED, runId: 'run-1', + threadId: 'thread-1', model: 'test', timestamp: Date.now(), } as unknown as StreamChunk, { - type: 'TEXT_MESSAGE_CONTENT', + type: EventType.TEXT_MESSAGE_CONTENT, messageId: 'msg-1', model: 'test', timestamp: Date.now(), @@ -438,8 +441,9 @@ describe('ChatClient', () => { content: 'Hi', } as unknown as StreamChunk, { - type: 'RUN_FINISHED', + type: EventType.RUN_FINISHED, runId: 'run-1', + threadId: 'thread-1', model: 'test', timestamp: Date.now(), finishReason: 'stop', @@ -464,12 +468,13 @@ describe('ChatClient', () => { while (!signal?.aborted) { if (!yieldedStart) { yieldedStart = true - yield asChunk({ - type: 'RUN_STARTED' as const, + yield { + type: EventType.RUN_STARTED as const, runId: 'run-1', + threadId: 'thread-1', model: 'test', timestamp: Date.now(), - }) + } satisfies StreamChunk } await new Promise((resolve) => { const onAbort = () => resolve() @@ -507,12 +512,13 @@ describe('ChatClient', () => { while (!signal?.aborted) { if (!yieldedStart) { yieldedStart = true - yield asChunk({ - type: 'RUN_STARTED' as const, + yield { + type: EventType.RUN_STARTED as const, runId: 'run-1', + threadId: 'thread-1', model: 'test', timestamp: Date.now(), - }) + } satisfies StreamChunk } await new Promise((resolve) => { const onAbort = () => resolve() @@ -546,19 +552,21 @@ describe('ChatClient', () => { it('should not emit duplicate callbacks on repeated same-state events', async () => { const chunks: Array = [ { - type: 'RUN_STARTED', + type: EventType.RUN_STARTED, runId: 'run-1', + threadId: 'thread-1', model: 'test', timestamp: Date.now(), } as unknown as StreamChunk, { - type: 'RUN_STARTED', + type: EventType.RUN_STARTED, runId: 'run-1', + threadId: 'thread-1', model: 'test', timestamp: Date.now(), } as unknown as StreamChunk, { - type: 'TEXT_MESSAGE_CONTENT', + type: EventType.TEXT_MESSAGE_CONTENT, messageId: 'msg-1', model: 'test', timestamp: Date.now(), @@ -566,15 +574,17 @@ describe('ChatClient', () => { content: 'Hi', } as unknown as StreamChunk, { - type: 'RUN_FINISHED', + type: EventType.RUN_FINISHED, runId: 'run-1', + threadId: 'thread-1', model: 'test', timestamp: Date.now(), finishReason: 'stop', } as unknown as StreamChunk, { - type: 'RUN_FINISHED', + type: EventType.RUN_FINISHED, runId: 'run-1', + threadId: 'thread-1', model: 'test', timestamp: Date.now(), finishReason: 'stop', @@ -597,13 +607,14 @@ describe('ChatClient', () => { it('should handle interleaved multi-run events from durable subscription', async () => { const chunks: Array = [ { - type: 'RUN_STARTED', + type: EventType.RUN_STARTED, runId: 'run-1', + threadId: 'thread-1', model: 'test', timestamp: Date.now(), } as unknown as StreamChunk, { - type: 'TEXT_MESSAGE_CONTENT', + type: EventType.TEXT_MESSAGE_CONTENT, messageId: 'msg-1', model: 'test', timestamp: Date.now(), @@ -611,8 +622,9 @@ describe('ChatClient', () => { content: 'A', } as unknown as StreamChunk, { - type: 'RUN_FINISHED', + type: EventType.RUN_FINISHED, runId: 'run-1', + threadId: 'thread-1', model: 'test', timestamp: Date.now(), finishReason: 'stop', @@ -671,14 +683,16 @@ describe('ChatClient', () => { // Simulate two concurrent runs starting chunks.push( { - type: 'RUN_STARTED', + type: EventType.RUN_STARTED, runId: 'run-1', + threadId: 'thread-1', model: 'test', timestamp: Date.now(), } as unknown as StreamChunk, { - type: 'RUN_STARTED', + type: EventType.RUN_STARTED, runId: 'run-2', + threadId: 'thread-1', model: 'test', timestamp: Date.now(), } as unknown as StreamChunk, @@ -690,8 +704,9 @@ describe('ChatClient', () => { // First run finishes — should still be generating because run-2 is active chunks.push({ - type: 'RUN_FINISHED', + type: EventType.RUN_FINISHED, runId: 'run-1', + threadId: 'thread-1', model: 'test', timestamp: Date.now(), finishReason: 'stop', @@ -703,8 +718,9 @@ describe('ChatClient', () => { // Second run finishes — now should be false chunks.push({ - type: 'RUN_FINISHED', + type: EventType.RUN_FINISHED, runId: 'run-2', + threadId: 'thread-1', model: 'test', timestamp: Date.now(), finishReason: 'stop', @@ -756,14 +772,16 @@ describe('ChatClient', () => { // Two runs active chunks.push( { - type: 'RUN_STARTED', + type: EventType.RUN_STARTED, runId: 'run-1', + threadId: 'thread-1', model: 'test', timestamp: Date.now(), } as unknown as StreamChunk, { - type: 'RUN_STARTED', + type: EventType.RUN_STARTED, runId: 'run-2', + threadId: 'thread-1', model: 'test', timestamp: Date.now(), } as unknown as StreamChunk, @@ -775,7 +793,8 @@ describe('ChatClient', () => { // Session-level error without runId clears everything chunks.push({ - type: 'RUN_ERROR', + type: EventType.RUN_ERROR, + message: 'session crashed', model: 'test', timestamp: Date.now(), error: { message: 'session crashed' }, @@ -795,12 +814,13 @@ describe('ChatClient', () => { subscribe: async function* (_signal?: AbortSignal) { if (!yieldedStart) { yieldedStart = true - yield asChunk({ - type: 'RUN_STARTED' as const, + yield { + type: EventType.RUN_STARTED as const, runId: 'run-1', + threadId: 'thread-1', model: 'test', timestamp: Date.now(), - }) + } satisfies StreamChunk await new Promise((resolve) => setTimeout(resolve, 10)) } throw new Error('subscription failed') @@ -1349,7 +1369,7 @@ describe('ChatClient', () => { const noTerminalAdapter = createMockConnectionAdapter({ chunks: [ { - type: 'TEXT_MESSAGE_CONTENT', + type: EventType.TEXT_MESSAGE_CONTENT, messageId: 'msg-1', model: 'test', timestamp: Date.now(), @@ -1991,7 +2011,7 @@ describe('ChatClient', () => { // Yield the tool call and approval request const preChunks: Array = [ { - type: 'TOOL_CALL_START', + type: EventType.TOOL_CALL_START, toolCallId: 'tc-2', toolName: 'dangerous_tool_2', model: 'test', @@ -1999,21 +2019,21 @@ describe('ChatClient', () => { index: 0, } as unknown as StreamChunk, { - type: 'TOOL_CALL_ARGS', + type: EventType.TOOL_CALL_ARGS, toolCallId: 'tc-2', model: 'test', timestamp: Date.now(), delta: '{}', } as unknown as StreamChunk, { - type: 'TOOL_CALL_END', + type: EventType.TOOL_CALL_END, toolCallId: 'tc-2', toolName: 'dangerous_tool_2', model: 'test', timestamp: Date.now(), } as unknown as StreamChunk, { - type: 'CUSTOM', + type: EventType.CUSTOM, model: 'test', timestamp: Date.now(), name: 'approval-requested', @@ -2032,13 +2052,14 @@ describe('ChatClient', () => { resolveStreamPause = resolve }) - yield asChunk({ - type: 'RUN_FINISHED' as const, + yield { + type: EventType.RUN_FINISHED as const, runId: 'run-2', + threadId: 'thread-1', model: 'test', timestamp: Date.now(), finishReason: 'tool_calls' as const, - }) + } satisfies StreamChunk } else if (streamCount === 3) { // Third stream (after second approval): final text response const chunks = createTextChunks('All done!') @@ -2134,20 +2155,21 @@ describe('ChatClient', () => { // Run A starts with text message chunks.push( { - type: 'RUN_STARTED', + type: EventType.RUN_STARTED, runId: 'run-a', + threadId: 'thread-1', model: 'test', timestamp: Date.now(), } as unknown as StreamChunk, { - type: 'TEXT_MESSAGE_START', + type: EventType.TEXT_MESSAGE_START, messageId: 'msg-a', role: 'assistant', model: 'test', timestamp: Date.now(), } as StreamChunk, { - type: 'TEXT_MESSAGE_CONTENT', + type: EventType.TEXT_MESSAGE_CONTENT, messageId: 'msg-a', model: 'test', timestamp: Date.now(), @@ -2160,20 +2182,21 @@ describe('ChatClient', () => { // Run B starts concurrently chunks.push( { - type: 'RUN_STARTED', + type: EventType.RUN_STARTED, runId: 'run-b', + threadId: 'thread-1', model: 'test', timestamp: Date.now(), } as unknown as StreamChunk, { - type: 'TEXT_MESSAGE_START', + type: EventType.TEXT_MESSAGE_START, messageId: 'msg-b', role: 'assistant', model: 'test', timestamp: Date.now(), } as StreamChunk, { - type: 'TEXT_MESSAGE_CONTENT', + type: EventType.TEXT_MESSAGE_CONTENT, messageId: 'msg-b', model: 'test', timestamp: Date.now(), @@ -2185,8 +2208,9 @@ describe('ChatClient', () => { // Run B finishes — Run A should still be active chunks.push({ - type: 'RUN_FINISHED', + type: EventType.RUN_FINISHED, runId: 'run-b', + threadId: 'thread-1', model: 'test', timestamp: Date.now(), finishReason: 'stop', @@ -2196,7 +2220,7 @@ describe('ChatClient', () => { // Run A continues streaming chunks.push({ - type: 'TEXT_MESSAGE_CONTENT', + type: EventType.TEXT_MESSAGE_CONTENT, messageId: 'msg-a', model: 'test', timestamp: Date.now(), @@ -2224,8 +2248,9 @@ describe('ChatClient', () => { // Finish run A chunks.push({ - type: 'RUN_FINISHED', + type: EventType.RUN_FINISHED, runId: 'run-a', + threadId: 'thread-1', model: 'test', timestamp: Date.now(), finishReason: 'stop', @@ -2289,21 +2314,23 @@ describe('ChatClient', () => { // Resumed content for in-progress message (no TEXT_MESSAGE_START) chunks.push( { - type: 'RUN_STARTED', + type: EventType.RUN_STARTED, runId: 'run-1', + threadId: 'thread-1', model: 'test', timestamp: Date.now(), } as unknown as StreamChunk, { - type: 'TEXT_MESSAGE_CONTENT', + type: EventType.TEXT_MESSAGE_CONTENT, messageId: 'asst-1', model: 'test', timestamp: Date.now(), delta: 'time...', } as StreamChunk, { - type: 'RUN_FINISHED', + type: EventType.RUN_FINISHED, runId: 'run-1', + threadId: 'thread-1', model: 'test', timestamp: Date.now(), finishReason: 'stop', diff --git a/packages/typescript/ai-client/tests/connection-adapters.test.ts b/packages/typescript/ai-client/tests/connection-adapters.test.ts index 60c36763a..263f3600d 100644 --- a/packages/typescript/ai-client/tests/connection-adapters.test.ts +++ b/packages/typescript/ai-client/tests/connection-adapters.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { EventType } from '@tanstack/ai' import { fetchHttpStream, fetchServerSentEvents, @@ -8,10 +9,6 @@ import { } from '../src/connection-adapters' import type { StreamChunk } from '@tanstack/ai' -/** Cast an event object to StreamChunk for type compatibility with EventType enum. */ -const asChunk = (chunk: Record) => - chunk as unknown as StreamChunk - describe('connection-adapters', () => { let originalFetch: typeof fetch let fetchMock: ReturnType @@ -63,7 +60,7 @@ describe('connection-adapters', () => { expect(chunks).toHaveLength(1) expect(chunks[0]).toMatchObject({ - type: 'TEXT_MESSAGE_CONTENT', + type: EventType.TEXT_MESSAGE_CONTENT, messageId: 'msg-1', delta: 'Hello', }) @@ -789,14 +786,14 @@ describe('connection-adapters', () => { describe('stream', () => { it('should delegate to stream factory', async () => { const streamFactory = vi.fn().mockImplementation(function* () { - yield asChunk({ - type: 'TEXT_MESSAGE_CONTENT', + yield { + type: EventType.TEXT_MESSAGE_CONTENT, messageId: 'msg-1', model: 'test', timestamp: Date.now(), delta: 'Hello', content: 'Hello', - }) + } satisfies StreamChunk }) const adapter = stream(streamFactory) @@ -814,13 +811,14 @@ describe('connection-adapters', () => { it('should pass data to stream factory', async () => { const streamFactory = vi.fn().mockImplementation(function* () { - yield asChunk({ - type: 'RUN_FINISHED', + yield { + type: EventType.RUN_FINISHED, runId: 'run-1', + threadId: 'thread-1', model: 'test', timestamp: Date.now(), finishReason: 'stop', - }) + } satisfies StreamChunk }) const adapter = stream(streamFactory) @@ -874,14 +872,14 @@ describe('connection-adapters', () => { it('should synthesize RUN_FINISHED when wrapped connect stream has no terminal event', async () => { const base = stream(async function* () { - yield asChunk({ - type: 'TEXT_MESSAGE_CONTENT', + yield { + type: EventType.TEXT_MESSAGE_CONTENT, messageId: 'msg-1', model: 'test', timestamp: Date.now(), delta: 'Hi', content: 'Hi', - }) + } satisfies StreamChunk }) const adapter = normalizeConnectionAdapter(base) @@ -933,13 +931,14 @@ describe('connection-adapters', () => { it('should not synthesize duplicate RUN_ERROR when stream already emitted one before throwing', async () => { const base = stream(async function* () { - yield asChunk({ - type: 'RUN_ERROR', + yield { + type: EventType.RUN_ERROR, + message: 'already failed', timestamp: Date.now(), error: { message: 'already failed', }, - }) + } satisfies StreamChunk throw new Error('connect exploded') }) @@ -972,14 +971,14 @@ describe('connection-adapters', () => { describe('rpcStream', () => { it('should delegate to RPC call', async () => { const rpcCall = vi.fn().mockImplementation(function* () { - yield asChunk({ - type: 'TEXT_MESSAGE_CONTENT', + yield { + type: EventType.TEXT_MESSAGE_CONTENT, messageId: 'msg-1', model: 'test', timestamp: Date.now(), delta: 'Hello', content: 'Hello', - }) + } satisfies StreamChunk }) const adapter = rpcStream(rpcCall) @@ -994,20 +993,21 @@ describe('connection-adapters', () => { expect(rpcCall).toHaveBeenCalled() expect(chunks).toHaveLength(1) expect(chunks[0]).toMatchObject({ - type: 'TEXT_MESSAGE_CONTENT', + type: EventType.TEXT_MESSAGE_CONTENT, delta: 'Hello', }) }) it('should pass messages and data to RPC call', async () => { const rpcCall = vi.fn().mockImplementation(function* () { - yield asChunk({ - type: 'RUN_FINISHED', + yield { + type: EventType.RUN_FINISHED, runId: 'run-1', + threadId: 'thread-1', model: 'test', timestamp: Date.now(), finishReason: 'stop', - }) + } satisfies StreamChunk }) const adapter = rpcStream(rpcCall) diff --git a/packages/typescript/ai-client/tests/generation-client.test.ts b/packages/typescript/ai-client/tests/generation-client.test.ts index e17a09f23..4ce69353c 100644 --- a/packages/typescript/ai-client/tests/generation-client.test.ts +++ b/packages/typescript/ai-client/tests/generation-client.test.ts @@ -1,12 +1,9 @@ import { describe, it, expect, vi } from 'vitest' +import { EventType } from '@tanstack/ai' import { GenerationClient } from '../src/generation-client' import type { StreamChunk } from '@tanstack/ai' import type { ConnectConnectionAdapter } from '../src/connection-adapters' -/** Cast an event object to StreamChunk for type compatibility with EventType enum. */ -const asChunk = (chunk: Record) => - chunk as unknown as StreamChunk - // Helper to create a mock connect-based adapter from StreamChunks function createMockConnection( chunks: Array, @@ -133,19 +130,25 @@ describe('GenerationClient', () => { const onResult = vi.fn() const connection = createMockConnection([ - asChunk({ type: 'RUN_STARTED', runId: 'run-1', timestamp: Date.now() }), - asChunk({ - type: 'CUSTOM', + { + type: EventType.RUN_STARTED, + runId: 'run-1', + threadId: 'thread-1', + timestamp: Date.now(), + } satisfies StreamChunk, + { + type: EventType.CUSTOM, name: 'generation:result', value: mockResult, timestamp: Date.now(), - }), - asChunk({ - type: 'RUN_FINISHED', + } satisfies StreamChunk, + { + type: EventType.RUN_FINISHED, runId: 'run-1', + threadId: 'thread-1', finishReason: 'stop', timestamp: Date.now(), - }), + } satisfies StreamChunk, ]) const client = new GenerationClient({ @@ -164,13 +167,19 @@ describe('GenerationClient', () => { const onError = vi.fn() const connection = createMockConnection([ - asChunk({ type: 'RUN_STARTED', runId: 'run-1', timestamp: Date.now() }), - asChunk({ - type: 'RUN_ERROR', + { + type: EventType.RUN_STARTED, + runId: 'run-1', + threadId: 'thread-1', + timestamp: Date.now(), + } satisfies StreamChunk, + { + type: EventType.RUN_ERROR, + message: 'Generation failed', runId: 'run-1', error: { message: 'Generation failed' }, timestamp: Date.now(), - }), + } satisfies StreamChunk, ]) const client = new GenerationClient({ @@ -189,25 +198,31 @@ describe('GenerationClient', () => { const onProgress = vi.fn() const connection = createMockConnection([ - asChunk({ type: 'RUN_STARTED', runId: 'run-1', timestamp: Date.now() }), - asChunk({ - type: 'CUSTOM', + { + type: EventType.RUN_STARTED, + runId: 'run-1', + threadId: 'thread-1', + timestamp: Date.now(), + } satisfies StreamChunk, + { + type: EventType.CUSTOM, name: 'generation:progress', value: { progress: 50, message: 'Halfway' }, timestamp: Date.now(), - }), - asChunk({ - type: 'CUSTOM', + } satisfies StreamChunk, + { + type: EventType.CUSTOM, name: 'generation:result', value: { id: '1' }, timestamp: Date.now(), - }), - asChunk({ - type: 'RUN_FINISHED', + } satisfies StreamChunk, + { + type: EventType.RUN_FINISHED, runId: 'run-1', + threadId: 'thread-1', finishReason: 'stop', timestamp: Date.now(), - }), + } satisfies StreamChunk, ]) const client = new GenerationClient({ @@ -225,19 +240,21 @@ describe('GenerationClient', () => { const chunks: Array = [ { - type: 'RUN_STARTED', + type: EventType.RUN_STARTED, runId: 'run-1', + threadId: 'thread-1', timestamp: Date.now(), } as unknown as StreamChunk, { - type: 'CUSTOM', + type: EventType.CUSTOM, name: 'generation:result', value: { id: '1' }, timestamp: Date.now(), } as unknown as StreamChunk, { - type: 'RUN_FINISHED', + type: EventType.RUN_FINISHED, runId: 'run-1', + threadId: 'thread-1', finishReason: 'stop', timestamp: Date.now(), } as unknown as StreamChunk, @@ -257,18 +274,19 @@ describe('GenerationClient', () => { it('should pass body and input as data to connection', async () => { const connectSpy = vi.fn(async function* () { - yield asChunk({ - type: 'CUSTOM' as const, + yield { + type: EventType.CUSTOM as const, name: 'generation:result', value: { id: '1' }, timestamp: Date.now(), - }) - yield asChunk({ - type: 'RUN_FINISHED' as const, + } satisfies StreamChunk + yield { + type: EventType.RUN_FINISHED as const, runId: 'run-1', + threadId: 'thread-1', finishReason: 'stop' as const, timestamp: Date.now(), - }) + } satisfies StreamChunk }) const connection: ConnectConnectionAdapter = { @@ -334,12 +352,13 @@ describe('GenerationClient', () => { describe('updateOptions()', () => { it('should update body without recreating client', async () => { const connectSpy = vi.fn(async function* () { - yield asChunk({ - type: 'RUN_FINISHED' as const, + yield { + type: EventType.RUN_FINISHED as const, runId: 'run-1', + threadId: 'thread-1', finishReason: 'stop' as const, timestamp: Date.now(), - }) + } satisfies StreamChunk }) const connection: ConnectConnectionAdapter = { connect: connectSpy } @@ -366,23 +385,24 @@ describe('GenerationClient', () => { const connection: ConnectConnectionAdapter = { async *connect(_msgs, _data, signal) { - yield asChunk({ - type: 'RUN_STARTED' as const, + yield { + type: EventType.RUN_STARTED as const, runId: 'run-1', + threadId: 'thread-1', timestamp: Date.now(), - }) + } satisfies StreamChunk // Wait until abort is triggered await new Promise((resolve) => { signal?.addEventListener('abort', () => resolve()) }) // Adapter honors abort signal and stops yielding if (signal?.aborted) return - yield asChunk({ - type: 'CUSTOM' as const, + yield { + type: EventType.CUSTOM as const, name: 'generation:result', value: { id: '1' }, timestamp: Date.now(), - }) + } satisfies StreamChunk }, } @@ -464,13 +484,19 @@ describe('GenerationClient', () => { const onResult = vi.fn() const connection = createMockConnection([ - asChunk({ type: 'RUN_STARTED', runId: 'run-1', timestamp: Date.now() }), - asChunk({ - type: 'RUN_FINISHED', + { + type: EventType.RUN_STARTED, + runId: 'run-1', + threadId: 'thread-1', + timestamp: Date.now(), + } satisfies StreamChunk, + { + type: EventType.RUN_FINISHED, runId: 'run-1', + threadId: 'thread-1', finishReason: 'stop', timestamp: Date.now(), - }), + } satisfies StreamChunk, ]) const client = new GenerationClient({ @@ -489,19 +515,25 @@ describe('GenerationClient', () => { const onChunk = vi.fn() const connection = createMockConnection([ - asChunk({ type: 'RUN_STARTED', runId: 'run-1', timestamp: Date.now() }), - asChunk({ - type: 'CUSTOM', + { + type: EventType.RUN_STARTED, + runId: 'run-1', + threadId: 'thread-1', + timestamp: Date.now(), + } satisfies StreamChunk, + { + type: EventType.CUSTOM, name: 'unknown:event', value: { foo: 'bar' }, timestamp: Date.now(), - }), - asChunk({ - type: 'RUN_FINISHED', + } satisfies StreamChunk, + { + type: EventType.RUN_FINISHED, runId: 'run-1', + threadId: 'thread-1', finishReason: 'stop', timestamp: Date.now(), - }), + } satisfies StreamChunk, ]) const client = new GenerationClient({ @@ -594,19 +626,25 @@ describe('GenerationClient', () => { it('should transform result from stream CUSTOM event', async () => { const connection = createMockConnection([ - asChunk({ type: 'RUN_STARTED', runId: 'run-1', timestamp: Date.now() }), - asChunk({ - type: 'CUSTOM', + { + type: EventType.RUN_STARTED, + runId: 'run-1', + threadId: 'thread-1', + timestamp: Date.now(), + } satisfies StreamChunk, + { + type: EventType.CUSTOM, name: 'generation:result', value: { id: '1', images: [] }, timestamp: Date.now(), - }), - asChunk({ - type: 'RUN_FINISHED', + } satisfies StreamChunk, + { + type: EventType.RUN_FINISHED, runId: 'run-1', + threadId: 'thread-1', finishReason: 'stop', timestamp: Date.now(), - }), + } satisfies StreamChunk, ]) const client = new GenerationClient< @@ -697,19 +735,21 @@ describe('GenerationClient', () => { const response = createSSEResponse([ JSON.stringify({ - type: 'RUN_STARTED', + type: EventType.RUN_STARTED, runId: 'run-1', + threadId: 'thread-1', timestamp: 100, }), JSON.stringify({ - type: 'CUSTOM', + type: EventType.CUSTOM, name: 'generation:result', value: mockResult, timestamp: 200, }), JSON.stringify({ - type: 'RUN_FINISHED', + type: EventType.RUN_FINISHED, runId: 'run-1', + threadId: 'thread-1', finishReason: 'stop', timestamp: 300, }), @@ -732,12 +772,14 @@ describe('GenerationClient', () => { const response = createSSEResponse([ JSON.stringify({ - type: 'RUN_STARTED', + type: EventType.RUN_STARTED, runId: 'run-1', + threadId: 'thread-1', timestamp: 100, }), JSON.stringify({ - type: 'RUN_ERROR', + type: EventType.RUN_ERROR, + message: 'Generation failed', runId: 'run-1', error: { message: 'Generation failed' }, timestamp: 200, @@ -761,19 +803,21 @@ describe('GenerationClient', () => { const response = createSSEResponse([ JSON.stringify({ - type: 'RUN_STARTED', + type: EventType.RUN_STARTED, runId: 'run-1', + threadId: 'thread-1', timestamp: 100, }), JSON.stringify({ - type: 'CUSTOM', + type: EventType.CUSTOM, name: 'generation:result', value: { id: '1' }, timestamp: 200, }), JSON.stringify({ - type: 'RUN_FINISHED', + type: EventType.RUN_FINISHED, runId: 'run-1', + threadId: 'thread-1', finishReason: 'stop', timestamp: 300, }), @@ -794,25 +838,27 @@ describe('GenerationClient', () => { const response = createSSEResponse([ JSON.stringify({ - type: 'RUN_STARTED', + type: EventType.RUN_STARTED, runId: 'run-1', + threadId: 'thread-1', timestamp: 100, }), JSON.stringify({ - type: 'CUSTOM', + type: EventType.CUSTOM, name: 'generation:progress', value: { progress: 50, message: 'Halfway' }, timestamp: 200, }), JSON.stringify({ - type: 'CUSTOM', + type: EventType.CUSTOM, name: 'generation:result', value: { id: '1' }, timestamp: 300, }), JSON.stringify({ - type: 'RUN_FINISHED', + type: EventType.RUN_FINISHED, runId: 'run-1', + threadId: 'thread-1', finishReason: 'stop', timestamp: 400, }), @@ -852,14 +898,15 @@ describe('GenerationClient', () => { const fetcherSpy = vi.fn(async (_input: { prompt: string }) => { return createSSEResponse([ JSON.stringify({ - type: 'CUSTOM', + type: EventType.CUSTOM, name: 'generation:result', value: { id: '1' }, timestamp: 100, }), JSON.stringify({ - type: 'RUN_FINISHED', + type: EventType.RUN_FINISHED, runId: 'run-1', + threadId: 'thread-1', finishReason: 'stop', timestamp: 200, }), diff --git a/packages/typescript/ai-client/tests/video-generation-client.test.ts b/packages/typescript/ai-client/tests/video-generation-client.test.ts index 7118dbf1b..a0aa267f0 100644 --- a/packages/typescript/ai-client/tests/video-generation-client.test.ts +++ b/packages/typescript/ai-client/tests/video-generation-client.test.ts @@ -1,12 +1,9 @@ import { describe, it, expect, vi } from 'vitest' +import { EventType } from '@tanstack/ai' import { VideoGenerationClient } from '../src/video-generation-client' import type { StreamChunk } from '@tanstack/ai' import type { ConnectConnectionAdapter } from '../src/connection-adapters' -/** Cast an event object to StreamChunk for type compatibility with EventType enum. */ -const asChunk = (chunk: Record) => - chunk as unknown as StreamChunk - // Helper to create a mock connect-based adapter from StreamChunks function createMockConnection( chunks: Array, @@ -145,15 +142,20 @@ describe('VideoGenerationClient', () => { const onStatusUpdate = vi.fn() const connection = createMockConnection([ - asChunk({ type: 'RUN_STARTED', runId: 'run-1', timestamp: Date.now() }), - asChunk({ - type: 'CUSTOM', + { + type: EventType.RUN_STARTED, + runId: 'run-1', + threadId: 'thread-1', + timestamp: Date.now(), + } satisfies StreamChunk, + { + type: EventType.CUSTOM, name: 'video:job:created', value: { jobId: 'job-123' }, timestamp: Date.now(), - }), - asChunk({ - type: 'CUSTOM', + } satisfies StreamChunk, + { + type: EventType.CUSTOM, name: 'video:status', value: { jobId: 'job-123', @@ -161,9 +163,9 @@ describe('VideoGenerationClient', () => { progress: 50, }, timestamp: Date.now(), - }), - asChunk({ - type: 'CUSTOM', + } satisfies StreamChunk, + { + type: EventType.CUSTOM, name: 'video:status', value: { jobId: 'job-123', @@ -171,9 +173,9 @@ describe('VideoGenerationClient', () => { progress: 100, }, timestamp: Date.now(), - }), - asChunk({ - type: 'CUSTOM', + } satisfies StreamChunk, + { + type: EventType.CUSTOM, name: 'generation:result', value: { jobId: 'job-123', @@ -181,13 +183,14 @@ describe('VideoGenerationClient', () => { url: 'https://example.com/video.mp4', }, timestamp: Date.now(), - }), - asChunk({ - type: 'RUN_FINISHED', + } satisfies StreamChunk, + { + type: EventType.RUN_FINISHED, runId: 'run-1', + threadId: 'thread-1', finishReason: 'stop', timestamp: Date.now(), - }), + } satisfies StreamChunk, ]) const client = new VideoGenerationClient({ @@ -215,9 +218,14 @@ describe('VideoGenerationClient', () => { const onVideoStatusChange = vi.fn() const connection = createMockConnection([ - asChunk({ type: 'RUN_STARTED', runId: 'run-1', timestamp: Date.now() }), - asChunk({ - type: 'CUSTOM', + { + type: EventType.RUN_STARTED, + runId: 'run-1', + threadId: 'thread-1', + timestamp: Date.now(), + } satisfies StreamChunk, + { + type: EventType.CUSTOM, name: 'video:status', value: { jobId: 'job-1', @@ -225,9 +233,9 @@ describe('VideoGenerationClient', () => { progress: 25, }, timestamp: Date.now(), - }), - asChunk({ - type: 'CUSTOM', + } satisfies StreamChunk, + { + type: EventType.CUSTOM, name: 'generation:result', value: { jobId: 'job-1', @@ -235,13 +243,14 @@ describe('VideoGenerationClient', () => { url: 'https://example.com/video.mp4', }, timestamp: Date.now(), - }), - asChunk({ - type: 'RUN_FINISHED', + } satisfies StreamChunk, + { + type: EventType.RUN_FINISHED, runId: 'run-1', + threadId: 'thread-1', finishReason: 'stop', timestamp: Date.now(), - }), + } satisfies StreamChunk, ]) const client = new VideoGenerationClient({ @@ -268,13 +277,19 @@ describe('VideoGenerationClient', () => { const onError = vi.fn() const connection = createMockConnection([ - asChunk({ type: 'RUN_STARTED', runId: 'run-1', timestamp: Date.now() }), - asChunk({ - type: 'RUN_ERROR', + { + type: EventType.RUN_STARTED, + runId: 'run-1', + threadId: 'thread-1', + timestamp: Date.now(), + } satisfies StreamChunk, + { + type: EventType.RUN_ERROR, + message: 'Video generation failed', runId: 'run-1', error: { message: 'Video generation failed' }, timestamp: Date.now(), - }), + } satisfies StreamChunk, ]) const client = new VideoGenerationClient({ @@ -293,9 +308,14 @@ describe('VideoGenerationClient', () => { const onProgress = vi.fn() const connection = createMockConnection([ - asChunk({ type: 'RUN_STARTED', runId: 'run-1', timestamp: Date.now() }), - asChunk({ - type: 'CUSTOM', + { + type: EventType.RUN_STARTED, + runId: 'run-1', + threadId: 'thread-1', + timestamp: Date.now(), + } satisfies StreamChunk, + { + type: EventType.CUSTOM, name: 'video:status', value: { jobId: 'job-1', @@ -303,13 +323,14 @@ describe('VideoGenerationClient', () => { progress: 50, }, timestamp: Date.now(), - }), - asChunk({ - type: 'RUN_FINISHED', + } satisfies StreamChunk, + { + type: EventType.RUN_FINISHED, runId: 'run-1', + threadId: 'thread-1', finishReason: 'stop', timestamp: Date.now(), - }), + } satisfies StreamChunk, ]) const client = new VideoGenerationClient({ @@ -326,19 +347,25 @@ describe('VideoGenerationClient', () => { const onProgress = vi.fn() const connection = createMockConnection([ - asChunk({ type: 'RUN_STARTED', runId: 'run-1', timestamp: Date.now() }), - asChunk({ - type: 'CUSTOM', + { + type: EventType.RUN_STARTED, + runId: 'run-1', + threadId: 'thread-1', + timestamp: Date.now(), + } satisfies StreamChunk, + { + type: EventType.CUSTOM, name: 'generation:progress', value: { progress: 75, message: 'Almost done' }, timestamp: Date.now(), - }), - asChunk({ - type: 'RUN_FINISHED', + } satisfies StreamChunk, + { + type: EventType.RUN_FINISHED, runId: 'run-1', + threadId: 'thread-1', finishReason: 'stop', timestamp: Date.now(), - }), + } satisfies StreamChunk, ]) const client = new VideoGenerationClient({ @@ -355,9 +382,14 @@ describe('VideoGenerationClient', () => { const onChunk = vi.fn() const connection = createMockConnection([ - asChunk({ type: 'RUN_STARTED', runId: 'run-1', timestamp: Date.now() }), - asChunk({ - type: 'CUSTOM', + { + type: EventType.RUN_STARTED, + runId: 'run-1', + threadId: 'thread-1', + timestamp: Date.now(), + } satisfies StreamChunk, + { + type: EventType.CUSTOM, name: 'generation:result', value: { jobId: 'job-1', @@ -365,13 +397,14 @@ describe('VideoGenerationClient', () => { url: 'https://example.com/video.mp4', }, timestamp: Date.now(), - }), - asChunk({ - type: 'RUN_FINISHED', + } satisfies StreamChunk, + { + type: EventType.RUN_FINISHED, runId: 'run-1', + threadId: 'thread-1', finishReason: 'stop', timestamp: Date.now(), - }), + } satisfies StreamChunk, ]) const client = new VideoGenerationClient({ @@ -386,12 +419,13 @@ describe('VideoGenerationClient', () => { it('should pass body and input as data to connection', async () => { const connectSpy = vi.fn(async function* () { - yield asChunk({ - type: 'RUN_FINISHED' as const, + yield { + type: EventType.RUN_FINISHED as const, runId: 'run-1', + threadId: 'thread-1', finishReason: 'stop' as const, timestamp: Date.now(), - }) + } satisfies StreamChunk }) const connection: ConnectConnectionAdapter = { connect: connectSpy } @@ -445,15 +479,20 @@ describe('VideoGenerationClient', () => { const onVideoStatusChange = vi.fn() const connection = createMockConnection([ - asChunk({ type: 'RUN_STARTED', runId: 'run-1', timestamp: Date.now() }), - asChunk({ - type: 'CUSTOM', + { + type: EventType.RUN_STARTED, + runId: 'run-1', + threadId: 'thread-1', + timestamp: Date.now(), + } satisfies StreamChunk, + { + type: EventType.CUSTOM, name: 'video:job:created', value: { jobId: 'job-123' }, timestamp: Date.now(), - }), - asChunk({ - type: 'CUSTOM', + } satisfies StreamChunk, + { + type: EventType.CUSTOM, name: 'video:status', value: { jobId: 'job-123', @@ -461,9 +500,9 @@ describe('VideoGenerationClient', () => { progress: 50, }, timestamp: Date.now(), - }), - asChunk({ - type: 'CUSTOM', + } satisfies StreamChunk, + { + type: EventType.CUSTOM, name: 'generation:result', value: { jobId: 'job-123', @@ -471,13 +510,14 @@ describe('VideoGenerationClient', () => { url: 'https://example.com/video.mp4', }, timestamp: Date.now(), - }), - asChunk({ - type: 'RUN_FINISHED', + } satisfies StreamChunk, + { + type: EventType.RUN_FINISHED, runId: 'run-1', + threadId: 'thread-1', finishReason: 'stop', timestamp: Date.now(), - }), + } satisfies StreamChunk, ]) const client = new VideoGenerationClient({ @@ -503,12 +543,13 @@ describe('VideoGenerationClient', () => { describe('updateOptions()', () => { it('should update body without recreating client', async () => { const connectSpy = vi.fn(async function* () { - yield asChunk({ - type: 'RUN_FINISHED' as const, + yield { + type: EventType.RUN_FINISHED as const, runId: 'run-1', + threadId: 'thread-1', finishReason: 'stop' as const, timestamp: Date.now(), - }) + } satisfies StreamChunk }) const connection: ConnectConnectionAdapter = { connect: connectSpy } @@ -536,25 +577,26 @@ describe('VideoGenerationClient', () => { const connection: ConnectConnectionAdapter = { async *connect(_msgs, _data, signal) { - yield asChunk({ - type: 'RUN_STARTED' as const, + yield { + type: EventType.RUN_STARTED as const, runId: 'run-1', + threadId: 'thread-1', timestamp: Date.now(), - }) - yield asChunk({ - type: 'CUSTOM' as const, + } satisfies StreamChunk + yield { + type: EventType.CUSTOM as const, name: 'video:job:created', value: { jobId: 'job-123' }, timestamp: Date.now(), - }) + } satisfies StreamChunk // Wait until abort is triggered await new Promise((resolve) => { signal?.addEventListener('abort', () => resolve()) }) // Adapter honors abort signal and stops yielding if (signal?.aborted) return - yield asChunk({ - type: 'CUSTOM' as const, + yield { + type: EventType.CUSTOM as const, name: 'generation:result', value: { jobId: 'job-123', @@ -562,7 +604,7 @@ describe('VideoGenerationClient', () => { url: 'https://example.com/video.mp4', }, timestamp: Date.now(), - }) + } satisfies StreamChunk }, } diff --git a/packages/typescript/ai-gemini/src/adapters/summarize.ts b/packages/typescript/ai-gemini/src/adapters/summarize.ts index e5b3330b5..e82b3ec29 100644 --- a/packages/typescript/ai-gemini/src/adapters/summarize.ts +++ b/packages/typescript/ai-gemini/src/adapters/summarize.ts @@ -1,4 +1,5 @@ import { FinishReason } from '@google/genai' +import { EventType } from '@tanstack/ai' import { createGeminiClient, generateId, @@ -13,10 +14,6 @@ import type { SummarizationResult, } from '@tanstack/ai' -/** Cast an event object to StreamChunk. */ -const asChunk = (chunk: Record) => - chunk as unknown as StreamChunk - /** * Configuration for Gemini summarize adapter */ @@ -142,6 +139,7 @@ export class GeminiSummarizeAdapter< const { logger } = options const model = options.model const id = generateId('sum') + const threadId = generateId('thread') let accumulatedContent = '' let inputTokens = 0 let outputTokens = 0 @@ -161,6 +159,14 @@ export class GeminiSummarizeAdapter< }) try { + yield { + type: EventType.RUN_STARTED, + runId: id, + threadId, + model, + timestamp: Date.now(), + } satisfies StreamChunk + const result = await this.client.models.generateContentStream({ model, contents: [ @@ -189,14 +195,14 @@ export class GeminiSummarizeAdapter< for (const part of chunk.candidates[0].content.parts) { if (part.text) { accumulatedContent += part.text - yield asChunk({ - type: 'TEXT_MESSAGE_CONTENT', + yield { + type: EventType.TEXT_MESSAGE_CONTENT, messageId: id, model, timestamp: Date.now(), delta: part.text, content: accumulatedContent, - }) + } satisfies StreamChunk } } } @@ -208,9 +214,10 @@ export class GeminiSummarizeAdapter< finishReason === FinishReason.MAX_TOKENS || finishReason === FinishReason.SAFETY ) { - yield asChunk({ - type: 'RUN_FINISHED', + yield { + type: EventType.RUN_FINISHED, runId: id, + threadId, model, timestamp: Date.now(), finishReason: @@ -224,7 +231,7 @@ export class GeminiSummarizeAdapter< completionTokens: outputTokens, totalTokens: inputTokens + outputTokens, }, - }) + } satisfies StreamChunk } } } catch (error) { diff --git a/packages/typescript/ai-gemini/src/adapters/text.ts b/packages/typescript/ai-gemini/src/adapters/text.ts index ea744f456..e52a74859 100644 --- a/packages/typescript/ai-gemini/src/adapters/text.ts +++ b/packages/typescript/ai-gemini/src/adapters/text.ts @@ -1,4 +1,5 @@ import { FinishReason } from '@google/genai' +import { EventType } from '@tanstack/ai' import { BaseTextAdapter } from '@tanstack/ai/adapters' import { convertToolsToProviderFormat } from '../tools/tool-converter' import { @@ -39,11 +40,6 @@ import type { } from '../message-types' import type { GeminiClientConfig } from '../utils' -/** Cast an event object to StreamChunk. Adapters construct events with string - * literal types which are structurally compatible with the EventType enum. */ -const asChunk = (chunk: Record) => - chunk as unknown as StreamChunk - /** * Configuration for Gemini text adapter */ @@ -136,15 +132,14 @@ export class GeminiTextAdapter< yield* this.processStreamChunks(result, options, logger) } catch (error) { - const timestamp = Date.now() logger.errors('gemini.chatStream fatal', { error, source: 'gemini.chatStream', }) - yield asChunk({ - type: 'RUN_ERROR', + yield { + type: EventType.RUN_ERROR, model: options.model, - timestamp, + timestamp: Date.now(), message: error instanceof Error ? error.message @@ -155,7 +150,7 @@ export class GeminiTextAdapter< ? error.message : 'An unknown error occurred during the chat stream.', }, - }) + } satisfies StreamChunk } } @@ -240,7 +235,6 @@ export class GeminiTextAdapter< logger: InternalLogger, ): AsyncIterable { const model = options.model - const timestamp = Date.now() let accumulatedContent = '' let accumulatedThinking = '' const toolCallMap = new Map< @@ -271,13 +265,13 @@ export class GeminiTextAdapter< // Emit RUN_STARTED on first chunk if (!hasEmittedRunStarted) { hasEmittedRunStarted = true - yield asChunk({ - type: 'RUN_STARTED', + yield { + type: EventType.RUN_STARTED, runId, threadId, model, - timestamp, - }) + timestamp: Date.now(), + } satisfies StreamChunk } if (chunk.candidates?.[0]?.content?.parts) { @@ -293,92 +287,92 @@ export class GeminiTextAdapter< reasoningMessageId = generateId(this.name) // Spec REASONING events - yield asChunk({ - type: 'REASONING_START', + yield { + type: EventType.REASONING_START, messageId: reasoningMessageId, model, - timestamp, - }) - yield asChunk({ - type: 'REASONING_MESSAGE_START', + timestamp: Date.now(), + } satisfies StreamChunk + yield { + type: EventType.REASONING_MESSAGE_START, messageId: reasoningMessageId, role: 'reasoning' as const, model, - timestamp, - }) + timestamp: Date.now(), + } satisfies StreamChunk // Legacy STEP events (kept during transition) - yield asChunk({ - type: 'STEP_STARTED', + yield { + type: EventType.STEP_STARTED, stepName: stepId, stepId, model, - timestamp, + timestamp: Date.now(), stepType: 'thinking', - }) + } satisfies StreamChunk } accumulatedThinking += part.text // Spec REASONING content event - yield asChunk({ - type: 'REASONING_MESSAGE_CONTENT', + yield { + type: EventType.REASONING_MESSAGE_CONTENT, messageId: reasoningMessageId!, delta: part.text, model, - timestamp, - }) + timestamp: Date.now(), + } satisfies StreamChunk // Legacy STEP event - yield asChunk({ - type: 'STEP_FINISHED', + yield { + type: EventType.STEP_FINISHED, stepName: stepId || generateId(this.name), stepId: stepId || generateId(this.name), model, - timestamp, + timestamp: Date.now(), delta: part.text, content: accumulatedThinking, - }) + } satisfies StreamChunk } else if (part.text.trim()) { // Close reasoning before text starts if (reasoningMessageId && !hasClosedReasoning) { hasClosedReasoning = true - yield asChunk({ - type: 'REASONING_MESSAGE_END', + yield { + type: EventType.REASONING_MESSAGE_END, messageId: reasoningMessageId, model, - timestamp, - }) - yield asChunk({ - type: 'REASONING_END', + timestamp: Date.now(), + } satisfies StreamChunk + yield { + type: EventType.REASONING_END, messageId: reasoningMessageId, model, - timestamp, - }) + timestamp: Date.now(), + } satisfies StreamChunk } // Skip whitespace-only text parts (e.g. "\n" during auto-continuation) // Emit TEXT_MESSAGE_START on first text content if (!hasEmittedTextMessageStart) { hasEmittedTextMessageStart = true - yield asChunk({ - type: 'TEXT_MESSAGE_START', + yield { + type: EventType.TEXT_MESSAGE_START, messageId, model, - timestamp, + timestamp: Date.now(), role: 'assistant', - }) + } satisfies StreamChunk } accumulatedContent += part.text - yield asChunk({ - type: 'TEXT_MESSAGE_CONTENT', + yield { + type: EventType.TEXT_MESSAGE_CONTENT, messageId, model, - timestamp, + timestamp: Date.now(), delta: part.text, content: accumulatedContent, - }) + } satisfies StreamChunk } } @@ -430,31 +424,31 @@ export class GeminiTextAdapter< // Emit TOOL_CALL_START if not already started if (!toolCallData.started) { toolCallData.started = true - yield asChunk({ - type: 'TOOL_CALL_START', + yield { + type: EventType.TOOL_CALL_START, toolCallId, toolCallName: toolCallData.name, toolName: toolCallData.name, model, - timestamp, + timestamp: Date.now(), index: toolCallData.index, ...(toolCallData.thoughtSignature && { metadata: { thoughtSignature: toolCallData.thoughtSignature, } satisfies GeminiToolCallMetadata, }), - }) + } satisfies StreamChunk } // Emit TOOL_CALL_ARGS - yield asChunk({ - type: 'TOOL_CALL_ARGS', + yield { + type: EventType.TOOL_CALL_ARGS, toolCallId, model, - timestamp, + timestamp: Date.now(), delta: toolCallData.args, args: toolCallData.args, - }) + } satisfies StreamChunk } } } else if (chunk.data && chunk.data.trim()) { @@ -462,24 +456,24 @@ export class GeminiTextAdapter< // Emit TEXT_MESSAGE_START on first text content if (!hasEmittedTextMessageStart) { hasEmittedTextMessageStart = true - yield asChunk({ - type: 'TEXT_MESSAGE_START', + yield { + type: EventType.TEXT_MESSAGE_START, messageId, model, - timestamp, + timestamp: Date.now(), role: 'assistant', - }) + } satisfies StreamChunk } accumulatedContent += chunk.data - yield asChunk({ - type: 'TEXT_MESSAGE_CONTENT', + yield { + type: EventType.TEXT_MESSAGE_CONTENT, messageId, model, - timestamp, + timestamp: Date.now(), delta: chunk.data, content: accumulatedContent, - }) + } satisfies StreamChunk } if (chunk.candidates?.[0]?.finishReason) { @@ -508,15 +502,15 @@ export class GeminiTextAdapter< }) // Emit TOOL_CALL_START - yield asChunk({ - type: 'TOOL_CALL_START', + yield { + type: EventType.TOOL_CALL_START, toolCallId, toolCallName: functionCall.name || '', toolName: functionCall.name || '', model, - timestamp, + timestamp: Date.now(), index: nextToolIndex - 1, - }) + } satisfies StreamChunk // Emit TOOL_CALL_END with parsed input let parsedInput: unknown = {} @@ -531,15 +525,15 @@ export class GeminiTextAdapter< parsedInput = {} } - yield asChunk({ - type: 'TOOL_CALL_END', + yield { + type: EventType.TOOL_CALL_END, toolCallId, toolCallName: functionCall.name || '', toolName: functionCall.name || '', model, - timestamp, + timestamp: Date.now(), input: parsedInput, - }) + } satisfies StreamChunk } } } @@ -555,15 +549,15 @@ export class GeminiTextAdapter< parsedInput = {} } - yield asChunk({ - type: 'TOOL_CALL_END', + yield { + type: EventType.TOOL_CALL_END, toolCallId, toolCallName: toolCallData.name, toolName: toolCallData.name, model, - timestamp, + timestamp: Date.now(), input: parsedInput, - }) + } satisfies StreamChunk } // Reset so a new TEXT_MESSAGE_START is emitted if text follows tool calls @@ -572,11 +566,11 @@ export class GeminiTextAdapter< } if (finishReason === FinishReason.MAX_TOKENS) { - yield asChunk({ - type: 'RUN_ERROR', + yield { + type: EventType.RUN_ERROR, runId, model, - timestamp, + timestamp: Date.now(), message: 'The response was cut off because the maximum token limit was reached.', code: 'max_tokens', @@ -585,42 +579,42 @@ export class GeminiTextAdapter< 'The response was cut off because the maximum token limit was reached.', code: 'max_tokens', }, - }) + } satisfies StreamChunk } // Close reasoning events if still open if (reasoningMessageId && !hasClosedReasoning) { hasClosedReasoning = true - yield asChunk({ - type: 'REASONING_MESSAGE_END', + yield { + type: EventType.REASONING_MESSAGE_END, messageId: reasoningMessageId, model, - timestamp, - }) - yield asChunk({ - type: 'REASONING_END', + timestamp: Date.now(), + } satisfies StreamChunk + yield { + type: EventType.REASONING_END, messageId: reasoningMessageId, model, - timestamp, - }) + timestamp: Date.now(), + } satisfies StreamChunk } // Emit TEXT_MESSAGE_END if we had text content if (hasEmittedTextMessageStart) { - yield asChunk({ - type: 'TEXT_MESSAGE_END', + yield { + type: EventType.TEXT_MESSAGE_END, messageId, model, - timestamp, - }) + timestamp: Date.now(), + } satisfies StreamChunk } - yield asChunk({ - type: 'RUN_FINISHED', + yield { + type: EventType.RUN_FINISHED, runId, threadId, model, - timestamp, + timestamp: Date.now(), finishReason: toolCallMap.size > 0 ? 'tool_calls' : 'stop', usage: chunk.usageMetadata ? { @@ -629,7 +623,7 @@ export class GeminiTextAdapter< totalTokens: chunk.usageMetadata.totalTokenCount ?? 0, } : undefined, - }) + } satisfies StreamChunk } } } diff --git a/packages/typescript/ai-groq/package.json b/packages/typescript/ai-groq/package.json index ee3a18a5b..54af3dcf0 100644 --- a/packages/typescript/ai-groq/package.json +++ b/packages/typescript/ai-groq/package.json @@ -43,6 +43,7 @@ "adapter" ], "devDependencies": { + "@tanstack/ai": "workspace:*", "@vitest/coverage-v8": "4.0.14", "vite": "^7.2.7" }, @@ -52,7 +53,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..3ed8b6546 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/message-types.ts b/packages/typescript/ai-groq/src/message-types.ts index 42c218189..ffa574d90 100644 --- a/packages/typescript/ai-groq/src/message-types.ts +++ b/packages/typescript/ai-groq/src/message-types.ts @@ -1,72 +1,22 @@ /** * Groq-specific message types for the Chat Completions API. * - * These type definitions mirror the Groq SDK types and are used internally - * by the adapter to avoid tight coupling to the SDK's exported types. + * Groq's wire format is OpenAI Chat Completions plus a few Groq-specific + * extensions (compound tools, citation/service-tier provider options, + * etc.). These type definitions describe that wire shape directly — the + * Groq SDK was dropped in favour of pointing the OpenAI SDK at Groq's + * `/openai/v1` base URL, so this file is the source of truth for + * Groq-only fields rather than a mirror of an external SDK's types. * * @see https://console.groq.com/docs/api-reference#chat */ -export interface ChatCompletionContentPartText { - /** The text content. */ - text: string - - /** The type of the content part. */ - type: 'text' -} - -export interface ChatCompletionContentPartImage { - image_url: { - /** Either a URL of the image or the base64 encoded image data. */ - url: string - - /** Specifies the detail level of the image. */ - detail?: 'auto' | 'low' | 'high' - } - - /** The type of the content part. */ - type: 'image_url' -} - -export interface ChatCompletionMessageToolCall { - /** The ID of the tool call. */ - id: string - - /** The function that the model called. */ - function: { - /** - * The arguments to call the function with, as generated by the model in JSON - * format. Note that the model does not always generate valid JSON, and may - * hallucinate parameters not defined by your function schema. Validate the - * arguments in your code before calling your function. - */ - arguments: string - - /** The name of the function to call. */ - name: string - } - - /** The type of the tool. Currently, only `function` is supported. */ - type: 'function' -} - -export interface ChatCompletionRequestMessageContentPartDocument { - document: { - /** The JSON document data. */ - data: { [key: string]: unknown } - - /** Optional unique identifier for the document. */ - id?: string | null - } - - /** The type of the content part. */ - type: 'document' -} - export type FunctionParameters = { [key: string]: unknown } export interface ChatCompletionNamedToolChoice { - Function: { + /** Always `function` for a named tool choice. */ + type: 'function' + function: { /** The name of the function to call. */ name: string } @@ -113,34 +63,6 @@ export type ChatCompletionToolChoiceOption = | 'required' | ChatCompletionNamedToolChoice -export type ChatCompletionContentPart = - | ChatCompletionContentPartText - | ChatCompletionContentPartImage - | ChatCompletionRequestMessageContentPartDocument - -export interface ChatCompletionAssistantMessageParam { - /** The role of the messages author, in this case `assistant`. */ - role: 'assistant' - - /** - * The contents of the assistant message. Required unless `tool_calls` or - * `function_call` is specified. - */ - content?: string | Array | null - - /** An optional name for the participant. */ - name?: string - - /** - * The reasoning output by the assistant if reasoning_format was set to 'parsed'. - * This field is only useable with qwen3 models. - */ - reasoning?: string | null - - /** The tool calls generated by the model, such as function calls. */ - tool_calls?: Array -} - export interface ChatCompletionTool { /** * The type of the tool. `function`, `browser_search`, and `code_interpreter` are @@ -151,48 +73,6 @@ export interface ChatCompletionTool { function?: FunctionDefinition } -export interface ChatCompletionToolMessageParam { - /** The contents of the tool message. */ - content: string | Array - - /** The role of the messages author, in this case `tool`. */ - role: 'tool' - - /** Tool call that this message is responding to. */ - tool_call_id: string -} - -export interface ChatCompletionSystemMessageParam { - /** The contents of the system message. */ - content: string | Array - - /** The role of the messages author, in this case `system`. */ - role: 'system' | 'developer' - - /** An optional name for the participant. */ - name?: string -} - -export interface ChatCompletionUserMessageParam { - /** The contents of the user message. */ - content: string | Array - - /** The role of the messages author, in this case `user`. */ - role: 'user' - - /** An optional name for the participant. */ - name?: string -} - -/** - * Union of all supported chat completion message params. - */ -export type ChatCompletionMessageParam = - | ChatCompletionSystemMessageParam - | ChatCompletionUserMessageParam - | ChatCompletionAssistantMessageParam - | ChatCompletionToolMessageParam - export interface CompoundCustomModels { /** Custom model to use for answering. */ answering_model?: string | null diff --git a/packages/typescript/ai-groq/src/text/text-provider-options.ts b/packages/typescript/ai-groq/src/text/text-provider-options.ts index c3ee2309e..5fc9fc226 100644 --- a/packages/typescript/ai-groq/src/text/text-provider-options.ts +++ b/packages/typescript/ai-groq/src/text/text-provider-options.ts @@ -1,6 +1,4 @@ import type { - ChatCompletionMessageParam, - ChatCompletionTool, ChatCompletionToolChoiceOption, CompoundCustom, Document, @@ -185,41 +183,7 @@ export interface GroqTextProviderOptions { user?: string | null } -/** - * Internal options interface used for validation within the adapter. - * Extends provider options with required fields for API requests. - */ -export interface InternalTextProviderOptions extends GroqTextProviderOptions { - /** An array of messages comprising the conversation. */ - messages: Array - - /** - * The model name (e.g. "llama-3.3-70b-versatile", "openai/gpt-oss-120b"). - * @see https://console.groq.com/docs/models - */ - model: string - - /** Whether to stream partial message deltas as server-sent events. */ - stream?: boolean | null - - /** - * Tools the model may call (functions, code_interpreter, etc). - * @see https://console.groq.com/docs/tool-use - */ - tools?: Array -} - /** * External provider options (what users pass in) */ export type ExternalTextProviderOptions = GroqTextProviderOptions - -/** - * Validates text provider options. - * Basic validation stub — Groq API handles detailed validation. - */ -export function validateTextProviderOptions( - _options: InternalTextProviderOptions, -): void { - // Groq API handles detailed validation -} diff --git a/packages/typescript/ai-groq/src/utils/client.ts b/packages/typescript/ai-groq/src/utils/client.ts index 4e4f64580..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..8615f7cfb 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,39 @@ function createAsyncIterable(chunks: Array): AsyncIterable { } } -// Helper to setup the mock SDK client for streaming responses +// Sets up a mock client on the most recently created adapter. Tests use the +// existing call order: `setupMockSdkClient(chunks)` first, then `const adapter +// = createGroqText(...)`. The wrapped factories below apply the pending +// mock to the returned adapter so it intercepts subsequent chatStream/ +// structuredOutput calls. +let pendingMockCreate: Mock<(...args: Array) => unknown> | undefined + function setupMockSdkClient( streamChunks: Array>, nonStreamResponse?: Record, -) { - mockCreate = vi.fn().mockImplementation((params) => { +): Mock<(...args: Array) => unknown> { + pendingMockCreate = vi.fn().mockImplementation((params) => { if (params.stream) { return Promise.resolve(createAsyncIterable(streamChunks)) } return Promise.resolve(nonStreamResponse) }) + return pendingMockCreate +} + +function applyPendingMock(adapter: T): T { + if (pendingMockCreate) { + ;(adapter as any).client = { + chat: { completions: { create: pendingMockCreate } }, + } + pendingMockCreate = undefined + } + return adapter } +const createGroqText: typeof _realCreateGroqText = (model, apiKey, config) => + applyPendingMock(_realCreateGroqText(model, apiKey, config)) +const groqText: typeof _realGroqText = (model, config) => + applyPendingMock(_realGroqText(model, config)) const weatherTool: Tool = { name: 'lookup_weather', @@ -66,6 +91,13 @@ const weatherTool: Tool = { } describe('Groq adapters', () => { + // Reset the module-level `pendingMockCreate` between tests so a previous + // test's setupMockSdkClient call can't leak into a later test that + // instantiates the adapter without setting up a mock. + beforeEach(() => { + pendingMockCreate = undefined + }) + afterEach(() => { vi.unstubAllEnvs() }) @@ -422,7 +454,7 @@ describe('Groq AG-UI event emission', () => { }, } - mockCreate = vi.fn().mockResolvedValue(errorIterable) + pendingMockCreate = vi.fn().mockResolvedValue(errorIterable) const adapter = createGroqText('llama-3.3-70b-versatile', 'test-api-key') const chunks: Array = [] diff --git a/packages/typescript/ai-ollama/src/adapters/summarize.ts b/packages/typescript/ai-ollama/src/adapters/summarize.ts index 0b8407e4b..b0729c662 100644 --- a/packages/typescript/ai-ollama/src/adapters/summarize.ts +++ b/packages/typescript/ai-ollama/src/adapters/summarize.ts @@ -1,3 +1,4 @@ +import { EventType } from '@tanstack/ai' import { createOllamaClient, estimateTokens, @@ -14,10 +15,6 @@ import type { SummarizationResult, } from '@tanstack/ai' -/** Cast an event object to StreamChunk. */ -const asChunk = (chunk: Record) => - chunk as unknown as StreamChunk - export type OllamaSummarizeModel = | (typeof OllamaSummarizeModels)[number] | (string & {}) @@ -128,6 +125,7 @@ export class OllamaSummarizeAdapter< const { logger } = options const model = options.model const id = generateId('sum') + const threadId = generateId('thread') const prompt = this.buildSummarizationPrompt(options) let accumulatedContent = '' @@ -138,6 +136,14 @@ export class OllamaSummarizeAdapter< }) try { + yield { + type: EventType.RUN_STARTED, + runId: id, + threadId, + model, + timestamp: Date.now(), + } satisfies StreamChunk + const stream = await this.client.generate({ model, prompt, @@ -153,22 +159,23 @@ export class OllamaSummarizeAdapter< if (chunk.response) { accumulatedContent += chunk.response - yield asChunk({ - type: 'TEXT_MESSAGE_CONTENT', + yield { + type: EventType.TEXT_MESSAGE_CONTENT, messageId: id, model: chunk.model, timestamp: Date.now(), delta: chunk.response, content: accumulatedContent, - }) + } satisfies StreamChunk } if (chunk.done) { const promptTokens = estimateTokens(prompt) const completionTokens = estimateTokens(accumulatedContent) - yield asChunk({ - type: 'RUN_FINISHED', + yield { + type: EventType.RUN_FINISHED, runId: id, + threadId, model: chunk.model, timestamp: Date.now(), finishReason: 'stop', @@ -177,7 +184,7 @@ export class OllamaSummarizeAdapter< completionTokens, totalTokens: promptTokens + completionTokens, }, - }) + } satisfies StreamChunk } } } catch (error) { diff --git a/packages/typescript/ai-ollama/src/adapters/text.ts b/packages/typescript/ai-ollama/src/adapters/text.ts index 209951569..907418330 100644 --- a/packages/typescript/ai-ollama/src/adapters/text.ts +++ b/packages/typescript/ai-ollama/src/adapters/text.ts @@ -1,3 +1,4 @@ +import { EventType } from '@tanstack/ai' import { BaseTextAdapter } from '@tanstack/ai/adapters' import { createOllamaClient, generateId, getOllamaHostFromEnv } from '../utils' @@ -24,11 +25,6 @@ import type { } from 'ollama' import type { StreamChunk, TextOptions, Tool } from '@tanstack/ai' -/** Cast an event object to StreamChunk. Adapters construct events with string - * literal types which are structurally compatible with the EventType enum. */ -const asChunk = (chunk: Record) => - chunk as unknown as StreamChunk - export type OllamaTextModel = | (typeof OLLAMA_TEXT_MODELS)[number] | (string & {}) @@ -227,7 +223,6 @@ export class OllamaTextAdapter extends BaseTextAdapter< logger: InternalLogger, ): AsyncIterable { let accumulatedContent = '' - const timestamp = Date.now() let accumulatedReasoning = '' const toolCallsEmitted = new Set() @@ -247,13 +242,13 @@ export class OllamaTextAdapter extends BaseTextAdapter< // Emit RUN_STARTED on first chunk if (!hasEmittedRunStarted) { hasEmittedRunStarted = true - yield asChunk({ - type: 'RUN_STARTED', + yield { + type: EventType.RUN_STARTED, runId, threadId, model: chunk.model, - timestamp, - }) + timestamp: Date.now(), + } satisfies StreamChunk } const handleToolCall = (toolCall: ToolCall): Array => { @@ -268,17 +263,15 @@ export class OllamaTextAdapter extends BaseTextAdapter< // Emit TOOL_CALL_START if not already emitted for this tool call if (!toolCallsEmitted.has(toolCallId)) { toolCallsEmitted.add(toolCallId) - events.push( - asChunk({ - type: 'TOOL_CALL_START', - toolCallId, - toolCallName: actualToolCall.function.name || '', - toolName: actualToolCall.function.name || '', - model: chunk.model, - timestamp, - index: actualToolCall.function.index, - }), - ) + events.push({ + type: EventType.TOOL_CALL_START, + toolCallId, + toolCallName: actualToolCall.function.name || '', + toolName: actualToolCall.function.name || '', + model: chunk.model, + timestamp: Date.now(), + index: actualToolCall.function.index, + } satisfies StreamChunk) } // Serialize arguments to a string for the TOOL_CALL_ARGS event @@ -295,29 +288,25 @@ export class OllamaTextAdapter extends BaseTextAdapter< } // Emit TOOL_CALL_ARGS with full args (Ollama doesn't stream args incrementally) - events.push( - asChunk({ - type: 'TOOL_CALL_ARGS', - toolCallId, - model: chunk.model, - timestamp, - delta: argsStr, - args: argsStr, - }), - ) + events.push({ + type: EventType.TOOL_CALL_ARGS, + toolCallId, + model: chunk.model, + timestamp: Date.now(), + delta: argsStr, + args: argsStr, + } satisfies StreamChunk) // Emit TOOL_CALL_END - events.push( - asChunk({ - type: 'TOOL_CALL_END', - toolCallId, - toolCallName: actualToolCall.function.name || '', - toolName: actualToolCall.function.name || '', - model: chunk.model, - timestamp, - input: parsedInput, - }), - ) + events.push({ + type: EventType.TOOL_CALL_END, + toolCallId, + toolCallName: actualToolCall.function.name || '', + toolName: actualToolCall.function.name || '', + model: chunk.model, + timestamp: Date.now(), + input: parsedInput, + } satisfies StreamChunk) return events } @@ -335,36 +324,36 @@ export class OllamaTextAdapter extends BaseTextAdapter< // Close reasoning events if still open if (reasoningMessageId && !hasClosedReasoning) { hasClosedReasoning = true - yield asChunk({ - type: 'REASONING_MESSAGE_END', + yield { + type: EventType.REASONING_MESSAGE_END, messageId: reasoningMessageId, model: chunk.model, - timestamp, - }) - yield asChunk({ - type: 'REASONING_END', + timestamp: Date.now(), + } satisfies StreamChunk + yield { + type: EventType.REASONING_END, messageId: reasoningMessageId, model: chunk.model, - timestamp, - }) + timestamp: Date.now(), + } satisfies StreamChunk } // Emit TEXT_MESSAGE_END if we had text content if (hasEmittedTextMessageStart) { - yield asChunk({ - type: 'TEXT_MESSAGE_END', + yield { + type: EventType.TEXT_MESSAGE_END, messageId, model: chunk.model, - timestamp, - }) + timestamp: Date.now(), + } satisfies StreamChunk } - yield asChunk({ - type: 'RUN_FINISHED', + yield { + type: EventType.RUN_FINISHED, runId, threadId, model: chunk.model, - timestamp, + timestamp: Date.now(), finishReason: toolCallsEmitted.size > 0 ? 'tool_calls' : 'stop', usage: { promptTokens: chunk.prompt_eval_count || 0, @@ -372,7 +361,7 @@ export class OllamaTextAdapter extends BaseTextAdapter< totalTokens: (chunk.prompt_eval_count || 0) + (chunk.eval_count || 0), }, - }) + } satisfies StreamChunk continue } @@ -380,41 +369,41 @@ export class OllamaTextAdapter extends BaseTextAdapter< // Close reasoning before text starts if (reasoningMessageId && !hasClosedReasoning) { hasClosedReasoning = true - yield asChunk({ - type: 'REASONING_MESSAGE_END', + yield { + type: EventType.REASONING_MESSAGE_END, messageId: reasoningMessageId, model: chunk.model, - timestamp, - }) - yield asChunk({ - type: 'REASONING_END', + timestamp: Date.now(), + } satisfies StreamChunk + yield { + type: EventType.REASONING_END, messageId: reasoningMessageId, model: chunk.model, - timestamp, - }) + timestamp: Date.now(), + } satisfies StreamChunk } // Emit TEXT_MESSAGE_START on first text content if (!hasEmittedTextMessageStart) { hasEmittedTextMessageStart = true - yield asChunk({ - type: 'TEXT_MESSAGE_START', + yield { + type: EventType.TEXT_MESSAGE_START, messageId, model: chunk.model, - timestamp, + timestamp: Date.now(), role: 'assistant', - }) + } satisfies StreamChunk } accumulatedContent += chunk.message.content - yield asChunk({ - type: 'TEXT_MESSAGE_CONTENT', + yield { + type: EventType.TEXT_MESSAGE_CONTENT, messageId, model: chunk.model, - timestamp, + timestamp: Date.now(), delta: chunk.message.content, content: accumulatedContent, - }) + } satisfies StreamChunk } if (chunk.message.tool_calls && chunk.message.tool_calls.length > 0) { @@ -434,52 +423,52 @@ export class OllamaTextAdapter extends BaseTextAdapter< reasoningMessageId = generateId('msg') // Spec REASONING events - yield asChunk({ - type: 'REASONING_START', + yield { + type: EventType.REASONING_START, messageId: reasoningMessageId, model: chunk.model, - timestamp, - }) - yield asChunk({ - type: 'REASONING_MESSAGE_START', + timestamp: Date.now(), + } satisfies StreamChunk + yield { + type: EventType.REASONING_MESSAGE_START, messageId: reasoningMessageId, role: 'reasoning' as const, model: chunk.model, - timestamp, - }) + timestamp: Date.now(), + } satisfies StreamChunk // Legacy STEP events (kept during transition) - yield asChunk({ - type: 'STEP_STARTED', + yield { + type: EventType.STEP_STARTED, stepName: stepId, stepId, model: chunk.model, - timestamp, + timestamp: Date.now(), stepType: 'thinking', - }) + } satisfies StreamChunk } accumulatedReasoning += chunk.message.thinking // Spec REASONING content event - yield asChunk({ - type: 'REASONING_MESSAGE_CONTENT', + yield { + type: EventType.REASONING_MESSAGE_CONTENT, messageId: reasoningMessageId!, delta: chunk.message.thinking, model: chunk.model, - timestamp, - }) + timestamp: Date.now(), + } satisfies StreamChunk // Legacy STEP event - yield asChunk({ - type: 'STEP_FINISHED', + yield { + type: EventType.STEP_FINISHED, stepName: stepId || generateId('step'), stepId: stepId || generateId('step'), model: chunk.model, - timestamp, + timestamp: Date.now(), delta: chunk.message.thinking, content: accumulatedReasoning, - }) + } satisfies StreamChunk } } } diff --git a/packages/typescript/ai-openrouter/package.json b/packages/typescript/ai-openrouter/package.json index a8c82d4ec..82bcf976c 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:*", @@ -53,6 +54,7 @@ "zod": "^4.2.0" }, "peerDependencies": { - "@tanstack/ai": "workspace:^" + "@tanstack/ai": "workspace:^", + "zod": "^4.0.0" } } diff --git a/packages/typescript/ai-openrouter/src/adapters/responses-text.ts b/packages/typescript/ai-openrouter/src/adapters/responses-text.ts new file mode 100644 index 000000000..f05a5f343 --- /dev/null +++ b/packages/typescript/ai-openrouter/src/adapters/responses-text.ts @@ -0,0 +1,631 @@ +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) + } + + /** + * Preserve nulls in structured-output results. OpenRouter routes through + * a wide variety of upstream providers; some of them return `null` as a + * distinct sentinel ("the field exists, the value is null") rather than + * collapsing it to absent. Stripping nulls here would erase that + * distinction. Mirrors the chat-completions adapter override. + */ + protected override transformStructuredOutput(parsed: unknown): unknown { + return parsed + } + + // ──────────────────────────────────────────────────────────────────────── + // 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') { + // For structured (Array) tool results, extract the text + // content rather than JSON-stringifying the parts — sending the raw + // ContentPart shape (e.g. `[{"type":"text","content":"…"}]`) into the + // `output` field would feed the literal JSON of the parts back to the + // model instead of the tool's textual result. + result.push({ + type: 'function_call_output', + callId: message.toolCallId || '', + output: + typeof message.content === 'string' + ? message.content + : this.extractTextContent(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 + + // 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': + 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 && { + // Stringify provider error codes so they survive `toRunErrorPayload`'s + // string-only `code` filter — mirrors the top-level `'error'` event + // branch in `adaptOpenRouterResponsesStreamEvents` and the chat- + // completions fix in commit 0171b18e. + error: { + message: r.error.message, + code: r.error.code != null ? String(r.error.code) : undefined, + }, + }), + } +} + +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/adapters/text.ts b/packages/typescript/ai-openrouter/src/adapters/text.ts index 29427171c..39f408f48 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,434 @@ 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', - }) - } - - 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 - } - } + // ──────────────────────────────────────────────────────────────────────── + // 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. + // ──────────────────────────────────────────────────────────────────────── + + protected override convertMessage(message: ModelMessage): any { + if (message.role === 'tool') { + // For structured (Array) tool results, extract the text + // content rather than JSON-stringifying the parts — sending the raw + // ContentPart shape (e.g. `[{"type":"text","content":"…"}]`) into the + // tool message's `content` field would feed the literal JSON of the + // parts back to the model instead of the tool's textual result. + return { + role: 'tool', + content: + typeof message.content === 'string' + ? message.content + : this.extractTextContent(message.content), + toolCallId: message.toolCallId || '', + } 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, - }) - } - } + if (message.role === 'assistant') { + // Stringify object-shaped tool-call arguments to match the SDK's + // `ChatToolCall.function.arguments: string` contract. Without this an + // assistant message that carries already-parsed args (common after a + // multi-turn run) would either serialise as `[object Object]` or be + // rejected by the SDK's Zod schema with an opaque validation error. + const toolCalls = message.toolCalls?.map((tc) => ({ + ...tc, + function: { + name: tc.function.name, + arguments: + typeof tc.function.arguments === 'string' + ? tc.function.arguments + : JSON.stringify(tc.function.arguments), + }, + })) + // Per the OpenAI-compatible Chat Completions contract, an assistant + // message that only carries tool_calls should have `content: null` + // rather than `content: ''` or `content: undefined`. For multi-part + // assistant content (Array) we extract the text rather + // than JSON-stringifying the parts, which would otherwise leak the + // literal part shape into the next-turn prompt. + const textContent = this.extractTextContent(message.content) + const hasToolCalls = !!toolCalls && toolCalls.length > 0 + return { + role: 'assistant', + content: hasToolCalls && !textContent ? null : textContent, + toolCalls, + } satisfies ChatMessages + } - // 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', - }) + // 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.`, + ) } - - 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, - }) + return { + role: 'user', + content: text, + } 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, - }) - } + const parts: Array = [] + for (const part of contentParts) { + const converted = this.convertContentPartToOpenRouter(part) + 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 (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' }, - }) + 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, + } 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 + // Default to `application/octet-stream` when the source didn't + // provide a MIME type — interpolating `undefined` into the URI + // ("data:undefined;base64,...") produces an invalid data URI the + // API rejects. Mirrors the base's defaulting in + // `OpenAICompatibleChatCompletionsTextAdapter.convertContentPart`. + const imageMime = part.source.mimeType || 'application/octet-stream' + const url = + part.source.type === 'data' && !value.startsWith('data:') + ? `data:${imageMime};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() + case 'audio': + // OpenRouter's chat-completions `input_audio` shape carries + // `{ data, format }` where `data` is base64 — there's no URL + // variant on this wire. For URL-sourced audio, fall back to a + // text reference rather than feeding the literal URL into the + // base64 slot (which would either be rejected upstream or + // silently misinterpreted as garbage audio bytes). The + // Responses adapter does have an `input_file` URL variant and + // routes URLs there directly — see `responses-text.ts`. + if (part.source.type === 'url') { + return { type: 'text', text: `[Audio: ${part.source.value}]` } } - - 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, - }) - } + return { + type: 'input_audio', + inputAudio: { data: part.source.value, format: 'mp3' }, } - - // 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 'video': + return { type: 'video_url', videoUrl: { url: part.source.value } } + case 'document': + // The chat-completions SDK has no document_url type. For URL + // sources, surface a text reference so the model at least sees + // the link. For data sources, `part.source.value` is the raw + // base64 payload — inlining it into the prompt would blow the + // context window with megabytes of binary and leak the document + // content verbatim. Throw instead so the caller can either + // switch to the Responses adapter (which has proper input_file + // support for data documents) or strip the document before + // sending. + if (part.source.type === 'data') { + throw new Error( + `${this.name} chat-completions does not support inline (data) document content parts. ` + + `Use the Responses adapter (openRouterResponsesText) for document data, ` + + `or pass the document as a URL.`, + ) } - } + return { type: 'text', text: `[Document: ${part.source.value}]` } + 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)) + } + + const tools = options.tools + ? convertToolsToProviderFormat(options.tools) + : undefined - // Spread modelOptions first, then conditionally override with explicit - // top-level options so undefined values don't clobber modelOptions. Fixes - // #310, where the reverse order silently dropped user-set values. - const request: ChatRequest = { - ...modelOptions, - model: - options.model + - (modelOptions?.variant ? `:${modelOptions.variant}` : ''), + // 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, - } - - return request + ...(options.topP !== undefined && { top_p: options.topP }), + ...(tools && tools.length > 0 && { tools }), + stream: true, + } as ChatCompletionCreateParamsStreaming } +} - 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 || '', - } +// ────────────────────────────────────────────────────────────────────────── +// 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) { + 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) { + 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 + } - 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, + // Streaming flag is set per-call by the SDK call hook, not here. + delete out.stream + if (!isStreaming) delete out.streamOptions + + 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. + // 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(errObj.message || 'OpenRouter stream error'), + { + code: errObj.code != null ? String(errObj.code) : undefined, + }, + ) } - return parts.length ? parts : [{ type: 'text', text: '' }] + + yield adapted as ChatCompletionChunk } } 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-adapter.test.ts b/packages/typescript/ai-openrouter/tests/openrouter-adapter.test.ts index 206d16525..f6e83df18 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), } @@ -374,6 +381,52 @@ describe('OpenRouter adapter option mapping', () => { }) }) + it('defaults base64 image data URIs to application/octet-stream when mimeType is missing', async () => { + setupMockSdkClient([ + { + id: 'x', + model: 'openai/gpt-4o-mini', + choices: [{ delta: { content: 'ok' }, finishReason: 'stop' }], + }, + ]) + const adapter = createAdapter() + for await (const _ of adapter.chatStream({ + model: 'openai/gpt-4o-mini', + messages: [ + { + role: 'user', + content: [ + { type: 'text', content: 'see image' }, + { + type: 'image', + // The TS type requires `mimeType` on data sources, but at + // runtime a JS caller (or a cast) can still elide it. Cast + // to bypass the type check so the adapter's defensive + // default — `application/octet-stream` — is exercised; the + // alternative is a literal `data:undefined;base64,...` URI + // that the upstream rejects. + source: { type: 'data', value: 'aGVsbG8=' } as any, + }, + ], + }, + ], + logger: testLogger, + })) { + // consume + } + + const [rawParams] = mockSend.mock.calls[0]! + const params = rawParams.chatRequest + const imagePart = params.messages[0].content.find( + (p: any) => p.type === 'image_url', + ) + expect(imagePart).toBeDefined() + expect(imagePart.imageUrl.url).toBe( + 'data:application/octet-stream;base64,aGVsbG8=', + ) + expect(imagePart.imageUrl.url).not.toContain('undefined') + }) + it('yields error chunk on SDK error', async () => { mockSend = vi.fn().mockRejectedValueOnce(new Error('Invalid API key')) @@ -789,6 +842,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') } }) @@ -1151,6 +1208,9 @@ describe('OpenRouter structured output', () => { const adapter = createAdapter() + // The shared base re-throws the underlying error rather than wrapping it + // with a "Structured output generation failed:" prefix — the prefix only + // existed in the pre-migration OpenRouter adapter. await expect( adapter.structuredOutput({ chatOptions: { @@ -1160,10 +1220,10 @@ describe('OpenRouter structured output', () => { }, outputSchema: { type: 'object' }, }), - ).rejects.toThrow('Structured output generation failed: Server error') + ).rejects.toThrow('Server error') }) - it('handles empty content gracefully', async () => { + it('throws a clear "no content" error when the response is empty', async () => { const nonStreamResponse = { choices: [ { @@ -1177,6 +1237,9 @@ describe('OpenRouter structured output', () => { setupMockSdkClient([], nonStreamResponse) const adapter = createAdapter() + // Empty content must surface as a distinct error so the actual failure + // mode (the model returned no content) is visible in logs rather than + // being masked by a misleading JSON-parse error on an empty string. await expect( adapter.structuredOutput({ chatOptions: { @@ -1186,7 +1249,7 @@ describe('OpenRouter structured output', () => { }, outputSchema: { type: 'object' }, }), - ).rejects.toThrow('Structured output response contained no content') + ).rejects.toThrow('response contained no content') }) }) @@ -1664,3 +1727,352 @@ 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' }) + }) + + it('extracts text from array-shaped assistant content instead of JSON-stringifying parts', async () => { + setupMockSdkClient([ + { + id: 'x', + model: 'openai/gpt-4o-mini', + choices: [{ delta: { content: 'ok' }, finishReason: 'stop' }], + }, + ]) + const adapter = createAdapter() + + for await (const _ of adapter.chatStream({ + model: 'openai/gpt-4o-mini', + messages: [ + { role: 'user', content: 'first' }, + { + role: 'assistant', + // Multi-part assistant content from a prior turn. The base extracts + // joined text; the OpenRouter override must do the same instead of + // JSON-stringifying the parts into the next-turn prompt. + content: [ + { type: 'text', content: 'hello ' }, + { type: 'text', content: 'world' }, + ], + }, + { role: 'user', content: 'second' }, + ], + logger: testLogger, + })) { + // consume + } + + const [rawParams] = mockSend.mock.calls[0]! + const assistantMsg = rawParams.chatRequest.messages.find( + (m: any) => m.role === 'assistant', + ) + expect(assistantMsg).toBeDefined() + expect(assistantMsg.content).toBe('hello world') + }) + + it('extracts text from array-shaped tool message content instead of JSON-stringifying parts', async () => { + setupMockSdkClient([ + { + id: 'x', + model: 'openai/gpt-4o-mini', + choices: [{ delta: { content: 'ok' }, finishReason: 'stop' }], + }, + ]) + const adapter = createAdapter() + + for await (const _ of adapter.chatStream({ + model: 'openai/gpt-4o-mini', + messages: [ + { role: 'user', content: 'hi' }, + { + role: 'assistant', + content: null, + toolCalls: [ + { + id: 'call_1', + type: 'function', + function: { + name: 'lookup_weather', + arguments: '{"location":"Berlin"}', + }, + }, + ], + }, + { + role: 'tool', + toolCallId: 'call_1', + // Structured tool result content. The adapter must extract the + // text rather than JSON-stringifying the parts; otherwise the + // model would see the literal `[{"type":"text","content":"..."}]` + // shape on its next turn instead of the actual tool output. + content: [ + { type: 'text', content: '{"temp":' }, + { type: 'text', content: '72}' }, + ], + }, + ], + logger: testLogger, + })) { + // consume + } + + const [rawParams] = mockSend.mock.calls[0]! + const toolMsg = rawParams.chatRequest.messages.find( + (m: any) => m.role === 'tool', + ) + expect(toolMsg).toBeDefined() + expect(toolMsg.content).toBe('{"temp":72}') + expect(toolMsg.content).not.toContain('"type":"text"') + }) + + it('emits content: null (not undefined) for assistant messages with only tool calls', async () => { + setupMockSdkClient([ + { + id: 'x', + model: 'openai/gpt-4o-mini', + choices: [{ delta: { content: 'ok' }, finishReason: 'stop' }], + }, + ]) + const adapter = createAdapter() + + for await (const _ of adapter.chatStream({ + model: 'openai/gpt-4o-mini', + messages: [ + { role: 'user', content: 'hi' }, + { + role: 'assistant', + content: null, + toolCalls: [ + { + id: 'call_1', + type: 'function', + function: { + name: 'lookup_weather', + arguments: '{"location":"Berlin"}', + }, + }, + ], + }, + { role: 'tool', toolCallId: 'call_1', content: '{"temp":72}' }, + ], + logger: testLogger, + })) { + // consume + } + + const [rawParams] = mockSend.mock.calls[0]! + const assistantMsg = rawParams.chatRequest.messages.find( + (m: any) => m.role === 'assistant', + ) + expect(assistantMsg).toBeDefined() + // Strictly null — the OpenAI Chat Completions contract documents `null` + // for tool-call-only assistant messages, and the SDK's Zod schema may + // strip `undefined` entirely. + expect(assistantMsg.content).toBeNull() + }) +}) diff --git a/packages/typescript/ai-openrouter/tests/openrouter-responses-adapter.test.ts b/packages/typescript/ai-openrouter/tests/openrouter-responses-adapter.test.ts new file mode 100644 index 000000000..0d5817605 --- /dev/null +++ b/packages/typescript/ai-openrouter/tests/openrouter-responses-adapter.test.ts @@ -0,0 +1,621 @@ +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') + }) + + it('stringifies non-string error.code on response.failed events', async () => { + setupMockSdkClient([ + { + type: 'response.created', + sequenceNumber: 0, + response: { model: 'm', output: [] }, + }, + { + type: 'response.failed', + sequenceNumber: 1, + response: { + model: 'm', + output: [], + error: { message: 'upstream auth failed', code: 401 }, + }, + }, + ]) + const adapter = createAdapter() + const chunks: Array = [] + for await (const c of adapter.chatStream({ + model: 'openai/gpt-4o-mini' as any, + messages: [{ role: 'user', content: 'hi' }], + logger: testLogger, + })) { + chunks.push(c) + } + const err = chunks.find((c) => c.type === 'RUN_ERROR') as any + expect(err).toBeDefined() + expect(err.message).toBe('upstream auth failed') + // Provider code must survive as a string so `toRunErrorPayload`'s + // string-only `code` filter doesn't drop it on the way through. + expect(err.code).toBe('401') + expect(err.error.code).toBe('401') + }) + + it('does not emit further lifecycle events after a top-level error event', async () => { + setupMockSdkClient([ + { + type: 'response.created', + sequenceNumber: 0, + response: { model: 'm', output: [] }, + }, + { + type: 'response.output_item.added', + sequenceNumber: 1, + outputIndex: 0, + item: { type: 'message', id: 'msg_1', role: 'assistant' }, + }, + { + type: 'response.output_text.delta', + sequenceNumber: 2, + itemId: 'msg_1', + outputIndex: 0, + contentIndex: 0, + delta: 'partial ', + }, + // Top-level error mid-stream — terminal. + { + type: 'error', + sequenceNumber: 3, + message: 'rate limit', + code: 429, + param: null, + }, + // The adapter MUST NOT process anything after the error event; + // these chunks would otherwise yield TEXT_MESSAGE_CONTENT / END + // events past the terminal RUN_ERROR. + { + type: 'response.output_text.delta', + sequenceNumber: 4, + itemId: 'msg_1', + outputIndex: 0, + contentIndex: 0, + delta: 'after-error', + }, + { + type: 'response.output_text.done', + sequenceNumber: 5, + itemId: 'msg_1', + outputIndex: 0, + contentIndex: 0, + text: 'partial after-error', + }, + ]) + const adapter = createAdapter() + const chunks: Array = [] + for await (const c of adapter.chatStream({ + model: 'openai/gpt-4o-mini' as any, + messages: [{ role: 'user', content: 'hi' }], + logger: testLogger, + })) { + chunks.push(c) + } + + const errIndex = chunks.findIndex((c) => c.type === 'RUN_ERROR') + expect(errIndex).toBeGreaterThanOrEqual(0) + // No content/lifecycle events emitted after RUN_ERROR. + const post = chunks.slice(errIndex + 1) + expect(post).toEqual([]) + // The first delta's content reached the consumer; the second did not. + const allContent = chunks + .filter((c) => c.type === 'TEXT_MESSAGE_CONTENT') + .map((c: any) => c.delta) + .join('') + expect(allContent).toBe('partial ') + expect(allContent).not.toContain('after-error') + }) +}) + +describe('OpenRouter responses adapter — structured output', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('preserves null values in structured output (does not strip nulls)', async () => { + // Non-streaming Responses API result with a `null` field in the parsed + // JSON. The base default `transformStructuredOutput` would convert + // nulls to undefined; the OpenRouter override must keep them intact + // so consumers that discriminate "field present but null" from + // "field absent" see the null sentinel the upstream returned. + setupMockSdkClient([], { + id: 'resp_1', + model: 'openai/gpt-4o-mini', + output: [ + { + type: 'message', + id: 'msg_1', + role: 'assistant', + content: [ + { + type: 'output_text', + text: JSON.stringify({ + name: 'Alice', + age: 30, + nickname: null, + }), + }, + ], + }, + ], + usage: { inputTokens: 5, outputTokens: 2, totalTokens: 7 }, + }) + + const adapter = createAdapter() + const result = await adapter.structuredOutput({ + chatOptions: { + model: 'openai/gpt-4o-mini' as any, + messages: [{ role: 'user', content: 'profile?' }], + logger: testLogger, + }, + outputSchema: { + type: 'object', + properties: { + name: { type: 'string' }, + age: { type: 'number' }, + nickname: { type: ['string', 'null'] }, + }, + required: ['name', 'age', 'nickname'], + }, + }) + + expect(result.data).toEqual({ + name: 'Alice', + age: 30, + nickname: null, + }) + // Critical: nickname should be `null`, not `undefined`. + expect((result.data as any).nickname).toBeNull() + }) +}) + +describe('OpenRouter responses adapter — SDK constructor wiring', () => { + beforeEach(() => { + vi.clearAllMocks() + lastOpenRouterConfig = undefined + }) + + it('forwards app-attribution headers (httpReferer, appTitle) to the SDK constructor', () => { + void createOpenRouterResponsesText('openai/gpt-4o-mini', 'test-key', { + httpReferer: 'https://app.example.com', + appTitle: 'TestApp', + } as any) + expect(lastOpenRouterConfig).toBeDefined() + expect(lastOpenRouterConfig.apiKey).toBe('test-key') + expect(lastOpenRouterConfig.httpReferer).toBe('https://app.example.com') + expect(lastOpenRouterConfig.appTitle).toBe('TestApp') + }) + + it('propagates the abort signal to the SDK call', async () => { + setupMockSdkClient([ + { + type: 'response.completed', + sequenceNumber: 1, + response: { + model: 'm', + output: [], + usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 }, + }, + }, + ]) + const adapter = createAdapter() + const controller = new AbortController() + for await (const _ of adapter.chatStream({ + model: 'openai/gpt-4o-mini' as any, + messages: [{ role: 'user', content: 'hi' }], + logger: testLogger, + request: { signal: controller.signal } as any, + })) { + // consume + } + const [, options] = mockSend.mock.calls[0]! + expect(options).toEqual({ signal: controller.signal }) + }) +}) 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/src/activities/stream-generation-result.ts b/packages/typescript/ai/src/activities/stream-generation-result.ts index 2a2274cbd..deeacda8f 100644 --- a/packages/typescript/ai/src/activities/stream-generation-result.ts +++ b/packages/typescript/ai/src/activities/stream-generation-result.ts @@ -34,7 +34,7 @@ export async function* streamGenerationResult( runId, threadId, timestamp: Date.now(), - } as StreamChunk + } satisfies StreamChunk try { const result = await generator() @@ -44,7 +44,7 @@ export async function* streamGenerationResult( name: 'generation:result', value: result as unknown, timestamp: Date.now(), - } as StreamChunk + } satisfies StreamChunk yield { type: EventType.RUN_FINISHED, @@ -52,18 +52,16 @@ export async function* streamGenerationResult( threadId, finishReason: 'stop', timestamp: Date.now(), - } as StreamChunk + } satisfies StreamChunk } catch (error: unknown) { const payload = toRunErrorPayload(error, 'Generation failed') yield { type: EventType.RUN_ERROR, - runId, - threadId, message: payload.message, code: payload.code, // Deprecated nested form for backward compatibility error: payload, timestamp: Date.now(), - } as StreamChunk + } satisfies StreamChunk } } diff --git a/packages/typescript/ai/src/types.ts b/packages/typescript/ai/src/types.ts index ec87c3259..7c34446b4 100644 --- a/packages/typescript/ai/src/types.ts +++ b/packages/typescript/ai/src/types.ts @@ -830,7 +830,13 @@ export interface RunFinishedEvent extends AGUIRunFinishedEvent { /** Model identifier for multi-model support */ model?: string /** Why the generation stopped */ - finishReason?: 'stop' | 'length' | 'content_filter' | 'tool_calls' | null + finishReason?: + | 'stop' + | 'length' + | 'content_filter' + | 'tool_calls' + | 'function_call' + | null /** Token usage statistics */ usage?: { promptTokens: number 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/ai/tests/test-utils.ts b/packages/typescript/ai/tests/test-utils.ts index 73b239b93..365d4f80c 100644 --- a/packages/typescript/ai/tests/test-utils.ts +++ b/packages/typescript/ai/tests/test-utils.ts @@ -1,3 +1,4 @@ +import { EventType } from '../src/types' import type { AnyTextAdapter } from '../src/activities/chat/adapter' import type { StreamChunk, TextMessageContentEvent, Tool } from '../src/types' @@ -5,7 +6,9 @@ import type { StreamChunk, TextMessageContentEvent, Tool } from '../src/types' // Chunk factory // ============================================================================ -/** Create a typed StreamChunk with minimal boilerplate. */ +/** Escape hatch for tests that deliberately construct off-spec chunks (e.g. + * to exercise deprecated-field handling or malformed input). Prefer the + * strictly-typed `ev.*` builders below for normal cases. */ export function chunk( type: string, fields: Record = {}, @@ -20,32 +23,61 @@ export function chunk( /** Shorthand chunk factories for common AG-UI events. */ export const ev = { runStarted: (runId = 'run-1', threadId = 'thread-1') => - chunk('RUN_STARTED', { runId, threadId }), + ({ + type: EventType.RUN_STARTED, + runId, + threadId, + timestamp: Date.now(), + }) satisfies StreamChunk, textStart: (messageId = 'msg-1') => - chunk('TEXT_MESSAGE_START', { messageId, role: 'assistant' as const }), + ({ + type: EventType.TEXT_MESSAGE_START, + messageId, + role: 'assistant', + timestamp: Date.now(), + }) satisfies StreamChunk, textContent: (delta: string, messageId = 'msg-1') => - chunk('TEXT_MESSAGE_CONTENT', { messageId, delta }), - textEnd: (messageId = 'msg-1') => chunk('TEXT_MESSAGE_END', { messageId }), + ({ + type: EventType.TEXT_MESSAGE_CONTENT, + messageId, + delta, + timestamp: Date.now(), + }) satisfies StreamChunk, + textEnd: (messageId = 'msg-1') => + ({ + type: EventType.TEXT_MESSAGE_END, + messageId, + timestamp: Date.now(), + }) satisfies StreamChunk, toolStart: (toolCallId: string, toolCallName: string, index?: number) => - chunk('TOOL_CALL_START', { + ({ + type: EventType.TOOL_CALL_START, toolCallId, toolCallName, toolName: toolCallName, + timestamp: Date.now(), ...(index !== undefined ? { index } : {}), - }), + }) satisfies StreamChunk, toolArgs: (toolCallId: string, delta: string) => - chunk('TOOL_CALL_ARGS', { toolCallId, delta }), + ({ + type: EventType.TOOL_CALL_ARGS, + toolCallId, + delta, + timestamp: Date.now(), + }) satisfies StreamChunk, toolEnd: ( toolCallId: string, toolCallName: string, opts?: { input?: unknown; result?: string }, ) => - chunk('TOOL_CALL_END', { + ({ + type: EventType.TOOL_CALL_END, toolCallId, toolCallName, toolName: toolCallName, + timestamp: Date.now(), ...opts, - }), + }) satisfies StreamChunk, runFinished: ( finishReason: | 'stop' @@ -61,17 +93,35 @@ export const ev = { }, threadId = 'thread-1', ) => - chunk('RUN_FINISHED', { + ({ + type: EventType.RUN_FINISHED, runId, threadId, finishReason, + timestamp: Date.now(), ...(usage ? { usage } : {}), - }), - runError: (message: string, runId = 'run-1') => - chunk('RUN_ERROR', { message, runId, error: { message } }), - stepStarted: (stepName = 'step-1') => chunk('STEP_STARTED', { stepName }), + }) satisfies StreamChunk, + runError: (message: string) => + ({ + type: EventType.RUN_ERROR, + message, + timestamp: Date.now(), + error: { message }, + }) satisfies StreamChunk, + stepStarted: (stepName = 'step-1') => + ({ + type: EventType.STEP_STARTED, + stepName, + timestamp: Date.now(), + }) satisfies StreamChunk, stepFinished: (delta: string, stepName = 'step-1') => - chunk('STEP_FINISHED', { stepName, stepId: stepName, delta }), + ({ + type: EventType.STEP_FINISHED, + stepName, + stepId: stepName, + delta, + timestamp: Date.now(), + }) satisfies StreamChunk, } // ============================================================================ diff --git a/packages/typescript/openai-base/src/adapters/chat-completions-text.ts b/packages/typescript/openai-base/src/adapters/chat-completions-text.ts index ac014f619..7ea5fffa8 100644 --- a/packages/typescript/openai-base/src/adapters/chat-completions-text.ts +++ b/packages/typescript/openai-base/src/adapters/chat-completions-text.ts @@ -1,3 +1,4 @@ +import { EventType } from '@tanstack/ai' import { BaseTextAdapter } from '@tanstack/ai/adapters' import { toRunErrorPayload } from '@tanstack/ai/adapter-internals' import { generateId, transformNullsToUndefined } from '@tanstack/ai-utils' @@ -20,11 +21,6 @@ import type { } from '@tanstack/ai' import type { OpenAICompatibleClientConfig } from '../types/config' -/** Cast an event object to StreamChunk. Adapters construct events with string - * literal types which are structurally compatible with the EventType enum. */ -const asChunk = (chunk: Record) => - chunk as unknown as StreamChunk - /** * OpenAI-compatible Chat Completions Text Adapter * @@ -71,13 +67,12 @@ export class OpenAICompatibleChatCompletionsTextAdapter< options: TextOptions, ): AsyncIterable { const requestParams = this.mapOptionsToRequest(options) - const timestamp = Date.now() // AG-UI lifecycle tracking (mutable state object for ESLint compatibility) const aguiState = { runId: generateId(this.name), + threadId: options.threadId ?? generateId(this.name), messageId: generateId(this.name), - timestamp, hasEmittedRunStarted: false, } @@ -86,7 +81,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, @@ -107,22 +102,24 @@ export class OpenAICompatibleChatCompletionsTextAdapter< // Emit RUN_STARTED if not yet emitted if (!aguiState.hasEmittedRunStarted) { aguiState.hasEmittedRunStarted = true - yield asChunk({ - type: 'RUN_STARTED', + yield { + type: EventType.RUN_STARTED, runId: aguiState.runId, + threadId: aguiState.threadId, model: options.model, - timestamp, - }) + timestamp: Date.now(), + } satisfies StreamChunk } // Emit AG-UI RUN_ERROR - yield asChunk({ - type: 'RUN_ERROR', - runId: aguiState.runId, + yield { + type: EventType.RUN_ERROR, model: options.model, - timestamp, + timestamp: Date.now(), + message: errorPayload.message, + code: errorPayload.code, error: errorPayload, - }) + } satisfies StreamChunk options.logger.errors(`${this.name}.chatStream fatal`, { error: errorPayload, @@ -165,7 +162,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, @@ -181,8 +178,16 @@ export class OpenAICompatibleChatCompletionsTextAdapter< extractRequestOptions(chatOptions.request), ) - // Extract text content from the response - const rawText = response.choices[0]?.message.content || '' + // Extract text content from the response. Fail loud on empty content + // rather than letting it cascade into a JSON-parse error on '' — the + // root cause (the model returned no content for the structured request) + // is then visible in logs. + const rawText = response.choices[0]?.message.content + if (typeof rawText !== 'string' || rawText.length === 0) { + throw new Error( + `${this.name}.structuredOutput: response contained no content`, + ) + } // Parse the JSON response let parsed: unknown @@ -195,8 +200,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 +231,65 @@ 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> { + 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. @@ -233,13 +299,12 @@ export class OpenAICompatibleChatCompletionsTextAdapter< options: TextOptions, aguiState: { runId: string + threadId: string messageId: string - timestamp: number hasEmittedRunStarted: boolean }, ): AsyncIterable { let accumulatedContent = '' - const timestamp = aguiState.timestamp let hasEmittedTextMessageStart = false let lastModel: string | undefined // Track usage from any chunk that carries it. With @@ -265,6 +330,17 @@ export class OpenAICompatibleChatCompletionsTextAdapter< started: boolean // Track if TOOL_CALL_START has been emitted } >() + + // Reasoning lifecycle (driven by extractReasoning() hook — see method + // docs). The base wire format (OpenAI Chat Completions) has no reasoning, + // so these stay unused for openai/grok/groq. OpenRouter etc. opt in. + let reasoningMessageId: string | undefined + let hasClosedReasoning = false + // Legacy STEP_STARTED/STEP_FINISHED pair emitted alongside REASONING_* + // for back-compat with consumers (UI, devtools) that haven't migrated + // to the spec REASONING_* events yet. + let stepId: string | undefined + let accumulatedReasoning = '' // Track whether ANY tool call lifecycle was actually completed across the // entire stream. Lets us downgrade a `tool_calls` finish_reason to `stop` // when the upstream signalled tool calls but never produced a complete @@ -298,12 +374,56 @@ export class OpenAICompatibleChatCompletionsTextAdapter< // `hasEmittedRunStarted`). if (!aguiState.hasEmittedRunStarted) { aguiState.hasEmittedRunStarted = true - yield asChunk({ - type: 'RUN_STARTED', + yield { + type: EventType.RUN_STARTED, runId: aguiState.runId, + threadId: aguiState.threadId, model: chunk.model || options.model, - timestamp, - }) + timestamp: Date.now(), + } satisfies StreamChunk + } + + // Reasoning content (extractReasoning() hook). Run before reading + // choice/delta so reasoning-only chunks (no `choices`) still drive + // the REASONING_* lifecycle on providers that send reasoning out of + // band. The base default returns undefined. + const reasoning = this.extractReasoning(chunk) + if (reasoning && reasoning.text) { + if (!reasoningMessageId) { + reasoningMessageId = generateId(this.name) + stepId = generateId(this.name) + yield { + type: EventType.REASONING_START, + messageId: reasoningMessageId, + model: chunk.model || options.model, + timestamp: Date.now(), + } satisfies StreamChunk + yield { + type: EventType.REASONING_MESSAGE_START, + messageId: reasoningMessageId, + role: 'reasoning' as const, + model: chunk.model || options.model, + timestamp: Date.now(), + } satisfies StreamChunk + // Legacy STEP_STARTED (single emission, paired with the + // STEP_FINISHED below when reasoning closes). + yield { + type: EventType.STEP_STARTED, + stepName: stepId, + stepId, + model: chunk.model || options.model, + timestamp: Date.now(), + stepType: 'thinking', + } satisfies StreamChunk + } + accumulatedReasoning += reasoning.text + yield { + type: EventType.REASONING_MESSAGE_CONTENT, + messageId: reasoningMessageId, + delta: reasoning.text, + model: chunk.model || options.model, + timestamp: Date.now(), + } satisfies StreamChunk } const choice = chunk.choices[0] @@ -316,29 +436,57 @@ export class OpenAICompatibleChatCompletionsTextAdapter< // Handle content delta if (deltaContent) { + // Close reasoning before text starts so consumers see a clean + // REASONING_END before any TEXT_MESSAGE_START. + if (reasoningMessageId && !hasClosedReasoning) { + hasClosedReasoning = true + yield { + type: EventType.REASONING_MESSAGE_END, + messageId: reasoningMessageId, + model: chunk.model || options.model, + timestamp: Date.now(), + } satisfies StreamChunk + yield { + type: EventType.REASONING_END, + messageId: reasoningMessageId, + model: chunk.model || options.model, + timestamp: Date.now(), + } satisfies StreamChunk + if (stepId) { + yield { + type: EventType.STEP_FINISHED, + stepName: stepId, + stepId, + model: chunk.model || options.model, + timestamp: Date.now(), + content: accumulatedReasoning, + } satisfies StreamChunk + } + } + // Emit TEXT_MESSAGE_START on first text content if (!hasEmittedTextMessageStart) { hasEmittedTextMessageStart = true - yield asChunk({ - type: 'TEXT_MESSAGE_START', + yield { + type: EventType.TEXT_MESSAGE_START, messageId: aguiState.messageId, model: chunk.model || options.model, - timestamp, + timestamp: Date.now(), role: 'assistant', - }) + } satisfies StreamChunk } accumulatedContent += deltaContent // Emit AG-UI TEXT_MESSAGE_CONTENT - yield asChunk({ - type: 'TEXT_MESSAGE_CONTENT', + yield { + type: EventType.TEXT_MESSAGE_CONTENT, messageId: aguiState.messageId, model: chunk.model || options.model, - timestamp, + timestamp: Date.now(), delta: deltaContent, content: accumulatedContent, - }) + } satisfies StreamChunk } // Handle tool calls - they come in as deltas @@ -372,26 +520,26 @@ export class OpenAICompatibleChatCompletionsTextAdapter< // Emit TOOL_CALL_START when we have id and name if (toolCall.id && toolCall.name && !toolCall.started) { toolCall.started = true - yield asChunk({ - type: 'TOOL_CALL_START', + yield { + type: EventType.TOOL_CALL_START, toolCallId: toolCall.id, toolCallName: toolCall.name, toolName: toolCall.name, model: chunk.model || options.model, - timestamp, + timestamp: Date.now(), index, - }) + } satisfies StreamChunk } // Emit TOOL_CALL_ARGS for argument deltas if (toolCallDelta.function?.arguments && toolCall.started) { - yield asChunk({ - type: 'TOOL_CALL_ARGS', + yield { + type: EventType.TOOL_CALL_ARGS, toolCallId: toolCall.id, model: chunk.model || options.model, - timestamp, + timestamp: Date.now(), delta: toolCallDelta.function.arguments, - }) + } satisfies StreamChunk } } } @@ -445,15 +593,15 @@ export class OpenAICompatibleChatCompletionsTextAdapter< } // Emit AG-UI TOOL_CALL_END - yield asChunk({ - type: 'TOOL_CALL_END', + yield { + type: EventType.TOOL_CALL_END, toolCallId: toolCall.id, toolCallName: toolCall.name, toolName: toolCall.name, model: chunk.model || options.model, - timestamp, + timestamp: Date.now(), input: parsedInput, - }) + } satisfies StreamChunk emittedAnyToolCallEnd = true } // Clear tool-call state after emission so a subsequent @@ -464,12 +612,12 @@ export class OpenAICompatibleChatCompletionsTextAdapter< // Emit TEXT_MESSAGE_END if we had text content if (hasEmittedTextMessageStart) { - yield asChunk({ - type: 'TEXT_MESSAGE_END', + yield { + type: EventType.TEXT_MESSAGE_END, messageId: aguiState.messageId, model: chunk.model || options.model, - timestamp, - }) + timestamp: Date.now(), + } satisfies StreamChunk hasEmittedTextMessageStart = false } @@ -497,19 +645,36 @@ export class OpenAICompatibleChatCompletionsTextAdapter< try { const parsed: unknown = JSON.parse(toolCall.arguments) parsedInput = parsed && typeof parsed === 'object' ? parsed : {} - } catch { + } catch (parseError) { + // Mirror the finish_reason path's logger call — a truncated + // stream emitting malformed tool-call JSON would otherwise + // silently invoke the tool with `{}`, the exact failure the + // finish_reason logger was added to prevent. + options.logger.errors( + `${this.name}.processStreamChunks tool-args JSON parse failed (drain)`, + { + error: toRunErrorPayload( + parseError, + `tool ${toolCall.name} (${toolCall.id}) returned malformed JSON arguments`, + ), + source: `${this.name}.processStreamChunks`, + toolCallId: toolCall.id, + toolName: toolCall.name, + rawArguments: toolCall.arguments, + }, + ) parsedInput = {} } } - yield asChunk({ - type: 'TOOL_CALL_END', + yield { + type: EventType.TOOL_CALL_END, toolCallId: toolCall.id, toolCallName: toolCall.name, toolName: toolCall.name, model: lastModel || options.model, - timestamp, + timestamp: Date.now(), input: parsedInput, - }) + } satisfies StreamChunk pendingToolCount += 1 emittedAnyToolCallEnd = true } @@ -518,12 +683,40 @@ export class OpenAICompatibleChatCompletionsTextAdapter< // Make sure the text message lifecycle is closed even on early // termination paths where finish_reason never arrives. if (hasEmittedTextMessageStart) { - yield asChunk({ - type: 'TEXT_MESSAGE_END', + yield { + type: EventType.TEXT_MESSAGE_END, messageId: aguiState.messageId, model: lastModel || options.model, - timestamp, - }) + timestamp: Date.now(), + } satisfies StreamChunk + } + + // Close any reasoning lifecycle that text never closed (no text + // content arrived, or the stream cut off before text started). + if (reasoningMessageId && !hasClosedReasoning) { + hasClosedReasoning = true + yield { + type: EventType.REASONING_MESSAGE_END, + messageId: reasoningMessageId, + model: lastModel || options.model, + timestamp: Date.now(), + } satisfies StreamChunk + yield { + type: EventType.REASONING_END, + messageId: reasoningMessageId, + model: lastModel || options.model, + timestamp: Date.now(), + } satisfies StreamChunk + if (stepId) { + yield { + type: EventType.STEP_FINISHED, + stepName: stepId, + stepId, + model: lastModel || options.model, + timestamp: Date.now(), + content: accumulatedReasoning, + } satisfies StreamChunk + } } // Map upstream finish_reason to AG-UI's narrower vocabulary while @@ -534,17 +727,18 @@ export class OpenAICompatibleChatCompletionsTextAdapter< // `tool_calls` but never produced a started/ended pair must NOT // surface `tool_calls` here, since downstream consumers wait for // tool results that would never arrive. - const finishReason: string = emittedAnyToolCallEnd + const finishReason = emittedAnyToolCallEnd ? 'tool_calls' : pendingFinishReason === 'tool_calls' ? 'stop' : (pendingFinishReason ?? 'stop') - yield asChunk({ - type: 'RUN_FINISHED', + yield { + type: EventType.RUN_FINISHED, runId: aguiState.runId, + threadId: aguiState.threadId, model: lastModel || options.model, - timestamp, + timestamp: Date.now(), usage: lastUsage ? { promptTokens: lastUsage.prompt_tokens || 0, @@ -553,7 +747,7 @@ export class OpenAICompatibleChatCompletionsTextAdapter< } : undefined, finishReason, - }) + } satisfies StreamChunk } } catch (error: unknown) { // Narrow before logging: raw SDK errors can carry request metadata @@ -568,13 +762,14 @@ export class OpenAICompatibleChatCompletionsTextAdapter< }) // Emit AG-UI RUN_ERROR - yield asChunk({ - type: 'RUN_ERROR', - runId: aguiState.runId, + yield { + type: EventType.RUN_ERROR, model: options.model, - timestamp, + timestamp: Date.now(), + message: errorPayload.message, + code: errorPayload.code, error: errorPayload, - }) + } satisfies StreamChunk } } diff --git a/packages/typescript/openai-base/src/adapters/responses-text.ts b/packages/typescript/openai-base/src/adapters/responses-text.ts index 48faadd21..93c48bcc6 100644 --- a/packages/typescript/openai-base/src/adapters/responses-text.ts +++ b/packages/typescript/openai-base/src/adapters/responses-text.ts @@ -1,3 +1,4 @@ +import { EventType } from '@tanstack/ai' import { BaseTextAdapter } from '@tanstack/ai/adapters' import { toRunErrorPayload } from '@tanstack/ai/adapter-internals' import { generateId, transformNullsToUndefined } from '@tanstack/ai-utils' @@ -21,11 +22,6 @@ import type { } from '@tanstack/ai' import type { OpenAICompatibleClientConfig } from '../types/config' -/** Cast an event object to StreamChunk. Adapters construct events with string - * literal types which are structurally compatible with the EventType enum. */ -const asChunk = (chunk: Record) => - chunk as unknown as StreamChunk - /** * OpenAI-compatible Responses API Text Adapter * @@ -90,13 +86,12 @@ export class OpenAICompatibleResponsesTextAdapter< { index: number; name: string; started: boolean } >() const requestParams = this.mapOptionsToRequest(options) - const timestamp = Date.now() // AG-UI lifecycle tracking const aguiState = { runId: generateId(this.name), + threadId: options.threadId ?? generateId(this.name), messageId: generateId(this.name), - timestamp, hasEmittedRunStarted: false, } @@ -105,7 +100,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, @@ -130,22 +125,24 @@ export class OpenAICompatibleResponsesTextAdapter< // Emit RUN_STARTED if not yet emitted if (!aguiState.hasEmittedRunStarted) { aguiState.hasEmittedRunStarted = true - yield asChunk({ - type: 'RUN_STARTED', + yield { + type: EventType.RUN_STARTED, runId: aguiState.runId, + threadId: aguiState.threadId, model: options.model, - timestamp, - }) + timestamp: Date.now(), + } satisfies StreamChunk } // Emit AG-UI RUN_ERROR - yield asChunk({ - type: 'RUN_ERROR', - runId: aguiState.runId, + yield { + type: EventType.RUN_ERROR, model: options.model, - timestamp, + timestamp: Date.now(), + message: errorPayload.message, + code: errorPayload.code, error: errorPayload, - }) + } satisfies StreamChunk options.logger.errors(`${this.name}.chatStream fatal`, { error: errorPayload, @@ -193,7 +190,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, @@ -231,9 +228,13 @@ export class OpenAICompatibleResponsesTextAdapter< ) } - // Transform null values to undefined to match original Zod schema expectations - // Provider returns null for optional fields we made nullable in the schema - const transformed = transformNullsToUndefined(parsed) + // Apply the provider-specific post-parse shaping (default: null → + // undefined to align with the original Zod schema's optional-field + // semantics; subclasses with different conventions can override + // `transformStructuredOutput`, mirroring the chat-completions base's + // hook so OpenRouter and other providers that preserve nulls in + // structured output can opt out without forking `structuredOutput`). + const transformed = this.transformStructuredOutput(parsed) return { data: transformed, @@ -261,6 +262,52 @@ export class OpenAICompatibleResponsesTextAdapter< return makeStructuredOutputCompatible(schema, originalRequired) } + /** + * Final shaping pass applied to parsed structured-output JSON before it is + * returned to the caller. Default converts `null` values to `undefined` so + * the result aligns with the original Zod schema's optional-field + * semantics. Subclasses with different conventions (OpenRouter historically + * preserves nulls) can override — mirrors the chat-completions base's hook + * so a subclass that opts out of null-stripping doesn't have to fork the + * whole `structuredOutput` method. + */ + protected transformStructuredOutput(parsed: unknown): unknown { + return transformNullsToUndefined(parsed) + } + + /** + * 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. @@ -322,14 +369,13 @@ export class OpenAICompatibleResponsesTextAdapter< options: TextOptions, aguiState: { runId: string + threadId: string messageId: string - timestamp: number hasEmittedRunStarted: boolean }, ): AsyncIterable { let accumulatedContent = '' let accumulatedReasoning = '' - const timestamp = aguiState.timestamp // Track if we've been streaming deltas to avoid duplicating content from done events let hasStreamedContentDeltas = false @@ -357,12 +403,13 @@ export class OpenAICompatibleResponsesTextAdapter< // Emit RUN_STARTED on first chunk if (!aguiState.hasEmittedRunStarted) { aguiState.hasEmittedRunStarted = true - yield asChunk({ - type: 'RUN_STARTED', + yield { + type: EventType.RUN_STARTED, runId: aguiState.runId, + threadId: aguiState.threadId, model: model || options.model, - timestamp, - }) + timestamp: Date.now(), + } satisfies StreamChunk } const handleContentPart = (contentPart: { @@ -372,14 +419,14 @@ export class OpenAICompatibleResponsesTextAdapter< }): StreamChunk => { if (contentPart.type === 'output_text') { accumulatedContent += contentPart.text || '' - return asChunk({ - type: 'TEXT_MESSAGE_CONTENT', + return { + type: EventType.TEXT_MESSAGE_CONTENT, messageId: aguiState.messageId, model: model || options.model, - timestamp, + timestamp: Date.now(), delta: contentPart.text || '', content: accumulatedContent, - }) + } satisfies StreamChunk } if (contentPart.type === 'reasoning_text') { @@ -391,14 +438,15 @@ export class OpenAICompatibleResponsesTextAdapter< if (!stepId) { stepId = generateId(this.name) } - return asChunk({ - type: 'STEP_FINISHED', + return { + type: EventType.STEP_FINISHED, + stepName: stepId, stepId, model: model || options.model, - timestamp, + timestamp: Date.now(), delta: contentPart.text || '', content: accumulatedReasoning, - }) + } satisfies StreamChunk } // Either a real refusal or an unknown content_part type. Surface // the part type in the error so unknown parts are debuggable @@ -407,16 +455,15 @@ export class OpenAICompatibleResponsesTextAdapter< const message = isRefusal ? contentPart.refusal || 'Refused without explanation' : `Unsupported response content_part type: ${contentPart.type}` - return asChunk({ - type: 'RUN_ERROR', - runId: aguiState.runId, + const code = isRefusal ? 'refusal' : contentPart.type + return { + type: EventType.RUN_ERROR, model: model || options.model, - timestamp, - error: { - message, - code: isRefusal ? 'refusal' : contentPart.type, - }, - }) + timestamp: Date.now(), + message, + code, + error: { message, code }, + } satisfies StreamChunk } // Capture model metadata from any of these events (created starts @@ -451,12 +498,12 @@ export class OpenAICompatibleResponsesTextAdapter< chunk.type === 'response.incomplete' ) { if (hasEmittedTextMessageStart) { - yield asChunk({ - type: 'TEXT_MESSAGE_END', + yield { + type: EventType.TEXT_MESSAGE_END, messageId: aguiState.messageId, model: chunk.response.model, - timestamp, - }) + timestamp: Date.now(), + } satisfies StreamChunk hasEmittedTextMessageStart = false } // Coalesce error + incomplete_details into a single RUN_ERROR @@ -469,23 +516,25 @@ export class OpenAICompatibleResponsesTextAdapter< ? 'Response failed' : 'Response ended incomplete') const errorCode = - chunk.response.error?.code || - (chunk.response.incomplete_details ? 'incomplete' : undefined) + chunk.response.error?.code ?? + (chunk.response.incomplete_details ? 'incomplete' : undefined) ?? + undefined // Always emit RUN_ERROR for terminal failure events, even when the // upstream omitted both `error` and `incomplete_details`. Skipping // emission on a `response.incomplete` with no detail would let the // post-loop synthetic block silently coerce the run to a clean // `RUN_FINISHED { finishReason: 'stop' }` — masking the failure. - yield asChunk({ - type: 'RUN_ERROR', - runId: aguiState.runId, + yield { + type: EventType.RUN_ERROR, model: chunk.response.model, - timestamp, + timestamp: Date.now(), + message: errorMessage, + ...(errorCode !== undefined && { code: errorCode }), error: { message: errorMessage, ...(errorCode !== undefined && { code: errorCode }), }, - }) + } satisfies StreamChunk // RUN_ERROR is the terminal event for this run; stop processing // any further chunks the iterator might still deliver. runFinishedEmitted = true @@ -506,25 +555,25 @@ export class OpenAICompatibleResponsesTextAdapter< // Emit TEXT_MESSAGE_START on first text content if (!hasEmittedTextMessageStart) { hasEmittedTextMessageStart = true - yield asChunk({ - type: 'TEXT_MESSAGE_START', + yield { + type: EventType.TEXT_MESSAGE_START, messageId: aguiState.messageId, model: model || options.model, - timestamp, + timestamp: Date.now(), role: 'assistant', - }) + } satisfies StreamChunk } accumulatedContent += textDelta hasStreamedContentDeltas = true - yield asChunk({ - type: 'TEXT_MESSAGE_CONTENT', + yield { + type: EventType.TEXT_MESSAGE_CONTENT, messageId: aguiState.messageId, model: model || options.model, - timestamp, + timestamp: Date.now(), delta: textDelta, content: accumulatedContent, - }) + } satisfies StreamChunk } } @@ -543,25 +592,28 @@ export class OpenAICompatibleResponsesTextAdapter< if (!hasEmittedStepStarted) { hasEmittedStepStarted = true stepId = generateId(this.name) - yield asChunk({ - type: 'STEP_STARTED', + yield { + type: EventType.STEP_STARTED, + stepName: stepId, stepId, model: model || options.model, - timestamp, + timestamp: Date.now(), stepType: 'thinking', - }) + } satisfies StreamChunk } accumulatedReasoning += reasoningDelta hasStreamedReasoningDeltas = true - yield asChunk({ - type: 'STEP_FINISHED', - stepId: stepId || generateId(this.name), + const fallbackStepId = stepId || generateId(this.name) + yield { + type: EventType.STEP_FINISHED, + stepName: fallbackStepId, + stepId: fallbackStepId, model: model || options.model, - timestamp, + timestamp: Date.now(), delta: reasoningDelta, content: accumulatedReasoning, - }) + } satisfies StreamChunk } } @@ -579,25 +631,28 @@ export class OpenAICompatibleResponsesTextAdapter< if (!hasEmittedStepStarted) { hasEmittedStepStarted = true stepId = generateId(this.name) - yield asChunk({ - type: 'STEP_STARTED', + yield { + type: EventType.STEP_STARTED, + stepName: stepId, stepId, model: model || options.model, - timestamp, + timestamp: Date.now(), stepType: 'thinking', - }) + } satisfies StreamChunk } accumulatedReasoning += summaryDelta hasStreamedReasoningDeltas = true - yield asChunk({ - type: 'STEP_FINISHED', - stepId: stepId || generateId(this.name), + const fallbackStepId = stepId || generateId(this.name) + yield { + type: EventType.STEP_FINISHED, + stepName: fallbackStepId, + stepId: fallbackStepId, model: model || options.model, - timestamp, + timestamp: Date.now(), delta: summaryDelta, content: accumulatedReasoning, - }) + } satisfies StreamChunk } } @@ -610,25 +665,26 @@ export class OpenAICompatibleResponsesTextAdapter< !hasEmittedTextMessageStart ) { hasEmittedTextMessageStart = true - yield asChunk({ - type: 'TEXT_MESSAGE_START', + yield { + type: EventType.TEXT_MESSAGE_START, messageId: aguiState.messageId, model: model || options.model, - timestamp, + timestamp: Date.now(), role: 'assistant', - }) + } satisfies StreamChunk } // Emit STEP_STARTED if this is reasoning content if (contentPart.type === 'reasoning_text' && !hasEmittedStepStarted) { hasEmittedStepStarted = true stepId = generateId(this.name) - yield asChunk({ - type: 'STEP_STARTED', + yield { + type: EventType.STEP_STARTED, + stepName: stepId, stepId, model: model || options.model, - timestamp, + timestamp: Date.now(), stepType: 'thinking', - }) + } satisfies StreamChunk } // Mark whichever stream we just emitted into so a subsequent // `content_part.done` doesn't duplicate the same text. Without @@ -693,15 +749,15 @@ export class OpenAICompatibleResponsesTextAdapter< started: false, }) } - yield asChunk({ - type: 'TOOL_CALL_START', + yield { + type: EventType.TOOL_CALL_START, toolCallId: item.id, toolCallName: item.name || '', toolName: item.name || '', model: model || options.model, - timestamp, + timestamp: Date.now(), index: chunk.output_index, - }) + } satisfies StreamChunk toolCallMetadata.get(item.id)!.started = true } } @@ -735,13 +791,13 @@ export class OpenAICompatibleResponsesTextAdapter< ) continue } - yield asChunk({ - type: 'TOOL_CALL_ARGS', + yield { + type: EventType.TOOL_CALL_ARGS, toolCallId: chunk.item_id, model: model || options.model, - timestamp, + timestamp: Date.now(), delta: chunk.delta, - }) + } satisfies StreamChunk } if (chunk.type === 'response.function_call_arguments.done') { @@ -792,26 +848,26 @@ export class OpenAICompatibleResponsesTextAdapter< } } - yield asChunk({ - type: 'TOOL_CALL_END', + yield { + type: EventType.TOOL_CALL_END, toolCallId: item_id, toolCallName: name, toolName: name, model: model || options.model, - timestamp, + timestamp: Date.now(), input: parsedInput, - }) + } satisfies StreamChunk } if (chunk.type === 'response.completed') { // Emit TEXT_MESSAGE_END if we had text content if (hasEmittedTextMessageStart) { - yield asChunk({ - type: 'TEXT_MESSAGE_END', + yield { + type: EventType.TEXT_MESSAGE_END, messageId: aguiState.messageId, model: model || options.model, - timestamp, - }) + timestamp: Date.now(), + } satisfies StreamChunk hasEmittedTextMessageStart = false } @@ -819,43 +875,62 @@ export class OpenAICompatibleResponsesTextAdapter< // Otherwise surface incomplete_details.reason when present so // callers can distinguish length-limit / content-filter cutoffs // from a clean stop, mirroring the chat-completions adapter. + // The Responses API's incomplete_details.reason ('max_output_tokens' + // | 'content_filter') maps to the AG-UI finishReason vocabulary: + // max_output_tokens → 'length', content_filter → 'content_filter'. const hasFunctionCalls = chunk.response.output.some( (item: unknown) => (item as { type: string }).type === 'function_call', ) - const finishReason: string = hasFunctionCalls + const incompleteReason = chunk.response.incomplete_details?.reason + const finishReason: + | 'tool_calls' + | 'length' + | 'content_filter' + | 'stop' = hasFunctionCalls ? 'tool_calls' - : (chunk.response.incomplete_details?.reason ?? 'stop') - - yield asChunk({ - type: 'RUN_FINISHED', + : incompleteReason === 'max_output_tokens' + ? 'length' + : incompleteReason === 'content_filter' + ? 'content_filter' + : 'stop' + + yield { + type: EventType.RUN_FINISHED, runId: aguiState.runId, + threadId: aguiState.threadId, model: model || options.model, - timestamp, + timestamp: Date.now(), usage: { promptTokens: chunk.response.usage?.input_tokens || 0, completionTokens: chunk.response.usage?.output_tokens || 0, totalTokens: chunk.response.usage?.total_tokens || 0, }, finishReason, - }) + } satisfies StreamChunk runFinishedEmitted = true } if (chunk.type === 'error') { - yield asChunk({ - type: 'RUN_ERROR', - runId: aguiState.runId, + yield { + type: EventType.RUN_ERROR, model: model || options.model, - timestamp, + timestamp: Date.now(), + message: chunk.message, + code: chunk.code ?? undefined, error: { message: chunk.message, code: chunk.code ?? undefined, }, - }) + } satisfies StreamChunk // RUN_ERROR is terminal — don't let the synthetic RUN_FINISHED - // block fire after a top-level stream error event. + // block fire after a top-level stream error event, and stop + // processing further chunks so no in-flight lifecycle events + // (TEXT_MESSAGE_CONTENT, TOOL_CALL_*) leak past the terminal + // error. Mirrors the `response.failed` / `response.incomplete` + // branches above which return after their RUN_ERROR emission. runFinishedEmitted = true + return } } @@ -865,21 +940,22 @@ export class OpenAICompatibleResponsesTextAdapter< // see a terminal event for every started run. if (!runFinishedEmitted && aguiState.hasEmittedRunStarted) { if (hasEmittedTextMessageStart) { - yield asChunk({ - type: 'TEXT_MESSAGE_END', + yield { + type: EventType.TEXT_MESSAGE_END, messageId: aguiState.messageId, model: model || options.model, - timestamp, - }) + timestamp: Date.now(), + } satisfies StreamChunk } - yield asChunk({ - type: 'RUN_FINISHED', + yield { + type: EventType.RUN_FINISHED, runId: aguiState.runId, + threadId: aguiState.threadId, model: model || options.model, - timestamp, + timestamp: Date.now(), usage: undefined, finishReason: toolCallMetadata.size > 0 ? 'tool_calls' : 'stop', - }) + } satisfies StreamChunk } } catch (error: unknown) { // Narrow before logging: raw SDK errors can carry request metadata @@ -892,13 +968,14 @@ export class OpenAICompatibleResponsesTextAdapter< error: errorPayload, source: `${this.name}.processStreamChunks`, }) - yield asChunk({ - type: 'RUN_ERROR', - runId: aguiState.runId, + yield { + type: EventType.RUN_ERROR, model: options.model, - timestamp, + timestamp: Date.now(), + message: errorPayload.message, + code: errorPayload.code, error: errorPayload, - }) + } satisfies StreamChunk } } diff --git a/packages/typescript/openai-base/src/index.ts b/packages/typescript/openai-base/src/index.ts index ab15140ea..22b3f429a 100644 --- a/packages/typescript/openai-base/src/index.ts +++ b/packages/typescript/openai-base/src/index.ts @@ -3,12 +3,33 @@ 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, 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/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', () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cc34515d4..07a916d8b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -642,7 +642,7 @@ importers: version: 7.3.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) vitest: specifier: ^4.0.14 - version: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.0.14)(happy-dom@20.0.11)(jsdom@27.3.0(postcss@8.5.9))(vite@7.3.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jsdom@27.3.0(postcss@8.5.9))(vite@7.3.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) web-vitals: specifier: ^5.1.0 version: 5.1.0 @@ -944,7 +944,7 @@ importers: version: 1.1.0 '@vitest/coverage-v8': specifier: 4.0.14 - version: 4.0.14(vitest@4.1.4) + version: 4.0.14(vitest@4.0.14(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) zod: specifier: ^4.2.0 version: 4.2.1 @@ -963,7 +963,7 @@ importers: version: link:../ai '@vitest/coverage-v8': specifier: 4.0.14 - version: 4.0.14(vitest@4.1.4) + version: 4.0.14(vitest@4.0.14(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) zod: specifier: ^4.2.0 version: 4.2.1 @@ -982,7 +982,7 @@ importers: version: 1.1.0 '@vitest/coverage-v8': specifier: 4.0.14 - version: 4.0.14(vitest@4.1.4) + version: 4.0.14(vitest@4.0.14(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) vite: specifier: ^7.2.7 version: 7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) @@ -1001,7 +1001,7 @@ importers: version: link:../ai '@vitest/coverage-v8': specifier: 4.0.14 - version: 4.0.14(vitest@4.1.4) + version: 4.0.14(vitest@4.0.14(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) zod: specifier: ^4.2.0 version: 4.3.6 @@ -1029,7 +1029,7 @@ importers: version: link:../ai-openai '@vitest/coverage-v8': specifier: 4.0.14 - version: 4.0.14(vitest@4.1.4) + version: 4.0.14(vitest@4.0.14(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) commander: specifier: ^13.1.0 version: 13.1.0 @@ -1106,7 +1106,7 @@ importers: devDependencies: '@vitest/coverage-v8': specifier: 4.0.14 - version: 4.0.14(vitest@4.1.4) + version: 4.0.14(vitest@4.0.14(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) jsdom: specifier: ^27.2.0 version: 27.3.0(postcss@8.5.9) @@ -1143,7 +1143,7 @@ importers: version: link:../ai-client '@vitest/coverage-v8': specifier: 4.0.14 - version: 4.0.14(vitest@4.1.4) + version: 4.0.14(vitest@4.0.14(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) packages/typescript/ai-event-client: dependencies: @@ -1156,7 +1156,7 @@ importers: devDependencies: '@vitest/coverage-v8': specifier: 4.0.14 - version: 4.0.14(vitest@4.1.4) + version: 4.0.14(vitest@4.0.14(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) packages/typescript/ai-fal: dependencies: @@ -1172,7 +1172,7 @@ importers: version: link:../ai '@vitest/coverage-v8': specifier: 4.0.14 - version: 4.0.14(vitest@4.1.4) + version: 4.0.14(vitest@4.0.14(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) vite: specifier: ^7.2.7 version: 7.3.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) @@ -1191,7 +1191,7 @@ importers: version: link:../ai '@vitest/coverage-v8': specifier: 4.0.14 - version: 4.0.14(vitest@4.1.4) + version: 4.0.14(vitest@4.0.14(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) vite: specifier: ^7.2.7 version: 7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) @@ -1219,32 +1219,29 @@ importers: version: link:../ai-client '@vitest/coverage-v8': specifier: 4.0.14 - version: 4.0.14(vitest@4.1.4) + version: 4.0.14(vitest@4.0.14(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) vite: specifier: ^7.2.7 version: 7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) packages/typescript/ai-groq: dependencies: - '@tanstack/ai': - specifier: workspace:^ - version: link:../ai '@tanstack/ai-utils': specifier: workspace:* version: link:../ai-utils '@tanstack/openai-base': specifier: workspace:* version: link:../openai-base - groq-sdk: - specifier: ^0.37.0 - version: 0.37.0 zod: specifier: ^4.0.0 version: 4.3.6 devDependencies: + '@tanstack/ai': + specifier: workspace:* + version: link:../ai '@vitest/coverage-v8': specifier: 4.0.14 - version: 4.0.14(vitest@4.1.4) + version: 4.0.14(vitest@4.0.14(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) vite: specifier: ^7.2.7 version: 7.3.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) @@ -1260,7 +1257,7 @@ importers: version: 4.20260317.1 '@vitest/coverage-v8': specifier: 4.0.14 - version: 4.0.14(vitest@4.1.4) + version: 4.0.14(vitest@4.0.14(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) wrangler: specifier: ^4.88.0 version: 4.88.0(@cloudflare/workers-types@4.20260317.1) @@ -1276,7 +1273,7 @@ importers: devDependencies: '@vitest/coverage-v8': specifier: 4.0.14 - version: 4.0.14(vitest@4.1.4) + version: 4.0.14(vitest@4.0.14(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) packages/typescript/ai-isolate-quickjs: dependencies: @@ -1289,7 +1286,7 @@ importers: devDependencies: '@vitest/coverage-v8': specifier: 4.0.14 - version: 4.0.14(vitest@4.1.4) + version: 4.0.14(vitest@4.0.14(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) packages/typescript/ai-ollama: dependencies: @@ -1305,7 +1302,7 @@ importers: version: link:../ai '@vitest/coverage-v8': specifier: 4.0.14 - version: 4.0.14(vitest@4.1.4) + version: 4.0.14(vitest@4.0.14(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) vite: specifier: ^7.2.7 version: 7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) @@ -1330,7 +1327,7 @@ importers: version: link:../ai-client '@vitest/coverage-v8': specifier: 4.0.14 - version: 4.0.14(vitest@4.1.4) + version: 4.0.14(vitest@4.0.14(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) vite: specifier: ^7.2.7 version: 7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) @@ -1346,13 +1343,16 @@ importers: '@tanstack/ai-utils': specifier: workspace:* version: link:../ai-utils + '@tanstack/openai-base': + specifier: workspace:* + version: link:../openai-base devDependencies: '@tanstack/ai': specifier: workspace:* version: link:../ai '@vitest/coverage-v8': specifier: 4.0.14 - version: 4.0.14(vitest@4.1.4) + version: 4.0.14(vitest@4.0.14(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) vite: specifier: ^7.2.7 version: 7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) @@ -1374,7 +1374,7 @@ importers: version: 3.2.4(preact@10.28.2) '@vitest/coverage-v8': specifier: 4.0.14 - version: 4.0.14(vitest@4.1.4) + version: 4.0.14(vitest@4.0.14(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) jsdom: specifier: ^27.2.0 version: 27.3.0(postcss@8.5.9) @@ -1402,7 +1402,7 @@ importers: version: 19.2.7 '@vitest/coverage-v8': specifier: 4.0.14 - version: 4.0.14(vitest@4.1.4) + version: 4.0.14(vitest@4.0.14(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) jsdom: specifier: ^27.2.0 version: 27.3.0(postcss@8.5.9) @@ -1442,7 +1442,7 @@ importers: version: 19.2.7 '@vitest/coverage-v8': specifier: 4.0.14 - version: 4.0.14(vitest@4.1.4) + version: 4.0.14(vitest@4.0.14(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) react: specifier: ^19.2.3 version: 19.2.3 @@ -1513,7 +1513,7 @@ importers: version: link:../ai-solid '@vitest/coverage-v8': specifier: 4.0.14 - version: 4.0.14(vitest@4.1.4) + version: 4.0.14(vitest@4.0.14(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) solid-js: specifier: ^1.9.10 version: 1.9.10 @@ -1541,7 +1541,7 @@ importers: version: 24.10.3 '@vitest/coverage-v8': specifier: 4.0.14 - version: 4.0.14(vitest@4.1.4) + version: 4.0.14(vitest@4.0.14(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) jsdom: specifier: ^27.2.0 version: 27.3.0(postcss@8.5.9) @@ -1565,7 +1565,7 @@ importers: version: 24.10.3 '@vitest/coverage-v8': specifier: 4.0.14 - version: 4.0.14(vitest@4.1.4) + version: 4.0.14(vitest@4.0.14(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) vite: specifier: ^7.2.7 version: 7.3.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) @@ -1630,7 +1630,7 @@ importers: version: 6.0.3(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.25(typescript@5.9.3)) '@vitest/coverage-v8': specifier: 4.0.14 - version: 4.0.14(vitest@4.1.4) + version: 4.0.14(vitest@4.0.14(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) vite: specifier: ^7.2.7 version: 7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) @@ -1655,7 +1655,7 @@ importers: version: link:../ai '@vitest/coverage-v8': specifier: 4.0.14 - version: 4.0.14(vitest@4.1.4) + version: 4.0.14(vitest@4.0.14(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) vite: specifier: ^7.2.7 version: 7.3.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) @@ -1674,7 +1674,7 @@ importers: devDependencies: '@vitest/coverage-v8': specifier: 4.0.14 - version: 4.0.14(vitest@4.1.4) + version: 4.0.14(vitest@4.0.14(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) vite: specifier: ^7.2.7 version: 7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) @@ -1693,7 +1693,7 @@ importers: version: 19.2.7 '@vitest/coverage-v8': specifier: 4.0.14 - version: 4.0.14(vitest@4.1.4) + version: 4.0.14(vitest@4.0.14(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) react: specifier: ^19.2.3 version: 19.2.3 @@ -1712,7 +1712,7 @@ importers: devDependencies: '@vitest/coverage-v8': specifier: 4.0.14 - version: 4.0.14(vitest@4.1.4) + version: 4.0.14(vitest@4.0.14(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) solid-js: specifier: ^1.9.10 version: 1.9.10 @@ -6511,15 +6511,9 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} - '@types/node-fetch@2.6.13': - resolution: {integrity: sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==} - '@types/node@12.20.55': resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} - '@types/node@18.19.130': - resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==} - '@types/node@20.19.26': resolution: {integrity: sha512-0l6cjgF0XnihUpndDhk+nyD3exio3iKaYROSgvh/qSevPXax3L8p5DBRFjbvalnwatGgHEQn2R88y2fA3g4irg==} @@ -6763,12 +6757,26 @@ packages: '@vitest/browser': optional: true + '@vitest/expect@4.0.14': + resolution: {integrity: sha512-RHk63V3zvRiYOWAV0rGEBRO820ce17hz7cI2kDmEdfQsBjT2luEKB5tCOc91u1oSQoUOZkSv3ZyzkdkSLD7lKw==} + '@vitest/expect@4.0.15': resolution: {integrity: sha512-Gfyva9/GxPAWXIWjyGDli9O+waHDC0Q0jaLdFP1qPAUUfo1FEXPXUfUkp3eZA0sSq340vPycSyOlYUeM15Ft1w==} '@vitest/expect@4.1.4': resolution: {integrity: sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww==} + '@vitest/mocker@4.0.14': + resolution: {integrity: sha512-RzS5NujlCzeRPF1MK7MXLiEFpkIXeMdQ+rN3Kk3tDI9j0mtbr7Nmuq67tpkOJQpgyClbOltCXMjLZicJHsH5Cg==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + '@vitest/mocker@4.0.15': resolution: {integrity: sha512-CZ28GLfOEIFkvCFngN8Sfx5h+Se0zN+h4B7yOsPVCcgtiO7t5jt9xQh2E1UkFep+eb9fjyMfuC5gBypwb07fvQ==} peerDependencies: @@ -6800,18 +6808,27 @@ packages: '@vitest/pretty-format@4.1.4': resolution: {integrity: sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==} + '@vitest/runner@4.0.14': + resolution: {integrity: sha512-BsAIk3FAqxICqREbX8SetIteT8PiaUL/tgJjmhxJhCsigmzzH8xeadtp7LRnTpCVzvf0ib9BgAfKJHuhNllKLw==} + '@vitest/runner@4.0.15': resolution: {integrity: sha512-+A+yMY8dGixUhHmNdPUxOh0la6uVzun86vAbuMT3hIDxMrAOmn5ILBHm8ajrqHE0t8R9T1dGnde1A5DTnmi3qw==} '@vitest/runner@4.1.4': resolution: {integrity: sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ==} + '@vitest/snapshot@4.0.14': + resolution: {integrity: sha512-aQVBfT1PMzDSA16Y3Fp45a0q8nKexx6N5Amw3MX55BeTeZpoC08fGqEZqVmPcqN0ueZsuUQ9rriPMhZ3Mu19Ag==} + '@vitest/snapshot@4.0.15': resolution: {integrity: sha512-A7Ob8EdFZJIBjLjeO0DZF4lqR6U7Ydi5/5LIZ0xcI+23lYlsYJAfGn8PrIWTYdZQRNnSRlzhg0zyGu37mVdy5g==} '@vitest/snapshot@4.1.4': resolution: {integrity: sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw==} + '@vitest/spy@4.0.14': + resolution: {integrity: sha512-JmAZT1UtZooO0tpY3GRyiC/8W7dCs05UOq9rfsUUgEZEdq+DuHLmWhPsrTt0TiW7WYeL/hXpaE07AZ2RCk44hg==} + '@vitest/spy@4.0.15': resolution: {integrity: sha512-+EIjOJmnY6mIfdXtE/bnozKEvTC4Uczg19yeZ2vtCz5Yyb0QQ31QWVQ8hswJ3Ysx/K2EqaNsVanjr//2+P3FHw==} @@ -6945,10 +6962,6 @@ packages: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} - agentkeepalive@4.6.0: - resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==} - engines: {node: '>= 8.0.0'} - ajv-draft-04@1.0.0: resolution: {integrity: sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==} peerDependencies: @@ -8306,9 +8319,6 @@ packages: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} - form-data-encoder@1.7.2: - resolution: {integrity: sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==} - form-data@4.0.5: resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} engines: {node: '>= 6'} @@ -8318,10 +8328,6 @@ packages: engines: {node: '>=18.3.0'} hasBin: true - formdata-node@4.4.1: - resolution: {integrity: sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==} - engines: {node: '>= 12.20'} - formdata-polyfill@4.0.10: resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} engines: {node: '>=12.20.0'} @@ -8503,9 +8509,6 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - groq-sdk@0.37.0: - resolution: {integrity: sha512-lT72pcT8b/X5XrzdKf+rWVzUGW1OQSKESmL8fFN5cTbsf02gq6oFam4SVeNtzELt9cYE2Pt3pdGgSImuTbHFDg==} - gtoken@8.0.0: resolution: {integrity: sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==} engines: {node: '>=18'} @@ -8684,9 +8687,6 @@ packages: resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==} engines: {node: '>=16.17.0'} - humanize-ms@1.2.1: - resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==} - iconv-lite@0.6.3: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} @@ -11305,9 +11305,6 @@ packages: unctx@2.5.0: resolution: {integrity: sha512-p+Rz9x0R7X+CYDkT+Xg8/GhpcShTlU8n+cf9OtOEf7zEQsNcCZO1dPKNRDqvUTaq+P32PMMkxWHwfrxkqfqAYg==} - undici-types@5.26.5: - resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} - undici-types@5.28.4: resolution: {integrity: sha512-3OeMF5Lyowe8VW0skf5qaIE7Or3yS9LS7fvMUI0gg4YxpIBVg0L8BxCmROw2CcYhSkpR68Epz7CGc8MPj94Uww==} @@ -11855,6 +11852,40 @@ packages: vite: optional: true + vitest@4.0.14: + resolution: {integrity: sha512-d9B2J9Cm9dN9+6nxMnnNJKJCtcyKfnHj15N6YNJfaFHRLua/d3sRKU9RuKmO9mB0XdFtUizlxfz/VPbd3OxGhw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.0.14 + '@vitest/browser-preview': 4.0.14 + '@vitest/browser-webdriverio': 4.0.14 + '@vitest/ui': 4.0.14 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + vitest@4.0.15: resolution: {integrity: sha512-n1RxDp8UJm6N0IbJLQo+yzLZ2sQCDyl1o0LeugbPWf8+8Fttp29GghsQBjYJVmWq3gBFfe9Hs1spR44vovn2wA==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -11979,10 +12010,6 @@ packages: resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} engines: {node: '>= 8'} - web-streams-polyfill@4.0.0-beta.3: - resolution: {integrity: sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==} - engines: {node: '>= 14'} - web-vitals@5.1.0: resolution: {integrity: sha512-ArI3kx5jI0atlTtmV0fWU3fjpLmq/nD3Zr1iFFlJLaqa5wLBkUSzINwBPySCX/8jRyjlmy1Volw1kz1g9XE4Jg==} @@ -17022,17 +17049,8 @@ snapshots: '@types/ms@2.1.0': {} - '@types/node-fetch@2.6.13': - dependencies: - '@types/node': 24.10.3 - form-data: 4.0.5 - '@types/node@12.20.55': {} - '@types/node@18.19.130': - dependencies: - undici-types: 5.26.5 - '@types/node@20.19.26': dependencies: undici-types: 6.21.0 @@ -17331,7 +17349,7 @@ snapshots: vite: 7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) vue: 3.5.25(typescript@5.9.3) - '@vitest/coverage-v8@4.0.14(vitest@4.0.15(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + '@vitest/coverage-v8@4.0.14(vitest@4.0.14(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@bcoe/v8-coverage': 1.0.2 '@vitest/utils': 4.0.14 @@ -17344,11 +17362,11 @@ snapshots: obug: 2.1.1 std-env: 3.10.0 tinyrainbow: 3.1.0 - vitest: 4.0.15(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vitest: 4.0.14(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - supports-color - '@vitest/coverage-v8@4.0.14(vitest@4.1.4)': + '@vitest/coverage-v8@4.0.14(vitest@4.0.15(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@bcoe/v8-coverage': 1.0.2 '@vitest/utils': 4.0.14 @@ -17361,10 +17379,19 @@ snapshots: obug: 2.1.1 std-env: 3.10.0 tinyrainbow: 3.1.0 - vitest: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.0.14)(happy-dom@20.0.11)(jsdom@27.3.0(postcss@8.5.9))(vite@7.3.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + vitest: 4.0.15(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - supports-color + '@vitest/expect@4.0.14': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.0.14 + '@vitest/utils': 4.0.14 + chai: 6.2.2 + tinyrainbow: 3.1.0 + '@vitest/expect@4.0.15': dependencies: '@standard-schema/spec': 1.1.0 @@ -17383,6 +17410,14 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 + '@vitest/mocker@4.0.14(vite@7.3.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + dependencies: + '@vitest/spy': 4.0.14 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + '@vitest/mocker@4.0.15(vite@7.3.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@vitest/spy': 4.0.15 @@ -17411,6 +17446,11 @@ snapshots: dependencies: tinyrainbow: 3.1.0 + '@vitest/runner@4.0.14': + dependencies: + '@vitest/utils': 4.0.14 + pathe: 2.0.3 + '@vitest/runner@4.0.15': dependencies: '@vitest/utils': 4.0.15 @@ -17421,6 +17461,12 @@ snapshots: '@vitest/utils': 4.1.4 pathe: 2.0.3 + '@vitest/snapshot@4.0.14': + dependencies: + '@vitest/pretty-format': 4.0.14 + magic-string: 0.30.21 + pathe: 2.0.3 + '@vitest/snapshot@4.0.15': dependencies: '@vitest/pretty-format': 4.0.15 @@ -17434,6 +17480,8 @@ snapshots: magic-string: 0.30.21 pathe: 2.0.3 + '@vitest/spy@4.0.14': {} + '@vitest/spy@4.0.15': {} '@vitest/spy@4.1.4': {} @@ -17606,10 +17654,6 @@ snapshots: agent-base@7.1.4: {} - agentkeepalive@4.6.0: - dependencies: - humanize-ms: 1.2.1 - ajv-draft-04@1.0.0(ajv@8.13.0): optionalDependencies: ajv: 8.13.0 @@ -19152,8 +19196,6 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 - form-data-encoder@1.7.2: {} - form-data@4.0.5: dependencies: asynckit: 0.4.0 @@ -19166,11 +19208,6 @@ snapshots: dependencies: fd-package-json: 2.0.0 - formdata-node@4.4.1: - dependencies: - node-domexception: 1.0.0 - web-streams-polyfill: 4.0.0-beta.3 - formdata-polyfill@4.0.10: dependencies: fetch-blob: 3.2.0 @@ -19372,18 +19409,6 @@ snapshots: graceful-fs@4.2.11: {} - groq-sdk@0.37.0: - dependencies: - '@types/node': 18.19.130 - '@types/node-fetch': 2.6.13 - abort-controller: 3.0.0 - agentkeepalive: 4.6.0 - form-data-encoder: 1.7.2 - formdata-node: 4.4.1 - node-fetch: 2.7.0 - transitivePeerDependencies: - - encoding - gtoken@8.0.0: dependencies: gaxios: 7.1.3 @@ -19645,10 +19670,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 @@ -23048,8 +23069,6 @@ snapshots: magic-string: 0.30.21 unplugin: 2.3.11 - undici-types@5.26.5: {} - undici-types@5.28.4: {} undici-types@6.21.0: {} @@ -23562,26 +23581,26 @@ snapshots: optionalDependencies: vite: 7.3.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) - vitest@4.0.15(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): + vitest@4.0.14(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): dependencies: - '@vitest/expect': 4.0.15 - '@vitest/mocker': 4.0.15(vite@7.3.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) - '@vitest/pretty-format': 4.0.15 - '@vitest/runner': 4.0.15 - '@vitest/snapshot': 4.0.15 - '@vitest/spy': 4.0.15 - '@vitest/utils': 4.0.15 + '@vitest/expect': 4.0.14 + '@vitest/mocker': 4.0.14(vite@7.3.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/pretty-format': 4.0.14 + '@vitest/runner': 4.0.14 + '@vitest/snapshot': 4.0.14 + '@vitest/spy': 4.0.14 + '@vitest/utils': 4.0.14 es-module-lexer: 1.7.0 expect-type: 1.3.0 magic-string: 0.30.21 obug: 2.1.1 pathe: 2.0.3 - picomatch: 4.0.3 + picomatch: 4.0.4 std-env: 3.10.0 tinybench: 2.9.0 - tinyexec: 1.0.2 - tinyglobby: 0.2.15 - tinyrainbow: 3.0.3 + tinyexec: 0.3.2 + tinyglobby: 0.2.16 + tinyrainbow: 3.1.0 vite: 7.3.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) why-is-node-running: 2.3.0 optionalDependencies: @@ -23602,36 +23621,45 @@ snapshots: - tsx - yaml - vitest@4.1.4(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.0.14)(happy-dom@20.0.11)(jsdom@27.3.0(postcss@8.5.9))(vite@7.3.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)): + vitest@4.0.15(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): dependencies: - '@vitest/expect': 4.1.4 - '@vitest/mocker': 4.1.4(vite@7.3.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) - '@vitest/pretty-format': 4.1.4 - '@vitest/runner': 4.1.4 - '@vitest/snapshot': 4.1.4 - '@vitest/spy': 4.1.4 - '@vitest/utils': 4.1.4 - es-module-lexer: 2.0.0 + '@vitest/expect': 4.0.15 + '@vitest/mocker': 4.0.15(vite@7.3.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/pretty-format': 4.0.15 + '@vitest/runner': 4.0.15 + '@vitest/snapshot': 4.0.15 + '@vitest/spy': 4.0.15 + '@vitest/utils': 4.0.15 + es-module-lexer: 1.7.0 expect-type: 1.3.0 magic-string: 0.30.21 obug: 2.1.1 pathe: 2.0.3 - picomatch: 4.0.4 - std-env: 4.0.0 + picomatch: 4.0.3 + std-env: 3.10.0 tinybench: 2.9.0 - tinyexec: 1.1.1 - tinyglobby: 0.2.16 - tinyrainbow: 3.1.0 + tinyexec: 1.0.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.0.3 vite: 7.3.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.1 '@types/node': 24.10.3 - '@vitest/coverage-v8': 4.0.14(vitest@4.1.4) happy-dom: 20.0.11 jsdom: 27.3.0(postcss@8.5.9) transitivePeerDependencies: + - jiti + - less + - lightningcss - msw + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - yaml vitest@4.1.4(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jsdom@27.3.0(postcss@8.5.9))(vite@7.3.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)): dependencies: @@ -23662,7 +23690,6 @@ snapshots: jsdom: 27.3.0(postcss@8.5.9) transitivePeerDependencies: - msw - optional: true vscode-uri@3.1.0: {} @@ -23715,8 +23742,6 @@ snapshots: web-streams-polyfill@3.3.3: {} - web-streams-polyfill@4.0.0-beta.3: {} - web-vitals@5.1.0: {} webdriver-bidi-protocol@0.4.1: {} diff --git a/testing/e2e/src/lib/feature-support.ts b/testing/e2e/src/lib/feature-support.ts index 25a8b4d43..7a8dbb8c8 100644 --- a/testing/e2e/src/lib/feature-support.ts +++ b/testing/e2e/src/lib/feature-support.ts @@ -16,6 +16,7 @@ export const matrix: Record> = { 'groq', 'grok', 'openrouter', + 'openrouter-responses', ]), 'one-shot-text': new Set([ 'openai', @@ -25,6 +26,7 @@ export const matrix: Record> = { 'groq', 'grok', 'openrouter', + 'openrouter-responses', ]), reasoning: new Set(['openai', 'anthropic', 'gemini']), 'multi-turn-reasoning': new Set(['anthropic']), @@ -36,6 +38,7 @@ export const matrix: Record> = { 'groq', 'grok', 'openrouter', + 'openrouter-responses', ]), 'tool-calling': new Set([ 'openai', @@ -45,6 +48,7 @@ export const matrix: Record> = { 'groq', 'grok', 'openrouter', + 'openrouter-responses', ]), 'parallel-tool-calls': new Set([ 'openai', @@ -53,6 +57,7 @@ export const matrix: Record> = { 'groq', 'grok', 'openrouter', + 'openrouter-responses', ]), // Gemini excluded: approval flow timing issues with Gemini's streaming format 'tool-approval': new Set([ @@ -62,6 +67,7 @@ export const matrix: Record> = { 'groq', 'grok', 'openrouter', + 'openrouter-responses', ]), // Ollama excluded: aimock doesn't support content+toolCalls for /api/chat format 'text-tool-text': new Set([ @@ -71,6 +77,7 @@ export const matrix: Record> = { 'groq', 'grok', 'openrouter', + 'openrouter-responses', ]), 'structured-output': new Set([ 'openai', @@ -80,6 +87,7 @@ export const matrix: Record> = { 'groq', 'grok', 'openrouter', + 'openrouter-responses', ]), 'agentic-structured': new Set([ 'openai', @@ -89,6 +97,7 @@ export const matrix: Record> = { 'groq', 'grok', 'openrouter', + 'openrouter-responses', ]), 'multimodal-image': new Set([ 'openai', @@ -96,6 +105,7 @@ export const matrix: Record> = { 'gemini', 'grok', 'openrouter', + 'openrouter-responses', ]), 'multimodal-structured': new Set([ 'openai', @@ -103,6 +113,7 @@ export const matrix: Record> = { 'gemini', 'grok', 'openrouter', + 'openrouter-responses', ]), summarize: new Set([ 'openai', @@ -111,6 +122,7 @@ export const matrix: Record> = { 'ollama', 'grok', 'openrouter', + 'openrouter-responses', ]), 'summarize-stream': new Set([ 'openai', @@ -119,6 +131,7 @@ export const matrix: Record> = { 'ollama', 'grok', 'openrouter', + 'openrouter-responses', ]), // Gemini excluded: aimock doesn't mock Gemini's Imagen predict endpoint format 'image-gen': new Set(['openai', 'grok']), diff --git a/testing/e2e/src/lib/providers.ts b/testing/e2e/src/lib/providers.ts index ca2d00c4d..fd11a01eb 100644 --- a/testing/e2e/src/lib/providers.ts +++ b/testing/e2e/src/lib/providers.ts @@ -6,7 +6,10 @@ import { createGeminiChat } from '@tanstack/ai-gemini' import { createOllamaChat } from '@tanstack/ai-ollama' import { createGroqText } from '@tanstack/ai-groq' import { createGrokText } from '@tanstack/ai-grok' -import { createOpenRouterText } from '@tanstack/ai-openrouter' +import { + createOpenRouterResponsesText, + createOpenRouterText, +} from '@tanstack/ai-openrouter' import { HTTPClient } from '@openrouter/sdk' import type { Provider } from '@/lib/types' @@ -21,6 +24,7 @@ const defaultModels: Record = { groq: 'llama-3.3-70b-versatile', grok: 'grok-3', openrouter: 'openai/gpt-4o', + 'openrouter-responses': 'openai/gpt-4o', // ElevenLabs has no chat/text model — the support matrix already filters // it out of text features, but we still need an entry to satisfy the // Record constraint. @@ -110,6 +114,26 @@ export function createTextAdapter( }), }) }, + 'openrouter-responses': () => { + // Same X-Test-Id injection rationale as the chat-completions factory + // above. The beta Responses endpoint uses the same SDK base URL + + // HTTPClient surface. + const httpClient = new HTTPClient() + if (testId) { + httpClient.addHook('beforeRequest', (req) => { + const next = new Request(req) + next.headers.set('X-Test-Id', testId) + return next + }) + } + return createChatOptions({ + adapter: createOpenRouterResponsesText( + model as 'openai/gpt-4o', + DUMMY_KEY, + { serverURL: openaiUrl, httpClient }, + ), + }) + }, elevenlabs: () => { throw new Error( 'ElevenLabs has no text/chat adapter — use createTTSAdapter or createTranscriptionAdapter.', diff --git a/testing/e2e/src/lib/types.ts b/testing/e2e/src/lib/types.ts index eafe588fc..2f5cd634c 100644 --- a/testing/e2e/src/lib/types.ts +++ b/testing/e2e/src/lib/types.ts @@ -8,6 +8,7 @@ export type Provider = | 'grok' | 'groq' | 'openrouter' + | 'openrouter-responses' | 'elevenlabs' export type Feature = @@ -41,6 +42,7 @@ export const ALL_PROVIDERS: Provider[] = [ 'grok', 'groq', 'openrouter', + 'openrouter-responses', 'elevenlabs', ] diff --git a/testing/e2e/src/routes/api.summarize.ts b/testing/e2e/src/routes/api.summarize.ts index e5912edf9..131aedac6 100644 --- a/testing/e2e/src/routes/api.summarize.ts +++ b/testing/e2e/src/routes/api.summarize.ts @@ -5,6 +5,7 @@ import { createAnthropicSummarize } from '@tanstack/ai-anthropic' import { createGeminiSummarize } from '@tanstack/ai-gemini' import { createOllamaSummarize } from '@tanstack/ai-ollama' import { createGrokSummarize } from '@tanstack/ai-grok' +import { createOpenRouterSummarize } from '@tanstack/ai-openrouter' import type { Provider } from '@/lib/types' const LLMOCK_BASE = process.env.LLMOCK_URL || 'http://127.0.0.1:4010' @@ -26,8 +27,19 @@ function createSummarizeAdapter(provider: Provider) { ollama: () => createOllamaSummarize('mistral', LLMOCK_BASE), grok: () => createGrokSummarize('grok-3', DUMMY_KEY, { baseURL: LLMOCK_OPENAI }), + // Both OpenRouter provider rows use the OpenRouter summarize adapter: + // `OpenRouterSummarizeAdapter` wraps the OpenRouter chat-completions + // text adapter regardless of whether the caller selected the Chat + // Completions or Responses surface, so a single factory backs both + // matrix entries. openrouter: () => - createOpenaiSummarize('gpt-4o', DUMMY_KEY, { baseURL: LLMOCK_OPENAI }), + createOpenRouterSummarize('openai/gpt-4o', DUMMY_KEY, { + serverURL: LLMOCK_OPENAI, + }), + 'openrouter-responses': () => + createOpenRouterSummarize('openai/gpt-4o', DUMMY_KEY, { + serverURL: LLMOCK_OPENAI, + }), } return factories[provider]?.() } diff --git a/testing/e2e/tests/test-matrix.ts b/testing/e2e/tests/test-matrix.ts index fea85dc59..f48dcebc0 100644 --- a/testing/e2e/tests/test-matrix.ts +++ b/testing/e2e/tests/test-matrix.ts @@ -21,6 +21,7 @@ export const providers: Provider[] = [ 'groq', 'grok', 'openrouter', + 'openrouter-responses', 'elevenlabs', ]