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
6 changes: 6 additions & 0 deletions .changeset/quiet-dragons-write.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@tanstack/openai-base': patch
'@tanstack/ai-openrouter': patch
---

Recover successful OpenAI and OpenRouter Responses API text that is present only on the completed response event.
81 changes: 81 additions & 0 deletions packages/ai-openrouter/src/adapters/responses-text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -984,6 +984,36 @@ export class OpenRouterResponsesTextAdapter<
}
}

// Some Responses-compatible providers omit text deltas and expose the
// completed text only on the dedicated done event.
if (
chunk.type === 'response.output_text.done' &&
chunk.text &&
accumulatedContent.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 = chunk.text
hasStreamedContentDeltas = true
yield {
type: EventType.TEXT_MESSAGE_CONTENT,
messageId: aguiState.messageId,
model: model || options.model,
timestamp: Date.now(),
delta: chunk.text,
content: accumulatedContent,
}
}

// Handle reasoning deltas
if (chunk.type === 'response.reasoning_text.delta' && chunk.delta) {
const reasoningDelta = Array.isArray(chunk.delta)
Expand Down Expand Up @@ -1061,6 +1091,17 @@ export class OpenRouterResponsesTextAdapter<
// handle content_part added events for text, reasoning and refusals
if (chunk.type === 'response.content_part.added' && chunk.part) {
const contentPart = chunk.part
// Some Responses-compatible providers announce an empty text part
// and put the actual text only on response.completed. Do not count
// that placeholder as streamed content; the completion backstop
// below must remain eligible to recover the final text.
if (
(contentPart.type === 'output_text' ||
contentPart.type === 'reasoning_text') &&
!contentPart.text
) {
continue
}
if (
contentPart.type === 'output_text' &&
!hasEmittedTextMessageStart
Expand Down Expand Up @@ -1344,6 +1385,45 @@ export class OpenRouterResponsesTextAdapter<
? responseObj.output
: []

const outputItemText = outputItems
.flatMap((item) =>
item.type === 'message' && Array.isArray(item.content)
? item.content
: [],
)
.filter((part) => part.type === 'output_text')
.map((part) => part.text)
.join('')
const completedText =
typeof responseObj.outputText === 'string' &&
responseObj.outputText.length > 0
? responseObj.outputText
: outputItemText

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.
for (const item of outputItems) {
if (item.type !== 'function_call' || !item.id) continue
Expand Down Expand Up @@ -1864,6 +1944,7 @@ function camelCaseResponseShape(
const out: Record<string, unknown> = { ...src }
if ('incomplete_details' in src)
out.incompleteDetails = src.incomplete_details
if ('output_text' in src) out.outputText = src.output_text
if (
'input_tokens' in src ||
'output_tokens' in src ||
Expand Down
197 changes: 197 additions & 0 deletions packages/ai-openrouter/tests/openrouter-responses-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -641,6 +641,203 @@ describe('OpenRouter responses adapter — stream event bridge', () => {
})
})

it('recovers final text from response.completed when no text was streamed', async () => {
setupMockSdkClient([
{
type: 'response.created',
sequenceNumber: 0,
response: { model: 'm', output: [] },
},
{
type: 'response.content_part.added',
sequenceNumber: 1,
outputIndex: 0,
contentIndex: 0,
itemId: 'msg_1',
part: { type: 'output_text', text: '' },
},
{
type: 'response.completed',
sequenceNumber: 2,
response: {
model: 'm',
output: [
{
id: 'msg_1',
type: 'message',
role: 'assistant',
status: 'completed',
content: [
{
type: 'output_text',
text: 'Recovered final answer',
annotations: [],
},
],
},
],
usage: { inputTokens: 5, outputTokens: 3, totalTokens: 8 },
},
},
])
const adapter = createAdapter()
const chunks: Array<StreamChunk> = []

for await (const chunk of adapter.chatStream({
model: 'openai/gpt-4o-mini' as any,
messages: [{ role: 'user', content: 'Answer carefully' }],
logger: testLogger,
})) {
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('recovers final text from response.output_text.done when no delta was streamed', async () => {
setupMockSdkClient([
{
type: 'response.created',
sequenceNumber: 0,
response: { model: 'm', output: [] },
},
{
type: 'response.output_text.done',
sequenceNumber: 1,
outputIndex: 0,
contentIndex: 0,
itemId: 'msg_1',
logprobs: [],
text: 'Recovered done-event answer',
},
{
type: 'response.completed',
sequenceNumber: 2,
response: {
model: 'm',
output: [],
usage: { inputTokens: 5, outputTokens: 3, totalTokens: 8 },
},
},
])
const adapter = createAdapter()
const chunks: Array<StreamChunk> = []

for await (const chunk of adapter.chatStream({
model: adapter.model,
messages: [{ role: 'user', content: 'Answer carefully' }],
logger: testLogger,
})) {
chunks.push(chunk)
}

expect(
chunks.filter((chunk) => chunk.type === EventType.TEXT_MESSAGE_CONTENT),
).toEqual([
expect.objectContaining({
delta: 'Recovered done-event answer',
content: 'Recovered done-event answer',
}),
])
})

it('recovers final text from response.completed outputText', async () => {
setupMockSdkClient([
{
type: 'response.created',
sequenceNumber: 0,
response: { model: 'm', output: [] },
},
{
type: 'response.completed',
sequenceNumber: 1,
response: {
model: 'm',
output: [],
outputText: 'Recovered outputText answer',
usage: { inputTokens: 5, outputTokens: 3, totalTokens: 8 },
},
},
])
const adapter = createAdapter()
const chunks: Array<StreamChunk> = []

for await (const chunk of adapter.chatStream({
model: adapter.model,
messages: [{ role: 'user', content: 'Answer carefully' }],
logger: testLogger,
})) {
chunks.push(chunk)
}

expect(
chunks.filter((chunk) => chunk.type === EventType.TEXT_MESSAGE_CONTENT),
).toEqual([
expect.objectContaining({
delta: 'Recovered outputText answer',
content: 'Recovered outputText answer',
}),
])
})

it('recovers final text from raw response.completed output_text', async () => {
setupMockSdkClient([
{
isUnknown: true,
raw: {
type: 'response.completed',
sequence_number: 1,
response: {
model: 'm',
output: [],
output_text: 'Recovered raw output_text answer',
usage: {
input_tokens: 5,
output_tokens: 3,
total_tokens: 8,
},
},
},
},
])
const adapter = createAdapter()
const chunks: Array<StreamChunk> = []

for await (const chunk of adapter.chatStream({
model: adapter.model,
messages: [{ role: 'user', content: 'Answer carefully' }],
logger: testLogger,
})) {
chunks.push(chunk)
}

expect(
chunks.filter((chunk) => chunk.type === EventType.TEXT_MESSAGE_CONTENT),
).toEqual([
expect.objectContaining({
delta: 'Recovered raw output_text answer',
content: 'Recovered raw output_text answer',
}),
])
})

it('routes function-call args through TOOL_CALL_START/ARGS/END', async () => {
setupMockSdkClient([
{
Expand Down
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
Loading