[codex] fix responses-to-chat tool stream fidelity (#378)

* fix responses chat tool stream fidelity

* fix responses tool call follow-up review comments
This commit is contained in:
Cita
2026-04-02 09:49:23 +08:00
committed by GitHub
parent 596d1b93cb
commit 4ca88d0fe5
4 changed files with 368 additions and 27 deletions
+41 -5
View File
@@ -18,6 +18,18 @@ function asRecord(value: unknown): Record<string, unknown> | null {
return isRecord(value) ? value : null; return isRecord(value) ? value : null;
} }
function cloneJson<T>(value: T): T {
if (Array.isArray(value)) {
return value.map((item) => cloneJson(item)) as T;
}
if (isRecord(value)) {
return Object.fromEntries(
Object.entries(value).map(([key, item]) => [key, cloneJson(item)]),
) as T;
}
return value;
}
function toStringArray(value: unknown): string[] { function toStringArray(value: unknown): string[] {
if (!Array.isArray(value)) return []; if (!Array.isArray(value)) return [];
return value return value
@@ -375,11 +387,19 @@ function extractCitationsFromPayload(
} }
export function extractChatUsageDetails(payload: Record<string, unknown>): OpenAiChatUsageDetails | undefined { export function extractChatUsageDetails(payload: Record<string, unknown>): OpenAiChatUsageDetails | undefined {
const usage = asRecord(payload.usage); const usage = asRecord(payload.usage) ?? asRecord(asRecord(payload.response)?.usage);
if (!usage) return undefined; if (!usage) return undefined;
const promptDetails = toNumericRecord(usage.prompt_tokens_details ?? usage.promptTokensDetails); const promptDetails = toNumericRecord(
const completionDetails = toNumericRecord(usage.completion_tokens_details ?? usage.completionTokensDetails); usage.prompt_tokens_details
?? usage.promptTokensDetails
?? usage.input_tokens_details,
);
const completionDetails = toNumericRecord(
usage.completion_tokens_details
?? usage.completionTokensDetails
?? usage.output_tokens_details,
);
if (!promptDetails && !completionDetails) return undefined; if (!promptDetails && !completionDetails) return undefined;
return { return {
@@ -416,9 +436,25 @@ export function mergeChatUsageDetails(
} }
export function extractUsagePayload(payload: Record<string, unknown>): Record<string, unknown> | undefined { export function extractUsagePayload(payload: Record<string, unknown>): Record<string, unknown> | undefined {
const usage = asRecord(payload.usage); const usage = asRecord(payload.usage) ?? asRecord(asRecord(payload.response)?.usage);
if (!usage) return undefined; if (!usage) return undefined;
return usage; const mappedUsage = cloneJson(usage);
const promptTokens = mappedUsage.prompt_tokens ?? mappedUsage.input_tokens;
const completionTokens = mappedUsage.completion_tokens ?? mappedUsage.output_tokens;
if (promptTokens !== undefined) mappedUsage.prompt_tokens = promptTokens;
if (completionTokens !== undefined) mappedUsage.completion_tokens = completionTokens;
delete mappedUsage.input_tokens;
delete mappedUsage.output_tokens;
const promptDetails = mappedUsage.prompt_tokens_details ?? mappedUsage.input_tokens_details;
const completionDetails = mappedUsage.completion_tokens_details ?? mappedUsage.output_tokens_details;
if (promptDetails !== undefined) mappedUsage.prompt_tokens_details = cloneJson(promptDetails);
if (completionDetails !== undefined) mappedUsage.completion_tokens_details = cloneJson(completionDetails);
delete mappedUsage.input_tokens_details;
delete mappedUsage.output_tokens_details;
return mappedUsage;
} }
export function extractChatResponseExtras(payload: unknown): Pick< export function extractChatResponseExtras(payload: unknown): Pick<
@@ -372,6 +372,37 @@ describe('openAiChatTransformer.outbound', () => {
}); });
describe('openAiChatTransformer.stream', () => { describe('openAiChatTransformer.stream', () => {
it('serializes assistant starter chunks for tool-first responses streams', () => {
const context = openAiChatTransformer.createStreamContext('gpt-5');
const event = openAiChatTransformer.transformStreamEvent({
type: 'response.created',
response: {
id: 'resp-tool-start',
model: 'gpt-5',
created_at: 1706000000,
status: 'in_progress',
output: [],
},
}, context, 'gpt-5');
const payloads = parseSsePayloads(
openAiChatTransformer.serializeStreamEvent(event, context, createClaudeDownstreamContext()),
);
expect(payloads[0]).toMatchObject({
id: 'resp-tool-start',
model: 'gpt-5',
choices: [{
index: 0,
delta: {
role: 'assistant',
content: '',
},
finish_reason: null,
}],
});
});
it('preserves annotations, citations, and usage payload on serialized stream chunks', () => { it('preserves annotations, citations, and usage payload on serialized stream chunks', () => {
const context = openAiChatTransformer.createStreamContext('gpt-5'); const context = openAiChatTransformer.createStreamContext('gpt-5');
const event = openAiChatTransformer.transformStreamEvent({ const event = openAiChatTransformer.transformStreamEvent({
@@ -416,6 +447,40 @@ describe('openAiChatTransformer.stream', () => {
]); ]);
}); });
it('maps nested responses usage into chat-completions usage chunks', () => {
const context = openAiChatTransformer.createStreamContext('gpt-5');
const event = openAiChatTransformer.transformStreamEvent({
type: 'response.completed',
response: {
id: 'resp-usage',
model: 'gpt-5',
status: 'completed',
output: [],
usage: {
input_tokens: 11,
output_tokens: 7,
total_tokens: 18,
input_tokens_details: { cached_tokens: 3 },
output_tokens_details: { reasoning_tokens: 2 },
},
},
}, context, 'gpt-5');
const payloads = parseSsePayloads(
openAiChatTransformer.serializeStreamEvent(event, context, createClaudeDownstreamContext()),
);
expect(payloads[0]).toMatchObject({
usage: {
prompt_tokens: 11,
completion_tokens: 7,
total_tokens: 18,
prompt_tokens_details: { cached_tokens: 3 },
completion_tokens_details: { reasoning_tokens: 2 },
},
});
});
it('serializes multi-choice stream chunks without collapsing choice-specific deltas', () => { it('serializes multi-choice stream chunks without collapsing choice-specific deltas', () => {
const context = openAiChatTransformer.createStreamContext('gpt-5'); const context = openAiChatTransformer.createStreamContext('gpt-5');
const event = openAiChatTransformer.transformStreamEvent({ const event = openAiChatTransformer.transformStreamEvent({
@@ -8,6 +8,14 @@ import {
serializeNormalizedStreamEvent, serializeNormalizedStreamEvent,
} from './chatFormatsCore.js'; } from './chatFormatsCore.js';
function parseOpenAiSsePayload(lines: string[]): Record<string, unknown> {
const dataLine = lines.find((line) => line.startsWith('data: ') && line.trim() !== 'data: [DONE]');
if (!dataLine) {
throw new Error('expected serialized SSE payload');
}
return JSON.parse(dataLine.slice(6)) as Record<string, unknown>;
}
describe('chatFormatsCore inline think parsing', () => { describe('chatFormatsCore inline think parsing', () => {
it('tracks split think tags across stream chunks', () => { it('tracks split think tags across stream chunks', () => {
const context = createStreamTransformContext('gpt-test'); const context = createStreamTransformContext('gpt-test');
@@ -158,6 +166,39 @@ describe('chatFormatsCore inline think parsing', () => {
}); });
}); });
it('emits an assistant starter chunk for tool-first responses streams', () => {
const context = createStreamTransformContext('gpt-test');
const claudeContext = createClaudeDownstreamContext();
const startEvent = normalizeUpstreamStreamEvent({
type: 'response.created',
response: {
id: 'resp_tool_start',
model: 'gpt-test',
created_at: 1706000000,
status: 'in_progress',
output: [],
},
}, context, 'gpt-test');
const payload = parseOpenAiSsePayload(
serializeNormalizedStreamEvent('openai', startEvent, context, claudeContext),
);
expect(payload).toMatchObject({
id: 'resp_tool_start',
model: 'gpt-test',
choices: [{
index: 0,
delta: {
role: 'assistant',
content: '',
},
finish_reason: null,
}],
});
});
it('keeps responses tool-call indices stable when response.completed replays mixed output arrays', () => { it('keeps responses tool-call indices stable when response.completed replays mixed output arrays', () => {
const context = createStreamTransformContext('gpt-test'); const context = createStreamTransformContext('gpt-test');
const claudeContext = createClaudeDownstreamContext(); const claudeContext = createClaudeDownstreamContext();
@@ -207,11 +248,200 @@ describe('chatFormatsCore inline think parsing', () => {
}, context, 'gpt-test')).toEqual({ }, context, 'gpt-test')).toEqual({
role: 'assistant', role: 'assistant',
contentDelta: 'working on it', contentDelta: 'working on it',
finishReason: 'stop', finishReason: 'tool_calls',
done: true, done: true,
}); });
}); });
it('keeps terminal tool-only responses completions marked as tool_calls after earlier streamed deltas', () => {
const context = createStreamTransformContext('gpt-test');
const claudeContext = createClaudeDownstreamContext();
const toolStarted = normalizeUpstreamStreamEvent({
type: 'response.output_item.added',
output_index: 0,
item: {
id: 'fc_1',
type: 'function_call',
call_id: 'call_tool_only',
name: 'lookup',
},
}, context, 'gpt-test');
serializeNormalizedStreamEvent('openai', toolStarted, context, claudeContext);
const toolArgs = normalizeUpstreamStreamEvent({
type: 'response.function_call_arguments.delta',
output_index: 0,
call_id: 'call_tool_only',
delta: '{"q":"x"}',
}, context, 'gpt-test');
serializeNormalizedStreamEvent('openai', toolArgs, context, claudeContext);
expect(normalizeUpstreamStreamEvent({
type: 'response.completed',
response: {
id: 'resp_tool_only',
model: 'gpt-test',
status: 'completed',
usage: { input_tokens: 3, output_tokens: 2, total_tokens: 5 },
},
}, context, 'gpt-test')).toEqual({
finishReason: 'tool_calls',
done: true,
});
});
it('keeps terminal tool-only responses completions marked as tool_calls before serialization side effects', () => {
const context = createStreamTransformContext('gpt-test');
expect(normalizeUpstreamStreamEvent({
type: 'response.output_item.added',
output_index: 0,
item: {
id: 'fc_pre_serialization',
type: 'function_call',
call_id: 'call_pre_serialization',
name: 'lookup',
},
}, context, 'gpt-test')).toEqual({
toolCallDeltas: [{
index: 0,
id: 'call_pre_serialization',
name: 'lookup',
}],
});
expect(normalizeUpstreamStreamEvent({
type: 'response.function_call_arguments.delta',
output_index: 0,
call_id: 'call_pre_serialization',
delta: '{"q":"x"}',
}, context, 'gpt-test')).toEqual({
toolCallDeltas: [{
index: 0,
id: 'call_pre_serialization',
argumentsDelta: '{"q":"x"}',
}],
});
expect(normalizeUpstreamStreamEvent({
type: 'response.completed',
response: {
id: 'resp_pre_serialization',
model: 'gpt-test',
status: 'completed',
usage: { input_tokens: 3, output_tokens: 2, total_tokens: 5 },
},
}, context, 'gpt-test')).toEqual({
finishReason: 'tool_calls',
done: true,
});
});
it('does not backfill historical tool identity into later arguments-only deltas', () => {
const context = createStreamTransformContext('gpt-test');
const claudeContext = createClaudeDownstreamContext();
const started = normalizeUpstreamStreamEvent({
type: 'response.output_item.added',
output_index: 0,
item: {
id: 'fc_identity',
type: 'function_call',
call_id: 'call_identity',
name: 'lookup',
},
}, context, 'gpt-test');
serializeNormalizedStreamEvent('openai', started, context, claudeContext);
const argumentDelta = normalizeUpstreamStreamEvent({
type: 'response.function_call_arguments.delta',
output_index: 0,
call_id: 'call_identity',
delta: '{"q":"x"}',
}, context, 'gpt-test');
const payload = parseOpenAiSsePayload(
serializeNormalizedStreamEvent('openai', argumentDelta, context, claudeContext),
);
expect(payload).toMatchObject({
choices: [{
index: 0,
delta: {
tool_calls: [{
index: 0,
function: {
arguments: '{"q":"x"}',
},
}],
},
finish_reason: null,
}],
});
const toolCall = (((payload.choices as any[])[0] as any).delta.tool_calls[0]) as Record<string, unknown>;
expect(toolCall.id).toBeUndefined();
expect(toolCall.type).toBeUndefined();
expect((toolCall.function as Record<string, unknown>).name).toBeUndefined();
});
it('backfills late real tool identity after earlier id-less argument deltas', () => {
const context = createStreamTransformContext('gpt-test');
const claudeContext = createClaudeDownstreamContext();
const argumentDelta = normalizeUpstreamStreamEvent({
type: 'response.function_call_arguments.delta',
output_index: 0,
delta: '{"q":"x"}',
}, context, 'gpt-test');
expect(argumentDelta).toEqual({
toolCallDeltas: [{
index: 0,
argumentsDelta: '{"q":"x"}',
}],
});
serializeNormalizedStreamEvent('openai', argumentDelta, context, claudeContext);
const started = normalizeUpstreamStreamEvent({
type: 'response.output_item.done',
output_index: 0,
item: {
id: 'fc_late_identity',
type: 'function_call',
call_id: 'call_late_identity',
name: 'lookup',
arguments: '{"q":"x"}',
},
}, context, 'gpt-test');
const payload = parseOpenAiSsePayload(
serializeNormalizedStreamEvent('openai', started, context, claudeContext),
);
expect(payload).toMatchObject({
choices: [{
index: 0,
delta: {
tool_calls: [{
index: 0,
id: 'call_late_identity',
type: 'function',
function: {
name: 'lookup',
},
}],
},
finish_reason: null,
}],
});
expect(context.toolCalls[0]).toEqual({
id: 'call_late_identity',
name: 'lookup',
arguments: '{"q":"x"}',
});
});
it('preserves terminal response.completed payload output when it carries the only final content', () => { it('preserves terminal response.completed payload output when it carries the only final content', () => {
const context = createStreamTransformContext('gpt-test'); const context = createStreamTransformContext('gpt-test');
@@ -820,8 +820,8 @@ function buildResponsesToolCallDeltaFromItem(
return { return {
toolCallDeltas: [{ toolCallDeltas: [{
index: canonicalIndex, index: canonicalIndex,
id: toolCallId, ...(shouldBackfillId && toolCallId ? { id: toolCallId } : {}),
name: toolName, ...(shouldBackfillName && toolName ? { name: toolName } : {}),
argumentsDelta: argumentsDelta || undefined, argumentsDelta: argumentsDelta || undefined,
}], }],
}; };
@@ -1533,6 +1533,14 @@ export function normalizeUpstreamStreamEvent(
}; };
} }
if (type === 'response.created' && isRecord((payload as any).response)) {
const responsePayload = (payload as any).response as Record<string, unknown>;
if (isNonEmptyString(responsePayload.id)) context.id = responsePayload.id;
if (isNonEmptyString(responsePayload.model)) context.model = responsePayload.model;
context.created = ensureIntegerTimestamp((responsePayload as any).created_at ?? responsePayload.created, context.created);
return { role: 'assistant' };
}
if (type === 'response.failed' || type === 'response.incomplete' || type === 'error') { if (type === 'response.failed' || type === 'response.incomplete' || type === 'error') {
const responsePayload = isRecord((payload as any).response) ? (payload as any).response : null; const responsePayload = isRecord((payload as any).response) ? (payload as any).response : null;
const finishReason = normalizeStopReason( const finishReason = normalizeStopReason(
@@ -1619,9 +1627,9 @@ export function normalizeUpstreamStreamEvent(
return { return {
toolCallDeltas: [{ toolCallDeltas: [{
index: canonicalIndex, index: canonicalIndex,
id: toolCallId, ...(shouldBackfillId && toolCallId ? { id: toolCallId } : {}),
name: toolName, ...(shouldBackfillName && toolName ? { name: toolName } : {}),
argumentsDelta, ...(argumentsDelta !== undefined ? { argumentsDelta } : {}),
}], }],
}; };
} }
@@ -1659,9 +1667,9 @@ export function normalizeUpstreamStreamEvent(
return { return {
toolCallDeltas: [{ toolCallDeltas: [{
index: canonicalIndex, index: canonicalIndex,
id: toolCallId, ...(shouldBackfillId && toolCallId ? { id: toolCallId } : {}),
name: toolName, ...(shouldBackfillName && toolName ? { name: toolName } : {}),
argumentsDelta: argumentsDelta || undefined, ...(argumentsDelta ? { argumentsDelta } : {}),
}], }],
}; };
} }
@@ -1702,19 +1710,20 @@ export function normalizeUpstreamStreamEvent(
} }
return { return {
index: canonicalIndex, index: canonicalIndex,
id: toolCall.id, ...(shouldBackfillId && toolCall.id ? { id: toolCall.id } : {}),
name: toolCall.name, ...(shouldBackfillName && toolCall.name ? { name: toolCall.name } : {}),
argumentsDelta: argumentsDelta || undefined, ...(argumentsDelta ? { argumentsDelta } : {}),
}; };
}) })
.filter((item): item is NonNullable<typeof item> => !!item); .filter((item): item is NonNullable<typeof item> => !!item);
const hasKnownToolCalls = toolCalls.length > 0 || context.nextResponsesToolCallIndex > 0;
return { return {
...(contentDelta || reasoningDelta ? { role: 'assistant' as const } : {}), ...(contentDelta || reasoningDelta ? { role: 'assistant' as const } : {}),
...(contentDelta ? { contentDelta } : {}), ...(contentDelta ? { contentDelta } : {}),
...(reasoningDelta ? { reasoningDelta } : {}), ...(reasoningDelta ? { reasoningDelta } : {}),
...(responsesReasoning.reasoningSignature ? { reasoningSignature: responsesReasoning.reasoningSignature } : {}), ...(responsesReasoning.reasoningSignature ? { reasoningSignature: responsesReasoning.reasoningSignature } : {}),
...(toolCallDeltas.length > 0 ? { toolCallDeltas } : {}), ...(toolCallDeltas.length > 0 ? { toolCallDeltas } : {}),
finishReason: toolCallDeltas.length > 0 finishReason: hasKnownToolCalls
? 'tool_calls' ? 'tool_calls'
: (normalizeStopReason(responsePayload.status) || 'stop'), : (normalizeStopReason(responsePayload.status) || 'stop'),
done: true, done: true,
@@ -1883,25 +1892,26 @@ function buildOpenAiStreamChunk(
const toolCalls = event.toolCallDeltas.map((toolDelta) => { const toolCalls = event.toolCallDeltas.map((toolDelta) => {
const index = Number.isFinite(toolDelta.index) ? Math.max(0, Math.trunc(toolDelta.index)) : 0; const index = Number.isFinite(toolDelta.index) ? Math.max(0, Math.trunc(toolDelta.index)) : 0;
const existing = context.toolCalls[index] || {}; const existing = context.toolCalls[index] || {};
const id = toolDelta.id || existing.id || `call_meta_${index}`; const id = toolDelta.id || existing.id;
const name = toolDelta.name || existing.name || ''; const name = toolDelta.name || existing.name || '';
const nextArguments = `${existing.arguments || ''}${toolDelta.argumentsDelta ?? ''}`; const nextArguments = `${existing.arguments || ''}${toolDelta.argumentsDelta ?? ''}`;
// Keep synthetic call_meta_* ids as serialization-only fallbacks so later real ids can still backfill.
context.toolCalls[index] = { context.toolCalls[index] = {
id, ...(id ? { id } : {}),
name: name || existing.name, ...(name || existing.name ? { name: name || existing.name } : {}),
arguments: nextArguments, arguments: nextArguments,
}; };
const fn: Record<string, unknown> = {}; const fn: Record<string, unknown> = {};
if (name) fn.name = name; if (toolDelta.name) fn.name = toolDelta.name;
if (toolDelta.argumentsDelta !== undefined) fn.arguments = toolDelta.argumentsDelta; if (toolDelta.argumentsDelta !== undefined) fn.arguments = toolDelta.argumentsDelta;
const serializedToolCall: Record<string, unknown> = {
return {
index, index,
id,
type: 'function',
function: fn,
}; };
if (toolDelta.id) serializedToolCall.id = toolDelta.id;
if (toolDelta.id || toolDelta.name) serializedToolCall.type = 'function';
if (Object.keys(fn).length > 0) serializedToolCall.function = fn;
return serializedToolCall;
}); });
if (toolCalls.length > 0) { if (toolCalls.length > 0) {