Skip to content
Merged
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/fix-openai-reasoning-summaries.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@core-ai/openai': patch
---

Preserve spacing between OpenAI reasoning summary parts.
58 changes: 55 additions & 3 deletions packages/openai/src/chat-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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',
Expand All @@ -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 () => {
Expand Down
80 changes: 73 additions & 7 deletions packages/openai/src/chat-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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(
Expand All @@ -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;
}

Expand Down Expand Up @@ -488,6 +494,24 @@ type BufferedToolCall = {
type ReasoningStartEvent = Extract<StreamEvent, { type: 'reasoning-start' }>;
type ReasoningEndEvent = Extract<StreamEvent, { type: 'reasoning-end' }>;

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;
Expand Down Expand Up @@ -539,6 +563,7 @@ export async function* transformStream(

let latestResponse: Response | undefined;
let reasoningStarted = false;
let latestReasoningSummaryPart: ReasoningSummaryPart | undefined;

const upsertBufferedToolCall = (
outputIndex: number,
Expand All @@ -565,19 +590,50 @@ export async function* transformStream(
providerMetadata
);
reasoningStarted = transition.nextReasoningStarted;
if (transition.event) {
latestReasoningSummaryPart = undefined;
}
return transition.event;
};

const getReasoningSummarySeparatorEvent = (
currentPart: ReasoningSummaryPart
): Extract<StreamEvent, { type: 'reasoning-delta' }> | 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();
if (reasoningStartEvent) {
yield reasoningStartEvent;
}

const separatorEvent = getReasoningSummarySeparatorEvent(summaryPart);
if (separatorEvent) {
yield separatorEvent;
}

yield {
type: 'reasoning-delta',
text: event.delta,
Expand All @@ -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);

Expand All @@ -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,
Expand Down
Loading