diff --git a/.changeset/fix-openai-reasoning-summaries.md b/.changeset/fix-openai-reasoning-summaries.md new file mode 100644 index 0000000..5a51218 --- /dev/null +++ b/.changeset/fix-openai-reasoning-summaries.md @@ -0,0 +1,5 @@ +--- +'@core-ai/openai': patch +--- + +Preserve spacing between OpenAI reasoning summary parts. diff --git a/packages/openai/src/chat-adapter.test.ts b/packages/openai/src/chat-adapter.test.ts index 8db538d..730069a 100644 --- a/packages/openai/src/chat-adapter.test.ts +++ b/packages/openai/src/chat-adapter.test.ts @@ -370,6 +370,55 @@ describe('mapGenerateResponse', () => { }); }); + it('should separate multiple reasoning summary parts', () => { + const response = asResponse({ + output: [ + { + type: 'reasoning', + summary: [ + { type: 'summary_text', text: 'first summary' }, + { type: 'summary_text', text: 'second summary' }, + ], + encrypted_content: 'enc_1', + }, + ], + status: 'completed', + }); + + const result = mapGenerateResponse(response); + + expect(result.reasoning).toBe('first summary\n\nsecond summary'); + expect(result.parts).toEqual([ + { + type: 'reasoning', + text: 'first summary\n\nsecond summary', + providerMetadata: { + openai: { encryptedContent: 'enc_1' }, + }, + }, + ]); + }); + + it('should separate multiple reasoning output items', () => { + const response = asResponse({ + output: [ + { + type: 'reasoning', + summary: [{ type: 'summary_text', text: 'first item' }], + }, + { + type: 'reasoning', + summary: [{ type: 'summary_text', text: 'second item' }], + }, + ], + status: 'completed', + }); + + expect(mapGenerateResponse(response).reasoning).toBe( + 'first item\n\nsecond item' + ); + }); + it('should return finishReason length when max_output_tokens', () => { const response = asResponse({ output: [ @@ -638,6 +687,7 @@ describe('transformStream', () => { expect(events).toEqual([ { type: 'reasoning-start' }, { type: 'reasoning-delta', text: 'first' }, + { type: 'reasoning-delta', text: '\n\n' }, { type: 'reasoning-delta', text: 'second' }, { type: 'reasoning-end', @@ -845,6 +895,7 @@ describe('transformStream', () => { expect(events).toEqual([ { type: 'reasoning-start' }, { type: 'reasoning-delta', text: 'alpha' }, + { type: 'reasoning-delta', text: '\n\n' }, { type: 'reasoning-delta', text: 'beta' }, { type: 'reasoning-end', @@ -870,7 +921,7 @@ describe('transformStream', () => { const reasoningDeltas = events.filter( (event) => event.type === 'reasoning-delta' ); - expect(reasoningDeltas).toHaveLength(2); + expect(reasoningDeltas).toHaveLength(3); }); it('should backfill reasoning from output_item.done summary when no deltas or .done text were seen', async () => { @@ -904,7 +955,7 @@ describe('transformStream', () => { expect(events).toEqual([ { type: 'reasoning-start' }, - { type: 'reasoning-delta', text: 'part onepart two' }, + { type: 'reasoning-delta', text: 'part one\n\npart two' }, { type: 'reasoning-end', providerMetadata: { @@ -971,6 +1022,7 @@ describe('transformStream', () => { expect(events).toEqual([ { type: 'reasoning-start' }, { type: 'reasoning-delta', text: 'one' }, + { type: 'reasoning-delta', text: '\n\n' }, { type: 'reasoning-delta', text: 'two' }, { type: 'reasoning-end', @@ -996,7 +1048,7 @@ describe('transformStream', () => { const reasoningDeltas = events.filter( (event) => event.type === 'reasoning-delta' ); - expect(reasoningDeltas).toHaveLength(2); + expect(reasoningDeltas).toHaveLength(3); }); it('should return finishReason length when stream is incomplete', async () => { diff --git a/packages/openai/src/chat-adapter.ts b/packages/openai/src/chat-adapter.ts index 6e8b938..7749035 100644 --- a/packages/openai/src/chat-adapter.ts +++ b/packages/openai/src/chat-adapter.ts @@ -50,6 +50,7 @@ export type OpenAIReasoningMetadata = { }; const ENCRYPTED_REASONING_INCLUDE = 'reasoning.encrypted_content'; +const REASONING_SUMMARY_SEPARATOR = '\n\n'; export function convertMessages(messages: Message[]): ResponseInputItem[] { return messages.flatMap(convertMessage); @@ -401,7 +402,7 @@ function mapReasoningPart( function getReasoningSummaryText( summary: ResponseReasoningItem['summary'] ): string { - return summary.map((item) => item.text).join(''); + return summary.map((item) => item.text).join(REASONING_SUMMARY_SEPARATOR); } function mapMessageTextParts( @@ -415,22 +416,27 @@ function mapMessageTextParts( } function getTextContent(parts: AssistantContentPart[]): string | null { - return getJoinedPartText(parts, 'text'); + return getJoinedPartText(parts, 'text', ''); } function getReasoningText(parts: AssistantContentPart[]): string | null { - return getJoinedPartText(parts, 'reasoning'); + return getJoinedPartText( + parts, + 'reasoning', + REASONING_SUMMARY_SEPARATOR + ); } function getJoinedPartText( parts: AssistantContentPart[], - type: 'text' | 'reasoning' + type: 'text' | 'reasoning', + separator: string ): string | null { const text = parts .flatMap((part) => part.type === type && 'text' in part ? [part.text] : [] ) - .join(''); + .join(separator); return text.length > 0 ? text : null; } @@ -488,6 +494,24 @@ type BufferedToolCall = { type ReasoningStartEvent = Extract; type ReasoningEndEvent = Extract; +type ReasoningSummaryPart = { + itemId: string; + summaryIndex: number; +}; + +function getReasoningSummaryKey(part: ReasoningSummaryPart): string { + return `${part.itemId}:${part.summaryIndex}`; +} + +function isSameReasoningSummaryPart( + left: ReasoningSummaryPart, + right: ReasoningSummaryPart +): boolean { + return ( + left.itemId === right.itemId && left.summaryIndex === right.summaryIndex + ); +} + function getReasoningStartTransition(reasoningStarted: boolean): { nextReasoningStarted: boolean; event: ReasoningStartEvent | null; @@ -539,6 +563,7 @@ export async function* transformStream( let latestResponse: Response | undefined; let reasoningStarted = false; + let latestReasoningSummaryPart: ReasoningSummaryPart | undefined; const upsertBufferedToolCall = ( outputIndex: number, @@ -565,12 +590,38 @@ export async function* transformStream( providerMetadata ); reasoningStarted = transition.nextReasoningStarted; + if (transition.event) { + latestReasoningSummaryPart = undefined; + } return transition.event; }; + const getReasoningSummarySeparatorEvent = ( + currentPart: ReasoningSummaryPart + ): Extract | null => { + const previousPart = latestReasoningSummaryPart; + latestReasoningSummaryPart = currentPart; + + if ( + previousPart === undefined || + isSameReasoningSummaryPart(previousPart, currentPart) + ) { + return null; + } + + return { + type: 'reasoning-delta', + text: REASONING_SUMMARY_SEPARATOR, + }; + }; + for await (const event of stream) { if (event.type === 'response.reasoning_summary_text.delta') { - seenSummaryDeltas.add(`${event.item_id}:${event.summary_index}`); + const summaryPart = { + itemId: event.item_id, + summaryIndex: event.summary_index, + }; + seenSummaryDeltas.add(getReasoningSummaryKey(summaryPart)); emittedReasoningItems.add(event.item_id); const reasoningStartEvent = getNextReasoningStartEvent(); @@ -578,6 +629,11 @@ export async function* transformStream( yield reasoningStartEvent; } + const separatorEvent = getReasoningSummarySeparatorEvent(summaryPart); + if (separatorEvent) { + yield separatorEvent; + } + yield { type: 'reasoning-delta', text: event.delta, @@ -586,7 +642,11 @@ export async function* transformStream( } if (event.type === 'response.reasoning_summary_text.done') { - const key = `${event.item_id}:${event.summary_index}`; + const summaryPart = { + itemId: event.item_id, + summaryIndex: event.summary_index, + }; + const key = getReasoningSummaryKey(summaryPart); if (!seenSummaryDeltas.has(key) && event.text.length > 0) { emittedReasoningItems.add(event.item_id); @@ -595,6 +655,12 @@ export async function* transformStream( yield reasoningStartEvent; } + const separatorEvent = + getReasoningSummarySeparatorEvent(summaryPart); + if (separatorEvent) { + yield separatorEvent; + } + yield { type: 'reasoning-delta', text: event.text,