[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;
}
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[] {
if (!Array.isArray(value)) return [];
return value
@@ -375,11 +387,19 @@ function extractCitationsFromPayload(
}
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;
const promptDetails = toNumericRecord(usage.prompt_tokens_details ?? usage.promptTokensDetails);
const completionDetails = toNumericRecord(usage.completion_tokens_details ?? usage.completionTokensDetails);
const promptDetails = toNumericRecord(
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;
return {
@@ -416,9 +436,25 @@ export function mergeChatUsageDetails(
}
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;
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<
@@ -372,6 +372,37 @@ describe('openAiChatTransformer.outbound', () => {
});
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', () => {
const context = openAiChatTransformer.createStreamContext('gpt-5');
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', () => {
const context = openAiChatTransformer.createStreamContext('gpt-5');
const event = openAiChatTransformer.transformStreamEvent({