Repository navigation
Description
TanStack AI version
@tanstack/openai-base 1.0.2 (through @tanstack/ai-openai 1.1.1), @tanstack/ai 1.2.0, openai 7.30.0. Reproduced on main at c32f641.
Framework/Library version
Node.js 24. No framework is needed.
Describe the bug and the steps to reproduce it
A caller abort that comes from the openai client is not classified as an abort. A direct adapter.chatStream() or structuredOutput() call, aborted before the response arrives, has two effects:
- It yields
RUN_ERRORwithstopReason: 'error'and nocode, instead ofcode: 'aborted'/stopReason: 'aborted'. - It logs
openai.chatStream fatal(oropenai.structuredOutput fatal) on theerrorschannel, which is on by default.
Through chat() this does not show, because core stops first.
Cause. On abort, the client throws new APIUserAbortError() from openai/core/error (packages/openai-base/src/client/client.ts:376). That class never sets name: in openai 7.30.0, new APIUserAbortError().name is 'Error' and constructor.name is 'APIUserAbortError'. Every abort classifier matches on name, so all of them miss it:
isAbortErrorinresponses-text.ts:1272andchat-completions-text.ts:803- core
isAbortShapedError/toRunErrorPayload(packages/ai/src/activities/error-payload.ts:15-34):toRunErrorPayloadreturns{ message: 'Request was aborted.' }with no code utilities/activity-abort.ts:11-16, though the media activities checksignal.abortedfirst, so they still route toonAbort
The name check landed in #527, when openai was ^6.9.1. openai 6.9.1's error also has name: 'Error', so this has never matched. A user-supplied new OpenAI() client fails the same way as createOpenAIClient(). The existing tests pass because they build a mock error with name: 'APIUserAbortError' set by hand (responses-text.test.ts:2548). @tanstack/ai-utils's own default makeAbortError names its error 'APIUserAbortError' on purpose (http/client.ts:191-193). openai-base overrides that default with the SDK class.
Related: #2090 has the same root. The client copies the SDK's types, but the adapters detect SDK shapes by SDK identity (instanceof Stream there, e.name here), and the client's output matches neither. Also related: #2092 / #2095 (the unconditional fatal log on a mid-stream abort). With only #2095's guards applied, every case below still fails, because the guard asks isAbortError, which returns false here. So the two fixes complement each other.
Expected: a caller abort before the response yields RUN_ERROR with code: 'aborted' and logs nothing on the errors channel.
Actual: stopReason: 'error', no code, and a fatal log.
Your Minimal, Reproducible Example - (Sandbox Highly Recommended)
A new file, packages/openai-base/tests/abort-before-response.test.ts. It uses the real client over a fetch that never answers, for both APIs and both client kinds. On main all 12 cases fail:
FAIL ... 'Chat Completions' adapter on 'createOpenAIClient()': a caller abort before the response > yields a RUN_ERROR classified as aborted
- "code": "aborted",
- "stopReason": "aborted",
+ "stopReason": "error",
FAIL ... 'Responses' adapter on 'createOpenAIClient()': a caller abort before the response > logs nothing on the errors channel
+ "❌ [tanstack-ai:errors] ❌ openai.chatStream fatal",
+ "error": { "code": undefined, "message": "Request was aborted." },
FAIL ... structuredOutput logs nothing on the errors channel
+ "❌ [tanstack-ai:errors] ❌ openai.structuredOutput fatal",
Tests 12 failed (12)
Repro test
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<ClientOptions['fetch']>
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<string> {}
class Completions extends OpenAIBaseChatCompletionsTextAdapter<string> {}
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([])
})
},
)Proposed fix
There are two parts. Both are needed: A alone passes 4 cases, B alone passes none, and A plus B passes all 12. With both, the full openai-base suite passes (477 tests) and tsc is clean.
A. Classify by class as well as name (both adapters):
+import { APIUserAbortError } from 'openai/core/error'
@@ protected runErrorPayload(error, fallbackMessage) {
+ if (this.isAbortError(error)) {
+ return { message: 'Request aborted', code: 'aborted' }
+ }
const payload = toRunErrorPayload(error, fallbackMessage)
@@ protected isAbortError(error: unknown): boolean {
return (
+ error instanceof APIUserAbortError ||
e.name === 'APIUserAbortError' ||B. Skip the fatal log for an abort in each chatStream and structuredOutput catch: chat-completions-text.ts:274 and :430, responses-text.ts:641 and :757. This is #2095's guard, applied to the catches that #2095 leaves out.
instanceof goes in the adapter, not a name fix in client.ts. Naming the error in the client would also fix core's name checks, but it would miss a user-supplied new OpenAI() client, and the repro shows that case fails too. Core can't use instanceof because it doesn't depend on openai.
The Anthropic client throws the same unnamed class (name: 'Error', checked). Its text adapter checks signal.aborted instead, so I didn't take it further here.
Do you intend to try to help solve this bug with your own PR?
Yes, I think I know how to fix it and will discuss it in the comments of this issue
Terms & Code of Conduct
- I agree to follow this project's Code of Conduct
- I understand that if my bug cannot be reliable reproduced in a debuggable environment, it will probably not be fixed and this issue may even be closed.
Activity
fix(openai-base): classify the openai client's abort error as an abort