diff --git a/patches/@ai-sdk__openai@3.0.71.patch b/patches/@ai-sdk__openai@3.0.71.patch new file mode 100644 index 00000000..11135d78 --- /dev/null +++ b/patches/@ai-sdk__openai@3.0.71.patch @@ -0,0 +1,191 @@ +diff --git a/dist/index.js b/dist/index.js +index f27f8c01885f41b8e43035f36075e2dc7e3bc148..abf9f82706c49f265ee6999c4d02b7e1a33b3605 100644 +--- a/dist/index.js ++++ b/dist/index.js +@@ -6122,7 +6122,7 @@ var OpenAIResponsesLanguageModel = class { + } + }); + } else if (value.item.type === "reasoning") { +- const activeReasoningPart = activeReasoning[value.item.id]; ++ const activeReasoningPart = activeReasoning[value.item.id] ?? { encryptedContent: null, summaryParts: {} }; + const summaryPartIndices = Object.entries( + activeReasoningPart.summaryParts + ).filter( +@@ -6253,7 +6253,7 @@ var OpenAIResponsesLanguageModel = class { + } + } else if (value.type === "response.reasoning_summary_part.added") { + if (value.summary_index > 0) { +- const activeReasoningPart = activeReasoning[value.item_id]; ++ const activeReasoningPart = activeReasoning[value.item_id] ?? (activeReasoning[value.item_id] = { encryptedContent: null, summaryParts: {} }); + activeReasoningPart.summaryParts[value.summary_index] = "active"; + for (const summaryIndex of Object.keys( + activeReasoningPart.summaryParts +@@ -6304,9 +6304,9 @@ var OpenAIResponsesLanguageModel = class { + } + } + }); +- activeReasoning[value.item_id].summaryParts[value.summary_index] = "concluded"; ++ (activeReasoning[value.item_id] ?? (activeReasoning[value.item_id] = { encryptedContent: null, summaryParts: {} })).summaryParts[value.summary_index] = "concluded"; + } else { +- activeReasoning[value.item_id].summaryParts[value.summary_index] = "can-conclude"; ++ (activeReasoning[value.item_id] ?? (activeReasoning[value.item_id] = { encryptedContent: null, summaryParts: {} })).summaryParts[value.summary_index] = "can-conclude"; + } + } else if (isResponseFinishedChunk(value)) { + finishReason = { +diff --git a/dist/index.mjs b/dist/index.mjs +index ed14861f8afe781d9af820afe82e78b3286283d9..6c31410fba40cbed351801751499a00e9acf4f99 100644 +--- a/dist/index.mjs ++++ b/dist/index.mjs +@@ -6226,7 +6226,7 @@ var OpenAIResponsesLanguageModel = class { + } + }); + } else if (value.item.type === "reasoning") { +- const activeReasoningPart = activeReasoning[value.item.id]; ++ const activeReasoningPart = activeReasoning[value.item.id] ?? { encryptedContent: null, summaryParts: {} }; + const summaryPartIndices = Object.entries( + activeReasoningPart.summaryParts + ).filter( +@@ -6357,7 +6357,7 @@ var OpenAIResponsesLanguageModel = class { + } + } else if (value.type === "response.reasoning_summary_part.added") { + if (value.summary_index > 0) { +- const activeReasoningPart = activeReasoning[value.item_id]; ++ const activeReasoningPart = activeReasoning[value.item_id] ?? (activeReasoning[value.item_id] = { encryptedContent: null, summaryParts: {} }); + activeReasoningPart.summaryParts[value.summary_index] = "active"; + for (const summaryIndex of Object.keys( + activeReasoningPart.summaryParts +@@ -6408,9 +6408,9 @@ var OpenAIResponsesLanguageModel = class { + } + } + }); +- activeReasoning[value.item_id].summaryParts[value.summary_index] = "concluded"; ++ (activeReasoning[value.item_id] ?? (activeReasoning[value.item_id] = { encryptedContent: null, summaryParts: {} })).summaryParts[value.summary_index] = "concluded"; + } else { +- activeReasoning[value.item_id].summaryParts[value.summary_index] = "can-conclude"; ++ (activeReasoning[value.item_id] ?? (activeReasoning[value.item_id] = { encryptedContent: null, summaryParts: {} })).summaryParts[value.summary_index] = "can-conclude"; + } + } else if (isResponseFinishedChunk(value)) { + finishReason = { +diff --git a/dist/internal/index.js b/dist/internal/index.js +index 141bfb10833c47aab12083cf3404e0ca9612232f..53b02919260d4fa7b9e71ebe301e1c2810740735 100644 +--- a/dist/internal/index.js ++++ b/dist/internal/index.js +@@ -6391,7 +6391,7 @@ var OpenAIResponsesLanguageModel = class { + } + }); + } else if (value.item.type === "reasoning") { +- const activeReasoningPart = activeReasoning[value.item.id]; ++ const activeReasoningPart = activeReasoning[value.item.id] ?? { encryptedContent: null, summaryParts: {} }; + const summaryPartIndices = Object.entries( + activeReasoningPart.summaryParts + ).filter( +@@ -6522,7 +6522,7 @@ var OpenAIResponsesLanguageModel = class { + } + } else if (value.type === "response.reasoning_summary_part.added") { + if (value.summary_index > 0) { +- const activeReasoningPart = activeReasoning[value.item_id]; ++ const activeReasoningPart = activeReasoning[value.item_id] ?? (activeReasoning[value.item_id] = { encryptedContent: null, summaryParts: {} }); + activeReasoningPart.summaryParts[value.summary_index] = "active"; + for (const summaryIndex of Object.keys( + activeReasoningPart.summaryParts +@@ -6573,9 +6573,9 @@ var OpenAIResponsesLanguageModel = class { + } + } + }); +- activeReasoning[value.item_id].summaryParts[value.summary_index] = "concluded"; ++ (activeReasoning[value.item_id] ?? (activeReasoning[value.item_id] = { encryptedContent: null, summaryParts: {} })).summaryParts[value.summary_index] = "concluded"; + } else { +- activeReasoning[value.item_id].summaryParts[value.summary_index] = "can-conclude"; ++ (activeReasoning[value.item_id] ?? (activeReasoning[value.item_id] = { encryptedContent: null, summaryParts: {} })).summaryParts[value.summary_index] = "can-conclude"; + } + } else if (isResponseFinishedChunk(value)) { + finishReason = { +diff --git a/dist/internal/index.mjs b/dist/internal/index.mjs +index 87a8d4b8628cf9343793e496e4720a1479030b3e..19e9d0b8cca087d540cac11ecc160b561f5b1583 100644 +--- a/dist/internal/index.mjs ++++ b/dist/internal/index.mjs +@@ -6471,7 +6471,7 @@ var OpenAIResponsesLanguageModel = class { + } + }); + } else if (value.item.type === "reasoning") { +- const activeReasoningPart = activeReasoning[value.item.id]; ++ const activeReasoningPart = activeReasoning[value.item.id] ?? { encryptedContent: null, summaryParts: {} }; + const summaryPartIndices = Object.entries( + activeReasoningPart.summaryParts + ).filter( +@@ -6602,7 +6602,7 @@ var OpenAIResponsesLanguageModel = class { + } + } else if (value.type === "response.reasoning_summary_part.added") { + if (value.summary_index > 0) { +- const activeReasoningPart = activeReasoning[value.item_id]; ++ const activeReasoningPart = activeReasoning[value.item_id] ?? (activeReasoning[value.item_id] = { encryptedContent: null, summaryParts: {} }); + activeReasoningPart.summaryParts[value.summary_index] = "active"; + for (const summaryIndex of Object.keys( + activeReasoningPart.summaryParts +@@ -6653,9 +6653,9 @@ var OpenAIResponsesLanguageModel = class { + } + } + }); +- activeReasoning[value.item_id].summaryParts[value.summary_index] = "concluded"; ++ (activeReasoning[value.item_id] ?? (activeReasoning[value.item_id] = { encryptedContent: null, summaryParts: {} })).summaryParts[value.summary_index] = "concluded"; + } else { +- activeReasoning[value.item_id].summaryParts[value.summary_index] = "can-conclude"; ++ (activeReasoning[value.item_id] ?? (activeReasoning[value.item_id] = { encryptedContent: null, summaryParts: {} })).summaryParts[value.summary_index] = "can-conclude"; + } + } else if (isResponseFinishedChunk(value)) { + finishReason = { +diff --git a/src/responses/openai-responses-language-model.ts b/src/responses/openai-responses-language-model.ts +index d9ad3bd18c7bb90ee83a60b1bfcfd0acc71fa5c5..743e91c130225ebcc44e04869d858ee46f4359d3 100644 +--- a/src/responses/openai-responses-language-model.ts ++++ b/src/responses/openai-responses-language-model.ts +@@ -1773,7 +1773,10 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 { + } satisfies InferSchema, + }); + } else if (value.item.type === 'reasoning') { +- const activeReasoningPart = activeReasoning[value.item.id]; ++ const activeReasoningPart = activeReasoning[value.item.id] ?? { ++ encryptedContent: null, ++ summaryParts: {}, ++ }; + + // get all active or can-conclude summary parts' ids + // to conclude ongoing reasoning parts: +@@ -1933,7 +1936,11 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 { + } else if (value.type === 'response.reasoning_summary_part.added') { + // the first reasoning start is pushed in isResponseOutputItemAddedReasoningChunk + if (value.summary_index > 0) { +- const activeReasoningPart = activeReasoning[value.item_id]!; ++ const activeReasoningPart = activeReasoning[value.item_id] ?? ++ (activeReasoning[value.item_id] = { ++ encryptedContent: null, ++ summaryParts: {}, ++ }); + + activeReasoningPart.summaryParts[value.summary_index] = + 'active'; +@@ -1999,15 +2006,19 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 { + }); + + // mark the summary part as concluded +- activeReasoning[value.item_id]!.summaryParts[ +- value.summary_index +- ] = 'concluded'; ++ (activeReasoning[value.item_id] ?? ++ (activeReasoning[value.item_id] = { ++ encryptedContent: null, ++ summaryParts: {}, ++ })).summaryParts[value.summary_index] = 'concluded'; + } else { + // mark the summary part as can-conclude only + // because we need to have a final summary part with the encrypted content +- activeReasoning[value.item_id]!.summaryParts[ +- value.summary_index +- ] = 'can-conclude'; ++ (activeReasoning[value.item_id] ?? ++ (activeReasoning[value.item_id] = { ++ encryptedContent: null, ++ summaryParts: {}, ++ })).summaryParts[value.summary_index] = 'can-conclude'; + } + } else if (isResponseFinishedChunk(value)) { + finishReason = { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 745c36ce..2141df15 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,7 @@ overrides: tmp: '>=0.2.6' patchedDependencies: + '@ai-sdk/openai@3.0.71': df300a5d22aecda3490a73885c5d63e34d4cd056d26fc5ba202ea4121d46adbf ink@6.8.0: ae5bd7c6d28e6a9440188c3c07fc7a83597933b06090aaa44830f65f3d103b5f importers: @@ -25,7 +26,7 @@ importers: version: 3.0.80(zod@4.4.3) '@ai-sdk/openai': specifier: ^3.0.71 - version: 3.0.71(zod@4.4.3) + version: 3.0.71(patch_hash=df300a5d22aecda3490a73885c5d63e34d4cd056d26fc5ba202ea4121d46adbf)(zod@4.4.3) '@ai-sdk/openai-compatible': specifier: 2.0.41 version: 2.0.41(zod@4.4.3) @@ -3744,7 +3745,7 @@ snapshots: '@ai-sdk/provider-utils': 4.0.23(zod@4.4.3) zod: 4.4.3 - '@ai-sdk/openai@3.0.71(zod@4.4.3)': + '@ai-sdk/openai@3.0.71(patch_hash=df300a5d22aecda3490a73885c5d63e34d4cd056d26fc5ba202ea4121d46adbf)(zod@4.4.3)': dependencies: '@ai-sdk/provider': 3.0.10 '@ai-sdk/provider-utils': 4.0.29(zod@4.4.3) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 6b3251d1..9e52cd59 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -29,4 +29,5 @@ onlyBuiltDependencies: - keytar patchedDependencies: + '@ai-sdk/openai@3.0.71': patches/@ai-sdk__openai@3.0.71.patch ink@6.8.0: patches/ink@6.8.0.patch diff --git a/source/ai-sdk-client/chat/chat-handler.spec.ts b/source/ai-sdk-client/chat/chat-handler.spec.ts index 95252a94..d765e466 100644 --- a/source/ai-sdk-client/chat/chat-handler.spec.ts +++ b/source/ai-sdk-client/chat/chat-handler.spec.ts @@ -1,4 +1,6 @@ import test from 'ava'; +import {createOpenAI} from '@ai-sdk/openai'; +import {streamText} from 'ai'; import type { AIProviderConfig, AISDKCoreTool, @@ -6,6 +8,7 @@ import type { StreamCallbacks, } from '@/types/index'; import type {LanguageModel} from 'ai'; +import {handleChat} from './chat-handler.js'; import type {ChatHandlerParams} from './chat-handler.js'; // Note: This file contains basic structure tests @@ -119,3 +122,172 @@ test('ChatHandlerParams accepts callbacks', t => { t.truthy(params.callbacks.onToolCall); t.truthy(params.callbacks.onFinish); }); + +test('handleChat returns streamed text when SDK final text is unavailable', async t => { + const streamedTokens: string[] = []; + const providerConfig: AIProviderConfig = { + name: 'TestProvider', + type: 'openai', + models: ['test-model'], + config: { + baseURL: 'https://api.test.com', + }, + }; + + const result = await handleChat({ + model: { + specificationVersion: 'v3', + provider: 'test-provider', + modelId: 'test-model', + doStream: async () => ({ + stream: new ReadableStream({ + start(controller) { + const usage = { + inputTokens: 1, + outputTokens: 1, + totalTokens: 2, + }; + controller.enqueue({type: 'text-start', id: '0'}); + controller.enqueue({type: 'text-delta', id: '0', delta: 'ok'}); + controller.enqueue({type: 'text-end', id: '0'}); + controller.enqueue({ + type: 'finish', + finishReason: 'stop', + usage, + }); + controller.close(); + }, + }), + }), + } as LanguageModel, + currentModel: 'test-model', + providerConfig, + messages: [{role: 'user', content: 'test'}], + tools: {}, + callbacks: { + onToken: token => streamedTokens.push(token), + }, + maxRetries: 0, + }); + + t.deepEqual(streamedTokens, ['ok']); + t.is(result.choices[0]?.message.content, 'ok'); +}); + +test('OpenAI Responses parser tolerates reasoning item completion without tracked summaries', async t => { + const provider = createOpenAI({ + apiKey: 'test-key', + fetch: async () => + new Response( + [ + toSse({ + type: 'response.created', + response: { + id: 'resp_1', + created_at: 1, + model: 'gpt-5.5', + }, + }), + toSse({ + type: 'response.output_item.done', + output_index: 0, + item: { + id: 'rs_1', + type: 'reasoning', + encrypted_content: null, + }, + }), + toSse({ + type: 'response.completed', + response: { + id: 'resp_1', + usage: { + input_tokens: 1, + output_tokens: 0, + total_tokens: 1, + }, + }, + }), + 'data: [DONE]\n\n', + ].join(''), + { + status: 200, + headers: {'content-type': 'text/event-stream'}, + }, + ), + }); + + const result = streamText({ + model: provider.responses('gpt-5.5'), + prompt: 'test', + }); + + await t.notThrowsAsync(async () => { + for await (const _chunk of result.fullStream) { + // Drain the stream to exercise the Responses parser. + } + }); +}); + +test('OpenAI Responses parser tolerates summary part events without tracked reasoning state', async t => { + const provider = createOpenAI({ + apiKey: 'test-key', + fetch: async () => + new Response( + [ + toSse({ + type: 'response.created', + response: { + id: 'resp_1', + created_at: 1, + model: 'gpt-5.5', + }, + }), + toSse({ + type: 'response.reasoning_summary_part.added', + item_id: 'rs_1', + output_index: 0, + summary_index: 1, + }), + toSse({ + type: 'response.reasoning_summary_part.done', + item_id: 'rs_1', + output_index: 0, + summary_index: 1, + part: {type: 'summary_text', text: ''}, + }), + toSse({ + type: 'response.completed', + response: { + id: 'resp_1', + usage: { + input_tokens: 1, + output_tokens: 0, + total_tokens: 1, + }, + }, + }), + 'data: [DONE]\n\n', + ].join(''), + { + status: 200, + headers: {'content-type': 'text/event-stream'}, + }, + ), + }); + + const result = streamText({ + model: provider.responses('gpt-5.5'), + prompt: 'test', + }); + + await t.notThrowsAsync(async () => { + for await (const _chunk of result.fullStream) { + // Drain the stream to exercise the Responses parser. + } + }); +}); + +function toSse(value: unknown): string { + return `data: ${JSON.stringify(value)}\n\n`; +} diff --git a/source/ai-sdk-client/chat/chat-handler.ts b/source/ai-sdk-client/chat/chat-handler.ts index 5a2db6ed..8ff13d4e 100644 --- a/source/ai-sdk-client/chat/chat-handler.ts +++ b/source/ai-sdk-client/chat/chat-handler.ts @@ -116,6 +116,7 @@ export async function handleChat( // reasoning-aware nudge depends on this for the GPT-5 case where the // SDK throws AI_NoOutputGeneratedError after a reasoning-only stream. let accumulatedReasoning = ''; + let accumulatedText = ''; return await withNewCorrelationContext(async _context => { try { @@ -248,6 +249,7 @@ export async function handleChat( } break; case 'text-delta': + accumulatedText += chunk.text; tokenBuffer += chunk.text; if (!flushTimer) { flushTimer = setTimeout(flushBuffer, FLUSH_INTERVAL_MS); @@ -329,7 +331,7 @@ export async function handleChat( ? convertAISDKToolCalls(resolvedToolCalls) : []; - const content = fullText; + const content = fullText || accumulatedText; // Calculate performance metrics const finalMetrics = endMetrics(metrics); @@ -487,19 +489,23 @@ export async function handleChat( // Hand control back to the conversation loop with an empty // response so its empty-turn handling (capped recursion, // reasoning-aware nudge) takes over instead of throwing. - logger.warn('Model produced no output; returning empty response', { - model: currentModel, - correlationId, - provider: providerConfig.name, - reasoningLength: accumulatedReasoning.length, - }); + logger.warn( + 'Model produced no output; returning streamed fallback response', + { + model: currentModel, + correlationId, + provider: providerConfig.name, + responseLength: accumulatedText.length, + reasoningLength: accumulatedReasoning.length, + }, + ); callbacks.onFinish?.(); return { choices: [ { message: { role: 'assistant', - content: '', + content: accumulatedText, reasoning: accumulatedReasoning || undefined, }, },