fix: preserve protocol parity across Claude fallbacks (#256)
* fix: preserve protocol parity across claude fallbacks * fix: tighten protocol parity across responses and anthropic fallbacks * fix: preserve stricter tool contracts across fallbacks * fix: unblock anthropic stream server build
This commit is contained in:
@@ -389,6 +389,54 @@ describe('serializeConvertedResponsesEvents', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('emits terminal done events before relaying an upstream response.completed event', () => {
|
||||
const state = createOpenAiResponsesAggregateState('gpt-5');
|
||||
const streamContext = createStreamTransformContext('gpt-5');
|
||||
const usage = {
|
||||
promptTokens: 2,
|
||||
completionTokens: 4,
|
||||
totalTokens: 6,
|
||||
};
|
||||
|
||||
serializeConvertedResponsesEvents({
|
||||
state,
|
||||
streamContext,
|
||||
usage,
|
||||
event: {
|
||||
contentDelta: 'hello world',
|
||||
} as any,
|
||||
});
|
||||
|
||||
const completionLines = serializeConvertedResponsesEvents({
|
||||
state,
|
||||
streamContext,
|
||||
usage,
|
||||
event: {
|
||||
responsesEventType: 'response.completed',
|
||||
responsesPayload: {
|
||||
type: 'response.completed',
|
||||
response: {
|
||||
id: 'resp_done_1',
|
||||
model: 'gpt-5',
|
||||
usage: {
|
||||
input_tokens: 2,
|
||||
output_tokens: 4,
|
||||
total_tokens: 6,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const events = parseSseEvents(completionLines);
|
||||
expect(events.map((entry) => entry.event ?? 'data')).toEqual([
|
||||
'response.output_text.done',
|
||||
'response.content_part.done',
|
||||
'response.output_item.done',
|
||||
'response.completed',
|
||||
]);
|
||||
});
|
||||
|
||||
it('emits canonical reasoning done events before response.completed when recovering sparse reasoning output', () => {
|
||||
const state = createOpenAiResponsesAggregateState('gpt-5');
|
||||
const streamContext = createStreamTransformContext('gpt-5');
|
||||
@@ -763,4 +811,433 @@ describe('serializeConvertedResponsesEvents', () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('emits terminal done events before forwarding an upstream response.completed event', () => {
|
||||
const state = createOpenAiResponsesAggregateState('gpt-5');
|
||||
const streamContext = createStreamTransformContext('gpt-5');
|
||||
const usage = {
|
||||
promptTokens: 2,
|
||||
completionTokens: 4,
|
||||
totalTokens: 6,
|
||||
};
|
||||
|
||||
serializeConvertedResponsesEvents({
|
||||
state,
|
||||
streamContext,
|
||||
usage,
|
||||
event: {
|
||||
contentDelta: 'hello world',
|
||||
} as any,
|
||||
});
|
||||
|
||||
const lines = serializeConvertedResponsesEvents({
|
||||
state,
|
||||
streamContext,
|
||||
usage,
|
||||
event: {
|
||||
responsesEventType: 'response.completed',
|
||||
responsesPayload: {
|
||||
type: 'response.completed',
|
||||
response: {
|
||||
id: 'resp_1',
|
||||
model: 'gpt-5',
|
||||
usage: {
|
||||
input_tokens: 2,
|
||||
output_tokens: 4,
|
||||
total_tokens: 6,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const events = parseSseEvents(lines);
|
||||
expect(events.map((entry) => entry.event)).toEqual([
|
||||
'response.output_text.done',
|
||||
'response.content_part.done',
|
||||
'response.output_item.done',
|
||||
'response.completed',
|
||||
]);
|
||||
});
|
||||
|
||||
it('closes reasoning summary parts before forwarding an upstream response.completed event', () => {
|
||||
const state = createOpenAiResponsesAggregateState('gpt-5');
|
||||
const streamContext = createStreamTransformContext('gpt-5');
|
||||
const usage = {
|
||||
promptTokens: 1,
|
||||
completionTokens: 2,
|
||||
totalTokens: 3,
|
||||
};
|
||||
|
||||
serializeConvertedResponsesEvents({
|
||||
state,
|
||||
streamContext,
|
||||
usage,
|
||||
event: {
|
||||
reasoningDelta: 'Think ',
|
||||
} as any,
|
||||
});
|
||||
|
||||
const lines = serializeConvertedResponsesEvents({
|
||||
state,
|
||||
streamContext,
|
||||
usage,
|
||||
event: {
|
||||
responsesEventType: 'response.completed',
|
||||
responsesPayload: {
|
||||
type: 'response.completed',
|
||||
response: {
|
||||
id: 'resp_reasoning_done',
|
||||
model: 'gpt-5',
|
||||
usage: {
|
||||
input_tokens: 1,
|
||||
output_tokens: 2,
|
||||
total_tokens: 3,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const events = parseSseEvents(lines);
|
||||
expect(events.map((entry) => entry.event)).toEqual([
|
||||
'response.reasoning_summary_text.done',
|
||||
'response.reasoning_summary_part.done',
|
||||
'response.output_item.done',
|
||||
'response.completed',
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves response.incomplete as a first-class terminal event', () => {
|
||||
const state = createOpenAiResponsesAggregateState('gpt-5');
|
||||
const streamContext = createStreamTransformContext('gpt-5');
|
||||
const usage = {
|
||||
promptTokens: 2,
|
||||
completionTokens: 4,
|
||||
totalTokens: 6,
|
||||
};
|
||||
|
||||
serializeConvertedResponsesEvents({
|
||||
state,
|
||||
streamContext,
|
||||
usage,
|
||||
event: {
|
||||
contentDelta: 'partial answer',
|
||||
} as any,
|
||||
});
|
||||
|
||||
const lines = serializeConvertedResponsesEvents({
|
||||
state,
|
||||
streamContext,
|
||||
usage,
|
||||
event: {
|
||||
responsesEventType: 'response.incomplete',
|
||||
responsesPayload: {
|
||||
type: 'response.incomplete',
|
||||
response: {
|
||||
id: 'resp_incomplete_1',
|
||||
model: 'gpt-5',
|
||||
status: 'incomplete',
|
||||
incomplete_details: {
|
||||
reason: 'max_output_tokens',
|
||||
},
|
||||
usage: {
|
||||
input_tokens: 2,
|
||||
output_tokens: 4,
|
||||
total_tokens: 6,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const events = parseSseEvents(lines);
|
||||
expect(events.map((entry) => entry.event)).toEqual([
|
||||
'response.output_text.done',
|
||||
'response.content_part.done',
|
||||
'response.output_item.done',
|
||||
'response.incomplete',
|
||||
]);
|
||||
expect(events[3]?.payload).toMatchObject({
|
||||
type: 'response.incomplete',
|
||||
response: {
|
||||
id: 'resp_incomplete_1',
|
||||
status: 'incomplete',
|
||||
incomplete_details: {
|
||||
reason: 'max_output_tokens',
|
||||
},
|
||||
output_text: 'partial answer',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('closes every unterminated message part and only emits output_text.done for text parts', () => {
|
||||
const state = createOpenAiResponsesAggregateState('gpt-5');
|
||||
const streamContext = createStreamTransformContext('gpt-5');
|
||||
const usage = {
|
||||
promptTokens: 1,
|
||||
completionTokens: 2,
|
||||
totalTokens: 3,
|
||||
};
|
||||
|
||||
serializeConvertedResponsesEvents({
|
||||
state,
|
||||
streamContext,
|
||||
usage,
|
||||
event: {
|
||||
responsesEventType: 'response.output_item.added',
|
||||
responsesPayload: {
|
||||
type: 'response.output_item.added',
|
||||
output_index: 0,
|
||||
item: {
|
||||
id: 'msg_multi_1',
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
status: 'in_progress',
|
||||
content: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
serializeConvertedResponsesEvents({
|
||||
state,
|
||||
streamContext,
|
||||
usage,
|
||||
event: {
|
||||
responsesEventType: 'response.content_part.added',
|
||||
responsesPayload: {
|
||||
type: 'response.content_part.added',
|
||||
output_index: 0,
|
||||
item_id: 'msg_multi_1',
|
||||
content_index: 0,
|
||||
part: {
|
||||
type: 'refusal',
|
||||
text: 'no',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
serializeConvertedResponsesEvents({
|
||||
state,
|
||||
streamContext,
|
||||
usage,
|
||||
event: {
|
||||
responsesEventType: 'response.content_part.added',
|
||||
responsesPayload: {
|
||||
type: 'response.content_part.added',
|
||||
output_index: 0,
|
||||
item_id: 'msg_multi_1',
|
||||
content_index: 1,
|
||||
part: {
|
||||
type: 'output_text',
|
||||
text: 'yes',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const lines = completeResponsesStream(state, streamContext, usage);
|
||||
const events = parseSseEvents(lines);
|
||||
|
||||
expect(events.map((entry) => entry.event ?? (entry.payload === '[DONE]' ? '[DONE]' : 'data'))).toEqual([
|
||||
'response.content_part.done',
|
||||
'response.output_text.done',
|
||||
'response.content_part.done',
|
||||
'response.output_item.done',
|
||||
'response.completed',
|
||||
'[DONE]',
|
||||
]);
|
||||
expect(events[0]?.payload).toMatchObject({
|
||||
type: 'response.content_part.done',
|
||||
content_index: 0,
|
||||
part: {
|
||||
type: 'refusal',
|
||||
text: 'no',
|
||||
},
|
||||
});
|
||||
expect(events[1]?.payload).toMatchObject({
|
||||
type: 'response.output_text.done',
|
||||
item_id: 'msg_multi_1',
|
||||
text: 'yes',
|
||||
});
|
||||
expect(events[2]?.payload).toMatchObject({
|
||||
type: 'response.content_part.done',
|
||||
content_index: 1,
|
||||
part: {
|
||||
type: 'output_text',
|
||||
text: 'yes',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('closes every unterminated reasoning summary part before completion', () => {
|
||||
const state = createOpenAiResponsesAggregateState('gpt-5');
|
||||
const streamContext = createStreamTransformContext('gpt-5');
|
||||
const usage = {
|
||||
promptTokens: 1,
|
||||
completionTokens: 2,
|
||||
totalTokens: 3,
|
||||
};
|
||||
|
||||
serializeConvertedResponsesEvents({
|
||||
state,
|
||||
streamContext,
|
||||
usage,
|
||||
event: {
|
||||
responsesEventType: 'response.output_item.added',
|
||||
responsesPayload: {
|
||||
type: 'response.output_item.added',
|
||||
output_index: 0,
|
||||
item: {
|
||||
id: 'rs_multi_1',
|
||||
type: 'reasoning',
|
||||
status: 'in_progress',
|
||||
summary: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
for (const [summaryIndex, text] of [[0, 'first'], [1, 'second']] as const) {
|
||||
serializeConvertedResponsesEvents({
|
||||
state,
|
||||
streamContext,
|
||||
usage,
|
||||
event: {
|
||||
responsesEventType: 'response.reasoning_summary_part.added',
|
||||
responsesPayload: {
|
||||
type: 'response.reasoning_summary_part.added',
|
||||
item_id: 'rs_multi_1',
|
||||
output_index: 0,
|
||||
summary_index: summaryIndex,
|
||||
part: {
|
||||
type: 'summary_text',
|
||||
text,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const lines = completeResponsesStream(state, streamContext, usage);
|
||||
const events = parseSseEvents(lines);
|
||||
|
||||
expect(events.map((entry) => entry.event ?? (entry.payload === '[DONE]' ? '[DONE]' : 'data'))).toEqual([
|
||||
'response.reasoning_summary_text.done',
|
||||
'response.reasoning_summary_part.done',
|
||||
'response.reasoning_summary_text.done',
|
||||
'response.reasoning_summary_part.done',
|
||||
'response.output_item.done',
|
||||
'response.completed',
|
||||
'[DONE]',
|
||||
]);
|
||||
expect(events[0]?.payload).toMatchObject({
|
||||
type: 'response.reasoning_summary_text.done',
|
||||
summary_index: 0,
|
||||
text: 'first',
|
||||
});
|
||||
expect(events[2]?.payload).toMatchObject({
|
||||
type: 'response.reasoning_summary_text.done',
|
||||
summary_index: 1,
|
||||
text: 'second',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not synthesize duplicate tool input done events after original tool completion already arrived', () => {
|
||||
const state = createOpenAiResponsesAggregateState('gpt-5');
|
||||
const streamContext = createStreamTransformContext('gpt-5');
|
||||
const usage = {
|
||||
promptTokens: 1,
|
||||
completionTokens: 2,
|
||||
totalTokens: 3,
|
||||
};
|
||||
|
||||
serializeConvertedResponsesEvents({
|
||||
state,
|
||||
streamContext,
|
||||
usage,
|
||||
event: {
|
||||
responsesEventType: 'response.output_item.added',
|
||||
responsesPayload: {
|
||||
type: 'response.output_item.added',
|
||||
output_index: 0,
|
||||
item: {
|
||||
id: 'fc_done_1',
|
||||
type: 'function_call',
|
||||
status: 'in_progress',
|
||||
call_id: 'fc_done_1',
|
||||
name: 'browser',
|
||||
arguments: '',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
serializeConvertedResponsesEvents({
|
||||
state,
|
||||
streamContext,
|
||||
usage,
|
||||
event: {
|
||||
responsesEventType: 'response.function_call_arguments.done',
|
||||
responsesPayload: {
|
||||
type: 'response.function_call_arguments.done',
|
||||
item_id: 'fc_done_1',
|
||||
call_id: 'fc_done_1',
|
||||
output_index: 0,
|
||||
name: 'browser',
|
||||
arguments: '{"url":"https://example.com"}',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
serializeConvertedResponsesEvents({
|
||||
state,
|
||||
streamContext,
|
||||
usage,
|
||||
event: {
|
||||
responsesEventType: 'response.output_item.added',
|
||||
responsesPayload: {
|
||||
type: 'response.output_item.added',
|
||||
output_index: 1,
|
||||
item: {
|
||||
id: 'ct_done_1',
|
||||
type: 'custom_tool_call',
|
||||
status: 'in_progress',
|
||||
call_id: 'ct_done_1',
|
||||
name: 'browser',
|
||||
input: '',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
serializeConvertedResponsesEvents({
|
||||
state,
|
||||
streamContext,
|
||||
usage,
|
||||
event: {
|
||||
responsesEventType: 'response.custom_tool_call_input.done',
|
||||
responsesPayload: {
|
||||
type: 'response.custom_tool_call_input.done',
|
||||
item_id: 'ct_done_1',
|
||||
call_id: 'ct_done_1',
|
||||
output_index: 1,
|
||||
name: 'browser',
|
||||
input: 'open example.com',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const lines = completeResponsesStream(state, streamContext, usage);
|
||||
const events = parseSseEvents(lines);
|
||||
|
||||
expect(events.map((entry) => entry.event ?? (entry.payload === '[DONE]' ? '[DONE]' : 'data'))).toEqual([
|
||||
'response.output_item.done',
|
||||
'response.output_item.done',
|
||||
'response.completed',
|
||||
'[DONE]',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -61,6 +61,8 @@ const outputTextDoneMarker = Symbol('responses.output_text.done');
|
||||
const contentPartDoneMarker = Symbol('responses.content_part.done');
|
||||
const reasoningSummaryTextDoneMarker = Symbol('responses.reasoning_summary_text.done');
|
||||
const reasoningSummaryPartDoneMarker = Symbol('responses.reasoning_summary_part.done');
|
||||
const functionCallArgumentsDoneMarker = Symbol('responses.function_call_arguments.done');
|
||||
const customToolInputDoneMarker = Symbol('responses.custom_tool_call_input.done');
|
||||
|
||||
export type OpenAiResponsesAggregateState = {
|
||||
modelName: string;
|
||||
@@ -76,6 +78,7 @@ export type OpenAiResponsesAggregateState = {
|
||||
usageExtras: Record<string, unknown>;
|
||||
completed: boolean;
|
||||
failed: boolean;
|
||||
incomplete: boolean;
|
||||
};
|
||||
|
||||
export function createOpenAiResponsesAggregateState(modelName: string): OpenAiResponsesAggregateState {
|
||||
@@ -93,6 +96,7 @@ export function createOpenAiResponsesAggregateState(modelName: string): OpenAiRe
|
||||
usageExtras: {},
|
||||
completed: false,
|
||||
failed: false,
|
||||
incomplete: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -395,7 +399,7 @@ function materializeResponse(
|
||||
streamContext: StreamTransformContext,
|
||||
usage: ResponsesUsageSummary,
|
||||
responseTemplate?: Record<string, unknown> | null,
|
||||
statusOverride?: 'completed' | 'failed',
|
||||
statusOverride?: 'completed' | 'failed' | 'incomplete',
|
||||
): Record<string, unknown> {
|
||||
const base = cloneRecord(responseTemplate) || {};
|
||||
const responseId = ensureResponseId(
|
||||
@@ -417,7 +421,7 @@ function materializeResponse(
|
||||
...item,
|
||||
status: status && status !== 'in_progress'
|
||||
? status
|
||||
: (state.failed ? 'failed' : 'completed'),
|
||||
: (state.failed ? 'failed' : state.incomplete ? 'incomplete' : 'completed'),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -426,7 +430,7 @@ function materializeResponse(
|
||||
id: responseId,
|
||||
object: 'response',
|
||||
created_at: createdAt,
|
||||
status: statusOverride ?? (state.failed ? 'failed' : 'completed'),
|
||||
status: statusOverride ?? (state.failed ? 'failed' : state.incomplete ? 'incomplete' : 'completed'),
|
||||
model: asTrimmedString(base.model) || streamContext.model || state.modelName,
|
||||
output,
|
||||
output_text: collectOutputText(state),
|
||||
@@ -497,6 +501,9 @@ function applyOriginalResponsesPayload(
|
||||
...reasoningState.item,
|
||||
...item,
|
||||
};
|
||||
if (eventType === 'response.output_item.done' && !asTrimmedString(next.status)) {
|
||||
next.status = 'completed';
|
||||
}
|
||||
if ((!Array.isArray(item.summary) || item.summary.length === 0) && Array.isArray(reasoningState.item.summary)) {
|
||||
next.summary = reasoningState.item.summary;
|
||||
}
|
||||
@@ -512,6 +519,9 @@ function applyOriginalResponsesPayload(
|
||||
...existing,
|
||||
...item,
|
||||
};
|
||||
if (eventType === 'response.output_item.done' && !asTrimmedString(next.status)) {
|
||||
next.status = 'completed';
|
||||
}
|
||||
if ((!Array.isArray(item.content) || item.content.length === 0) && Array.isArray(existing.content)) {
|
||||
next.content = existing.content;
|
||||
}
|
||||
@@ -575,6 +585,7 @@ function applyOriginalResponsesPayload(
|
||||
);
|
||||
if (eventType === 'response.function_call_arguments.done') {
|
||||
entry.item.arguments = typeof payload.arguments === 'string' ? payload.arguments : String(payload.arguments ?? '');
|
||||
markTerminalMarker(entry.item, functionCallArgumentsDoneMarker);
|
||||
} else {
|
||||
entry.item.arguments = `${typeof entry.item.arguments === 'string' ? entry.item.arguments : ''}${typeof payload.delta === 'string' ? payload.delta : ''}`;
|
||||
}
|
||||
@@ -591,6 +602,7 @@ function applyOriginalResponsesPayload(
|
||||
);
|
||||
if (eventType === 'response.custom_tool_call_input.done') {
|
||||
entry.item.input = typeof payload.input === 'string' ? payload.input : String(payload.input ?? '');
|
||||
markTerminalMarker(entry.item, customToolInputDoneMarker);
|
||||
} else {
|
||||
entry.item.input = `${typeof entry.item.input === 'string' ? entry.item.input : ''}${typeof payload.delta === 'string' ? payload.delta : ''}`;
|
||||
}
|
||||
@@ -654,17 +666,36 @@ function applyOriginalResponsesPayload(
|
||||
}
|
||||
case 'response.completed': {
|
||||
mergeUsageExtras(state, payload.response && isRecord(payload.response) ? payload.response.usage : payload.usage);
|
||||
const terminalLines = buildSyntheticTerminalItemDoneEvents(state, 'completed');
|
||||
state.completed = true;
|
||||
const responsePayload = cloneRecord(payload.response);
|
||||
const materialized = materializeResponse(state, streamContext, usage, responsePayload, 'completed');
|
||||
return [serializeSse('response.completed', { ...payload, response: materialized })];
|
||||
return [
|
||||
...terminalLines,
|
||||
serializeSse('response.completed', { ...payload, response: materialized }),
|
||||
];
|
||||
}
|
||||
case 'response.failed': {
|
||||
mergeUsageExtras(state, payload.response && isRecord(payload.response) ? payload.response.usage : payload.usage);
|
||||
const terminalLines = buildSyntheticTerminalItemDoneEvents(state, 'failed');
|
||||
state.failed = true;
|
||||
const responsePayload = cloneRecord(payload.response);
|
||||
const materialized = materializeResponse(state, streamContext, usage, responsePayload, 'failed');
|
||||
return [serializeSse('response.failed', { ...payload, response: materialized })];
|
||||
return [
|
||||
...terminalLines,
|
||||
serializeSse('response.failed', { ...payload, response: materialized }),
|
||||
];
|
||||
}
|
||||
case 'response.incomplete': {
|
||||
mergeUsageExtras(state, payload.response && isRecord(payload.response) ? payload.response.usage : payload.usage);
|
||||
const terminalLines = buildSyntheticTerminalItemDoneEvents(state, 'incomplete');
|
||||
state.incomplete = true;
|
||||
const responsePayload = cloneRecord(payload.response);
|
||||
const materialized = materializeResponse(state, streamContext, usage, responsePayload, 'incomplete');
|
||||
return [
|
||||
...terminalLines,
|
||||
serializeSse('response.incomplete', { ...payload, response: materialized }),
|
||||
];
|
||||
}
|
||||
default:
|
||||
mergeUsageExtras(state, payload.usage);
|
||||
@@ -803,7 +834,7 @@ function buildSyntheticToolEvents(
|
||||
|
||||
function buildSyntheticTerminalItemDoneEvents(
|
||||
state: OpenAiResponsesAggregateState,
|
||||
status: 'completed' | 'failed',
|
||||
status: 'completed' | 'failed' | 'incomplete',
|
||||
): string[] {
|
||||
const lines: string[] = [];
|
||||
|
||||
@@ -812,7 +843,7 @@ function buildSyntheticTerminalItemDoneEvents(
|
||||
if (!isRecord(item)) continue;
|
||||
|
||||
const currentStatus = asTrimmedString(item.status).toLowerCase();
|
||||
if (currentStatus === 'completed' || currentStatus === 'failed') continue;
|
||||
if (currentStatus === 'completed' || currentStatus === 'failed' || currentStatus === 'incomplete') continue;
|
||||
|
||||
const itemType = asTrimmedString(item.type).toLowerCase();
|
||||
const itemId = ensureOutputItemId(asTrimmedString(item.id), 'out', index);
|
||||
@@ -820,10 +851,12 @@ function buildSyntheticTerminalItemDoneEvents(
|
||||
|
||||
if (itemType === 'message') {
|
||||
const content = Array.isArray(item.content) ? item.content as AggregateOutputItem[] : [];
|
||||
const part = content[0];
|
||||
if (isRecord(part)) {
|
||||
for (let contentIndex = 0; contentIndex < content.length; contentIndex += 1) {
|
||||
const part = content[contentIndex];
|
||||
if (!isRecord(part)) continue;
|
||||
const partType = asTrimmedString(part.type).toLowerCase();
|
||||
const text = typeof part.text === 'string' ? part.text : String(part.text ?? '');
|
||||
if (!hasTerminalMarker(part, outputTextDoneMarker)) {
|
||||
if ((partType === 'output_text' || partType === 'text') && !hasTerminalMarker(part, outputTextDoneMarker)) {
|
||||
lines.push(serializeSse('response.output_text.done', {
|
||||
type: 'response.output_text.done',
|
||||
output_index: index,
|
||||
@@ -837,7 +870,7 @@ function buildSyntheticTerminalItemDoneEvents(
|
||||
type: 'response.content_part.done',
|
||||
output_index: index,
|
||||
item_id: itemId,
|
||||
content_index: 0,
|
||||
content_index: contentIndex,
|
||||
part: cloneJson(part),
|
||||
}));
|
||||
markTerminalMarker(part, contentPartDoneMarker);
|
||||
@@ -845,15 +878,16 @@ function buildSyntheticTerminalItemDoneEvents(
|
||||
}
|
||||
} else if (itemType === 'reasoning') {
|
||||
const summary = Array.isArray(item.summary) ? item.summary as AggregateOutputItem[] : [];
|
||||
const part = summary[0];
|
||||
if (isRecord(part)) {
|
||||
for (let summaryIndex = 0; summaryIndex < summary.length; summaryIndex += 1) {
|
||||
const part = summary[summaryIndex];
|
||||
if (!isRecord(part)) continue;
|
||||
const text = typeof part.text === 'string' ? part.text : String(part.text ?? '');
|
||||
if (!hasTerminalMarker(part, reasoningSummaryTextDoneMarker)) {
|
||||
lines.push(serializeSse('response.reasoning_summary_text.done', {
|
||||
type: 'response.reasoning_summary_text.done',
|
||||
item_id: itemId,
|
||||
output_index: index,
|
||||
summary_index: 0,
|
||||
summary_index: summaryIndex,
|
||||
text,
|
||||
}));
|
||||
markTerminalMarker(part, reasoningSummaryTextDoneMarker);
|
||||
@@ -863,7 +897,7 @@ function buildSyntheticTerminalItemDoneEvents(
|
||||
type: 'response.reasoning_summary_part.done',
|
||||
item_id: itemId,
|
||||
output_index: index,
|
||||
summary_index: 0,
|
||||
summary_index: summaryIndex,
|
||||
part: cloneJson(part),
|
||||
}));
|
||||
markTerminalMarker(part, reasoningSummaryPartDoneMarker);
|
||||
@@ -873,26 +907,32 @@ function buildSyntheticTerminalItemDoneEvents(
|
||||
const callId = asTrimmedString(item.call_id) || itemId;
|
||||
const name = asTrimmedString(item.name);
|
||||
const argumentsText = typeof item.arguments === 'string' ? item.arguments : String(item.arguments ?? '');
|
||||
lines.push(serializeSse('response.function_call_arguments.done', {
|
||||
type: 'response.function_call_arguments.done',
|
||||
item_id: itemId,
|
||||
call_id: callId,
|
||||
output_index: index,
|
||||
...(name ? { name } : {}),
|
||||
arguments: argumentsText,
|
||||
}));
|
||||
if (!hasTerminalMarker(item, functionCallArgumentsDoneMarker)) {
|
||||
lines.push(serializeSse('response.function_call_arguments.done', {
|
||||
type: 'response.function_call_arguments.done',
|
||||
item_id: itemId,
|
||||
call_id: callId,
|
||||
output_index: index,
|
||||
...(name ? { name } : {}),
|
||||
arguments: argumentsText,
|
||||
}));
|
||||
markTerminalMarker(item, functionCallArgumentsDoneMarker);
|
||||
}
|
||||
} else if (itemType === 'custom_tool_call') {
|
||||
const callId = asTrimmedString(item.call_id) || itemId;
|
||||
const name = asTrimmedString(item.name);
|
||||
const inputText = typeof item.input === 'string' ? item.input : String(item.input ?? '');
|
||||
lines.push(serializeSse('response.custom_tool_call_input.done', {
|
||||
type: 'response.custom_tool_call_input.done',
|
||||
item_id: itemId,
|
||||
call_id: callId,
|
||||
output_index: index,
|
||||
...(name ? { name } : {}),
|
||||
input: inputText,
|
||||
}));
|
||||
if (!hasTerminalMarker(item, customToolInputDoneMarker)) {
|
||||
lines.push(serializeSse('response.custom_tool_call_input.done', {
|
||||
type: 'response.custom_tool_call_input.done',
|
||||
item_id: itemId,
|
||||
call_id: callId,
|
||||
output_index: index,
|
||||
...(name ? { name } : {}),
|
||||
input: inputText,
|
||||
}));
|
||||
markTerminalMarker(item, customToolInputDoneMarker);
|
||||
}
|
||||
}
|
||||
|
||||
item.status = status;
|
||||
@@ -941,7 +981,7 @@ export function completeResponsesStream(
|
||||
streamContext: StreamTransformContext,
|
||||
usage: ResponsesUsageSummary,
|
||||
): string[] {
|
||||
if (state.failed || state.completed) {
|
||||
if (state.failed || state.completed || state.incomplete) {
|
||||
return [serializeDone()];
|
||||
}
|
||||
const lines = buildSyntheticTerminalItemDoneEvents(state, 'completed');
|
||||
@@ -986,3 +1026,25 @@ export function failResponsesStream(
|
||||
serializeDone(),
|
||||
];
|
||||
}
|
||||
|
||||
export function incompleteResponsesStream(
|
||||
state: OpenAiResponsesAggregateState,
|
||||
streamContext: StreamTransformContext,
|
||||
usage: ResponsesUsageSummary,
|
||||
payload: unknown,
|
||||
): string[] {
|
||||
if (state.failed || state.completed || state.incomplete) {
|
||||
return [serializeDone()];
|
||||
}
|
||||
const lines = buildSyntheticTerminalItemDoneEvents(state, 'incomplete');
|
||||
state.incomplete = true;
|
||||
const incompletePayload = cloneRecord(payload);
|
||||
return [
|
||||
...lines,
|
||||
serializeSse('response.incomplete', {
|
||||
...incompletePayload,
|
||||
response: materializeResponse(state, streamContext, usage, cloneRecord(incompletePayload?.response), 'incomplete'),
|
||||
}),
|
||||
serializeDone(),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -61,6 +61,8 @@ export function buildResponsesCompatibilityBodies(
|
||||
if (instructions !== null) richCandidate.instructions = instructions;
|
||||
|
||||
const passthroughFields = [
|
||||
'tools',
|
||||
'tool_choice',
|
||||
'parallel_tool_calls',
|
||||
'include',
|
||||
'reasoning',
|
||||
@@ -228,6 +230,8 @@ function buildStrictResponsesBody(
|
||||
model,
|
||||
input: body.input,
|
||||
stream: body.stream === true,
|
||||
...(body.tools !== undefined ? { tools: cloneJsonValue(body.tools) } : {}),
|
||||
...(body.tool_choice !== undefined ? { tool_choice: cloneJsonValue(body.tool_choice) } : {}),
|
||||
...(explicitInstructions !== null
|
||||
? { instructions: explicitInstructions }
|
||||
: {}),
|
||||
|
||||
@@ -1109,6 +1109,219 @@ describe('convertResponsesBodyToOpenAiBody', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves tools and tool_choice across all compatibility retry bodies', () => {
|
||||
const candidates = buildResponsesCompatibilityBodies({
|
||||
model: 'gpt-5',
|
||||
input: 'hello',
|
||||
stream: true,
|
||||
metadata: { user_id: 'user-1' },
|
||||
instructions: 'be helpful',
|
||||
tools: [{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'lookup_weather',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
city: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
}],
|
||||
tool_choice: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'lookup_weather',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(candidates.length).toBeGreaterThan(0);
|
||||
for (const candidate of candidates) {
|
||||
expect(candidate.tools).toEqual([{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'lookup_weather',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
city: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
}]);
|
||||
expect(candidate.tool_choice).toEqual({
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'lookup_weather',
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('maps native tool choices and strict function tools into OpenAI-compatible fallback bodies', () => {
|
||||
const result = convertResponsesBodyToOpenAiBody(
|
||||
{
|
||||
model: 'gpt-5',
|
||||
input: 'hello',
|
||||
tools: [{
|
||||
type: 'function',
|
||||
name: 'lookup_weather',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
city: { type: 'string' },
|
||||
},
|
||||
},
|
||||
strict: true,
|
||||
}],
|
||||
tool_choice: {
|
||||
type: 'tool',
|
||||
name: 'lookup_weather',
|
||||
},
|
||||
},
|
||||
'gpt-5',
|
||||
false,
|
||||
);
|
||||
|
||||
expect(result.tools).toEqual([{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'lookup_weather',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
city: { type: 'string' },
|
||||
},
|
||||
},
|
||||
strict: true,
|
||||
},
|
||||
}]);
|
||||
expect(result.tool_choice).toEqual({
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'lookup_weather',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('derives Responses reasoning config from chat-style reasoning request fields', () => {
|
||||
const result = convertOpenAiBodyToResponsesBody(
|
||||
{
|
||||
model: 'gpt-5',
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
reasoning_effort: 'high',
|
||||
reasoning_budget: 2048,
|
||||
reasoning_summary: 'detailed',
|
||||
},
|
||||
'gpt-5',
|
||||
false,
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
model: 'gpt-5',
|
||||
reasoning: {
|
||||
effort: 'high',
|
||||
budget_tokens: 2048,
|
||||
summary: 'detailed',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves assistant reasoning history when converting OpenAI-compatible bodies to Responses input', () => {
|
||||
const result = convertOpenAiBodyToResponsesBody(
|
||||
{
|
||||
model: 'gpt-5',
|
||||
messages: [{
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
reasoning_content: 'plan quietly',
|
||||
reasoning_signature: 'sig_1',
|
||||
tool_calls: [{
|
||||
id: 'call_1',
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'Glob',
|
||||
arguments: '{"pattern":"README*"}',
|
||||
},
|
||||
}],
|
||||
}],
|
||||
},
|
||||
'gpt-5',
|
||||
false,
|
||||
);
|
||||
|
||||
expect(result.input).toEqual([
|
||||
{
|
||||
type: 'reasoning',
|
||||
summary: [{
|
||||
type: 'summary_text',
|
||||
text: 'plan quietly',
|
||||
}],
|
||||
encrypted_content: 'sig_1',
|
||||
},
|
||||
{
|
||||
type: 'function_call',
|
||||
call_id: 'call_1',
|
||||
name: 'Glob',
|
||||
arguments: '{"pattern":"README*"}',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves chat-native modalities and audio settings when converting to Responses bodies', () => {
|
||||
const result = convertOpenAiBodyToResponsesBody(
|
||||
{
|
||||
model: 'gpt-5',
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
metadata: { user_id: 'user-1' },
|
||||
modalities: ['text', 'audio'],
|
||||
audio: {
|
||||
voice: 'alloy',
|
||||
format: 'mp3',
|
||||
},
|
||||
},
|
||||
'gpt-5',
|
||||
false,
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
metadata: { user_id: 'user-1' },
|
||||
modalities: ['text', 'audio'],
|
||||
audio: {
|
||||
voice: 'alloy',
|
||||
format: 'mp3',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves metadata, modalities, and audio when converting Responses bodies back to OpenAI-compatible bodies', () => {
|
||||
const result = convertResponsesBodyToOpenAiBody(
|
||||
{
|
||||
model: 'gpt-5',
|
||||
input: 'hello',
|
||||
metadata: { user_id: 'user-1' },
|
||||
modalities: ['text', 'audio'],
|
||||
audio: {
|
||||
voice: 'alloy',
|
||||
format: 'mp3',
|
||||
},
|
||||
},
|
||||
'gpt-5',
|
||||
false,
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
model: 'gpt-5',
|
||||
metadata: { user_id: 'user-1' },
|
||||
modalities: ['text', 'audio'],
|
||||
audio: {
|
||||
voice: 'alloy',
|
||||
format: 'mp3',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('maps Responses text.format back into OpenAI response_format', () => {
|
||||
const result = convertResponsesBodyToOpenAiBody(
|
||||
{
|
||||
@@ -1151,6 +1364,27 @@ describe('convertResponsesBodyToOpenAiBody', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves responses metadata when converting back to OpenAI-compatible bodies', () => {
|
||||
const result = convertResponsesBodyToOpenAiBody(
|
||||
{
|
||||
model: 'gpt-5',
|
||||
input: 'hello',
|
||||
metadata: {
|
||||
user_id: 'user-1',
|
||||
},
|
||||
},
|
||||
'gpt-5',
|
||||
false,
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
model: 'gpt-5',
|
||||
metadata: {
|
||||
user_id: 'user-1',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes field parity when converting Responses input back to OpenAI-compatible input', () => {
|
||||
const result = convertResponsesBodyToOpenAiBody(
|
||||
{
|
||||
|
||||
@@ -179,6 +179,20 @@ function normalizeResponsesRequestFieldParity(
|
||||
normalized.text = textConfig;
|
||||
}
|
||||
|
||||
if (!isRecord(normalized.reasoning)) {
|
||||
const reasoning: Record<string, unknown> = {};
|
||||
const effort = normalizeOptionalTrimmedString((normalized as Record<string, unknown>).reasoning_effort);
|
||||
if (effort) reasoning.effort = effort;
|
||||
const budgetTokens = toFiniteIntegerLike((normalized as Record<string, unknown>).reasoning_budget);
|
||||
if (budgetTokens !== null) reasoning.budget_tokens = budgetTokens;
|
||||
const summary = normalizeOptionalTrimmedString((normalized as Record<string, unknown>).reasoning_summary);
|
||||
if (summary) reasoning.summary = summary;
|
||||
if (Object.keys(reasoning).length > 0) normalized.reasoning = reasoning;
|
||||
}
|
||||
delete normalized.reasoning_effort;
|
||||
delete normalized.reasoning_budget;
|
||||
delete normalized.reasoning_summary;
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
@@ -448,6 +462,28 @@ export function convertOpenAiBodyToResponsesBody(
|
||||
}
|
||||
|
||||
if (role === 'assistant') {
|
||||
const reasoningContent = extractTextContent(
|
||||
item.reasoning_content
|
||||
?? item.reasoning
|
||||
?? item.thinking,
|
||||
).trim();
|
||||
const reasoningSignature = asTrimmedString(item.reasoning_signature);
|
||||
if (reasoningContent || reasoningSignature) {
|
||||
const reasoningItem: Record<string, unknown> = {
|
||||
type: 'reasoning',
|
||||
};
|
||||
if (reasoningContent) {
|
||||
reasoningItem.summary = [{
|
||||
type: 'summary_text',
|
||||
text: reasoningContent,
|
||||
}];
|
||||
}
|
||||
if (reasoningSignature) {
|
||||
reasoningItem.encrypted_content = reasoningSignature;
|
||||
}
|
||||
inputItems.push(reasoningItem);
|
||||
}
|
||||
|
||||
const normalizedContent = normalizeResponsesMessageContent('assistant', item.content);
|
||||
if (normalizedContent.length > 0) {
|
||||
inputItems.push({
|
||||
@@ -527,7 +563,12 @@ export function convertOpenAiBodyToResponsesBody(
|
||||
if (topP !== null) body.top_p = topP;
|
||||
|
||||
if (openaiBody.metadata !== undefined) body.metadata = openaiBody.metadata;
|
||||
if (openaiBody.modalities !== undefined) body.modalities = cloneJsonValue(openaiBody.modalities);
|
||||
if (openaiBody.audio !== undefined) body.audio = cloneJsonValue(openaiBody.audio);
|
||||
if (openaiBody.reasoning !== undefined) body.reasoning = openaiBody.reasoning;
|
||||
if (openaiBody.reasoning_effort !== undefined) body.reasoning_effort = openaiBody.reasoning_effort;
|
||||
if (openaiBody.reasoning_budget !== undefined) body.reasoning_budget = openaiBody.reasoning_budget;
|
||||
if (openaiBody.reasoning_summary !== undefined) body.reasoning_summary = openaiBody.reasoning_summary;
|
||||
if (openaiBody.parallel_tool_calls !== undefined) body.parallel_tool_calls = openaiBody.parallel_tool_calls;
|
||||
if (openaiBody.tools !== undefined) body.tools = convertOpenAiToolsToResponses(openaiBody.tools, toolNameMap);
|
||||
if (openaiBody.safety_identifier !== undefined) body.safety_identifier = openaiBody.safety_identifier;
|
||||
@@ -696,6 +737,14 @@ function convertResponsesToolChoiceToOpenAi(rawToolChoice: unknown): unknown {
|
||||
if (!isRecord(rawToolChoice)) return rawToolChoice;
|
||||
|
||||
const type = asTrimmedString(rawToolChoice.type).toLowerCase();
|
||||
if (type === 'tool') {
|
||||
const name = asTrimmedString(rawToolChoice.name);
|
||||
if (!name) return 'required';
|
||||
return {
|
||||
type: 'function',
|
||||
function: { name },
|
||||
};
|
||||
}
|
||||
if (type === 'function') {
|
||||
if (isRecord(rawToolChoice.function) && asTrimmedString(rawToolChoice.function.name)) {
|
||||
return rawToolChoice;
|
||||
@@ -845,6 +894,9 @@ export function convertResponsesBodyToOpenAiBody(
|
||||
if (typeof normalizedBody.max_output_tokens === 'number' && Number.isFinite(normalizedBody.max_output_tokens)) {
|
||||
payload.max_tokens = normalizedBody.max_output_tokens;
|
||||
}
|
||||
if (normalizedBody.metadata !== undefined) payload.metadata = cloneJsonValue(normalizedBody.metadata);
|
||||
if (normalizedBody.modalities !== undefined) payload.modalities = cloneJsonValue(normalizedBody.modalities);
|
||||
if (normalizedBody.audio !== undefined) payload.audio = cloneJsonValue(normalizedBody.audio);
|
||||
if (normalizedBody.parallel_tool_calls !== undefined) payload.parallel_tool_calls = normalizedBody.parallel_tool_calls;
|
||||
if (normalizedBody.tools !== undefined) payload.tools = convertResponsesToolsToOpenAi(normalizedBody.tools);
|
||||
if (normalizedBody.tool_choice !== undefined) payload.tool_choice = convertResponsesToolChoiceToOpenAi(normalizedBody.tool_choice);
|
||||
|
||||
@@ -109,6 +109,47 @@ describe('serializeResponsesFinalPayload', () => {
|
||||
expect(payload).not.toHaveProperty('output_text');
|
||||
});
|
||||
|
||||
it('serializes response mode with created_at instead of created', () => {
|
||||
const payload = serializeResponsesFinalPayload({
|
||||
upstreamPayload: {
|
||||
id: 'chatcmpl_response_mode',
|
||||
object: 'chat.completion',
|
||||
created: 1700000000,
|
||||
model: 'gpt-5',
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
finish_reason: 'stop',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: 'hello',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
normalized: {
|
||||
id: 'chatcmpl_response_mode',
|
||||
model: 'gpt-5',
|
||||
created: 1700000000,
|
||||
content: 'hello',
|
||||
reasoningContent: '',
|
||||
finishReason: 'stop',
|
||||
toolCalls: [],
|
||||
},
|
||||
usage: {
|
||||
promptTokens: 11,
|
||||
completionTokens: 7,
|
||||
totalTokens: 18,
|
||||
},
|
||||
} as any);
|
||||
|
||||
expect(payload).toMatchObject({
|
||||
object: 'response',
|
||||
created_at: 1700000000,
|
||||
});
|
||||
expect(payload).not.toHaveProperty('created');
|
||||
});
|
||||
|
||||
it('preserves top-level chat annotations on synthetic assistant messages', () => {
|
||||
const payload = serializeResponsesFinalPayload({
|
||||
upstreamPayload: {
|
||||
@@ -427,4 +468,49 @@ describe('serializeResponsesFinalPayload', () => {
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves image generation mime_type on synthesized response payloads', () => {
|
||||
const payload = serializeResponsesFinalPayload({
|
||||
upstreamPayload: {
|
||||
id: 'resp_like_mime_1',
|
||||
model: 'gpt-5',
|
||||
output: [
|
||||
{
|
||||
id: 'img_1',
|
||||
type: 'image_generation_call',
|
||||
status: 'completed',
|
||||
result: 'data:image/png;base64,final',
|
||||
mime_type: 'image/png',
|
||||
output_format: 'png',
|
||||
},
|
||||
],
|
||||
},
|
||||
normalized: {
|
||||
id: 'resp_like_mime_1',
|
||||
model: 'gpt-5',
|
||||
created: 1700000000,
|
||||
content: '',
|
||||
reasoningContent: '',
|
||||
finishReason: 'stop',
|
||||
toolCalls: [],
|
||||
},
|
||||
usage: {
|
||||
promptTokens: 1,
|
||||
completionTokens: 2,
|
||||
totalTokens: 3,
|
||||
},
|
||||
});
|
||||
|
||||
expect(payload.output).toEqual([
|
||||
{
|
||||
id: 'img_1',
|
||||
type: 'image_generation_call',
|
||||
status: 'completed',
|
||||
result: 'data:image/png;base64,final',
|
||||
mime_type: 'image/png',
|
||||
output_format: 'png',
|
||||
partial_images: [],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -220,6 +220,7 @@ function extractSyntheticOutputItemsFromUpstream(payload: unknown): ResponsesOut
|
||||
result: cloneJson(rawItem.result),
|
||||
partial_images: Array.isArray(rawItem.partial_images) ? cloneJson(rawItem.partial_images) : [],
|
||||
...(rawItem.background !== undefined ? { background: cloneJson(rawItem.background) } : {}),
|
||||
...(rawItem.mime_type !== undefined ? { mime_type: cloneJson(rawItem.mime_type) } : {}),
|
||||
...(rawItem.output_format !== undefined ? { output_format: cloneJson(rawItem.output_format) } : {}),
|
||||
...(rawItem.quality !== undefined ? { quality: cloneJson(rawItem.quality) } : {}),
|
||||
...(rawItem.size !== undefined ? { size: cloneJson(rawItem.size) } : {}),
|
||||
@@ -418,7 +419,7 @@ export function serializeResponsesFinalPayload(input: {
|
||||
return {
|
||||
id: responseId,
|
||||
object: 'response',
|
||||
created: normalized.created,
|
||||
created_at: normalized.created,
|
||||
status: 'completed',
|
||||
model: normalized.model,
|
||||
output,
|
||||
|
||||
@@ -69,4 +69,193 @@ describe('createResponsesProxyStreamSession', () => {
|
||||
expect(output).toContain('"output_text":"hello from responses upstream"');
|
||||
expect(output).toContain('data: [DONE]');
|
||||
});
|
||||
|
||||
it('preserves the canonical [DONE] terminator after an explicit response.completed SSE event', async () => {
|
||||
const lines: string[] = [];
|
||||
let ended = false;
|
||||
const usage = {
|
||||
promptTokens: 5,
|
||||
completionTokens: 3,
|
||||
totalTokens: 8,
|
||||
cacheReadTokens: 0,
|
||||
cacheCreationTokens: 0,
|
||||
promptTokensIncludeCache: null,
|
||||
};
|
||||
const chunk = [
|
||||
'event: response.completed',
|
||||
'data: {"type":"response.completed","response":{"id":"resp_stream_1","model":"gpt-5","usage":{"input_tokens":5,"output_tokens":3,"total_tokens":8}}}',
|
||||
'',
|
||||
'data: [DONE]',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
const reader = {
|
||||
reads: 0,
|
||||
async read() {
|
||||
if (this.reads > 0) return { done: true };
|
||||
this.reads += 1;
|
||||
return { done: false, value: new TextEncoder().encode(chunk) };
|
||||
},
|
||||
async cancel() {
|
||||
return undefined;
|
||||
},
|
||||
releaseLock() {},
|
||||
};
|
||||
|
||||
const session = createResponsesProxyStreamSession({
|
||||
modelName: 'gpt-5',
|
||||
successfulUpstreamPath: '/v1/responses',
|
||||
getUsage: () => usage,
|
||||
writeLines: (nextLines) => {
|
||||
lines.push(...nextLines);
|
||||
},
|
||||
writeRaw: () => {},
|
||||
});
|
||||
|
||||
const result = await session.run(reader as any, {
|
||||
end() {
|
||||
ended = true;
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
status: 'completed',
|
||||
errorMessage: null,
|
||||
});
|
||||
expect(ended).toBe(true);
|
||||
const output = lines.join('');
|
||||
expect(output).toContain('event: response.completed');
|
||||
expect(output).toContain('data: [DONE]');
|
||||
});
|
||||
|
||||
it('preserves response.incomplete SSE terminals instead of coercing them to response.failed', async () => {
|
||||
const lines: string[] = [];
|
||||
let ended = false;
|
||||
const usage = {
|
||||
promptTokens: 5,
|
||||
completionTokens: 3,
|
||||
totalTokens: 8,
|
||||
cacheReadTokens: 0,
|
||||
cacheCreationTokens: 0,
|
||||
promptTokensIncludeCache: null,
|
||||
};
|
||||
const chunk = [
|
||||
'event: response.incomplete',
|
||||
'data: {"type":"response.incomplete","response":{"id":"resp_incomplete_1","model":"gpt-5","status":"incomplete","incomplete_details":{"reason":"max_output_tokens"},"usage":{"input_tokens":5,"output_tokens":3,"total_tokens":8}}}',
|
||||
'',
|
||||
'data: [DONE]',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
const reader = {
|
||||
reads: 0,
|
||||
async read() {
|
||||
if (this.reads > 0) return { done: true };
|
||||
this.reads += 1;
|
||||
return { done: false, value: new TextEncoder().encode(chunk) };
|
||||
},
|
||||
async cancel() {
|
||||
return undefined;
|
||||
},
|
||||
releaseLock() {},
|
||||
};
|
||||
|
||||
const session = createResponsesProxyStreamSession({
|
||||
modelName: 'gpt-5',
|
||||
successfulUpstreamPath: '/v1/responses',
|
||||
getUsage: () => usage,
|
||||
writeLines: (nextLines) => {
|
||||
lines.push(...nextLines);
|
||||
},
|
||||
writeRaw: () => {},
|
||||
});
|
||||
|
||||
const result = await session.run(reader as any, {
|
||||
end() {
|
||||
ended = true;
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
status: 'completed',
|
||||
errorMessage: null,
|
||||
});
|
||||
expect(ended).toBe(true);
|
||||
const output = lines.join('');
|
||||
expect(output).toContain('event: response.incomplete');
|
||||
expect(output).toContain('"status":"incomplete"');
|
||||
expect(output).toContain('"incomplete_details":{"reason":"max_output_tokens"}');
|
||||
expect(output).not.toContain('event: response.failed');
|
||||
expect(output).toContain('data: [DONE]');
|
||||
});
|
||||
|
||||
it('preserves non-SSE incomplete fallback payloads as response.incomplete', () => {
|
||||
const lines: string[] = [];
|
||||
let ended = false;
|
||||
const usage = {
|
||||
promptTokens: 5,
|
||||
completionTokens: 3,
|
||||
totalTokens: 8,
|
||||
cacheReadTokens: 0,
|
||||
cacheCreationTokens: 0,
|
||||
promptTokensIncludeCache: null,
|
||||
};
|
||||
const payload = {
|
||||
id: 'resp_incomplete_fallback_1',
|
||||
object: 'response',
|
||||
status: 'incomplete',
|
||||
incomplete_details: {
|
||||
reason: 'max_output_tokens',
|
||||
},
|
||||
model: 'gpt-5.2',
|
||||
output_text: 'partial answer',
|
||||
output: [
|
||||
{
|
||||
id: 'msg_incomplete_1',
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
status: 'incomplete',
|
||||
content: [{ type: 'output_text', text: 'partial answer' }],
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
input_tokens: usage.promptTokens,
|
||||
output_tokens: usage.completionTokens,
|
||||
total_tokens: usage.totalTokens,
|
||||
},
|
||||
};
|
||||
|
||||
const session = createResponsesProxyStreamSession({
|
||||
modelName: 'gpt-5.2',
|
||||
successfulUpstreamPath: '/v1/responses',
|
||||
getUsage: () => usage,
|
||||
writeLines: (nextLines) => {
|
||||
lines.push(...nextLines);
|
||||
},
|
||||
writeRaw: () => {},
|
||||
});
|
||||
|
||||
const result = session.consumeUpstreamFinalPayload(
|
||||
payload,
|
||||
JSON.stringify(payload),
|
||||
{
|
||||
end() {
|
||||
ended = true;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
status: 'completed',
|
||||
errorMessage: null,
|
||||
});
|
||||
expect(ended).toBe(true);
|
||||
|
||||
const output = lines.join('');
|
||||
expect(output).toContain('event: response.incomplete');
|
||||
expect(output).toContain('"status":"incomplete"');
|
||||
expect(output).toContain('"output_text":"partial answer"');
|
||||
expect(output).not.toContain('event: response.completed');
|
||||
expect(output).toContain('data: [DONE]');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -119,6 +119,7 @@ export function createResponsesProxyStreamSession(input: ResponsesProxyStreamSes
|
||||
|| input.successfulUpstreamPath.endsWith('/responses')
|
||||
|| input.successfulUpstreamPath.endsWith('/responses/compact');
|
||||
let finalized = false;
|
||||
let terminalEventSeen = false;
|
||||
let terminalResult: ResponsesProxyStreamResult = {
|
||||
status: 'completed',
|
||||
errorMessage: null,
|
||||
@@ -145,8 +146,6 @@ export function createResponsesProxyStreamSession(input: ResponsesProxyStreamSes
|
||||
};
|
||||
|
||||
const complete = () => {
|
||||
if (finalized) return;
|
||||
finalized = true;
|
||||
terminalResult = {
|
||||
status: 'completed',
|
||||
errorMessage: null,
|
||||
@@ -155,6 +154,10 @@ export function createResponsesProxyStreamSession(input: ResponsesProxyStreamSes
|
||||
|
||||
const closeOut = () => {
|
||||
if (finalized) return;
|
||||
if (terminalEventSeen) {
|
||||
finalize();
|
||||
return;
|
||||
}
|
||||
if (requiresExplicitTerminalEvent) {
|
||||
fail({
|
||||
type: 'response.failed',
|
||||
@@ -190,15 +193,14 @@ export function createResponsesProxyStreamSession(input: ResponsesProxyStreamSes
|
||||
const isFailureEvent = (
|
||||
eventBlock.event === 'error'
|
||||
|| eventBlock.event === 'response.failed'
|
||||
|| eventBlock.event === 'response.incomplete'
|
||||
|| payloadType === 'error'
|
||||
|| payloadType === 'response.failed'
|
||||
|| payloadType === 'response.incomplete'
|
||||
);
|
||||
if (isFailureEvent) {
|
||||
fail(parsedPayload);
|
||||
return true;
|
||||
}
|
||||
const isIncompleteEvent = eventBlock.event === 'response.incomplete' || payloadType === 'response.incomplete';
|
||||
|
||||
if (parsedPayload && typeof parsedPayload === 'object') {
|
||||
const normalizedEvent = openAiResponsesStream.normalizeEvent(parsedPayload, streamContext, input.modelName);
|
||||
@@ -225,7 +227,8 @@ export function createResponsesProxyStreamSession(input: ResponsesProxyStreamSes
|
||||
return true;
|
||||
}
|
||||
input.writeLines(convertedLines);
|
||||
if (eventBlock.event === 'response.completed' || payloadType === 'response.completed') {
|
||||
if (eventBlock.event === 'response.completed' || payloadType === 'response.completed' || isIncompleteEvent) {
|
||||
terminalEventSeen = true;
|
||||
complete();
|
||||
}
|
||||
return false;
|
||||
@@ -249,6 +252,9 @@ export function createResponsesProxyStreamSession(input: ResponsesProxyStreamSes
|
||||
const payloadType = (isRecord(payload) && typeof payload.type === 'string')
|
||||
? payload.type
|
||||
: '';
|
||||
const payloadStatus = isRecord(payload) && typeof payload.status === 'string'
|
||||
? payload.status
|
||||
: '';
|
||||
if (payloadType === 'error' || payloadType === 'response.failed') {
|
||||
fail(payload);
|
||||
response?.end();
|
||||
@@ -266,7 +272,8 @@ export function createResponsesProxyStreamSession(input: ResponsesProxyStreamSes
|
||||
usage: input.getUsage(),
|
||||
serializationMode: 'response',
|
||||
});
|
||||
if (shouldFailEmptyResponsesCompletion({
|
||||
const isIncompletePayload = payloadType === 'response.incomplete' || payloadStatus === 'incomplete';
|
||||
if (!isIncompletePayload && shouldFailEmptyResponsesCompletion({
|
||||
payload: { type: 'response.completed', response: streamPayload },
|
||||
state: responsesState,
|
||||
usage: input.getUsage(),
|
||||
@@ -287,16 +294,29 @@ export function createResponsesProxyStreamSession(input: ResponsesProxyStreamSes
|
||||
output_text: '',
|
||||
};
|
||||
|
||||
finalized = true;
|
||||
terminalResult = {
|
||||
status: 'completed',
|
||||
errorMessage: null,
|
||||
};
|
||||
input.writeLines([
|
||||
`event: response.created\ndata: ${JSON.stringify({ type: 'response.created', response: createdPayload })}\n\n`,
|
||||
`event: response.completed\ndata: ${JSON.stringify({ type: 'response.completed', response: streamPayload })}\n\n`,
|
||||
'data: [DONE]\n\n',
|
||||
]);
|
||||
if (isIncompletePayload) {
|
||||
finalized = true;
|
||||
terminalResult = {
|
||||
status: 'completed',
|
||||
errorMessage: null,
|
||||
};
|
||||
input.writeLines([
|
||||
`event: response.created\ndata: ${JSON.stringify({ type: 'response.created', response: createdPayload })}\n\n`,
|
||||
`event: response.incomplete\ndata: ${JSON.stringify({ type: 'response.incomplete', response: streamPayload })}\n\n`,
|
||||
'data: [DONE]\n\n',
|
||||
]);
|
||||
} else {
|
||||
finalized = true;
|
||||
terminalResult = {
|
||||
status: 'completed',
|
||||
errorMessage: null,
|
||||
};
|
||||
input.writeLines([
|
||||
`event: response.created\ndata: ${JSON.stringify({ type: 'response.created', response: createdPayload })}\n\n`,
|
||||
`event: response.completed\ndata: ${JSON.stringify({ type: 'response.completed', response: streamPayload })}\n\n`,
|
||||
'data: [DONE]\n\n',
|
||||
]);
|
||||
}
|
||||
response?.end();
|
||||
return terminalResult;
|
||||
},
|
||||
|
||||
@@ -46,6 +46,7 @@ export const openAiResponsesStream = {
|
||||
'response.image_generation_call.partial_image',
|
||||
'response.image_generation_call.completed',
|
||||
'response.completed',
|
||||
'response.incomplete',
|
||||
'response.failed',
|
||||
] as const,
|
||||
createContext(modelName: string): StreamTransformContext {
|
||||
|
||||
Reference in New Issue
Block a user