diff --git a/.changeset/openai-base-abort-log.md b/.changeset/openai-base-abort-log.md new file mode 100644 index 0000000000..a3440a0b40 --- /dev/null +++ b/.changeset/openai-base-abort-log.md @@ -0,0 +1,5 @@ +--- +'@tanstack/openai-base': patch +--- + +A caller abort no longer logs a `fatal` entry on the `errors` debug channel. A direct `chatStream()` call that is aborted before the response arrives now yields `RUN_ERROR` with `code: 'aborted'`. diff --git a/packages/openai-base/src/adapters/chat-completions-text.ts b/packages/openai-base/src/adapters/chat-completions-text.ts index 99dfadc9ad..b112198bab 100644 --- a/packages/openai-base/src/adapters/chat-completions-text.ts +++ b/packages/openai-base/src/adapters/chat-completions-text.ts @@ -17,6 +17,7 @@ import { transformMessagesForReplay, } from '@tanstack/ai/adapter-internals' import { generateId } from '@tanstack/ai-utils' +import { APIUserAbortError } from 'openai/core/error' import { clientFor, extractRequestOptions } from '../utils/request-options' import type { Fetch } from '../utils/request-options' import { @@ -271,10 +272,13 @@ export abstract class OpenAIBaseChatCompletionsTextAdapter< return } - options.logger.errors(`${this.name}.${source} fatal`, { - error: errorPayload, - source: `${this.name}.${source}`, - }) + // A caller abort is not a failure, so it stays off the errors channel. + if (!this.isAbortError(error)) { + options.logger.errors(`${this.name}.${source} fatal`, { + error: errorPayload, + source: `${this.name}.${source}`, + }) + } yield { type: EventType.RUN_ERROR, @@ -427,10 +431,15 @@ export abstract class OpenAIBaseChatCompletionsTextAdapter< } catch (error: unknown) { // Narrow before logging: raw SDK errors can carry request metadata // (including auth headers) which we must never surface to user loggers. - chatOptions.logger.errors(`${this.name}.structuredOutput fatal`, { - error: toRunErrorPayload(error, `${this.name}.structuredOutput failed`), - source: `${this.name}.structuredOutput`, - }) + if (!this.isAbortError(error)) { + chatOptions.logger.errors(`${this.name}.structuredOutput fatal`, { + error: toRunErrorPayload( + error, + `${this.name}.structuredOutput failed`, + ), + source: `${this.name}.structuredOutput`, + }) + } throw error } } @@ -776,6 +785,9 @@ export abstract class OpenAIBaseChatCompletionsTextAdapter< error: unknown, fallbackMessage: string, ): { message: string; code: string | undefined } { + if (this.isAbortError(error)) { + return { message: 'Request aborted', code: 'aborted' } + } const payload = toRunErrorPayload(error, fallbackMessage) return this.isContextOverflow(payload) ? { ...payload, code: CONTEXT_OVERFLOW_CODE } @@ -804,6 +816,8 @@ export abstract class OpenAIBaseChatCompletionsTextAdapter< if (!error || typeof error !== 'object') return false const e = error as { name?: unknown; code?: unknown } return ( + // The openai client's APIUserAbortError leaves `name` as 'Error'. + error instanceof APIUserAbortError || e.name === 'APIUserAbortError' || e.name === 'AbortError' || e.code === 'ERR_CANCELED' diff --git a/packages/openai-base/src/adapters/responses-text.ts b/packages/openai-base/src/adapters/responses-text.ts index 01fba8b5a6..9936da8cbb 100644 --- a/packages/openai-base/src/adapters/responses-text.ts +++ b/packages/openai-base/src/adapters/responses-text.ts @@ -19,6 +19,7 @@ import { transformMessagesForReplay, } from '@tanstack/ai/adapter-internals' import { drainAfterTerminal, generateId } from '@tanstack/ai-utils' +import { APIUserAbortError } from 'openai/core/error' import { Stream } from 'openai/streaming' import { clientFor, extractRequestOptions } from '../utils/request-options' import type { Fetch } from '../utils/request-options' @@ -638,10 +639,12 @@ export abstract class OpenAIBaseResponsesTextAdapter< }, } - options.logger.errors(`${this.name}.chatStream fatal`, { - error: errorPayload, - source: `${this.name}.chatStream`, - }) + if (!this.isAbortError(error)) { + options.logger.errors(`${this.name}.chatStream fatal`, { + error: errorPayload, + source: `${this.name}.chatStream`, + }) + } } } @@ -754,10 +757,15 @@ export abstract class OpenAIBaseResponsesTextAdapter< } catch (error: unknown) { // Narrow before logging: raw SDK errors can carry request metadata // (including auth headers) which we must never surface to user loggers. - chatOptions.logger.errors(`${this.name}.structuredOutput fatal`, { - error: toRunErrorPayload(error, `${this.name}.structuredOutput failed`), - source: `${this.name}.structuredOutput`, - }) + if (!this.isAbortError(error)) { + chatOptions.logger.errors(`${this.name}.structuredOutput fatal`, { + error: toRunErrorPayload( + error, + `${this.name}.structuredOutput failed`, + ), + source: `${this.name}.structuredOutput`, + }) + } throw error } } @@ -1247,6 +1255,9 @@ export abstract class OpenAIBaseResponsesTextAdapter< error: unknown, fallbackMessage: string, ): { message: string; code: string | undefined } { + if (this.isAbortError(error)) { + return { message: 'Request aborted', code: 'aborted' } + } const payload = toRunErrorPayload(error, fallbackMessage) return this.isContextOverflow(payload) ? { ...payload, code: CONTEXT_OVERFLOW_CODE } @@ -1273,6 +1284,8 @@ export abstract class OpenAIBaseResponsesTextAdapter< if (!error || typeof error !== 'object') return false const e = error as { name?: unknown; code?: unknown } return ( + // The openai client's APIUserAbortError leaves `name` as 'Error'. + error instanceof APIUserAbortError || e.name === 'APIUserAbortError' || e.name === 'AbortError' || e.code === 'ERR_CANCELED' @@ -2707,10 +2720,13 @@ export abstract class OpenAIBaseResponsesTextAdapter< error, `${this.name}.processStreamChunks failed`, ) - options.logger.errors(`${this.name}.processStreamChunks fatal`, { - error: errorPayload, - source: `${this.name}.processStreamChunks`, - }) + // A caller abort is not a failure, so it stays off the errors channel. + if (!this.isAbortError(error)) { + options.logger.errors(`${this.name}.processStreamChunks fatal`, { + error: errorPayload, + source: `${this.name}.processStreamChunks`, + }) + } // A held refusal wins over a later stream error. A caller abort stays // an abort. if (pendingRunError && !this.isAbortError(error)) { diff --git a/packages/openai-base/tests/abort-before-response.test.ts b/packages/openai-base/tests/abort-before-response.test.ts new file mode 100644 index 0000000000..44499b1968 --- /dev/null +++ b/packages/openai-base/tests/abort-before-response.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it, vi } from 'vitest' +import OpenAI from 'openai' +import { resolveDebugOption } from '@tanstack/ai/adapter-internals' +import { createOpenAIClient } from '../src/client' +import { OpenAIBaseChatCompletionsTextAdapter } from '../src/adapters/chat-completions-text' +import { OpenAIBaseResponsesTextAdapter } from '../src/adapters/responses-text' +import type { ClientOptions } from 'openai' +import type { OpenAIClientLike } from '../src/client' + +type Fetch = NonNullable + +const model = 'test-model' + +/** A fetch that never answers and rejects when the request aborts. */ +const hangingFetch: Fetch = (_url, init) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => + reject(init.signal?.reason ?? new Error('aborted')), + ) + }) + +class Responses extends OpenAIBaseResponsesTextAdapter {} +class Completions extends OpenAIBaseChatCompletionsTextAdapter {} + +const clients = [ + { + client: 'createOpenAIClient()', + make: () => + createOpenAIClient({ + apiKey: 'test-key', + maxRetries: 0, + fetch: hangingFetch, + }), + }, + { + client: 'new OpenAI()', + make: () => + new OpenAI({ apiKey: 'test-key', maxRetries: 0, fetch: hangingFetch }), + }, +] + +const apis = [ + { + api: 'Responses', + adapter: (client: OpenAIClientLike) => + new Responses(model, 'openai', client, { fetch: hangingFetch }), + }, + { + api: 'Chat Completions', + adapter: (client: OpenAIClientLike) => + new Completions(model, 'openai', client, { fetch: hangingFetch }), + }, +] + +const cases = clients.flatMap((c) => apis.map((a) => ({ ...c, ...a }))) + +function errorsLogger() { + const error = vi.fn() + const logger = resolveDebugOption({ + logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error }, + }) + return { error, logger } +} + +describe.each(cases)( + '$api adapter on $client: a caller abort before the response', + ({ make, adapter }) => { + async function runAborted() { + const { error, logger } = errorsLogger() + const controller = new AbortController() + setTimeout(() => controller.abort(), 10) + const chunks = [] + for await (const chunk of adapter(make()).chatStream({ + threadId: 'thread-1', + logger, + model, + messages: [{ role: 'user', content: 'Hi' }], + request: { signal: controller.signal }, + })) { + chunks.push(chunk) + } + return { chunks, error } + } + + it('yields a RUN_ERROR classified as aborted', async () => { + const { chunks } = await runAborted() + expect(chunks.at(-1)).toMatchObject({ + type: 'RUN_ERROR', + code: 'aborted', + metadata: { tanstack: { stopReason: 'aborted' } }, + }) + }) + + it('logs nothing on the errors channel', async () => { + const { error } = await runAborted() + expect(error.mock.calls).toEqual([]) + }) + + it('structuredOutput logs nothing on the errors channel', async () => { + const { error, logger } = errorsLogger() + const controller = new AbortController() + setTimeout(() => controller.abort(), 10) + await expect( + adapter(make()).structuredOutput({ + chatOptions: { + threadId: 'thread-1', + logger, + model, + messages: [{ role: 'user', content: 'Hi' }], + request: { signal: controller.signal }, + }, + outputSchema: { + type: 'object', + properties: { a: { type: 'string' } }, + required: ['a'], + }, + }), + ).rejects.toThrow('Request was aborted.') + expect(error.mock.calls).toEqual([]) + }) + }, +) diff --git a/packages/openai-base/tests/caller-abort-logging.test.ts b/packages/openai-base/tests/caller-abort-logging.test.ts new file mode 100644 index 0000000000..152cc966b8 --- /dev/null +++ b/packages/openai-base/tests/caller-abort-logging.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it, vi } from 'vitest' +import { chat } from '@tanstack/ai' +import { createOpenAIClient } from '../src/client' +import { OpenAIBaseChatCompletionsTextAdapter } from '../src/adapters/chat-completions-text' +import { OpenAIBaseResponsesTextAdapter } from '../src/adapters/responses-text' +import type { ClientOptions } from 'openai' +import type { Logger } from '@tanstack/ai' + +type Fetch = NonNullable + +const model = 'test-model' + +/** An SSE body that sends `events`, then stays open until the request aborts. */ +function openStream(events: Array>): Fetch { + return async (_url, init) => { + const body = new ReadableStream({ + start(controller) { + for (const event of events) { + controller.enqueue( + new TextEncoder().encode(`data: ${JSON.stringify(event)}\n\n`), + ) + } + init?.signal?.addEventListener('abort', () => { + controller.error(init.signal?.reason ?? new Error('aborted')) + }) + }, + }) + return new Response(body, { + headers: { 'content-type': 'text/event-stream' }, + }) + } +} + +class Responses extends OpenAIBaseResponsesTextAdapter {} +class Completions extends OpenAIBaseChatCompletionsTextAdapter {} + +const adapters = [ + { + name: 'Responses', + make: () => { + const fetch = openStream([ + { + type: 'response.created', + response: { id: 'resp-1', model, status: 'in_progress' }, + }, + { type: 'response.output_text.delta', delta: 'The answer is ' }, + ]) + return new Responses( + model, + 'openai', + createOpenAIClient({ apiKey: 'test-key', maxRetries: 0, fetch }), + { fetch }, + ) + }, + }, + { + name: 'Chat Completions', + make: () => { + const fetch = openStream([ + { + id: 'chatcmpl-1', + object: 'chat.completion.chunk', + created: 1, + model, + choices: [ + { index: 0, delta: { content: 'partial' }, finish_reason: null }, + ], + }, + ]) + return new Completions( + model, + 'openai', + createOpenAIClient({ apiKey: 'test-key', maxRetries: 0, fetch }), + { fetch }, + ) + }, + }, +] + +describe.each(adapters)('$name: a caller abort mid-stream', ({ make }) => { + it('reaches onAbort and logs nothing on the errors channel', async () => { + const logger: Logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } + const onAbort = vi.fn() + const onError = vi.fn() + const controller = new AbortController() + + for await (const event of chat({ + threadId: 'thread-1', + adapter: make(), + messages: [{ role: 'user', content: 'Hello' }], + abortController: controller, + // Default categories: only `errors` is on, as when `debug` is left out. + debug: { + logger, + provider: false, + output: false, + middleware: false, + tools: false, + agentLoop: false, + config: false, + request: false, + sandbox: false, + }, + middleware: [{ name: 'observe', onAbort, onError }], + })) { + // Abort while the adapter waits for the next SSE event, as a user's + // Stop does between two tokens. + if (event.type === 'TEXT_MESSAGE_CONTENT') { + setTimeout(() => controller.abort(), 10) + } + } + + expect(onAbort).toHaveBeenCalledTimes(1) + expect(onError).not.toHaveBeenCalled() + expect(vi.mocked(logger.error).mock.calls).toEqual([]) + }) +}) diff --git a/testing/e2e/src/routeTree.gen.ts b/testing/e2e/src/routeTree.gen.ts index 31c7217dd5..1169ab7782 100644 --- a/testing/e2e/src/routeTree.gen.ts +++ b/testing/e2e/src/routeTree.gen.ts @@ -78,6 +78,7 @@ import { Route as ApiByteplusEncryptedReasoningWireRouteImport } from './routes/ import { Route as ApiByteplusSeedance1080pWireRouteImport } from './routes/api.byteplus-seedance-1080p-wire' import { Route as ApiCallOwnershipWireRouteImport } from './routes/api.call-ownership-wire' import { Route as ApiChatRouteImport } from './routes/api.chat' +import { Route as ApiChatCallerAbortRouteImport } from './routes/api.chat-caller-abort' import { Route as ApiChatCompletionsIncompleteStreamRouteImport } from './routes/api.chat-completions-incomplete-stream' import { Route as ApiClientToolResumeStoreRouteImport } from './routes/api.client-tool-resume-store' import { Route as ApiCloudflareBindingHeadersWireRouteImport } from './routes/api.cloudflare-binding-headers-wire' @@ -582,6 +583,11 @@ const ApiChatRoute = ApiChatRouteImport.update({ path: '/api/chat', getParentRoute: () => rootRouteImport, } as any) +const ApiChatCallerAbortRoute = ApiChatCallerAbortRouteImport.update({ + id: '/api/chat-caller-abort', + path: '/api/chat-caller-abort', + getParentRoute: () => rootRouteImport, +} as any) const ApiChatCompletionsIncompleteStreamRoute = ApiChatCompletionsIncompleteStreamRouteImport.update({ id: '/api/chat-completions-incomplete-stream', @@ -1392,6 +1398,7 @@ export interface FileRoutesByFullPath { '/api/byteplus-seedance-1080p-wire': typeof ApiByteplusSeedance1080pWireRoute '/api/call-ownership-wire': typeof ApiCallOwnershipWireRoute '/api/chat': typeof ApiChatRoute + '/api/chat-caller-abort': typeof ApiChatCallerAbortRoute '/api/chat-completions-incomplete-stream': typeof ApiChatCompletionsIncompleteStreamRoute '/api/client-tool-resume-store': typeof ApiClientToolResumeStoreRoute '/api/cloudflare-binding-headers-wire': typeof ApiCloudflareBindingHeadersWireRoute @@ -1604,6 +1611,7 @@ export interface FileRoutesByTo { '/api/byteplus-seedance-1080p-wire': typeof ApiByteplusSeedance1080pWireRoute '/api/call-ownership-wire': typeof ApiCallOwnershipWireRoute '/api/chat': typeof ApiChatRoute + '/api/chat-caller-abort': typeof ApiChatCallerAbortRoute '/api/chat-completions-incomplete-stream': typeof ApiChatCompletionsIncompleteStreamRoute '/api/client-tool-resume-store': typeof ApiClientToolResumeStoreRoute '/api/cloudflare-binding-headers-wire': typeof ApiCloudflareBindingHeadersWireRoute @@ -1817,6 +1825,7 @@ export interface FileRoutesById { '/api/byteplus-seedance-1080p-wire': typeof ApiByteplusSeedance1080pWireRoute '/api/call-ownership-wire': typeof ApiCallOwnershipWireRoute '/api/chat': typeof ApiChatRoute + '/api/chat-caller-abort': typeof ApiChatCallerAbortRoute '/api/chat-completions-incomplete-stream': typeof ApiChatCompletionsIncompleteStreamRoute '/api/client-tool-resume-store': typeof ApiClientToolResumeStoreRoute '/api/cloudflare-binding-headers-wire': typeof ApiCloudflareBindingHeadersWireRoute @@ -2031,6 +2040,7 @@ export interface FileRouteTypes { | '/api/byteplus-seedance-1080p-wire' | '/api/call-ownership-wire' | '/api/chat' + | '/api/chat-caller-abort' | '/api/chat-completions-incomplete-stream' | '/api/client-tool-resume-store' | '/api/cloudflare-binding-headers-wire' @@ -2243,6 +2253,7 @@ export interface FileRouteTypes { | '/api/byteplus-seedance-1080p-wire' | '/api/call-ownership-wire' | '/api/chat' + | '/api/chat-caller-abort' | '/api/chat-completions-incomplete-stream' | '/api/client-tool-resume-store' | '/api/cloudflare-binding-headers-wire' @@ -2455,6 +2466,7 @@ export interface FileRouteTypes { | '/api/byteplus-seedance-1080p-wire' | '/api/call-ownership-wire' | '/api/chat' + | '/api/chat-caller-abort' | '/api/chat-completions-incomplete-stream' | '/api/client-tool-resume-store' | '/api/cloudflare-binding-headers-wire' @@ -2668,6 +2680,7 @@ export interface RootRouteChildren { ApiByteplusSeedance1080pWireRoute: typeof ApiByteplusSeedance1080pWireRoute ApiCallOwnershipWireRoute: typeof ApiCallOwnershipWireRoute ApiChatRoute: typeof ApiChatRoute + ApiChatCallerAbortRoute: typeof ApiChatCallerAbortRoute ApiChatCompletionsIncompleteStreamRoute: typeof ApiChatCompletionsIncompleteStreamRoute ApiClientToolResumeStoreRoute: typeof ApiClientToolResumeStoreRoute ApiCloudflareBindingHeadersWireRoute: typeof ApiCloudflareBindingHeadersWireRoute @@ -3292,6 +3305,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiChatRouteImport parentRoute: typeof rootRouteImport } + '/api/chat-caller-abort': { + id: '/api/chat-caller-abort' + path: '/api/chat-caller-abort' + fullPath: '/api/chat-caller-abort' + preLoaderRoute: typeof ApiChatCallerAbortRouteImport + parentRoute: typeof rootRouteImport + } '/api/chat-completions-incomplete-stream': { id: '/api/chat-completions-incomplete-stream' path: '/api/chat-completions-incomplete-stream' @@ -4413,6 +4433,7 @@ const rootRouteChildren: RootRouteChildren = { ApiByteplusSeedance1080pWireRoute: ApiByteplusSeedance1080pWireRoute, ApiCallOwnershipWireRoute: ApiCallOwnershipWireRoute, ApiChatRoute: ApiChatRoute, + ApiChatCallerAbortRoute: ApiChatCallerAbortRoute, ApiChatCompletionsIncompleteStreamRoute: ApiChatCompletionsIncompleteStreamRoute, ApiClientToolResumeStoreRoute: ApiClientToolResumeStoreRoute, diff --git a/testing/e2e/src/routes/api.chat-caller-abort.ts b/testing/e2e/src/routes/api.chat-caller-abort.ts new file mode 100644 index 0000000000..28e6a0fdd7 --- /dev/null +++ b/testing/e2e/src/routes/api.chat-caller-abort.ts @@ -0,0 +1,82 @@ +import { createFileRoute } from '@tanstack/react-router' +import { chat } from '@tanstack/ai' +import { resolveDebugOption } from '@tanstack/ai/adapter-internals' +import { createTextAdapter } from '@/lib/providers' +import type { Logger, ModelMessage } from '@tanstack/ai' +import type { Provider } from '@/lib/types' + +/** + * Aborts a chat 10 ms after its first text chunk, while the adapter waits for + * the next SSE event of the slow `[abort-test]` story, as a user's Stop does. + * Returns what the logger got on the `errors` channel and the `onAbort` count. + * + * With `at=before-response`, aborts 10 ms after the call instead, before + * aimock sends its first byte (200 ms). `chat()` stops before the adapter + * reports that abort, so this mode calls `adapter.chatStream()` directly and + * returns the `errors` lines and the `RUN_ERROR` code. + */ +export const Route = createFileRoute('/api/chat-caller-abort')({ + server: { + handlers: { + POST: async ({ request }) => { + const url = new URL(request.url) + const provider = url.searchParams.get('provider') as Provider + const testId = url.searchParams.get('testId') ?? undefined + const errors: Array = [] + const logger: Logger = { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: (message) => errors.push(message), + } + const messages: Array = [ + { role: 'user', content: '[abort-test] tell me a long story' }, + ] + const controller = new AbortController() + + if (url.searchParams.get('at') === 'before-response') { + const { adapter } = createTextAdapter( + provider, + undefined, + undefined, + testId, + ) + setTimeout(() => controller.abort(), 10) + let code: string | undefined + for await (const chunk of adapter.chatStream({ + threadId: 'chat-caller-abort', + model: adapter.model, + messages, + logger: resolveDebugOption({ logger }), + request: { signal: controller.signal }, + })) { + if (chunk.type === 'RUN_ERROR') code = chunk.code + } + return Response.json({ errors, code }) + } + + let aborts = 0 + for await (const event of chat({ + ...createTextAdapter(provider, undefined, undefined, testId), + threadId: 'chat-caller-abort', + messages, + abortController: controller, + debug: { logger }, + middleware: [ + { + name: 'count-aborts', + onAbort: () => { + aborts++ + }, + }, + ], + })) { + if (event.type === 'TEXT_MESSAGE_CONTENT') { + setTimeout(() => controller.abort(), 10) + } + } + return Response.json({ errors, aborts }) + }, + }, + }, +}) diff --git a/testing/e2e/tests/chat.spec.ts b/testing/e2e/tests/chat.spec.ts index a50795ae49..4dae4507af 100644 --- a/testing/e2e/tests/chat.spec.ts +++ b/testing/e2e/tests/chat.spec.ts @@ -57,6 +57,31 @@ for (const provider of providersFor('chat')) { }) } +// openai streams over the Responses API, openai-compatible over Chat Completions. +for (const provider of ['openai', 'openai-compatible']) { + test(`${provider} — a caller abort mid-stream runs onAbort and logs no error`, async ({ + request, + testId, + }) => { + const response = await request.post( + `/api/chat-caller-abort?provider=${provider}&testId=${encodeURIComponent(testId)}`, + ) + expect(response.ok()).toBe(true) + expect(await response.json()).toEqual({ errors: [], aborts: 1 }) + }) + + test(`${provider} — a caller abort before the response is an abort and logs no error`, async ({ + request, + testId, + }) => { + const response = await request.post( + `/api/chat-caller-abort?at=before-response&provider=${provider}&testId=${encodeURIComponent(testId)}`, + ) + expect(response.ok()).toBe(true) + expect(await response.json()).toEqual({ errors: [], code: 'aborted' }) + }) +} + test('preserves UI message IDs at the server conversion boundary', async ({ request, }) => {