From ffaea5a65d85e685ca7f60a17ea88fd0044eeda0 Mon Sep 17 00:00:00 2001 From: Alex Castillo Date: Tue, 27 Oct 2026 22:26:56 -0400 Subject: [PATCH 1/2] fix(openai-base): finish a Responses max_output_tokens cutoff with finishReason length instead of RUN_ERROR Co-Authored-By: Claude Sonnet 5.5 --- .../responses-incomplete-finish-reason.md | 5 + .../src/adapters/responses-text.ts | 31 ++++++ .../openai-base/tests/responses-text.test.ts | 96 +++++++++++++++++-- 3 files changed, 124 insertions(+), 8 deletions(-) create mode 100644 .changeset/responses-incomplete-finish-reason.md diff --git a/.changeset/responses-incomplete-finish-reason.md b/.changeset/responses-incomplete-finish-reason.md new file mode 100644 index 0000000000..33e7518fac --- /dev/null +++ b/.changeset/responses-incomplete-finish-reason.md @@ -0,0 +1,5 @@ +--- +'@tanstack/openai-base': patch +--- + +A Responses stream that ends with `response.incomplete` because of `max_output_tokens` or `content_filter` now finishes with `RUN_FINISHED` carrying the response's usage and `finishReason` `'length'` or `'content_filter'`, like the Chat Completions adapter, instead of `RUN_ERROR`. `response.failed` and an incomplete response with another or no reason still end in `RUN_ERROR`. diff --git a/packages/openai-base/src/adapters/responses-text.ts b/packages/openai-base/src/adapters/responses-text.ts index 8dedd805f7..b3d1dfc291 100644 --- a/packages/openai-base/src/adapters/responses-text.ts +++ b/packages/openai-base/src/adapters/responses-text.ts @@ -1767,6 +1767,37 @@ export abstract class OpenAIBaseResponsesTextAdapter< } hasEmittedTextMessageStart = false } + // A response cut off by a known limit is a finished-but-capped + // answer, not a failure: end it like the chat-completions adapter + // does for finish_reason 'length' / 'content_filter', keeping usage. + const cappedFinishReason = + chunk.type === 'response.incomplete' + ? chunk.response.incomplete_details?.reason === + 'max_output_tokens' + ? ('length' as const) + : chunk.response.incomplete_details?.reason === 'content_filter' + ? ('content_filter' as const) + : undefined + : undefined + if (cappedFinishReason) { + yield { + type: EventType.RUN_FINISHED, + ...(responseId ? { responseId } : {}), + runId: aguiState.runId, + threadId: aguiState.threadId, + model: chunk.response.model || options.model, + timestamp: Date.now(), + ...(chunk.response.usage && { + usage: this.responsesTokenUsage( + chunk.response.usage, + chunk.response, + ), + }), + finishReason: cappedFinishReason, + } + runFinishedEmitted = true + return + } // Coalesce error + incomplete_details into a single RUN_ERROR // payload — emitting two distinct events for one terminal upstream // event would force consumers to handle a non-existent ordering. diff --git a/packages/openai-base/tests/responses-text.test.ts b/packages/openai-base/tests/responses-text.test.ts index f5e668f9dc..e34c827256 100644 --- a/packages/openai-base/tests/responses-text.test.ts +++ b/packages/openai-base/tests/responses-text.test.ts @@ -2221,7 +2221,7 @@ describe('OpenAIBaseResponsesTextAdapter', () => { }) describe('error handling', () => { - it('flushes completed web searches before RUN_ERROR on incomplete response', async () => { + it('flushes completed web searches before RUN_FINISHED on a capped response', async () => { const webSearchCall = { type: 'web_search_call', id: 'ws-incomplete', @@ -2278,15 +2278,18 @@ describe('OpenAIBaseResponsesTextAdapter', () => { const toolEndIndex = chunks.findIndex( (chunk) => chunk.type === EventType.TOOL_CALL_END, ) - const runErrorIndex = chunks.findIndex( - (chunk) => chunk.type === EventType.RUN_ERROR, + const runFinishedIndex = chunks.findIndex( + (chunk) => chunk.type === EventType.RUN_FINISHED, ) expect(toolStartIndex).toBeGreaterThanOrEqual(0) expect(toolEndIndex).toBeGreaterThan(toolStartIndex) - expect(runErrorIndex).toBeGreaterThan(toolEndIndex) + expect(runFinishedIndex).toBeGreaterThan(toolEndIndex) expect( - chunks.filter((chunk) => chunk.type === EventType.RUN_ERROR), + chunks.filter((chunk) => chunk.type === EventType.RUN_FINISHED), ).toHaveLength(1) + expect( + chunks.filter((chunk) => chunk.type === EventType.RUN_ERROR), + ).toHaveLength(0) expect(chunks[toolStartIndex]).toMatchObject({ toolCallId: 'ws-incomplete', metadata: { @@ -2412,7 +2415,7 @@ describe('OpenAIBaseResponsesTextAdapter', () => { } }) - it('emits RUN_ERROR on response.incomplete event', async () => { + it('emits RUN_FINISHED with finishReason length and usage on a max_output_tokens response.incomplete', async () => { const streamChunks = [ { type: 'response.incomplete', @@ -2423,6 +2426,84 @@ describe('OpenAIBaseResponsesTextAdapter', () => { incomplete_details: { reason: 'max_output_tokens', }, + usage: { + input_tokens: 19, + input_tokens_details: { cached_tokens: 0 }, + output_tokens: 16, + output_tokens_details: { reasoning_tokens: 0 }, + total_tokens: 35, + }, + }, + }, + ] + + setupMockResponsesClient(streamChunks) + const adapter = new TestResponsesAdapter(testConfig, 'test-model') + const chunks: Array = [] + + for await (const chunk of adapter.chatStream({ + threadId: 'thread-1', + logger: testLogger, + model: 'test-model', + messages: [{ role: 'user', content: 'Write a long story' }], + })) { + chunks.push(chunk) + } + + expect(chunks.filter((c) => c.type === 'RUN_ERROR')).toHaveLength(0) + const finished = chunks.filter((c) => c.type === 'RUN_FINISHED') + expect(finished).toHaveLength(1) + expect(finished[0]).toMatchObject({ + finishReason: 'length', + usage: { promptTokens: 19, completionTokens: 16, totalTokens: 35 }, + }) + }) + + it('emits RUN_FINISHED with finishReason content_filter on a content_filter response.incomplete', async () => { + const streamChunks = [ + { + type: 'response.incomplete', + response: { + id: 'resp-123', + model: 'test-model', + status: 'incomplete', + incomplete_details: { + reason: 'content_filter', + }, + }, + }, + ] + + setupMockResponsesClient(streamChunks) + const adapter = new TestResponsesAdapter(testConfig, 'test-model') + const chunks: Array = [] + + for await (const chunk of adapter.chatStream({ + threadId: 'thread-1', + logger: testLogger, + model: 'test-model', + messages: [{ role: 'user', content: 'Write a long story' }], + })) { + chunks.push(chunk) + } + + expect(chunks.filter((c) => c.type === 'RUN_ERROR')).toHaveLength(0) + expect(chunks.filter((c) => c.type === 'RUN_FINISHED')).toMatchObject([ + { finishReason: 'content_filter' }, + ]) + }) + + it('emits RUN_ERROR on response.incomplete without a known reason', async () => { + const streamChunks = [ + { + type: 'response.incomplete', + response: { + id: 'resp-123', + model: 'test-model', + status: 'incomplete', + incomplete_details: { + reason: 'max_messages', + }, }, }, ] @@ -2443,8 +2524,7 @@ describe('OpenAIBaseResponsesTextAdapter', () => { const errorChunks = chunks.filter((c) => c.type === 'RUN_ERROR') expect(errorChunks.length).toBeGreaterThan(0) const incompleteError = errorChunks.find( - (c) => - c.type === 'RUN_ERROR' && c.error!.message === 'max_output_tokens', + (c) => c.type === 'RUN_ERROR' && c.error!.message === 'max_messages', ) expect(incompleteError).toBeDefined() }) From 500fe98da48dc49c81103bb1e1335ac3f33738df Mon Sep 17 00:00:00 2001 From: Alex Castillo Date: Tue, 27 Oct 2026 22:42:06 -0400 Subject: [PATCH 2/2] test(e2e): cover a Responses max_output_tokens cutoff ending in RUN_FINISHED Co-Authored-By: Claude Sonnet 5.5 --- testing/e2e/src/routeTree.gen.ts | 21 +++++ .../src/routes/api.responses-incomplete.ts | 59 +++++++++++++ .../e2e/tests/responses-incomplete.spec.ts | 84 +++++++++++++++++++ 3 files changed, 164 insertions(+) create mode 100644 testing/e2e/src/routes/api.responses-incomplete.ts create mode 100644 testing/e2e/tests/responses-incomplete.spec.ts diff --git a/testing/e2e/src/routeTree.gen.ts b/testing/e2e/src/routeTree.gen.ts index 445df11af5..77424a2ad0 100644 --- a/testing/e2e/src/routeTree.gen.ts +++ b/testing/e2e/src/routeTree.gen.ts @@ -179,6 +179,7 @@ import { Route as ApiProviderSearchMetadataWireRouteImport } from './routes/api. import { Route as ApiProviderToolDispatchWireRouteImport } from './routes/api.provider-tool-dispatch-wire' import { Route as ApiReasoningWireRouteImport } from './routes/api.reasoning-wire' import { Route as ApiResponsesAnswerItemsWireRouteImport } from './routes/api.responses-answer-items-wire' +import { Route as ApiResponsesIncompleteRouteImport } from './routes/api.responses-incomplete' import { Route as ApiRetryAfterRouteImport } from './routes/api.retry-after' import { Route as ApiSandboxDurabilityRouteImport } from './routes/api.sandbox-durability' import { Route as ApiSandboxFilePersistenceRouteImport } from './routes/api.sandbox-file-persistence' @@ -1106,6 +1107,11 @@ const ApiResponsesAnswerItemsWireRoute = path: '/api/responses-answer-items-wire', getParentRoute: () => rootRouteImport, } as any) +const ApiResponsesIncompleteRoute = ApiResponsesIncompleteRouteImport.update({ + id: '/api/responses-incomplete', + path: '/api/responses-incomplete', + getParentRoute: () => rootRouteImport, +} as any) const ApiRetryAfterRoute = ApiRetryAfterRouteImport.update({ id: '/api/retry-after', path: '/api/retry-after', @@ -1437,6 +1443,7 @@ export interface FileRoutesByFullPath { '/api/provider-tool-dispatch-wire': typeof ApiProviderToolDispatchWireRoute '/api/reasoning-wire': typeof ApiReasoningWireRoute '/api/responses-answer-items-wire': typeof ApiResponsesAnswerItemsWireRoute + '/api/responses-incomplete': typeof ApiResponsesIncompleteRoute '/api/retry-after': typeof ApiRetryAfterRoute '/api/sandbox-durability': typeof ApiSandboxDurabilityRoute '/api/sandbox-file-persistence': typeof ApiSandboxFilePersistenceRoute @@ -1640,6 +1647,7 @@ export interface FileRoutesByTo { '/api/provider-tool-dispatch-wire': typeof ApiProviderToolDispatchWireRoute '/api/reasoning-wire': typeof ApiReasoningWireRoute '/api/responses-answer-items-wire': typeof ApiResponsesAnswerItemsWireRoute + '/api/responses-incomplete': typeof ApiResponsesIncompleteRoute '/api/retry-after': typeof ApiRetryAfterRoute '/api/sandbox-durability': typeof ApiSandboxDurabilityRoute '/api/sandbox-file-persistence': typeof ApiSandboxFilePersistenceRoute @@ -1844,6 +1852,7 @@ export interface FileRoutesById { '/api/provider-tool-dispatch-wire': typeof ApiProviderToolDispatchWireRoute '/api/reasoning-wire': typeof ApiReasoningWireRoute '/api/responses-answer-items-wire': typeof ApiResponsesAnswerItemsWireRoute + '/api/responses-incomplete': typeof ApiResponsesIncompleteRoute '/api/retry-after': typeof ApiRetryAfterRoute '/api/sandbox-durability': typeof ApiSandboxDurabilityRoute '/api/sandbox-file-persistence': typeof ApiSandboxFilePersistenceRoute @@ -2049,6 +2058,7 @@ export interface FileRouteTypes { | '/api/provider-tool-dispatch-wire' | '/api/reasoning-wire' | '/api/responses-answer-items-wire' + | '/api/responses-incomplete' | '/api/retry-after' | '/api/sandbox-durability' | '/api/sandbox-file-persistence' @@ -2252,6 +2262,7 @@ export interface FileRouteTypes { | '/api/provider-tool-dispatch-wire' | '/api/reasoning-wire' | '/api/responses-answer-items-wire' + | '/api/responses-incomplete' | '/api/retry-after' | '/api/sandbox-durability' | '/api/sandbox-file-persistence' @@ -2455,6 +2466,7 @@ export interface FileRouteTypes { | '/api/provider-tool-dispatch-wire' | '/api/reasoning-wire' | '/api/responses-answer-items-wire' + | '/api/responses-incomplete' | '/api/retry-after' | '/api/sandbox-durability' | '/api/sandbox-file-persistence' @@ -2659,6 +2671,7 @@ export interface RootRouteChildren { ApiProviderToolDispatchWireRoute: typeof ApiProviderToolDispatchWireRoute ApiReasoningWireRoute: typeof ApiReasoningWireRoute ApiResponsesAnswerItemsWireRoute: typeof ApiResponsesAnswerItemsWireRoute + ApiResponsesIncompleteRoute: typeof ApiResponsesIncompleteRoute ApiRetryAfterRoute: typeof ApiRetryAfterRoute ApiSandboxDurabilityRoute: typeof ApiSandboxDurabilityRoute ApiSandboxFilePersistenceRoute: typeof ApiSandboxFilePersistenceRoute @@ -3880,6 +3893,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiResponsesAnswerItemsWireRouteImport parentRoute: typeof rootRouteImport } + '/api/responses-incomplete': { + id: '/api/responses-incomplete' + path: '/api/responses-incomplete' + fullPath: '/api/responses-incomplete' + preLoaderRoute: typeof ApiResponsesIncompleteRouteImport + parentRoute: typeof rootRouteImport + } '/api/retry-after': { id: '/api/retry-after' path: '/api/retry-after' @@ -4334,6 +4354,7 @@ const rootRouteChildren: RootRouteChildren = { ApiProviderToolDispatchWireRoute: ApiProviderToolDispatchWireRoute, ApiReasoningWireRoute: ApiReasoningWireRoute, ApiResponsesAnswerItemsWireRoute: ApiResponsesAnswerItemsWireRoute, + ApiResponsesIncompleteRoute: ApiResponsesIncompleteRoute, ApiRetryAfterRoute: ApiRetryAfterRoute, ApiSandboxDurabilityRoute: ApiSandboxDurabilityRoute, ApiSandboxFilePersistenceRoute: ApiSandboxFilePersistenceRoute, diff --git a/testing/e2e/src/routes/api.responses-incomplete.ts b/testing/e2e/src/routes/api.responses-incomplete.ts new file mode 100644 index 0000000000..132b7b8c7f --- /dev/null +++ b/testing/e2e/src/routes/api.responses-incomplete.ts @@ -0,0 +1,59 @@ +import { createFileRoute } from '@tanstack/react-router' +import { chat } from '@tanstack/ai' +import { createOpenaiChat } from '@tanstack/ai-openai' + +const DUMMY_KEY = 'sk-e2e-test-dummy-key' + +/** + * Streams one OpenAI Responses chat against the stub server the spec passes in + * `baseURL`. aimock reports a capped response as `response.completed`, so the + * spec serves a real `response.incomplete` event itself (issue #1916). Returns + * the terminal chunk `chat()` yields and what the middleware hooks saw. + */ +export const Route = createFileRoute('/api/responses-incomplete')({ + server: { + handlers: { + POST: async ({ request }) => { + const { baseURL } = (await request.json()) as { baseURL: string } + const adapter = createOpenaiChat('gpt-4.1-nano', DUMMY_KEY, { baseURL }) + + const hooks: Array = [] + const usage: Array = [] + let terminal: Record | undefined + try { + for await (const chunk of chat({ + threadId: 'responses-incomplete', + adapter, + modelOptions: { max_output_tokens: 16 }, + messages: [{ role: 'user', content: 'Count from 1 to 300.' }], + middleware: [ + { + name: 'probe', + onUsage: (_ctx, info) => { + usage.push(info) + }, + onFinish: (_ctx, info) => { + hooks.push(`onFinish:${info.finishReason}`) + }, + onError: () => { + hooks.push('onError') + }, + }, + ], + })) { + if (chunk.type === 'RUN_FINISHED' || chunk.type === 'RUN_ERROR') { + terminal = { type: chunk.type, usage: chunk.usage } + } + } + } catch (error) { + return Response.json({ + ok: false, + error: error instanceof Error ? error.message : String(error), + }) + } + + return Response.json({ ok: true, terminal, hooks, usage }) + }, + }, + }, +}) diff --git a/testing/e2e/tests/responses-incomplete.spec.ts b/testing/e2e/tests/responses-incomplete.spec.ts new file mode 100644 index 0000000000..7a2c511046 --- /dev/null +++ b/testing/e2e/tests/responses-incomplete.spec.ts @@ -0,0 +1,84 @@ +import { createServer } from 'node:http' +import { test, expect } from './fixtures' + +/** + * Regression for #1916. A Responses stream that hits `max_output_tokens` ends + * with `response.incomplete`, which carries the response's usage. It must end + * the run like a Chat Completions `length` cutoff: `RUN_FINISHED` with usage, + * `onFinish` with `finishReason: 'length'`, and no `RUN_ERROR`. + * + * aimock reports a capped response as `response.completed`, so this spec + * serves the real `response.incomplete` event from a local SSE server. + */ +const events = [ + { + type: 'response.created', + response: { id: 'resp_1916', model: 'gpt-4.1-nano', status: 'in_progress' }, + }, + { type: 'response.output_text.delta', delta: '1, 2, 3, 4, 5, 6, 7, 8' }, + { + type: 'response.incomplete', + response: { + id: 'resp_1916', + model: 'gpt-4.1-nano', + status: 'incomplete', + incomplete_details: { reason: 'max_output_tokens' }, + output: [], + usage: { + input_tokens: 19, + input_tokens_details: { cached_tokens: 0 }, + output_tokens: 16, + output_tokens_details: { reasoning_tokens: 0 }, + total_tokens: 35, + }, + }, + }, +] + +test.describe('openai responses — max_output_tokens stop', () => { + test('ends with RUN_FINISHED, usage and finishReason length', async ({ + request, + }) => { + const server = createServer((req, res) => { + req.resume() + res.writeHead(200, { 'Content-Type': 'text/event-stream' }) + for (const event of events) { + res.write(`event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`) + } + res.end() + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + try { + const address = server.address() + if (!address || typeof address === 'string') + throw new Error('Missing test server port') + + const res = await request.post('/api/responses-incomplete', { + data: { baseURL: `http://127.0.0.1:${address.port}/v1` }, + }) + expect(res.ok()).toBe(true) + + const { ok, error, terminal, hooks, usage } = (await res.json()) as { + ok: boolean + error?: string + terminal?: { type: string; usage?: Record } + hooks: Array + usage: Array> + } + + expect(error ?? null).toBeNull() + expect(ok).toBe(true) + expect(terminal?.type).toBe('RUN_FINISHED') + expect(terminal?.usage).toEqual({ + promptTokens: 19, + completionTokens: 16, + totalTokens: 35, + }) + expect(hooks).toEqual(['onFinish:length']) + expect(usage).toHaveLength(1) + } finally { + server.closeAllConnections() + await new Promise((resolve) => server.close(() => resolve())) + } + }) +})