Skip to content

openai-base: a caller abort mid-stream logs 'processStreamChunks fatal' on the errors channel #2092

Open

Description

@aayushbtw

TanStack AI version

@tanstack/openai-base 1.0.2 (through @tanstack/ai-openai 1.1.1), with @tanstack/ai 1.2.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

When the caller aborts a chat run while the OpenAI adapter waits for the next SSE event, the adapter logs a fatal error on the errors channel:

❌ [tanstack-ai:errors] ❌ openai.processStreamChunks fatal { error: { message: 'Request aborted', code: 'aborted' }, source: 'openai.processStreamChunks' }

This happens on a user's Stop. errors is the one category that stays on when debug is left out (packages/ai/src/logger/resolve.ts:30-33, 63-65; the docs say "Errors are always logged"). So every Stop prints console.error, or reaches the app's logger and its error monitoring.

The catch blocks call logger.errors('… fatal') with no condition. This happens at packages/openai-base/src/adapters/responses-text.ts:2710 (processStreamChunks) and :641 (chatStream), and at packages/openai-base/src/adapters/chat-completions-text.ts:274. The same blocks already classify the error as an abort: isAbortError sets the metadata to 'aborted', and the payload carries code: "aborted". Core itself treats a caller abort as onAbort, not onError, and logs nothing for it. A comment in the Anthropic adapter says an abort "is the caller's choice and not an error" (packages/ai-anthropic/src/adapters/text.ts:2354-2357). Its chatStream catch still logs fatal with no abort check, though, so Anthropic has the same gap (see "Other adapters").

If the abort lands between chunks instead, core stops before the next read and nothing is logged. So the log depends on timing.

Expected: a caller abort runs onAbort, yields RUN_ERROR with code: "aborted", and logs nothing on the errors channel.

Actual: the same, plus one errors log entry marked fatal.

Your Minimal, Reproducible Example - (Sandbox Highly Recommended)

A new test file, packages/openai-base/tests/caller-abort-logging.test.ts. It drives the real client over a fake SSE body, in the style of responses-stream-drain.test.ts. On main, both cases fail:

 × 'Responses': a caller abort mid-stream > reaches onAbort and logs nothing on the errors channel
 × 'Chat Completions': a caller abort mid-stream > reaches onAbort and logs nothing on the errors channel
AssertionError: expected [ [ …(2) ] ] to deeply equal []
+   [
+     "❌ [tanstack-ai:errors] ❌ openai.processStreamChunks fatal",
+     { "error": { "code": "aborted", "message": "Request aborted" }, "source": "openai.processStreamChunks" },
+   ],
 Tests  2 failed (2)
Repro test
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, StreamChunk } from '@tanstack/ai'

type Fetch = NonNullable<ClientOptions['fetch']>

const model = 'test-model'

/** An SSE body that sends `events`, then stays open until the request aborts. */
function openStream(events: Array<Record<string, unknown>>): Fetch {
  return async (_url, init) => {
    const body = new ReadableStream<Uint8Array>({
      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<string> {}
class Completions extends OpenAIBaseChatCompletionsTextAdapter<string> {}

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()
    const events: Array<StreamChunk> = []

    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 }],
    })) {
      events.push(event)
      // Abort while the adapter waits for the next SSE event, as a Stop between two tokens does.
      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([])
  })
})

Proposed fix

Skip the errors log when isAbortError(error) is true. RUN_ERROR with code: "aborted" is still yielded. With this change both cases pass, and so does the full openai-base suite (467 tests). tsc is clean.

--- a/packages/openai-base/src/adapters/chat-completions-text.ts
+++ b/packages/openai-base/src/adapters/chat-completions-text.ts
@@ -271,10 +271,12 @@
-    options.logger.errors(`${this.name}.${source} fatal`, {
-      error: errorPayload,
-      source: `${this.name}.${source}`,
-    })
+    if (!this.isAbortError(error)) {
+      options.logger.errors(`${this.name}.${source} fatal`, {
+        error: errorPayload,
+        source: `${this.name}.${source}`,
+      })
+    }
--- a/packages/openai-base/src/adapters/responses-text.ts
+++ b/packages/openai-base/src/adapters/responses-text.ts
@@ -638,10 +638,12 @@
-      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`,
+        })
+      }
@@ -2707,10 +2709,12 @@
-      options.logger.errors(`${this.name}.processStreamChunks fatal`, {
-        error: errorPayload,
-        source: `${this.name}.processStreamChunks`,
-      })
+      if (!this.isAbortError(error)) {
+        options.logger.errors(`${this.name}.processStreamChunks fatal`, {
+          error: errorPayload,
+          source: `${this.name}.processStreamChunks`,
+        })
+      }

Other adapters

From reading the code (not run): the same unconditional fatal log appears in other adapters' stream catch blocks. These are Anthropic (ai-anthropic/src/adapters/text.ts:750, checked: an abort before the response logs anthropic.chatStream fatal; mid-stream its client ends quietly), Gemini (ai-gemini/src/adapters/text.ts:515), Ollama (ai-ollama/src/adapters/text.ts:234), Bedrock (converse-text.ts:306) and OpenRouter (responses-text.ts:1802, text.ts:1331). structuredOutputStream in openai-base also logs fatal on abort (responses-text.ts:1198, chat-completions-text.ts:764). The fix above covers only the proven chat paths. Aborts before the response, and direct adapter calls, are #2096: the openai client's APIUserAbortError has no name, so isAbortError misses it.

The debug-logging doc defines errors as "Each caught error in the pipeline", so one could read this as intended. I think core's own handling, where an abort goes to onAbort with no log, and that Anthropic comment point the other way.

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

has-prAn open PR references this issuewaiting-on: maintainerThe ball is in the maintainers’ court

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions