diff --git a/.changeset/quiet-dragons-write.md b/.changeset/quiet-dragons-write.md new file mode 100644 index 000000000..ef9492af2 --- /dev/null +++ b/.changeset/quiet-dragons-write.md @@ -0,0 +1,5 @@ +--- +'@tanstack/openai-base': patch +--- + +Recover successful Responses API text that is present only on the completed response event. diff --git a/packages/openai-base/src/adapters/responses-text.ts b/packages/openai-base/src/adapters/responses-text.ts index d48018209..bb2b9e063 100644 --- a/packages/openai-base/src/adapters/responses-text.ts +++ b/packages/openai-base/src/adapters/responses-text.ts @@ -1063,6 +1063,17 @@ export abstract class OpenAIBaseResponsesTextAdapter< // handle content_part added events for text, reasoning and refusals if (chunk.type === 'response.content_part.added') { const contentPart = chunk.part + // The Responses API can announce a text part with an empty + // placeholder before putting the actual text only on the completed + // response. An empty placeholder is not streamed content and must + // not suppress the completion backstop below. + if ( + (contentPart.type === 'output_text' || + contentPart.type === 'reasoning_text') && + !contentPart.text + ) { + continue + } // Emit TEXT_MESSAGE_START if this is text content if ( contentPart.type === 'output_text' && @@ -1396,6 +1407,40 @@ export abstract class OpenAIBaseResponsesTextAdapter< } if (chunk.type === 'response.completed') { + // Some Responses API streams, notably reasoning-model responses, + // can omit text deltas and carry the successful final text only in + // response.completed.output. Recover that text so consumers never + // observe an empty result for a successful response. + const completedText = chunk.response.output + .flatMap((item) => (item.type === 'message' ? item.content : [])) + .filter((part) => part.type === 'output_text') + .map((part) => part.text) + .join('') + + if (accumulatedContent.length === 0 && completedText.length > 0) { + if (!hasEmittedTextMessageStart) { + hasEmittedTextMessageStart = true + yield { + type: EventType.TEXT_MESSAGE_START, + messageId: aguiState.messageId, + model: model || options.model, + timestamp: Date.now(), + role: 'assistant', + } + } + + accumulatedContent = completedText + hasStreamedContentDeltas = true + yield { + type: EventType.TEXT_MESSAGE_CONTENT, + messageId: aguiState.messageId, + model: model || options.model, + timestamp: Date.now(), + delta: completedText, + content: accumulatedContent, + } + } + // Final backstop for function_call lifecycle: if a function_call // appears in `response.output[]` but was never matched by an // output_item.added/done with a name, recover the missing START diff --git a/packages/openai-base/tests/responses-text.test.ts b/packages/openai-base/tests/responses-text.test.ts index 884954267..8e67b3ba2 100644 --- a/packages/openai-base/tests/responses-text.test.ts +++ b/packages/openai-base/tests/responses-text.test.ts @@ -1398,6 +1398,77 @@ describe('OpenAIBaseResponsesTextAdapter', () => { expect(startIdx).toBeLessThan(contentIdx) }) + it('recovers final text from response.completed when no text was streamed', async () => { + const streamChunks = [ + { + type: 'response.created', + response: { + id: 'resp-completed-text', + model: 'test-model', + status: 'in_progress', + }, + }, + { + type: 'response.content_part.added', + part: { type: 'output_text', text: '' }, + }, + { + type: 'response.completed', + response: { + id: 'resp-completed-text', + model: 'test-model', + status: 'completed', + output: [ + { + id: 'msg-completed-text', + type: 'message', + role: 'assistant', + content: [ + { + type: 'output_text', + text: 'Recovered final answer', + annotations: [], + }, + ], + }, + ], + usage: { input_tokens: 5, output_tokens: 3, total_tokens: 8 }, + }, + }, + ] + + setupMockResponsesClient(streamChunks) + const adapter = new TestResponsesAdapter(testConfig, 'test-model') + const chunks: Array = [] + + for await (const chunk of adapter.chatStream({ + logger: testLogger, + model: 'test-model', + messages: [{ role: 'user', content: 'Answer carefully' }], + })) { + chunks.push(chunk) + } + + const contentChunks = chunks.filter( + (chunk) => chunk.type === EventType.TEXT_MESSAGE_CONTENT, + ) + expect(contentChunks).toHaveLength(1) + expect(contentChunks[0]).toMatchObject({ + delta: 'Recovered final answer', + content: 'Recovered final answer', + }) + + const eventTypes = chunks.map((chunk) => chunk.type) + const startIndex = eventTypes.indexOf(EventType.TEXT_MESSAGE_START) + const contentIndex = eventTypes.indexOf(EventType.TEXT_MESSAGE_CONTENT) + const endIndex = eventTypes.indexOf(EventType.TEXT_MESSAGE_END) + const finishedIndex = eventTypes.indexOf(EventType.RUN_FINISHED) + expect(startIndex).toBeGreaterThanOrEqual(0) + expect(contentIndex).toBeGreaterThan(startIndex) + expect(endIndex).toBeGreaterThan(contentIndex) + expect(finishedIndex).toBeGreaterThan(endIndex) + }) + it('skips content_part.done when deltas were already streamed', async () => { const streamChunks = [ { diff --git a/testing/e2e/src/routeTree.gen.ts b/testing/e2e/src/routeTree.gen.ts index 14ffbf330..3cb3e0450 100644 --- a/testing/e2e/src/routeTree.gen.ts +++ b/testing/e2e/src/routeTree.gen.ts @@ -33,6 +33,7 @@ import { Route as ApiOpenrouterWebToolsWireRouteImport } from './routes/api.open import { Route as ApiOpenrouterCostRouteImport } from './routes/api.openrouter-cost' import { Route as ApiOpenaiUsageDetailsRouteImport } from './routes/api.openai-usage-details' import { Route as ApiOpenaiShellSkillsWireRouteImport } from './routes/api.openai-shell-skills-wire' +import { Route as ApiOpenaiCompletedResponseTextRouteImport } from './routes/api.openai-completed-response-text' import { Route as ApiMultimodalToolResultWireRouteImport } from './routes/api.multimodal-tool-result-wire' import { Route as ApiMiddlewareTestRouteImport } from './routes/api.middleware-test' import { Route as ApiMcpTestRouteImport } from './routes/api.mcp-test' @@ -181,6 +182,12 @@ const ApiOpenaiShellSkillsWireRoute = path: '/api/openai-shell-skills-wire', getParentRoute: () => rootRouteImport, } as any) +const ApiOpenaiCompletedResponseTextRoute = + ApiOpenaiCompletedResponseTextRouteImport.update({ + id: '/api/openai-completed-response-text', + path: '/api/openai-completed-response-text', + getParentRoute: () => rootRouteImport, + } as any) const ApiMultimodalToolResultWireRoute = ApiMultimodalToolResultWireRouteImport.update({ id: '/api/multimodal-tool-result-wire', @@ -335,6 +342,7 @@ export interface FileRoutesByFullPath { '/api/mcp-test': typeof ApiMcpTestRoute '/api/middleware-test': typeof ApiMiddlewareTestRoute '/api/multimodal-tool-result-wire': typeof ApiMultimodalToolResultWireRoute + '/api/openai-completed-response-text': typeof ApiOpenaiCompletedResponseTextRoute '/api/openai-shell-skills-wire': typeof ApiOpenaiShellSkillsWireRoute '/api/openai-usage-details': typeof ApiOpenaiUsageDetailsRoute '/api/openrouter-cost': typeof ApiOpenrouterCostRoute @@ -385,6 +393,7 @@ export interface FileRoutesByTo { '/api/mcp-test': typeof ApiMcpTestRoute '/api/middleware-test': typeof ApiMiddlewareTestRoute '/api/multimodal-tool-result-wire': typeof ApiMultimodalToolResultWireRoute + '/api/openai-completed-response-text': typeof ApiOpenaiCompletedResponseTextRoute '/api/openai-shell-skills-wire': typeof ApiOpenaiShellSkillsWireRoute '/api/openai-usage-details': typeof ApiOpenaiUsageDetailsRoute '/api/openrouter-cost': typeof ApiOpenrouterCostRoute @@ -436,6 +445,7 @@ export interface FileRoutesById { '/api/mcp-test': typeof ApiMcpTestRoute '/api/middleware-test': typeof ApiMiddlewareTestRoute '/api/multimodal-tool-result-wire': typeof ApiMultimodalToolResultWireRoute + '/api/openai-completed-response-text': typeof ApiOpenaiCompletedResponseTextRoute '/api/openai-shell-skills-wire': typeof ApiOpenaiShellSkillsWireRoute '/api/openai-usage-details': typeof ApiOpenaiUsageDetailsRoute '/api/openrouter-cost': typeof ApiOpenrouterCostRoute @@ -488,6 +498,7 @@ export interface FileRouteTypes { | '/api/mcp-test' | '/api/middleware-test' | '/api/multimodal-tool-result-wire' + | '/api/openai-completed-response-text' | '/api/openai-shell-skills-wire' | '/api/openai-usage-details' | '/api/openrouter-cost' @@ -538,6 +549,7 @@ export interface FileRouteTypes { | '/api/mcp-test' | '/api/middleware-test' | '/api/multimodal-tool-result-wire' + | '/api/openai-completed-response-text' | '/api/openai-shell-skills-wire' | '/api/openai-usage-details' | '/api/openrouter-cost' @@ -588,6 +600,7 @@ export interface FileRouteTypes { | '/api/mcp-test' | '/api/middleware-test' | '/api/multimodal-tool-result-wire' + | '/api/openai-completed-response-text' | '/api/openai-shell-skills-wire' | '/api/openai-usage-details' | '/api/openrouter-cost' @@ -639,6 +652,7 @@ export interface RootRouteChildren { ApiMcpTestRoute: typeof ApiMcpTestRoute ApiMiddlewareTestRoute: typeof ApiMiddlewareTestRoute ApiMultimodalToolResultWireRoute: typeof ApiMultimodalToolResultWireRoute + ApiOpenaiCompletedResponseTextRoute: typeof ApiOpenaiCompletedResponseTextRoute ApiOpenaiShellSkillsWireRoute: typeof ApiOpenaiShellSkillsWireRoute ApiOpenaiUsageDetailsRoute: typeof ApiOpenaiUsageDetailsRoute ApiOpenrouterCostRoute: typeof ApiOpenrouterCostRoute @@ -824,6 +838,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiOpenaiShellSkillsWireRouteImport parentRoute: typeof rootRouteImport } + '/api/openai-completed-response-text': { + id: '/api/openai-completed-response-text' + path: '/api/openai-completed-response-text' + fullPath: '/api/openai-completed-response-text' + preLoaderRoute: typeof ApiOpenaiCompletedResponseTextRouteImport + parentRoute: typeof rootRouteImport + } '/api/multimodal-tool-result-wire': { id: '/api/multimodal-tool-result-wire' path: '/api/multimodal-tool-result-wire' @@ -1084,6 +1105,7 @@ const rootRouteChildren: RootRouteChildren = { ApiMcpTestRoute: ApiMcpTestRoute, ApiMiddlewareTestRoute: ApiMiddlewareTestRoute, ApiMultimodalToolResultWireRoute: ApiMultimodalToolResultWireRoute, + ApiOpenaiCompletedResponseTextRoute: ApiOpenaiCompletedResponseTextRoute, ApiOpenaiShellSkillsWireRoute: ApiOpenaiShellSkillsWireRoute, ApiOpenaiUsageDetailsRoute: ApiOpenaiUsageDetailsRoute, ApiOpenrouterCostRoute: ApiOpenrouterCostRoute, diff --git a/testing/e2e/src/routes/api.openai-completed-response-text.ts b/testing/e2e/src/routes/api.openai-completed-response-text.ts new file mode 100644 index 000000000..9da0ee1b4 --- /dev/null +++ b/testing/e2e/src/routes/api.openai-completed-response-text.ts @@ -0,0 +1,95 @@ +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' +const FINAL_TEXT = 'Recovered from response.completed' + +function makeCompletionOnlyResponsesStream(): ReadableStream { + const encoder = new TextEncoder() + const responseId = 'resp_completion_only' + const events = [ + { + type: 'response.created', + response: { + id: responseId, + object: 'response', + status: 'in_progress', + model: 'gpt-5.2', + output: [], + }, + }, + { + type: 'response.content_part.added', + response_id: responseId, + item_id: 'msg_completion_only', + output_index: 0, + content_index: 0, + part: { type: 'output_text', text: '' }, + }, + { + type: 'response.completed', + response: { + id: responseId, + object: 'response', + status: 'completed', + model: 'gpt-5.2', + output: [ + { + id: 'msg_completion_only', + type: 'message', + role: 'assistant', + status: 'completed', + content: [ + { type: 'output_text', text: FINAL_TEXT, annotations: [] }, + ], + }, + ], + usage: { + input_tokens: 5, + output_tokens: 4, + total_tokens: 9, + }, + }, + }, + ] + + return new ReadableStream({ + start(controller) { + for (const event of events) { + controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`)) + } + controller.enqueue(encoder.encode('data: [DONE]\n\n')) + controller.close() + }, + }) +} + +export const Route = createFileRoute('/api/openai-completed-response-text')({ + server: { + handlers: { + POST: async () => { + const adapter = createOpenaiChat('gpt-5.2', DUMMY_KEY, { + fetch: async () => + new Response(makeCompletionOnlyResponsesStream(), { + status: 200, + headers: { 'Content-Type': 'text/event-stream' }, + }), + }) + + const text = await chat({ + adapter, + messages: [ + { + role: 'user', + content: '[completed-response-text] answer carefully', + }, + ], + stream: false, + }) + + return Response.json({ text }) + }, + }, + }, +}) diff --git a/testing/e2e/tests/openai-completed-response-text.spec.ts b/testing/e2e/tests/openai-completed-response-text.spec.ts new file mode 100644 index 000000000..9a9ac8166 --- /dev/null +++ b/testing/e2e/tests/openai-completed-response-text.spec.ts @@ -0,0 +1,14 @@ +import { test, expect } from './fixtures' + +test.describe('openai — completion-only response text', () => { + test('returns final text when response.completed is the only populated text event', async ({ + request, + }) => { + const response = await request.post('/api/openai-completed-response-text') + + expect(response.ok()).toBe(true) + await expect(response.json()).resolves.toEqual({ + text: 'Recovered from response.completed', + }) + }) +})