Add comprehensive tests for Anthropics and Gemini transformers

- Implement tests for anthropicMessagesTransformer including serialization and event handling in stream.test.ts.
- Add usage extraction tests for anthropicMessagesUsage in usage.test.ts.
- Create extensive tests for Gemini generate content aggregation and outbound usage in aggregator-outbound-usage.test.ts.
- Introduce tests for Gemini stream handling and payload parsing in stream.test.ts.
- Enhance OpenAI response serialization tests to ensure proper mapping of usage details and preservation of annotations in outbound.test.ts.
This commit is contained in:
cita
2026-03-08 16:42:41 +08:00
parent 7081be14c7
commit ede8998244
48 changed files with 6357 additions and 1110 deletions
@@ -1,336 +1,180 @@
import { describe, expect, it } from 'vitest';
import { createStreamTransformContext } from '../../shared/normalized.js';
import {
createOpenAiResponsesAggregateState,
serializeConvertedResponsesEvents,
type OpenAiResponsesAggregateState,
completeResponsesStream,
} from './aggregator.js';
import { openAiResponsesStream } from './stream.js';
function collectSerializedEvents(input: string[]): Array<{ event: string; data: any }> {
const events: Array<{ event: string; data: any }> = [];
for (const chunk of input) {
const blocks = chunk.split('\n\n').filter((item) => item.trim().length > 0);
for (const block of blocks) {
const lines = block.split('\n');
const eventLine = lines.find((line) => line.startsWith('event: '));
const dataLine = lines.find((line) => line.startsWith('data: '));
if (!dataLine) continue;
const rawData = dataLine.slice('data: '.length);
if (rawData === '[DONE]') {
events.push({ event: eventLine?.slice('event: '.length) || 'done', data: '[DONE]' });
continue;
function parseSsePayloads(lines: string[]): Array<Record<string, unknown>> {
return lines
.flatMap((line) => line.split('\n\n').filter((block) => block.trim().length > 0))
.map((block) => {
const dataLine = block
.split('\n')
.find((line) => line.startsWith('data: '));
if (!dataLine) return null;
try {
return JSON.parse(dataLine.slice('data: '.length)) as Record<string, unknown>;
} catch {
return null;
}
events.push({
event: eventLine?.slice('event: '.length) || '',
data: JSON.parse(rawData),
});
}
}
return events;
})
.filter((item): item is Record<string, unknown> => !!item);
}
function feedResponsesEvent(
state: OpenAiResponsesAggregateState,
payload: Record<string, unknown>,
): Array<{ event: string; data: any }> {
const context = openAiResponsesStream.createContext('gpt-5');
const normalizedEvent = openAiResponsesStream.normalizeEvent(payload, context, 'gpt-5');
return collectSerializedEvents(
describe('serializeConvertedResponsesEvents', () => {
it('aggregates reasoning summary events into the completed response payload', () => {
const state = createOpenAiResponsesAggregateState('gpt-5');
const streamContext = createStreamTransformContext('gpt-5');
const usage = {
promptTokens: 1,
completionTokens: 2,
totalTokens: 3,
};
serializeConvertedResponsesEvents({
state,
streamContext: context,
event: normalizedEvent,
usage: {
promptTokens: 0,
completionTokens: 0,
totalTokens: 0,
},
}),
);
}
describe('openAiResponsesStream / aggregator', () => {
it('keeps reasoning separate from assistant text for normalized events and merges usage details', () => {
const state = createOpenAiResponsesAggregateState('gpt-5');
const context = openAiResponsesStream.createContext('gpt-5');
const contentEvent = {
role: 'assistant',
contentDelta: 'hello',
};
const reasoningEvent = {
reasoningDelta: 'plan first',
responsesPayload: {
usage: {
input_tokens: 5,
output_tokens: 3,
total_tokens: 8,
output_tokens_details: { reasoning_tokens: 2 },
streamContext,
usage,
event: {
responsesEventType: 'response.reasoning_summary_part.added',
responsesPayload: {
type: 'response.reasoning_summary_part.added',
item_id: 'rs_1',
output_index: 0,
summary_index: 0,
part: {
type: 'summary_text',
text: '',
},
},
},
};
});
collectSerializedEvents(
serializeConvertedResponsesEvents({
state,
streamContext: context,
event: contentEvent as any,
usage: { promptTokens: 5, completionTokens: 3, totalTokens: 8 },
}),
);
const reasoningEmitted = collectSerializedEvents(
serializeConvertedResponsesEvents({
state,
streamContext: context,
event: reasoningEvent as any,
usage: { promptTokens: 5, completionTokens: 3, totalTokens: 8 },
}),
);
expect(reasoningEmitted.some((item) => item.event === 'response.reasoning_summary_part.added')).toBe(true);
expect(reasoningEmitted.some((item) => item.event === 'response.reasoning_summary_text.delta')).toBe(true);
const completed = collectSerializedEvents(
completeResponsesStream(state, context, { promptTokens: 5, completionTokens: 3, totalTokens: 8 }),
).find((item) => item.event === 'response.completed');
expect(completed?.data.response.output).toEqual([
{
id: expect.any(String),
type: 'message',
role: 'assistant',
status: 'completed',
content: [
{
type: 'output_text',
text: 'hello',
},
],
serializeConvertedResponsesEvents({
state,
streamContext,
usage,
event: {
responsesEventType: 'response.reasoning_summary_text.delta',
responsesPayload: {
type: 'response.reasoning_summary_text.delta',
item_id: 'rs_1',
output_index: 0,
summary_index: 0,
delta: 'Think ',
},
},
{
id: expect.any(String),
type: 'reasoning',
status: 'completed',
summary: [
{
type: 'summary_text',
text: 'plan first',
});
const completedLines = serializeConvertedResponsesEvents({
state,
streamContext,
usage,
event: {
responsesEventType: 'response.completed',
responsesPayload: {
type: 'response.completed',
response: {
id: 'resp_1',
model: 'gpt-5',
usage: {
input_tokens: 1,
output_tokens: 2,
total_tokens: 3,
},
},
],
},
},
]);
expect(completed?.data.response.usage).toMatchObject({
input_tokens: 5,
output_tokens: 3,
total_tokens: 8,
output_tokens_details: { reasoning_tokens: 2 },
});
const payloads = parseSsePayloads(completedLines);
const completed = payloads.find((item) => item.type === 'response.completed');
expect(completed).toBeTruthy();
expect(completed?.response).toMatchObject({
id: 'resp_1',
output: [
{
id: 'rs_1',
type: 'reasoning',
summary: [
{
type: 'summary_text',
text: 'Think ',
},
],
},
],
});
});
it('tracks reasoning summary events into completed response output', () => {
it('preserves richer image_generation_call fields while aggregating progress events', () => {
const state = createOpenAiResponsesAggregateState('gpt-5');
const streamContext = createStreamTransformContext('gpt-5');
const usage = {
promptTokens: 1,
completionTokens: 2,
totalTokens: 3,
};
const emitted = [
...feedResponsesEvent(state, {
type: 'response.output_item.added',
output_index: 0,
item: {
id: 'rs_1',
type: 'reasoning',
status: 'in_progress',
summary: [],
serializeConvertedResponsesEvents({
state,
streamContext,
usage,
event: {
responsesEventType: 'response.image_generation_call.partial_image',
responsesPayload: {
type: 'response.image_generation_call.partial_image',
item_id: 'img_1',
output_index: 0,
partial_image_index: 0,
partial_image_b64: 'partial',
background: 'transparent',
output_format: 'png',
quality: 'high',
size: '1024x1024',
},
}),
...feedResponsesEvent(state, {
type: 'response.reasoning_summary_part.added',
item_id: 'rs_1',
output_index: 0,
summary_index: 0,
part: {
type: 'summary_text',
text: '',
},
}),
...feedResponsesEvent(state, {
type: 'response.reasoning_summary_text.delta',
item_id: 'rs_1',
output_index: 0,
summary_index: 0,
delta: 'Plan',
}),
...feedResponsesEvent(state, {
type: 'response.reasoning_summary_text.done',
item_id: 'rs_1',
output_index: 0,
summary_index: 0,
text: 'Plan it out',
}),
...feedResponsesEvent(state, {
type: 'response.reasoning_summary_part.done',
item_id: 'rs_1',
output_index: 0,
summary_index: 0,
part: {
type: 'summary_text',
text: 'Plan it out',
},
}),
...feedResponsesEvent(state, {
type: 'response.output_item.done',
output_index: 0,
item: {
id: 'rs_1',
type: 'reasoning',
status: 'completed',
summary: [],
},
}),
...feedResponsesEvent(state, {
type: 'response.completed',
response: {
id: 'resp_reasoning',
object: 'response',
created_at: 1700000000,
status: 'completed',
model: 'gpt-5',
output: [],
},
}),
];
expect(emitted.some((item) => item.event === 'response.reasoning_summary_part.added')).toBe(true);
expect(emitted.some((item) => item.event === 'response.reasoning_summary_text.done')).toBe(true);
const completed = emitted.find((item) => item.event === 'response.completed');
expect(completed?.data.response.output).toEqual([
{
id: 'rs_1',
type: 'reasoning',
status: 'completed',
summary: [
{
type: 'summary_text',
text: 'Plan it out',
},
],
},
]);
});
});
it('tracks custom tool input and image generation results', () => {
const state = createOpenAiResponsesAggregateState('gpt-5');
const emitted = [
...feedResponsesEvent(state, {
type: 'response.output_item.added',
output_index: 0,
item: {
id: 'ct_1',
type: 'custom_tool_call',
status: 'in_progress',
call_id: 'ct_1',
name: 'browser',
input: '',
const completedLines = serializeConvertedResponsesEvents({
state,
streamContext,
usage,
event: {
responsesEventType: 'response.image_generation_call.completed',
responsesPayload: {
type: 'response.image_generation_call.completed',
item_id: 'img_1',
output_index: 0,
result: 'final-image',
background: 'transparent',
output_format: 'png',
quality: 'high',
size: '1024x1024',
},
}),
...feedResponsesEvent(state, {
type: 'response.custom_tool_call_input.delta',
item_id: 'ct_1',
output_index: 0,
delta: 'open ',
}),
...feedResponsesEvent(state, {
type: 'response.custom_tool_call_input.done',
item_id: 'ct_1',
output_index: 0,
input: 'open example.com',
}),
...feedResponsesEvent(state, {
type: 'response.output_item.done',
output_index: 0,
item: {
id: 'ct_1',
type: 'custom_tool_call',
status: 'completed',
call_id: 'ct_1',
name: 'browser',
input: 'open example.com',
},
}),
...feedResponsesEvent(state, {
type: 'response.output_item.added',
output_index: 1,
item: {
id: 'img_1',
type: 'image_generation_call',
status: 'in_progress',
result: null,
},
}),
...feedResponsesEvent(state, {
type: 'response.image_generation_call.partial_image',
item_id: 'img_1',
output_index: 1,
partial_image_index: 0,
partial_image_b64: 'abc123',
}),
...feedResponsesEvent(state, {
type: 'response.image_generation_call.completed',
item_id: 'img_1',
output_index: 1,
result: 'data:image/png;base64,final',
}),
...feedResponsesEvent(state, {
type: 'response.output_item.done',
output_index: 1,
item: {
id: 'img_1',
type: 'image_generation_call',
status: 'completed',
result: 'data:image/png;base64,final',
},
}),
...feedResponsesEvent(state, {
type: 'response.completed',
response: {
id: 'resp_tools',
object: 'response',
created_at: 1700000001,
status: 'completed',
model: 'gpt-5',
output: [],
},
}),
];
expect(emitted.some((item) => item.event === 'response.custom_tool_call_input.done')).toBe(true);
expect(emitted.some((item) => item.event === 'response.image_generation_call.completed')).toBe(true);
const completed = emitted.find((item) => item.event === 'response.completed');
expect(completed?.data.response.output).toEqual([
{
id: 'ct_1',
type: 'custom_tool_call',
status: 'completed',
call_id: 'ct_1',
name: 'browser',
input: 'open example.com',
},
{
id: 'img_1',
type: 'image_generation_call',
status: 'completed',
result: 'data:image/png;base64,final',
partial_images: [
{
partial_image_index: 0,
partial_image_b64: 'abc123',
},
],
},
]);
});
const payloads = parseSsePayloads(completedLines);
const imageEvent = payloads.find((item) => item.type === 'response.image_generation_call.completed');
expect(imageEvent).toBeTruthy();
expect(state.outputItems[0]).toMatchObject({
id: 'img_1',
type: 'image_generation_call',
status: 'completed',
result: 'final-image',
background: 'transparent',
output_format: 'png',
quality: 'high',
size: '1024x1024',
partial_images: [
{
partial_image_index: 0,
partial_image_b64: 'partial',
},
],
});
});
});
@@ -407,6 +407,26 @@ function serializeOriginalResponsesEvent(eventType: string, payload: Record<stri
return [serializeSse(eventType, payload)];
}
function mergeImageGenerationFields(
item: AggregateOutputItem,
payload: Record<string, unknown>,
): void {
const passthroughKeys = [
'background',
'output_format',
'quality',
'size',
'revised_prompt',
'mime_type',
] as const;
for (const key of passthroughKeys) {
if (payload[key] !== undefined) {
item[key] = cloneJson(payload[key]);
}
}
}
function computeNovelDelta(existingText: string, incomingDelta: string): string {
if (!incomingDelta) return '';
if (!existingText) return incomingDelta;
@@ -555,6 +575,7 @@ function applyOriginalResponsesPayload(
});
entry.item.partial_images = partialImages;
}
mergeImageGenerationFields(entry.item, payload);
if (payload.result !== undefined) {
entry.item.result = payload.result;
}
@@ -165,6 +165,117 @@ export function normalizeResponsesInputForCompatibility(input: unknown): unknown
return input;
}
export function buildResponsesCompatibilityBodies(
body: Record<string, unknown>,
): Record<string, unknown>[] {
const candidates: Record<string, unknown>[] = [];
const seen = new Set<string>();
try {
const originalKey = JSON.stringify(body);
if (originalKey) seen.add(originalKey);
} catch {
// ignore non-serializable bodies
}
const push = (next: Record<string, unknown> | null) => {
if (!next) return;
let key = '';
try {
key = JSON.stringify(next);
} catch {
return;
}
if (!key || seen.has(key)) return;
seen.add(key);
candidates.push(next);
};
push(stripResponsesMetadata(body));
push(buildCoreResponsesBody(body));
push(buildStrictResponsesBody(body));
return candidates;
}
export function buildResponsesCompatibilityHeaderCandidates(
headers: Record<string, string>,
stream: boolean,
): Record<string, string>[] {
const candidates: Record<string, string>[] = [];
const seen = new Set<string>();
const push = (next: Record<string, string>) => {
const normalizedEntries = Object.entries(next)
.filter(([, value]) => typeof value === 'string' && value.trim().length > 0)
.map(([key, value]) => [key.toLowerCase(), value] as const)
.sort(([a], [b]) => a.localeCompare(b));
const key = JSON.stringify(normalizedEntries);
if (!key || seen.has(key)) return;
seen.add(key);
candidates.push(Object.fromEntries(normalizedEntries));
};
push(headers);
const minimal: Record<string, string> = {};
for (const [rawKey, rawValue] of Object.entries(headers)) {
const key = rawKey.toLowerCase();
if (
key === 'authorization'
|| key === 'x-api-key'
|| key === 'content-type'
|| key === 'accept'
) {
minimal[key] = rawValue;
}
}
if (!minimal['content-type']) minimal['content-type'] = 'application/json';
if (stream && !minimal.accept) minimal.accept = 'text/event-stream';
push(minimal);
return candidates;
}
export function shouldRetryResponsesCompatibility(input: {
endpoint: string;
status: number;
rawErrText: string;
}): boolean {
if (input.endpoint !== 'responses') return false;
if (input.status !== 400) return false;
const parsedError = parseUpstreamErrorShape(input.rawErrText);
const type = parsedError.type.trim().toLowerCase();
const code = parsedError.code.trim().toLowerCase();
const message = parsedError.message.trim().toLowerCase();
const compact = `${type} ${code} ${message}`.trim();
const rawCompact = (input.rawErrText || '').toLowerCase();
if (
compact.includes('invalid_api_key')
|| compact.includes('authentication')
|| compact.includes('unauthorized')
|| compact.includes('forbidden')
|| compact.includes('insufficient_quota')
|| compact.includes('rate_limit')
) {
return false;
}
if (type === 'upstream_error' || code === 'upstream_error') return true;
if (message === 'upstream_error' || message === 'upstream request failed') return true;
if (rawCompact.includes('upstream_error')) return true;
return true;
}
export function shouldDowngradeResponsesChatToMessages(
endpointPath: string,
status: number,
upstreamErrorText: string,
): boolean {
if (!endpointPath.includes('/chat/completions')) return false;
if (status < 400 || status >= 500) return false;
return /messages\s+is\s+required/i.test(upstreamErrorText);
}
function extractTextFromResponsesContent(content: unknown): string {
if (typeof content === 'string') return content.trim();
if (!Array.isArray(content)) return '';
@@ -216,6 +327,79 @@ function toFiniteNumber(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null;
}
function parseUpstreamErrorShape(rawText: string): {
type: string;
code: string;
message: string;
} {
try {
const parsed = JSON.parse(rawText) as Record<string, unknown>;
const error = (parsed.error && typeof parsed.error === 'object')
? parsed.error as Record<string, unknown>
: parsed;
return {
type: typeof error.type === 'string' ? error.type.trim().toLowerCase() : '',
code: typeof error.code === 'string' ? error.code.trim().toLowerCase() : '',
message: typeof error.message === 'string' ? error.message.trim() : '',
};
} catch {
return { type: '', code: '', message: '' };
}
}
function stripResponsesMetadata(
body: Record<string, unknown>,
): Record<string, unknown> | null {
if (!Object.prototype.hasOwnProperty.call(body, 'metadata')) return null;
const next = { ...body };
delete next.metadata;
return next;
}
function buildCoreResponsesBody(
body: Record<string, unknown>,
): Record<string, unknown> | null {
const model = typeof body.model === 'string' ? body.model.trim() : '';
if (!model) return null;
if (body.input === undefined) return null;
const core: Record<string, unknown> = {
model,
input: body.input,
stream: body.stream === true,
};
const maxOutputTokens = toFiniteNumber(body.max_output_tokens);
if (maxOutputTokens !== null && maxOutputTokens > 0) {
core.max_output_tokens = Math.trunc(maxOutputTokens);
}
const temperature = toFiniteNumber(body.temperature);
if (temperature !== null) core.temperature = temperature;
const topP = toFiniteNumber(body.top_p);
if (topP !== null) core.top_p = topP;
const instructions = typeof body.instructions === 'string' ? body.instructions.trim() : '';
if (instructions) core.instructions = instructions;
return core;
}
function buildStrictResponsesBody(
body: Record<string, unknown>,
): Record<string, unknown> | null {
const model = typeof body.model === 'string' ? body.model.trim() : '';
if (!model) return null;
if (body.input === undefined) return null;
return {
model,
input: body.input,
stream: body.stream === true,
};
}
const ALLOWED_RESPONSES_FIELDS = new Set([
'model',
'input',
@@ -19,6 +19,7 @@ describe('sanitizeResponsesBodyForProxy', () => {
stream_options: { include_obfuscation: true },
background: true,
text: { format: { type: 'text' }, verbosity: 'high' },
top_logprobs: 2,
},
'gpt-5',
true,
@@ -34,6 +35,48 @@ describe('sanitizeResponsesBodyForProxy', () => {
stream_options: { include_obfuscation: true },
background: true,
text: { format: { type: 'text' }, verbosity: 'high' },
top_logprobs: 2,
});
});
it('normalizes current Responses inbound parity fields', () => {
const result = sanitizeResponsesBodyForProxy(
{
input: 'hello',
safety_identifier: ' safe-user-4 ',
max_tool_calls: '5',
prompt_cache_key: ' cache-key-2 ',
prompt_cache_retention: ' 24h ',
stream_options: { include_obfuscation: 'true', extra: 'keep-me' },
background: 'false',
text: { format: { type: 'text' }, verbosity: ' high ' },
truncation: ' auto ',
previous_response_id: ' resp_prev_2 ',
include: [' reasoning.encrypted_content ', '', 123, 'message.input_image.image_url'],
top_logprobs: '7',
user: ' user-456 ',
service_tier: ' priority ',
},
'gpt-5',
false,
);
expect(result).toMatchObject({
model: 'gpt-5',
stream: false,
safety_identifier: 'safe-user-4',
max_tool_calls: 5,
prompt_cache_key: 'cache-key-2',
prompt_cache_retention: '24h',
stream_options: { include_obfuscation: true, extra: 'keep-me' },
background: false,
text: { format: { type: 'text' }, verbosity: 'high' },
truncation: 'auto',
previous_response_id: 'resp_prev_2',
include: ['reasoning.encrypted_content', 'message.input_image.image_url'],
top_logprobs: 7,
user: 'user-456',
service_tier: 'priority',
});
});
});
@@ -94,6 +137,92 @@ describe('convertOpenAiBodyToResponsesBody', () => {
],
});
});
it('maps OpenAI response_format into Responses text.format while preserving verbosity', () => {
const result = convertOpenAiBodyToResponsesBody(
{
model: 'gpt-5',
messages: [{ role: 'user', content: 'return structured data' }],
response_format: {
type: 'json_schema',
json_schema: {
name: 'payload',
schema: {
type: 'object',
properties: {
name: { type: 'string' },
},
required: ['name'],
},
},
},
verbosity: 'high',
},
'gpt-5',
false,
);
expect(result).toMatchObject({
text: {
format: {
type: 'json_schema',
json_schema: {
name: 'payload',
schema: {
type: 'object',
properties: {
name: { type: 'string' },
},
required: ['name'],
},
},
},
verbosity: 'high',
},
});
});
it('normalizes and preserves field parity when converting from OpenAI-compatible input', () => {
const result = convertOpenAiBodyToResponsesBody(
{
model: 'gpt-5',
messages: [{ role: 'user', content: 'hello' }],
safety_identifier: ' safe-user-5 ',
max_tool_calls: '6',
prompt_cache_key: ' cache-key-3 ',
prompt_cache_retention: ' in-memory ',
stream_options: { include_obfuscation: 'false' },
background: 'true',
verbosity: ' medium ',
truncation: ' disabled ',
previous_response_id: ' resp_prev_3 ',
include: 'reasoning.encrypted_content',
top_logprobs: '4',
user: ' user-789 ',
service_tier: ' flex ',
},
'gpt-5',
true,
);
expect(result).toMatchObject({
model: 'gpt-5',
stream: true,
safety_identifier: 'safe-user-5',
max_tool_calls: 6,
prompt_cache_key: 'cache-key-3',
prompt_cache_retention: 'in-memory',
stream_options: { include_obfuscation: false },
background: true,
text: { verbosity: 'medium' },
truncation: 'disabled',
previous_response_id: 'resp_prev_3',
include: ['reasoning.encrypted_content'],
top_logprobs: 4,
user: 'user-789',
service_tier: 'flex',
});
});
});
describe('convertResponsesBodyToOpenAiBody', () => {
@@ -258,4 +387,88 @@ describe('convertResponsesBodyToOpenAiBody', () => {
top_logprobs: 4,
});
});
it('maps Responses text.format back into OpenAI response_format', () => {
const result = convertResponsesBodyToOpenAiBody(
{
model: 'gpt-5',
input: 'hello',
text: {
format: {
type: 'json_schema',
json_schema: {
name: 'payload',
schema: {
type: 'object',
properties: {
value: { type: 'string' },
},
},
},
},
verbosity: 'medium',
},
},
'gpt-5',
false,
);
expect(result).toMatchObject({
response_format: {
type: 'json_schema',
json_schema: {
name: 'payload',
schema: {
type: 'object',
properties: {
value: { type: 'string' },
},
},
},
},
verbosity: 'medium',
});
});
it('normalizes field parity when converting Responses input back to OpenAI-compatible input', () => {
const result = convertResponsesBodyToOpenAiBody(
{
model: 'gpt-5',
input: 'hello',
safety_identifier: ' safe-user-6 ',
max_tool_calls: '8',
prompt_cache_key: ' cache-key-4 ',
prompt_cache_retention: ' 24h ',
stream_options: { include_obfuscation: 'true' },
background: 'false',
text: { verbosity: ' low ' },
truncation: ' auto ',
previous_response_id: ' resp_prev_4 ',
include: [' reasoning.encrypted_content ', ''],
top_logprobs: '9',
user: ' user-999 ',
service_tier: ' default ',
},
'gpt-5',
false,
);
expect(result).toMatchObject({
model: 'gpt-5',
stream: false,
safety_identifier: 'safe-user-6',
max_tool_calls: 8,
prompt_cache_key: 'cache-key-4',
prompt_cache_retention: '24h',
stream_options: { include_obfuscation: true },
background: false,
verbosity: 'low',
truncation: 'auto',
previous_response_id: 'resp_prev_4',
include: ['reasoning.encrypted_content'],
top_logprobs: 9,
user: 'user-999',
service_tier: 'default',
});
});
});
@@ -15,6 +15,29 @@ function toFiniteNumber(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null;
}
function toFiniteIntegerLike(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value)) {
return Math.trunc(value);
}
if (typeof value === 'string') {
const trimmed = value.trim();
if (!trimmed) return null;
const parsed = Number(trimmed);
return Number.isFinite(parsed) ? Math.trunc(parsed) : null;
}
return null;
}
function toBooleanLike(value: unknown): boolean | undefined {
if (typeof value === 'boolean') return value;
if (typeof value === 'string') {
const trimmed = value.trim().toLowerCase();
if (trimmed === 'true') return true;
if (trimmed === 'false') return false;
}
return undefined;
}
function safeJsonStringify(value: unknown): string {
try {
return JSON.stringify(value);
@@ -40,6 +63,103 @@ function cloneJsonValue<T>(value: T): T {
return value;
}
function normalizeOptionalTrimmedString(value: unknown): string | undefined {
const trimmed = asTrimmedString(value);
return trimmed || undefined;
}
function normalizeIncludeList(value: unknown): unknown {
if (typeof value === 'string') {
const trimmed = value.trim();
return trimmed ? [trimmed] : [];
}
if (!Array.isArray(value)) return value;
return value
.map((item) => (typeof item === 'string' ? item.trim() : ''))
.filter((item) => item.length > 0);
}
function normalizeTextConfig(
rawText: unknown,
fallbackVerbosity?: unknown,
): Record<string, unknown> | undefined {
const textConfig = cloneRecord(rawText) || {};
const verbosity = (
normalizeOptionalTrimmedString(textConfig.verbosity)
?? normalizeOptionalTrimmedString(fallbackVerbosity)
);
if (verbosity) {
textConfig.verbosity = verbosity;
}
return Object.keys(textConfig).length > 0 ? textConfig : undefined;
}
function normalizeStreamOptions(rawStreamOptions: unknown): unknown {
const streamOptions = cloneRecord(rawStreamOptions);
if (!streamOptions) return rawStreamOptions;
const includeObfuscation = toBooleanLike(streamOptions.include_obfuscation);
if (includeObfuscation !== undefined) {
streamOptions.include_obfuscation = includeObfuscation;
}
return streamOptions;
}
function normalizeResponsesRequestFieldParity(
body: Record<string, unknown>,
options?: { verbositySource?: unknown },
): Record<string, unknown> {
const normalized: Record<string, unknown> = { ...body };
const safetyIdentifier = normalizeOptionalTrimmedString(normalized.safety_identifier);
if (safetyIdentifier) normalized.safety_identifier = safetyIdentifier;
const maxToolCalls = toFiniteIntegerLike(normalized.max_tool_calls);
if (maxToolCalls !== null) normalized.max_tool_calls = maxToolCalls;
const promptCacheKey = normalizeOptionalTrimmedString(normalized.prompt_cache_key);
if (promptCacheKey) normalized.prompt_cache_key = promptCacheKey;
const promptCacheRetention = normalizeOptionalTrimmedString(normalized.prompt_cache_retention);
if (promptCacheRetention) normalized.prompt_cache_retention = promptCacheRetention;
const background = toBooleanLike(normalized.background);
if (background !== undefined) normalized.background = background;
const user = normalizeOptionalTrimmedString(normalized.user);
if (user) normalized.user = user;
const previousResponseId = normalizeOptionalTrimmedString(normalized.previous_response_id);
if (previousResponseId) normalized.previous_response_id = previousResponseId;
const truncation = normalizeOptionalTrimmedString(normalized.truncation);
if (truncation) normalized.truncation = truncation;
const serviceTier = normalizeOptionalTrimmedString(normalized.service_tier);
if (serviceTier) normalized.service_tier = serviceTier;
const topLogprobs = toFiniteIntegerLike(normalized.top_logprobs);
if (topLogprobs !== null) normalized.top_logprobs = topLogprobs;
if (normalized.include !== undefined) {
normalized.include = normalizeIncludeList(normalized.include);
}
if (normalized.stream_options !== undefined) {
normalized.stream_options = normalizeStreamOptions(normalized.stream_options);
}
const textConfig = normalizeTextConfig(normalized.text, options?.verbositySource);
if (textConfig) {
normalized.text = textConfig;
}
return normalized;
}
function parseJsonString(raw: string): unknown {
const trimmed = raw.trim();
if (!trimmed) return {};
@@ -219,6 +339,7 @@ const ALLOWED_RESPONSES_FIELDS = new Set([
'include',
'response_format',
'service_tier',
'top_logprobs',
'stop',
'n',
]);
@@ -250,6 +371,10 @@ export function sanitizeResponsesBodyForProxy(
}
}
normalized = normalizeResponsesRequestFieldParity(normalized, {
verbositySource: body.verbosity,
});
const sanitized: Record<string, unknown> = {};
for (const [key, value] of Object.entries(normalized)) {
if (!ALLOWED_RESPONSES_FIELDS.has(key)) continue;
@@ -388,23 +513,23 @@ export function convertOpenAiBodyToResponsesBody(
if (openaiBody.service_tier !== undefined) body.service_tier = openaiBody.service_tier;
if (openaiBody.top_logprobs !== undefined) body.top_logprobs = openaiBody.top_logprobs;
if (openaiBody.stream_options !== undefined) body.stream_options = openaiBody.stream_options;
const textConfig = cloneRecord(openaiBody.text) || {};
const verbosity = (
asTrimmedString(openaiBody.verbosity)
|| asTrimmedString(isRecord(openaiBody.text) ? openaiBody.text.verbosity : undefined)
);
if (verbosity) {
textConfig.verbosity = verbosity;
if (openaiBody.response_format !== undefined) {
const existingTextConfig = cloneRecord(body.text) || {};
existingTextConfig.format = cloneJsonValue(openaiBody.response_format);
body.text = existingTextConfig;
}
if (Object.keys(textConfig).length > 0) {
const textConfig = normalizeTextConfig(body.text, openaiBody.verbosity);
if (textConfig) {
body.text = textConfig;
}
const responsesToolChoice = convertOpenAiToolChoiceToResponses(openaiBody.tool_choice);
if (responsesToolChoice !== undefined) body.tool_choice = responsesToolChoice;
return normalizeResponsesBodyForCompatibility(body);
return normalizeResponsesBodyForCompatibility(
normalizeResponsesRequestFieldParity(body, { verbositySource: openaiBody.verbosity }),
);
}
type OpenAiToolCall = {
@@ -560,8 +685,9 @@ export function convertResponsesBodyToOpenAiBody(
modelName: string,
stream: boolean,
): Record<string, unknown> {
const normalizedBody = normalizeResponsesRequestFieldParity(body);
const messages: Array<Record<string, unknown>> = [];
const input = body.input;
const input = normalizedBody.input;
let functionCallIndex = 0;
let pendingToolCalls: OpenAiToolCall[] = [];
@@ -655,7 +781,7 @@ export function convertResponsesBodyToOpenAiBody(
}
flushPendingToolCalls();
const instructions = asTrimmedString(body.instructions);
const instructions = asTrimmedString(normalizedBody.instructions);
if (instructions) {
messages.unshift({ role: 'system', content: instructions });
}
@@ -666,32 +792,35 @@ export function convertResponsesBodyToOpenAiBody(
messages,
};
if (typeof body.temperature === 'number' && Number.isFinite(body.temperature)) {
payload.temperature = body.temperature;
if (typeof normalizedBody.temperature === 'number' && Number.isFinite(normalizedBody.temperature)) {
payload.temperature = normalizedBody.temperature;
}
if (typeof body.top_p === 'number' && Number.isFinite(body.top_p)) {
payload.top_p = body.top_p;
if (typeof normalizedBody.top_p === 'number' && Number.isFinite(normalizedBody.top_p)) {
payload.top_p = normalizedBody.top_p;
}
if (typeof body.max_output_tokens === 'number' && Number.isFinite(body.max_output_tokens)) {
payload.max_tokens = body.max_output_tokens;
if (typeof normalizedBody.max_output_tokens === 'number' && Number.isFinite(normalizedBody.max_output_tokens)) {
payload.max_tokens = normalizedBody.max_output_tokens;
}
if (body.parallel_tool_calls !== undefined) payload.parallel_tool_calls = body.parallel_tool_calls;
if (body.tools !== undefined) payload.tools = convertResponsesToolsToOpenAi(body.tools);
if (body.tool_choice !== undefined) payload.tool_choice = convertResponsesToolChoiceToOpenAi(body.tool_choice);
if (body.safety_identifier !== undefined) payload.safety_identifier = body.safety_identifier;
if (body.max_tool_calls !== undefined) payload.max_tool_calls = body.max_tool_calls;
if (body.prompt_cache_key !== undefined) payload.prompt_cache_key = body.prompt_cache_key;
if (body.prompt_cache_retention !== undefined) payload.prompt_cache_retention = body.prompt_cache_retention;
if (body.background !== undefined) payload.background = body.background;
if (body.user !== undefined) payload.user = body.user;
if (body.include !== undefined) payload.include = cloneJsonValue(body.include);
if (body.previous_response_id !== undefined) payload.previous_response_id = body.previous_response_id;
if (body.truncation !== undefined) payload.truncation = body.truncation;
if (body.service_tier !== undefined) payload.service_tier = body.service_tier;
if (body.top_logprobs !== undefined) payload.top_logprobs = body.top_logprobs;
if (body.stream_options !== undefined) payload.stream_options = body.stream_options;
if (isRecord(body.text) && asTrimmedString(body.text.verbosity)) {
payload.verbosity = asTrimmedString(body.text.verbosity);
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);
if (normalizedBody.safety_identifier !== undefined) payload.safety_identifier = normalizedBody.safety_identifier;
if (normalizedBody.max_tool_calls !== undefined) payload.max_tool_calls = normalizedBody.max_tool_calls;
if (normalizedBody.prompt_cache_key !== undefined) payload.prompt_cache_key = normalizedBody.prompt_cache_key;
if (normalizedBody.prompt_cache_retention !== undefined) payload.prompt_cache_retention = normalizedBody.prompt_cache_retention;
if (normalizedBody.background !== undefined) payload.background = normalizedBody.background;
if (normalizedBody.user !== undefined) payload.user = normalizedBody.user;
if (normalizedBody.include !== undefined) payload.include = cloneJsonValue(normalizedBody.include);
if (normalizedBody.previous_response_id !== undefined) payload.previous_response_id = normalizedBody.previous_response_id;
if (normalizedBody.truncation !== undefined) payload.truncation = normalizedBody.truncation;
if (normalizedBody.service_tier !== undefined) payload.service_tier = normalizedBody.service_tier;
if (normalizedBody.top_logprobs !== undefined) payload.top_logprobs = normalizedBody.top_logprobs;
if (normalizedBody.stream_options !== undefined) payload.stream_options = normalizedBody.stream_options;
if (isRecord(normalizedBody.text) && normalizedBody.text.format !== undefined) {
payload.response_format = cloneJsonValue(normalizedBody.text.format);
}
if (isRecord(normalizedBody.text) && asTrimmedString(normalizedBody.text.verbosity)) {
payload.verbosity = asTrimmedString(normalizedBody.text.verbosity);
}
return payload;
@@ -2,11 +2,17 @@ import { type StreamTransformContext } from '../../shared/normalized.js';
import {
convertOpenAiBodyToResponsesBody,
convertResponsesBodyToOpenAiBody,
normalizeResponsesInputForCompatibility,
normalizeResponsesMessageContent,
sanitizeResponsesBodyForProxy,
} from './conversion.js';
import { normalizeResponsesMessageItem } from './compatibility.js';
import {
buildResponsesCompatibilityBodies as buildRetryBodies,
buildResponsesCompatibilityHeaderCandidates as buildRetryHeaders,
normalizeResponsesInputForCompatibility,
normalizeResponsesMessageContent,
normalizeResponsesMessageItem,
shouldDowngradeResponsesChatToMessages as shouldDowngradeChatToMessages,
shouldRetryResponsesCompatibility as shouldRetry,
} from './compatibility.js';
import {
type OpenAiResponsesAggregateState,
completeResponsesStream,
@@ -31,6 +37,12 @@ export const openAiResponsesTransformer = {
outbound: openAiResponsesOutbound,
stream: openAiResponsesStream,
usage: openAiResponsesUsage,
compatibility: {
buildRetryBodies,
buildRetryHeaders,
shouldRetry,
shouldDowngradeChatToMessages,
},
aggregator: {
createState: createOpenAiResponsesAggregateState,
serialize: serializeConvertedResponsesEvents,
@@ -0,0 +1,251 @@
import { describe, expect, it } from 'vitest';
import { serializeResponsesFinalPayload } from './outbound.js';
describe('serializeResponsesFinalPayload', () => {
it('preserves top-level chat annotations on synthetic assistant messages', () => {
const payload = serializeResponsesFinalPayload({
upstreamPayload: {
id: 'chatcmpl_1',
object: 'chat.completion',
created: 1700000000,
model: 'gpt-5',
choices: [
{
index: 0,
finish_reason: 'stop',
message: {
role: 'assistant',
content: 'hello',
annotations: [
{
type: 'url_citation',
url: 'https://example.com',
title: 'Example',
},
],
},
},
],
},
normalized: {
id: 'chatcmpl_1',
model: 'gpt-5',
created: 1700000000,
content: 'hello',
reasoningContent: '',
finishReason: 'stop',
toolCalls: [],
},
usage: {
promptTokens: 1,
completionTokens: 2,
totalTokens: 3,
},
});
expect(payload.output).toEqual([
{
id: 'msg_chatcmpl_1',
type: 'message',
role: 'assistant',
status: 'completed',
content: [
{
type: 'output_text',
text: 'hello',
annotations: [
{
type: 'url_citation',
url: 'https://example.com',
title: 'Example',
},
],
},
],
},
]);
});
it('falls back to normalized tool calls when upstream payload no longer exposes them directly', () => {
const payload = serializeResponsesFinalPayload({
upstreamPayload: {
id: 'opaque_1',
model: 'gpt-5',
},
normalized: {
id: 'opaque_1',
model: 'gpt-5',
created: 1700000000,
content: '',
reasoningContent: '',
finishReason: 'tool_calls',
toolCalls: [
{
id: 'call_1',
name: 'browser',
arguments: '{"url":"https://example.com"}',
},
],
},
usage: {
promptTokens: 1,
completionTokens: 2,
totalTokens: 3,
},
});
expect(payload.output).toEqual([
{
id: 'call_1',
type: 'function_call',
status: 'completed',
call_id: 'call_1',
name: 'browser',
arguments: '{"url":"https://example.com"}',
},
]);
});
it('preserves response-like custom tool and image generation items when synthesizing object=response payloads', () => {
const payload = serializeResponsesFinalPayload({
upstreamPayload: {
id: 'resp_like_1',
model: 'gpt-5',
output: [
{
id: 'ct_1',
type: 'custom_tool_call',
status: 'completed',
call_id: 'ct_1',
name: 'browser',
input: 'open example.com',
},
{
id: 'img_1',
type: 'image_generation_call',
status: 'completed',
result: 'data:image/png;base64,final',
background: 'transparent',
output_format: 'png',
quality: 'high',
size: '1024x1024',
partial_images: [
{
partial_image_index: 0,
partial_image_b64: 'partial',
},
],
},
],
},
normalized: {
id: 'resp_like_1',
model: 'gpt-5',
created: 1700000000,
content: '',
reasoningContent: '',
finishReason: 'stop',
toolCalls: [],
},
usage: {
promptTokens: 1,
completionTokens: 2,
totalTokens: 3,
},
});
expect(payload.output).toEqual([
{
id: 'ct_1',
type: 'custom_tool_call',
status: 'completed',
call_id: 'ct_1',
name: 'browser',
input: 'open example.com',
},
{
id: 'img_1',
type: 'image_generation_call',
status: 'completed',
result: 'data:image/png;base64,final',
background: 'transparent',
output_format: 'png',
quality: 'high',
size: '1024x1024',
partial_images: [
{
partial_image_index: 0,
partial_image_b64: 'partial',
},
],
},
]);
});
it('maps upstream chat-completion usage details into Responses usage details', () => {
const payload = serializeResponsesFinalPayload({
upstreamPayload: {
id: 'chatcmpl_usage',
object: 'chat.completion',
created: 1700000000,
model: 'gpt-5',
choices: [
{
index: 0,
finish_reason: 'stop',
message: {
role: 'assistant',
content: 'hello',
},
},
],
usage: {
prompt_tokens: 11,
completion_tokens: 7,
total_tokens: 18,
prompt_tokens_details: {
cached_tokens: 5,
audio_tokens: 2,
},
completion_tokens_details: {
reasoning_tokens: 3,
audio_tokens: 1,
accepted_prediction_tokens: 4,
rejected_prediction_tokens: 6,
},
},
},
normalized: {
id: 'chatcmpl_usage',
model: 'gpt-5',
created: 1700000000,
content: 'hello',
reasoningContent: '',
finishReason: 'stop',
toolCalls: [],
},
usage: {
promptTokens: 11,
completionTokens: 7,
totalTokens: 18,
},
});
expect(payload.usage).toMatchObject({
input_tokens: 11,
output_tokens: 7,
total_tokens: 18,
input_tokens_details: {
cached_tokens: 5,
audio_tokens: 2,
},
output_tokens_details: {
reasoning_tokens: 3,
audio_tokens: 1,
accepted_prediction_tokens: 4,
rejected_prediction_tokens: 6,
},
});
});
});
@@ -11,6 +11,18 @@ function asTrimmedString(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
}
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 ensureResponseId(rawId: string): string {
const trimmed = rawId.trim() || `resp_${Date.now()}`;
return trimmed.startsWith('resp_') ? trimmed : `resp_${trimmed}`;
@@ -41,6 +53,8 @@ export type ResponsesUsageSummary = {
promptTokens: number;
completionTokens: number;
totalTokens: number;
inputTokensDetails?: Record<string, unknown>;
outputTokensDetails?: Record<string, unknown>;
};
export type ResponsesToolCall = {
@@ -49,6 +63,8 @@ export type ResponsesToolCall = {
arguments: string;
};
type ResponsesOutputItem = Record<string, unknown>;
function extractToolCallsFromUpstream(payload: unknown): ResponsesToolCall[] {
if (!isRecord(payload)) return [];
@@ -95,6 +111,166 @@ function extractToolCallsFromUpstream(payload: unknown): ResponsesToolCall[] {
return [];
}
function extractToolCallsFromNormalized(normalized: NormalizedFinalResponse): ResponsesToolCall[] {
return Array.isArray(normalized.toolCalls)
? normalized.toolCalls
.map((item) => {
const id = ensureFunctionCallId(asTrimmedString(item.id));
const name = asTrimmedString(item.name);
if (!name) return null;
return {
id,
name,
arguments: typeof item.arguments === 'string' ? item.arguments : '',
};
})
.filter((item): item is ResponsesToolCall => !!item)
: [];
}
function extractAnnotationsFromUpstream(payload: unknown): unknown[] {
if (!isRecord(payload)) return [];
if (Array.isArray(payload.choices)) {
const choice = payload.choices[0];
const message = isRecord((choice as any)?.message) ? (choice as any).message : {};
const annotations = Array.isArray((message as any).annotations) ? (message as any).annotations : [];
return annotations.map((item) => cloneJson(item));
}
if (payload.type === 'message' && Array.isArray(payload.content)) {
const annotations = payload.content
.filter((item): item is Record<string, unknown> => isRecord(item))
.flatMap((item) => (Array.isArray(item.annotations) ? item.annotations : []));
return annotations.map((item) => cloneJson(item));
}
return [];
}
function extractSyntheticOutputItemsFromUpstream(payload: unknown): ResponsesOutputItem[] {
if (!isRecord(payload) || !Array.isArray(payload.output)) return [];
return payload.output
.map((rawItem): ResponsesOutputItem | null => {
if (!isRecord(rawItem)) return null;
const itemType = asTrimmedString(rawItem.type).toLowerCase();
if (!itemType) return null;
if (itemType === 'message') {
return {
id: asTrimmedString(rawItem.id) || ensureMessageId(''),
type: 'message',
role: asTrimmedString(rawItem.role) || 'assistant',
status: asTrimmedString(rawItem.status) || 'completed',
content: Array.isArray(rawItem.content) ? cloneJson(rawItem.content) : [],
};
}
if (itemType === 'reasoning') {
return {
id: asTrimmedString(rawItem.id) || ensureMessageId(''),
type: 'reasoning',
status: asTrimmedString(rawItem.status) || 'completed',
encrypted_content: rawItem.encrypted_content,
summary: Array.isArray(rawItem.summary) ? cloneJson(rawItem.summary) : [],
};
}
if (itemType === 'function_call') {
return {
id: asTrimmedString(rawItem.id) || ensureFunctionCallId(''),
type: 'function_call',
status: asTrimmedString(rawItem.status) || 'completed',
call_id: asTrimmedString(rawItem.call_id) || ensureFunctionCallId(asTrimmedString(rawItem.id)),
name: asTrimmedString(rawItem.name),
arguments: typeof rawItem.arguments === 'string' ? rawItem.arguments : '',
};
}
if (itemType === 'custom_tool_call') {
return {
id: asTrimmedString(rawItem.id) || ensureFunctionCallId(''),
type: 'custom_tool_call',
status: asTrimmedString(rawItem.status) || 'completed',
call_id: asTrimmedString(rawItem.call_id) || ensureFunctionCallId(asTrimmedString(rawItem.id)),
name: asTrimmedString(rawItem.name),
input: typeof rawItem.input === 'string' ? rawItem.input : '',
};
}
if (itemType === 'function_call_output' || itemType === 'custom_tool_call_output') {
return {
id: asTrimmedString(rawItem.id) || ensureMessageId(''),
type: itemType,
status: asTrimmedString(rawItem.status) || 'completed',
call_id: asTrimmedString(rawItem.call_id),
output: cloneJson(rawItem.output),
};
}
if (itemType === 'image_generation_call') {
return {
id: asTrimmedString(rawItem.id) || ensureMessageId(''),
type: 'image_generation_call',
status: asTrimmedString(rawItem.status) || 'completed',
result: cloneJson(rawItem.result),
partial_images: Array.isArray(rawItem.partial_images) ? cloneJson(rawItem.partial_images) : [],
...(rawItem.background !== undefined ? { background: cloneJson(rawItem.background) } : {}),
...(rawItem.output_format !== undefined ? { output_format: cloneJson(rawItem.output_format) } : {}),
...(rawItem.quality !== undefined ? { quality: cloneJson(rawItem.quality) } : {}),
...(rawItem.size !== undefined ? { size: cloneJson(rawItem.size) } : {}),
...(rawItem.revised_prompt !== undefined ? { revised_prompt: cloneJson(rawItem.revised_prompt) } : {}),
};
}
return null;
})
.filter((item): item is ResponsesOutputItem => item !== null);
}
function collectOutputTextFromItems(items: ResponsesOutputItem[]): string {
const textParts: string[] = [];
for (const item of items) {
if (asTrimmedString(item.type).toLowerCase() !== 'message') continue;
const content = Array.isArray(item.content) ? item.content : [];
for (const part of content) {
if (!isRecord(part)) continue;
const partType = asTrimmedString(part.type).toLowerCase();
if ((partType === 'output_text' || partType === 'text') && typeof part.text === 'string') {
textParts.push(part.text);
}
}
}
return textParts.join('');
}
function extractSyntheticUsageDetails(upstreamPayload: unknown): {
inputTokensDetails?: Record<string, unknown>;
outputTokensDetails?: Record<string, unknown>;
} {
if (!isRecord(upstreamPayload) || !isRecord(upstreamPayload.usage)) {
return {};
}
const usage = upstreamPayload.usage;
const promptDetails = (
(isRecord(usage.input_tokens_details) ? usage.input_tokens_details : null)
?? (isRecord(usage.prompt_tokens_details) ? usage.prompt_tokens_details : null)
);
const completionDetails = (
(isRecord(usage.output_tokens_details) ? usage.output_tokens_details : null)
?? (isRecord(usage.completion_tokens_details) ? usage.completion_tokens_details : null)
);
return {
...(promptDetails ? { inputTokensDetails: cloneJson(promptDetails) } : {}),
...(completionDetails ? { outputTokensDetails: cloneJson(completionDetails) } : {}),
};
}
export function normalizeResponsesFinalPayload(
payload: unknown,
modelName: string,
@@ -118,10 +294,30 @@ export function serializeResponsesFinalPayload(input: {
: `resp_${Date.now()}`;
const responseId = ensureResponseId(normalizedId);
const messageId = ensureMessageId(normalizedId);
const toolCalls = extractToolCallsFromUpstream(upstreamPayload);
const syntheticOutput = extractSyntheticOutputItemsFromUpstream(upstreamPayload);
const syntheticUsageDetails = extractSyntheticUsageDetails(upstreamPayload);
const toolCalls = (() => {
const extracted = extractToolCallsFromUpstream(upstreamPayload);
if (extracted.length > 0) return extracted;
return extractToolCallsFromNormalized(normalized);
})();
const annotations = extractAnnotationsFromUpstream(upstreamPayload);
const output: Array<Record<string, unknown>> = [];
if (normalized.reasoningContent) {
const output: Array<Record<string, unknown>> = syntheticOutput.map((item) => cloneJson(item));
const hasReasoningItem = output.some((item) => asTrimmedString(item.type).toLowerCase() === 'reasoning');
const hasMessageItem = output.some((item) => asTrimmedString(item.type).toLowerCase() === 'message');
const hasToolLikeItem = output.some((item) => {
const itemType = asTrimmedString(item.type).toLowerCase();
return (
itemType === 'function_call'
|| itemType === 'custom_tool_call'
|| itemType === 'function_call_output'
|| itemType === 'custom_tool_call_output'
|| itemType === 'image_generation_call'
);
});
if (normalized.reasoningContent && !hasReasoningItem) {
output.push({
id: ensureMessageId(`${normalizedId}_reasoning`),
type: 'reasoning',
@@ -132,28 +328,35 @@ export function serializeResponsesFinalPayload(input: {
}],
});
}
if (normalized.content || toolCalls.length === 0) {
if ((normalized.content || (!hasToolLikeItem && output.length === 0 && toolCalls.length === 0)) && !hasMessageItem) {
const textPart: Record<string, unknown> = {
type: 'output_text',
text: normalized.content || '',
};
if (annotations.length > 0) {
textPart.annotations = annotations;
}
output.push({
id: messageId,
type: 'message',
role: 'assistant',
status: 'completed',
content: [{
type: 'output_text',
text: normalized.content || '',
}],
content: [textPart],
});
}
for (const toolCall of toolCalls) {
output.push({
id: toolCall.id,
type: 'function_call',
status: 'completed',
call_id: toolCall.id,
name: toolCall.name,
arguments: toolCall.arguments,
});
if (toolCalls.length > 0 && !hasToolLikeItem) {
for (const toolCall of toolCalls) {
output.push({
id: toolCall.id,
type: 'function_call',
status: 'completed',
call_id: toolCall.id,
name: toolCall.name,
arguments: toolCall.arguments,
});
}
}
return {
@@ -163,11 +366,17 @@ export function serializeResponsesFinalPayload(input: {
status: 'completed',
model: normalized.model,
output,
output_text: normalized.content || '',
output_text: normalized.content || collectOutputTextFromItems(output),
usage: {
input_tokens: usage.promptTokens,
output_tokens: usage.completionTokens,
total_tokens: usage.totalTokens,
...(usage.inputTokensDetails ?? syntheticUsageDetails.inputTokensDetails
? { input_tokens_details: cloneJson(usage.inputTokensDetails ?? syntheticUsageDetails.inputTokensDetails) }
: {}),
...(usage.outputTokensDetails ?? syntheticUsageDetails.outputTokensDetails
? { output_tokens_details: cloneJson(usage.outputTokensDetails ?? syntheticUsageDetails.outputTokensDetails) }
: {}),
},
};
}