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:
@@ -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) }
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user