Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/quiet-dragons-write.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/openai-base': patch
---

Recover successful Responses API text that is present only on the completed response event.
45 changes: 45 additions & 0 deletions packages/openai-base/src/adapters/responses-text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' &&
Expand Down Expand Up @@ -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
Expand Down
71 changes: 71 additions & 0 deletions packages/openai-base/tests/responses-text.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<StreamChunk> = []

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 = [
{
Expand Down
22 changes: 22 additions & 0 deletions testing/e2e/src/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -1084,6 +1105,7 @@ const rootRouteChildren: RootRouteChildren = {
ApiMcpTestRoute: ApiMcpTestRoute,
ApiMiddlewareTestRoute: ApiMiddlewareTestRoute,
ApiMultimodalToolResultWireRoute: ApiMultimodalToolResultWireRoute,
ApiOpenaiCompletedResponseTextRoute: ApiOpenaiCompletedResponseTextRoute,
ApiOpenaiShellSkillsWireRoute: ApiOpenaiShellSkillsWireRoute,
ApiOpenaiUsageDetailsRoute: ApiOpenaiUsageDetailsRoute,
ApiOpenrouterCostRoute: ApiOpenrouterCostRoute,
Expand Down
95 changes: 95 additions & 0 deletions testing/e2e/src/routes/api.openai-completed-response-text.ts
Original file line number Diff line number Diff line change
@@ -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<Uint8Array> {
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<Uint8Array>({
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 })
},
},
},
})
14 changes: 14 additions & 0 deletions testing/e2e/tests/openai-completed-response-text.spec.ts
Original file line number Diff line number Diff line change
@@ -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',
})
})
})