[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:
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user