feat: Add normalized types and utility functions for handling chat formats
- Introduced `NormalizedContentBlock`, `NormalizedUsage`, and related types for structured data handling. - Implemented utility functions for creating and merging normalized usage statistics. - Added a new module for shared transformers to manage chat formats. feat: Implement app local state management - Created functions to clear application installation state and manage local storage keys. - Added constants for theme and user profile storage keys. test: Add tests for BrandIcon component rendering - Implemented tests to ensure BrandIcon does not force a colored background for recognized brands. test: Enhance ModelAnalysisPanel tests - Added tests for rendering token summaries with compact units in ModelAnalysisPanel. test: Add tests for ModernSelect component - Implemented tests to verify icon rendering for selected options in ModernSelect. test: Add number formatting tests - Created tests for `formatCompactTokenMetric` to validate token formatting logic. feat: Implement account creation intent handling - Added logic to handle session and API key creation intents in the Accounts page. test: Add tests for dashboard performance card - Implemented tests to verify rendering of performance metrics in the dashboard. feat: Implement default connection segment resolution - Added logic to determine initial connection segments based on platform types. test: Add tests for settings factory reset functionality - Implemented tests to verify the factory reset process and local state clearing. test: Add tests for site creation redirect logic - Implemented tests to verify redirection based on site capabilities during creation. test: Add tests for token routes refresh decision action - Implemented tests to verify refresh decision actions in TokenRoutes. test: Add tests for token routes snapshot load - Implemented tests to verify loading of cached snapshot data in TokenRoutes.
This commit is contained in:
@@ -0,0 +1,336 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
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;
|
||||
}
|
||||
events.push({
|
||||
event: eventLine?.slice('event: '.length) || '',
|
||||
data: JSON.parse(rawData),
|
||||
});
|
||||
}
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
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(
|
||||
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 },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
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',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: expect.any(String),
|
||||
type: 'reasoning',
|
||||
status: 'completed',
|
||||
summary: [
|
||||
{
|
||||
type: 'summary_text',
|
||||
text: 'plan first',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
expect(completed?.data.response.usage).toMatchObject({
|
||||
input_tokens: 5,
|
||||
output_tokens: 3,
|
||||
total_tokens: 8,
|
||||
output_tokens_details: { reasoning_tokens: 2 },
|
||||
});
|
||||
});
|
||||
|
||||
it('tracks reasoning summary events into completed response output', () => {
|
||||
const state = createOpenAiResponsesAggregateState('gpt-5');
|
||||
|
||||
const emitted = [
|
||||
...feedResponsesEvent(state, {
|
||||
type: 'response.output_item.added',
|
||||
output_index: 0,
|
||||
item: {
|
||||
id: 'rs_1',
|
||||
type: 'reasoning',
|
||||
status: 'in_progress',
|
||||
summary: [],
|
||||
},
|
||||
}),
|
||||
...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: '',
|
||||
},
|
||||
}),
|
||||
...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',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,762 @@
|
||||
import { type StreamTransformContext } from '../../shared/normalized.js';
|
||||
import type { OpenAiResponsesStreamEvent } from './stream.js';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function asTrimmedString(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function cloneRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (!isRecord(value)) return null;
|
||||
return { ...value };
|
||||
}
|
||||
|
||||
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}`;
|
||||
}
|
||||
|
||||
function ensureOutputItemId(rawId: string, prefix: string, index: number): string {
|
||||
const trimmed = rawId.trim();
|
||||
if (trimmed) return trimmed;
|
||||
return `${prefix}_${index}`;
|
||||
}
|
||||
|
||||
function serializeSse(event: string, payload: unknown): string {
|
||||
return `event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`;
|
||||
}
|
||||
|
||||
function serializeDone(): string {
|
||||
return 'data: [DONE]\n\n';
|
||||
}
|
||||
|
||||
type ResponsesUsageSummary = {
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
cacheReadTokens?: number;
|
||||
cacheCreationTokens?: number;
|
||||
promptTokensIncludeCache?: boolean | null;
|
||||
};
|
||||
|
||||
type AggregateOutputItem = Record<string, unknown>;
|
||||
|
||||
export type OpenAiResponsesAggregateState = {
|
||||
modelName: string;
|
||||
responseId: string | null;
|
||||
createdAt: number | null;
|
||||
outputItems: Array<AggregateOutputItem | undefined>;
|
||||
outputIndexById: Record<string, number>;
|
||||
messageIndex: number | null;
|
||||
reasoningIndexById: Record<string, number>;
|
||||
functionIndexById: Record<string, number>;
|
||||
customToolIndexById: Record<string, number>;
|
||||
imageGenerationIndexById: Record<string, number>;
|
||||
usageExtras: Record<string, unknown>;
|
||||
completed: boolean;
|
||||
failed: boolean;
|
||||
};
|
||||
|
||||
export function createOpenAiResponsesAggregateState(modelName: string): OpenAiResponsesAggregateState {
|
||||
return {
|
||||
modelName,
|
||||
responseId: null,
|
||||
createdAt: null,
|
||||
outputItems: [],
|
||||
outputIndexById: {},
|
||||
messageIndex: null,
|
||||
reasoningIndexById: {},
|
||||
functionIndexById: {},
|
||||
customToolIndexById: {},
|
||||
imageGenerationIndexById: {},
|
||||
usageExtras: {},
|
||||
completed: false,
|
||||
failed: false,
|
||||
};
|
||||
}
|
||||
|
||||
function mergeUsageExtras(
|
||||
state: OpenAiResponsesAggregateState,
|
||||
usagePayload: unknown,
|
||||
): void {
|
||||
if (!isRecord(usagePayload)) return;
|
||||
for (const [key, value] of Object.entries(usagePayload)) {
|
||||
if (key === 'input_tokens' || key === 'output_tokens' || key === 'total_tokens') continue;
|
||||
state.usageExtras[key] = cloneJson(value);
|
||||
}
|
||||
}
|
||||
|
||||
function rememberOutputId(state: OpenAiResponsesAggregateState, index: number, item: AggregateOutputItem) {
|
||||
const itemId = asTrimmedString(item.id);
|
||||
if (itemId) {
|
||||
state.outputIndexById[itemId] = index;
|
||||
}
|
||||
const itemType = asTrimmedString(item.type).toLowerCase();
|
||||
const callId = asTrimmedString(item.call_id);
|
||||
if (itemType === 'function_call') {
|
||||
if (callId) state.functionIndexById[callId] = index;
|
||||
if (itemId) state.functionIndexById[itemId] = index;
|
||||
}
|
||||
if (itemType === 'custom_tool_call') {
|
||||
if (callId) state.customToolIndexById[callId] = index;
|
||||
if (itemId) state.customToolIndexById[itemId] = index;
|
||||
}
|
||||
if (itemType === 'image_generation_call' && itemId) {
|
||||
state.imageGenerationIndexById[itemId] = index;
|
||||
}
|
||||
if (itemType === 'reasoning' && itemId) {
|
||||
state.reasoningIndexById[itemId] = index;
|
||||
}
|
||||
if (itemType === 'message' && state.messageIndex === null) {
|
||||
state.messageIndex = index;
|
||||
}
|
||||
}
|
||||
|
||||
function setOutputItem(
|
||||
state: OpenAiResponsesAggregateState,
|
||||
index: number,
|
||||
item: AggregateOutputItem,
|
||||
): AggregateOutputItem {
|
||||
const current = cloneRecord(state.outputItems[index]) || {};
|
||||
const incoming = cloneJson(item);
|
||||
const next = {
|
||||
...current,
|
||||
...incoming,
|
||||
};
|
||||
if (Array.isArray(current.summary) && (!Array.isArray(incoming.summary) || incoming.summary.length <= 0)) {
|
||||
next.summary = current.summary;
|
||||
}
|
||||
if (Array.isArray(current.content) && (!Array.isArray(incoming.content) || incoming.content.length <= 0)) {
|
||||
next.content = current.content;
|
||||
}
|
||||
if (
|
||||
Array.isArray(current.partial_images)
|
||||
&& (!Array.isArray(incoming.partial_images) || incoming.partial_images.length <= 0)
|
||||
) {
|
||||
next.partial_images = current.partial_images;
|
||||
}
|
||||
state.outputItems[index] = next;
|
||||
rememberOutputId(state, index, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
function ensureOutputItem(
|
||||
state: OpenAiResponsesAggregateState,
|
||||
index: number,
|
||||
factory: () => AggregateOutputItem,
|
||||
): AggregateOutputItem {
|
||||
const existing = state.outputItems[index];
|
||||
if (existing) return existing;
|
||||
return setOutputItem(state, index, factory());
|
||||
}
|
||||
|
||||
function resolveOutputIndex(
|
||||
state: OpenAiResponsesAggregateState,
|
||||
rawIndex: unknown,
|
||||
...candidateIds: Array<unknown>
|
||||
): number {
|
||||
if (typeof rawIndex === 'number' && Number.isFinite(rawIndex)) {
|
||||
return Math.max(0, Math.trunc(rawIndex));
|
||||
}
|
||||
for (const rawId of candidateIds) {
|
||||
const itemId = asTrimmedString(rawId);
|
||||
if (itemId && state.outputIndexById[itemId] !== undefined) {
|
||||
return state.outputIndexById[itemId];
|
||||
}
|
||||
}
|
||||
return state.outputItems.length;
|
||||
}
|
||||
|
||||
function ensureMessageItem(state: OpenAiResponsesAggregateState, indexHint?: number): { index: number; item: AggregateOutputItem } {
|
||||
const index = state.messageIndex ?? indexHint ?? 0;
|
||||
const item = ensureOutputItem(state, index, () => ({
|
||||
id: ensureOutputItemId('', 'msg', index),
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
status: 'in_progress',
|
||||
content: [],
|
||||
}));
|
||||
if (!Array.isArray(item.content)) item.content = [];
|
||||
if (state.messageIndex === null) state.messageIndex = index;
|
||||
return { index, item };
|
||||
}
|
||||
|
||||
function ensureMessageOutputTextPart(
|
||||
state: OpenAiResponsesAggregateState,
|
||||
indexHint?: number,
|
||||
): { index: number; item: AggregateOutputItem; part: AggregateOutputItem; created: boolean } {
|
||||
const { index, item } = ensureMessageItem(state, indexHint);
|
||||
const content = Array.isArray(item.content) ? item.content as AggregateOutputItem[] : [];
|
||||
if (!Array.isArray(item.content)) item.content = content;
|
||||
let part = content[0];
|
||||
const created = !isRecord(part) || asTrimmedString(part.type).toLowerCase() !== 'output_text';
|
||||
if (created) {
|
||||
part = { type: 'output_text', text: '' };
|
||||
content[0] = part;
|
||||
}
|
||||
return { index, item, part, created };
|
||||
}
|
||||
|
||||
function ensureReasoningItem(
|
||||
state: OpenAiResponsesAggregateState,
|
||||
itemIdRaw: unknown,
|
||||
indexHint?: unknown,
|
||||
): { index: number; item: AggregateOutputItem; created: boolean } {
|
||||
const itemId = asTrimmedString(itemIdRaw);
|
||||
const existingIndex = itemId ? state.reasoningIndexById[itemId] : undefined;
|
||||
const index = existingIndex ?? resolveOutputIndex(state, indexHint, itemId);
|
||||
const created = !state.outputItems[index];
|
||||
const item = ensureOutputItem(state, index, () => ({
|
||||
id: ensureOutputItemId(itemId, 'rs', index),
|
||||
type: 'reasoning',
|
||||
status: 'in_progress',
|
||||
summary: [],
|
||||
}));
|
||||
if (!Array.isArray(item.summary)) item.summary = [];
|
||||
return { index, item, created };
|
||||
}
|
||||
|
||||
function ensureReasoningSummaryPart(
|
||||
state: OpenAiResponsesAggregateState,
|
||||
itemIdRaw: unknown,
|
||||
summaryIndexRaw: unknown,
|
||||
indexHint?: unknown,
|
||||
): { item: AggregateOutputItem; summary: AggregateOutputItem; index: number; summaryIndex: number; created: boolean } {
|
||||
const reasoningState = ensureReasoningItem(state, itemIdRaw, indexHint);
|
||||
const summaryIndex = typeof summaryIndexRaw === 'number' && Number.isFinite(summaryIndexRaw)
|
||||
? Math.max(0, Math.trunc(summaryIndexRaw))
|
||||
: 0;
|
||||
const summary = Array.isArray(reasoningState.item.summary) ? reasoningState.item.summary as AggregateOutputItem[] : [];
|
||||
if (!Array.isArray(reasoningState.item.summary)) reasoningState.item.summary = summary;
|
||||
let part = summary[summaryIndex];
|
||||
const created = !isRecord(part);
|
||||
if (!isRecord(part)) {
|
||||
part = { type: 'summary_text', text: '' };
|
||||
summary[summaryIndex] = part;
|
||||
}
|
||||
return { item: reasoningState.item, summary: part, index: reasoningState.index, summaryIndex, created: reasoningState.created || created };
|
||||
}
|
||||
|
||||
function ensureFunctionCallItem(
|
||||
state: OpenAiResponsesAggregateState,
|
||||
callIdRaw: unknown,
|
||||
nameRaw: unknown,
|
||||
indexHint?: number,
|
||||
): { index: number; item: AggregateOutputItem; created: boolean } {
|
||||
const callId = asTrimmedString(callIdRaw);
|
||||
const name = asTrimmedString(nameRaw);
|
||||
const existingIndex = callId ? state.functionIndexById[callId] : undefined;
|
||||
const index = existingIndex ?? resolveOutputIndex(state, indexHint, callId);
|
||||
const created = !state.outputItems[index];
|
||||
const item = ensureOutputItem(state, index, () => ({
|
||||
id: ensureOutputItemId(callId, 'fc', index),
|
||||
type: 'function_call',
|
||||
status: 'in_progress',
|
||||
call_id: ensureOutputItemId(callId, 'call', index),
|
||||
name,
|
||||
arguments: '',
|
||||
}));
|
||||
if (callId) item.call_id = ensureOutputItemId(callId, 'call', index);
|
||||
if (name) item.name = name;
|
||||
if (typeof item.arguments !== 'string') item.arguments = '';
|
||||
return { index, item, created };
|
||||
}
|
||||
|
||||
function ensureCustomToolItem(
|
||||
state: OpenAiResponsesAggregateState,
|
||||
itemIdRaw: unknown,
|
||||
callIdRaw: unknown,
|
||||
nameRaw: unknown,
|
||||
indexHint?: number,
|
||||
): { index: number; item: AggregateOutputItem; created: boolean } {
|
||||
const itemId = asTrimmedString(itemIdRaw);
|
||||
const callId = asTrimmedString(callIdRaw);
|
||||
const name = asTrimmedString(nameRaw);
|
||||
const existingIndex = (callId && state.customToolIndexById[callId] !== undefined)
|
||||
? state.customToolIndexById[callId]
|
||||
: (itemId && state.customToolIndexById[itemId] !== undefined ? state.customToolIndexById[itemId] : undefined);
|
||||
const index = existingIndex ?? resolveOutputIndex(state, indexHint, itemId, callId);
|
||||
const created = !state.outputItems[index];
|
||||
const item = ensureOutputItem(state, index, () => ({
|
||||
id: ensureOutputItemId(itemId, 'ct', index),
|
||||
type: 'custom_tool_call',
|
||||
status: 'in_progress',
|
||||
call_id: ensureOutputItemId(callId || itemId, 'call', index),
|
||||
name,
|
||||
input: '',
|
||||
}));
|
||||
if (callId || itemId) item.call_id = ensureOutputItemId(callId || itemId, 'call', index);
|
||||
if (name) item.name = name;
|
||||
if (typeof item.input !== 'string') item.input = '';
|
||||
return { index, item, created };
|
||||
}
|
||||
|
||||
function ensureImageGenerationItem(
|
||||
state: OpenAiResponsesAggregateState,
|
||||
itemIdRaw: unknown,
|
||||
indexHint?: number,
|
||||
): { index: number; item: AggregateOutputItem; created: boolean } {
|
||||
const itemId = asTrimmedString(itemIdRaw);
|
||||
const existingIndex = itemId ? state.imageGenerationIndexById[itemId] : undefined;
|
||||
const index = existingIndex ?? resolveOutputIndex(state, indexHint, itemId);
|
||||
const created = !state.outputItems[index];
|
||||
const item = ensureOutputItem(state, index, () => ({
|
||||
id: ensureOutputItemId(itemId, 'img', index),
|
||||
type: 'image_generation_call',
|
||||
status: 'in_progress',
|
||||
result: null,
|
||||
partial_images: [],
|
||||
}));
|
||||
if (!Array.isArray(item.partial_images)) item.partial_images = [];
|
||||
return { index, item, created };
|
||||
}
|
||||
|
||||
function buildUsagePayload(usage: ResponsesUsageSummary): Record<string, unknown> {
|
||||
const payload: Record<string, unknown> = {
|
||||
input_tokens: usage.promptTokens,
|
||||
output_tokens: usage.completionTokens,
|
||||
total_tokens: usage.totalTokens,
|
||||
};
|
||||
const inputDetails: Record<string, unknown> = {};
|
||||
if ((usage.cacheReadTokens || 0) > 0) inputDetails.cached_tokens = usage.cacheReadTokens;
|
||||
if ((usage.cacheCreationTokens || 0) > 0) inputDetails.cache_creation_tokens = usage.cacheCreationTokens;
|
||||
if (Object.keys(inputDetails).length > 0) payload.input_tokens_details = inputDetails;
|
||||
return {
|
||||
...payload,
|
||||
};
|
||||
}
|
||||
|
||||
function collectOutputText(state: OpenAiResponsesAggregateState): string {
|
||||
const parts: string[] = [];
|
||||
for (const item of state.outputItems) {
|
||||
if (!isRecord(item)) continue;
|
||||
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 type = asTrimmedString(part.type).toLowerCase();
|
||||
if ((type === 'output_text' || type === 'text') && typeof part.text === 'string' && part.text) {
|
||||
parts.push(part.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
return parts.join('');
|
||||
}
|
||||
|
||||
function materializeResponse(
|
||||
state: OpenAiResponsesAggregateState,
|
||||
streamContext: StreamTransformContext,
|
||||
usage: ResponsesUsageSummary,
|
||||
responseTemplate?: Record<string, unknown> | null,
|
||||
statusOverride?: 'completed' | 'failed',
|
||||
): Record<string, unknown> {
|
||||
const base = cloneRecord(responseTemplate) || {};
|
||||
const responseId = ensureResponseId(
|
||||
asTrimmedString(base.id)
|
||||
|| state.responseId
|
||||
|| streamContext.id
|
||||
|| state.modelName,
|
||||
);
|
||||
const createdAt = (
|
||||
typeof base.created_at === 'number' && Number.isFinite(base.created_at)
|
||||
? base.created_at
|
||||
: (typeof base.created === 'number' && Number.isFinite(base.created) ? base.created : null)
|
||||
) ?? state.createdAt ?? Math.floor(Date.now() / 1000);
|
||||
const output = state.outputItems
|
||||
.filter((item): item is AggregateOutputItem => isRecord(item))
|
||||
.map((item) => {
|
||||
const status = asTrimmedString(item.status).toLowerCase();
|
||||
return {
|
||||
...item,
|
||||
status: status && status !== 'in_progress'
|
||||
? status
|
||||
: (state.failed ? 'failed' : 'completed'),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
...base,
|
||||
id: responseId,
|
||||
object: 'response',
|
||||
created_at: createdAt,
|
||||
status: statusOverride ?? (state.failed ? 'failed' : 'completed'),
|
||||
model: asTrimmedString(base.model) || streamContext.model || state.modelName,
|
||||
output,
|
||||
output_text: collectOutputText(state),
|
||||
usage: buildUsagePayload(usage),
|
||||
...Object.keys(state.usageExtras).length > 0 ? { usage: { ...buildUsagePayload(usage), ...state.usageExtras } } : {},
|
||||
};
|
||||
}
|
||||
|
||||
function serializeOriginalResponsesEvent(eventType: string, payload: Record<string, unknown>): string[] {
|
||||
return [serializeSse(eventType, payload)];
|
||||
}
|
||||
|
||||
function computeNovelDelta(existingText: string, incomingDelta: string): string {
|
||||
if (!incomingDelta) return '';
|
||||
if (!existingText) return incomingDelta;
|
||||
if (existingText.endsWith(incomingDelta)) return '';
|
||||
if (incomingDelta.startsWith(existingText)) {
|
||||
return incomingDelta.slice(existingText.length);
|
||||
}
|
||||
if (existingText.includes(incomingDelta)) return '';
|
||||
|
||||
const maxOverlap = Math.min(existingText.length, incomingDelta.length);
|
||||
for (let overlap = maxOverlap; overlap > 0; overlap -= 1) {
|
||||
if (existingText.slice(-overlap) === incomingDelta.slice(0, overlap)) {
|
||||
return incomingDelta.slice(overlap);
|
||||
}
|
||||
}
|
||||
return incomingDelta;
|
||||
}
|
||||
|
||||
function applyOriginalResponsesPayload(
|
||||
state: OpenAiResponsesAggregateState,
|
||||
eventType: string,
|
||||
payload: Record<string, unknown>,
|
||||
streamContext: StreamTransformContext,
|
||||
usage: ResponsesUsageSummary,
|
||||
): string[] {
|
||||
switch (eventType) {
|
||||
case 'response.output_item.added':
|
||||
case 'response.output_item.done': {
|
||||
const outputIndex = resolveOutputIndex(state, payload.output_index, (payload.item as Record<string, unknown> | undefined)?.id);
|
||||
const item = cloneRecord(payload.item) || {};
|
||||
if (Object.keys(item).length > 0) {
|
||||
const existing = isRecord(state.outputItems[outputIndex]) ? cloneRecord(state.outputItems[outputIndex]) || {} : {};
|
||||
const next = {
|
||||
...existing,
|
||||
...item,
|
||||
};
|
||||
if ((!Array.isArray(item.content) || item.content.length === 0) && Array.isArray(existing.content)) {
|
||||
next.content = existing.content;
|
||||
}
|
||||
if ((!Array.isArray(item.summary) || item.summary.length === 0) && Array.isArray(existing.summary)) {
|
||||
next.summary = existing.summary;
|
||||
}
|
||||
if ((!Array.isArray(item.partial_images) || item.partial_images.length === 0) && Array.isArray(existing.partial_images)) {
|
||||
next.partial_images = existing.partial_images;
|
||||
}
|
||||
setOutputItem(state, outputIndex, next);
|
||||
}
|
||||
return serializeOriginalResponsesEvent(eventType, {
|
||||
...payload,
|
||||
item: state.outputItems[outputIndex] ?? payload.item,
|
||||
});
|
||||
}
|
||||
case 'response.content_part.added':
|
||||
case 'response.content_part.done': {
|
||||
const outputIndex = resolveOutputIndex(state, payload.output_index, payload.item_id);
|
||||
const contentIndex = typeof payload.content_index === 'number' && Number.isFinite(payload.content_index)
|
||||
? Math.max(0, Math.trunc(payload.content_index))
|
||||
: 0;
|
||||
const message = ensureMessageItem(state, outputIndex).item;
|
||||
const content = Array.isArray(message.content) ? message.content as AggregateOutputItem[] : [];
|
||||
if (!Array.isArray(message.content)) message.content = content;
|
||||
const part = cloneRecord(payload.part);
|
||||
if (part) content[contentIndex] = part;
|
||||
return serializeOriginalResponsesEvent(eventType, payload);
|
||||
}
|
||||
case 'response.output_text.delta':
|
||||
case 'response.output_text.done': {
|
||||
const outputIndex = resolveOutputIndex(state, payload.output_index, payload.item_id);
|
||||
const textPart = ensureMessageOutputTextPart(state, outputIndex);
|
||||
if (eventType === 'response.output_text.done') {
|
||||
textPart.part.text = typeof payload.text === 'string' ? payload.text : String(payload.text ?? '');
|
||||
} else {
|
||||
textPart.part.text = `${typeof textPart.part.text === 'string' ? textPart.part.text : ''}${typeof payload.delta === 'string' ? payload.delta : ''}`;
|
||||
}
|
||||
return serializeOriginalResponsesEvent(eventType, payload);
|
||||
}
|
||||
case 'response.function_call_arguments.delta':
|
||||
case 'response.function_call_arguments.done': {
|
||||
const entry = ensureFunctionCallItem(
|
||||
state,
|
||||
payload.call_id ?? payload.item_id,
|
||||
payload.name,
|
||||
resolveOutputIndex(state, payload.output_index, payload.item_id, payload.call_id),
|
||||
);
|
||||
if (eventType === 'response.function_call_arguments.done') {
|
||||
entry.item.arguments = typeof payload.arguments === 'string' ? payload.arguments : String(payload.arguments ?? '');
|
||||
} else {
|
||||
entry.item.arguments = `${typeof entry.item.arguments === 'string' ? entry.item.arguments : ''}${typeof payload.delta === 'string' ? payload.delta : ''}`;
|
||||
}
|
||||
return serializeOriginalResponsesEvent(eventType, payload);
|
||||
}
|
||||
case 'response.custom_tool_call_input.delta':
|
||||
case 'response.custom_tool_call_input.done': {
|
||||
const entry = ensureCustomToolItem(
|
||||
state,
|
||||
payload.item_id,
|
||||
payload.call_id,
|
||||
payload.name,
|
||||
resolveOutputIndex(state, payload.output_index, payload.item_id, payload.call_id),
|
||||
);
|
||||
if (eventType === 'response.custom_tool_call_input.done') {
|
||||
entry.item.input = typeof payload.input === 'string' ? payload.input : String(payload.input ?? '');
|
||||
} else {
|
||||
entry.item.input = `${typeof entry.item.input === 'string' ? entry.item.input : ''}${typeof payload.delta === 'string' ? payload.delta : ''}`;
|
||||
}
|
||||
return serializeOriginalResponsesEvent(eventType, payload);
|
||||
}
|
||||
case 'response.reasoning_summary_part.added':
|
||||
case 'response.reasoning_summary_part.done': {
|
||||
const summaryState = ensureReasoningSummaryPart(state, payload.item_id, payload.summary_index, payload.output_index);
|
||||
const part = cloneRecord(payload.part);
|
||||
if (part) {
|
||||
const summary = Array.isArray(summaryState.item.summary) ? summaryState.item.summary as AggregateOutputItem[] : [];
|
||||
summary[summaryState.summaryIndex] = {
|
||||
...summaryState.summary,
|
||||
...part,
|
||||
};
|
||||
summaryState.item.summary = summary;
|
||||
}
|
||||
return serializeOriginalResponsesEvent(eventType, payload);
|
||||
}
|
||||
case 'response.reasoning_summary_text.delta':
|
||||
case 'response.reasoning_summary_text.done': {
|
||||
const summaryState = ensureReasoningSummaryPart(state, payload.item_id, payload.summary_index, payload.output_index);
|
||||
if (eventType === 'response.reasoning_summary_text.done') {
|
||||
summaryState.summary.text = typeof payload.text === 'string' ? payload.text : String(payload.text ?? '');
|
||||
} else {
|
||||
summaryState.summary.text = `${typeof summaryState.summary.text === 'string' ? summaryState.summary.text : ''}${typeof payload.delta === 'string' ? payload.delta : ''}`;
|
||||
}
|
||||
return serializeOriginalResponsesEvent(eventType, payload);
|
||||
}
|
||||
case 'response.image_generation_call.generating':
|
||||
case 'response.image_generation_call.in_progress':
|
||||
case 'response.image_generation_call.partial_image':
|
||||
case 'response.image_generation_call.completed': {
|
||||
const entry = ensureImageGenerationItem(
|
||||
state,
|
||||
payload.item_id,
|
||||
resolveOutputIndex(state, payload.output_index, payload.item_id),
|
||||
);
|
||||
if (eventType === 'response.image_generation_call.partial_image') {
|
||||
const partialImages = Array.isArray(entry.item.partial_images) ? entry.item.partial_images as AggregateOutputItem[] : [];
|
||||
partialImages.push({
|
||||
partial_image_index: payload.partial_image_index,
|
||||
partial_image_b64: payload.partial_image_b64,
|
||||
});
|
||||
entry.item.partial_images = partialImages;
|
||||
}
|
||||
if (payload.result !== undefined) {
|
||||
entry.item.result = payload.result;
|
||||
}
|
||||
if (eventType === 'response.image_generation_call.completed') {
|
||||
entry.item.status = 'completed';
|
||||
}
|
||||
return serializeOriginalResponsesEvent(eventType, payload);
|
||||
}
|
||||
case 'response.completed': {
|
||||
mergeUsageExtras(state, payload.response && isRecord(payload.response) ? payload.response.usage : payload.usage);
|
||||
state.completed = true;
|
||||
const responsePayload = cloneRecord(payload.response);
|
||||
const materialized = materializeResponse(state, streamContext, usage, responsePayload, 'completed');
|
||||
return [serializeSse('response.completed', { ...payload, response: materialized })];
|
||||
}
|
||||
case 'response.failed': {
|
||||
mergeUsageExtras(state, payload.response && isRecord(payload.response) ? payload.response.usage : payload.usage);
|
||||
state.failed = true;
|
||||
const responsePayload = cloneRecord(payload.response);
|
||||
const materialized = materializeResponse(state, streamContext, usage, responsePayload, 'failed');
|
||||
return [serializeSse('response.failed', { ...payload, response: materialized })];
|
||||
}
|
||||
default:
|
||||
mergeUsageExtras(state, payload.usage);
|
||||
return serializeOriginalResponsesEvent(eventType, payload);
|
||||
}
|
||||
}
|
||||
|
||||
function buildSyntheticMessageEvents(
|
||||
state: OpenAiResponsesAggregateState,
|
||||
delta: string,
|
||||
): string[] {
|
||||
const { index, item } = ensureMessageItem(state);
|
||||
const textPartState = ensureMessageOutputTextPart(state, index);
|
||||
const lines: string[] = [];
|
||||
const currentText = typeof textPartState.part.text === 'string' ? textPartState.part.text : '';
|
||||
const novelDelta = computeNovelDelta(currentText, delta);
|
||||
if (textPartState.created) {
|
||||
lines.push(serializeSse('response.output_item.added', {
|
||||
type: 'response.output_item.added',
|
||||
output_index: index,
|
||||
item,
|
||||
}));
|
||||
lines.push(serializeSse('response.content_part.added', {
|
||||
type: 'response.content_part.added',
|
||||
output_index: index,
|
||||
item_id: item.id,
|
||||
content_index: 0,
|
||||
part: { type: 'output_text', text: '' },
|
||||
}));
|
||||
}
|
||||
if (novelDelta) {
|
||||
textPartState.part.text = `${currentText}${novelDelta}`;
|
||||
lines.push(serializeSse('response.output_text.delta', {
|
||||
type: 'response.output_text.delta',
|
||||
output_index: index,
|
||||
item_id: item.id,
|
||||
delta: novelDelta,
|
||||
}));
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
function buildSyntheticReasoningEvents(
|
||||
state: OpenAiResponsesAggregateState,
|
||||
delta: string,
|
||||
): string[] {
|
||||
const summaryState = ensureReasoningSummaryPart(state, '', 0, state.outputItems.length);
|
||||
const itemId = asTrimmedString(summaryState.item.id);
|
||||
const lines: string[] = [];
|
||||
const currentText = typeof summaryState.summary.text === 'string' ? summaryState.summary.text : '';
|
||||
const novelDelta = computeNovelDelta(currentText, delta);
|
||||
if (summaryState.created) {
|
||||
lines.push(serializeSse('response.output_item.added', {
|
||||
type: 'response.output_item.added',
|
||||
output_index: summaryState.index,
|
||||
item: summaryState.item,
|
||||
}));
|
||||
lines.push(serializeSse('response.reasoning_summary_part.added', {
|
||||
type: 'response.reasoning_summary_part.added',
|
||||
item_id: itemId,
|
||||
output_index: summaryState.index,
|
||||
summary_index: 0,
|
||||
part: { type: 'summary_text', text: '' },
|
||||
}));
|
||||
}
|
||||
if (novelDelta) {
|
||||
summaryState.summary.text = `${currentText}${novelDelta}`;
|
||||
lines.push(serializeSse('response.reasoning_summary_text.delta', {
|
||||
type: 'response.reasoning_summary_text.delta',
|
||||
item_id: itemId,
|
||||
output_index: summaryState.index,
|
||||
summary_index: 0,
|
||||
delta: novelDelta,
|
||||
}));
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
function buildSyntheticToolEvents(
|
||||
state: OpenAiResponsesAggregateState,
|
||||
event: OpenAiResponsesStreamEvent,
|
||||
): string[] {
|
||||
const lines: string[] = [];
|
||||
if (!Array.isArray(event.toolCallDeltas)) return lines;
|
||||
for (const toolDelta of event.toolCallDeltas) {
|
||||
const entry = ensureFunctionCallItem(state, toolDelta.id, toolDelta.name, toolDelta.index);
|
||||
if (entry.created) {
|
||||
lines.push(serializeSse('response.output_item.added', {
|
||||
type: 'response.output_item.added',
|
||||
output_index: entry.index,
|
||||
item: entry.item,
|
||||
}));
|
||||
}
|
||||
if (toolDelta.argumentsDelta !== undefined && toolDelta.argumentsDelta.length > 0) {
|
||||
entry.item.arguments = `${typeof entry.item.arguments === 'string' ? entry.item.arguments : ''}${toolDelta.argumentsDelta}`;
|
||||
lines.push(serializeSse('response.function_call_arguments.delta', {
|
||||
type: 'response.function_call_arguments.delta',
|
||||
item_id: entry.item.id,
|
||||
call_id: entry.item.call_id,
|
||||
output_index: entry.index,
|
||||
name: entry.item.name,
|
||||
delta: toolDelta.argumentsDelta,
|
||||
}));
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
export function serializeConvertedResponsesEvents(input: {
|
||||
state: OpenAiResponsesAggregateState;
|
||||
streamContext: StreamTransformContext;
|
||||
event: OpenAiResponsesStreamEvent;
|
||||
usage: ResponsesUsageSummary;
|
||||
}): string[] {
|
||||
const { state, streamContext, event, usage } = input;
|
||||
mergeUsageExtras(state, event.responsesPayload && isRecord(event.responsesPayload) ? event.responsesPayload.usage : undefined);
|
||||
|
||||
if (event.responsesEventType && event.responsesPayload) {
|
||||
return applyOriginalResponsesPayload(
|
||||
state,
|
||||
event.responsesEventType,
|
||||
event.responsesPayload,
|
||||
streamContext,
|
||||
usage,
|
||||
);
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
if (event.contentDelta) {
|
||||
lines.push(...buildSyntheticMessageEvents(state, event.contentDelta));
|
||||
}
|
||||
if (event.reasoningDelta) {
|
||||
lines.push(...buildSyntheticReasoningEvents(state, event.reasoningDelta));
|
||||
}
|
||||
lines.push(...buildSyntheticToolEvents(state, event));
|
||||
return lines;
|
||||
}
|
||||
|
||||
export function completeResponsesStream(
|
||||
state: OpenAiResponsesAggregateState,
|
||||
streamContext: StreamTransformContext,
|
||||
usage: ResponsesUsageSummary,
|
||||
): string[] {
|
||||
if (state.failed || state.completed) {
|
||||
return [serializeDone()];
|
||||
}
|
||||
state.completed = true;
|
||||
return [
|
||||
serializeSse('response.completed', {
|
||||
type: 'response.completed',
|
||||
response: materializeResponse(state, streamContext, usage, null, 'completed'),
|
||||
}),
|
||||
serializeDone(),
|
||||
];
|
||||
}
|
||||
|
||||
export function failResponsesStream(
|
||||
state: OpenAiResponsesAggregateState,
|
||||
streamContext: StreamTransformContext,
|
||||
usage: ResponsesUsageSummary,
|
||||
payload: unknown,
|
||||
): string[] {
|
||||
if (state.failed) {
|
||||
return [serializeDone()];
|
||||
}
|
||||
state.failed = true;
|
||||
const errorPayload = cloneRecord(payload);
|
||||
const message = (
|
||||
isRecord(errorPayload?.error) && typeof errorPayload.error.message === 'string'
|
||||
? errorPayload.error.message
|
||||
: (typeof errorPayload?.message === 'string' ? errorPayload.message : 'upstream stream failed')
|
||||
);
|
||||
return [
|
||||
serializeSse('response.failed', {
|
||||
type: 'response.failed',
|
||||
response: materializeResponse(state, streamContext, usage, cloneRecord(errorPayload?.response), 'failed'),
|
||||
error: {
|
||||
message,
|
||||
type: 'upstream_error',
|
||||
},
|
||||
}),
|
||||
serializeDone(),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object';
|
||||
}
|
||||
|
||||
function asTrimmedString(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function toTextBlockType(role: string): 'input_text' | 'output_text' {
|
||||
return role === 'assistant' ? 'output_text' : 'input_text';
|
||||
}
|
||||
|
||||
function normalizeImageUrlValue(value: unknown): string | Record<string, unknown> | null {
|
||||
if (typeof value === 'string' && value.trim().length > 0) return value;
|
||||
if (!isRecord(value)) return null;
|
||||
const url = asTrimmedString(value.url);
|
||||
if (url) return { ...value, url };
|
||||
const imageUrl = asTrimmedString(value.image_url);
|
||||
if (imageUrl) return imageUrl;
|
||||
return Object.keys(value).length > 0 ? value : null;
|
||||
}
|
||||
|
||||
function normalizeAudioInputValue(value: unknown): Record<string, unknown> | null {
|
||||
if (!isRecord(value)) return null;
|
||||
const data = asTrimmedString(value.data);
|
||||
const format = asTrimmedString(value.format);
|
||||
if (!data && !format) return Object.keys(value).length > 0 ? value : null;
|
||||
return {
|
||||
...value,
|
||||
...(data ? { data } : {}),
|
||||
...(format ? { format } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeResponsesContentItem(
|
||||
item: unknown,
|
||||
role: string,
|
||||
): Record<string, unknown> | null {
|
||||
if (typeof item === 'string') {
|
||||
const text = item.trim();
|
||||
return text ? { type: toTextBlockType(role), text } : null;
|
||||
}
|
||||
|
||||
if (!isRecord(item)) return null;
|
||||
|
||||
const type = asTrimmedString(item.type).toLowerCase();
|
||||
if (!type) {
|
||||
const text = asTrimmedString(item.text ?? item.content ?? item.output_text);
|
||||
return text ? { type: toTextBlockType(role), text } : null;
|
||||
}
|
||||
|
||||
if (type === 'input_text' || type === 'output_text' || type === 'text') {
|
||||
const text = asTrimmedString(item.text ?? item.content ?? item.output_text);
|
||||
if (!text) return null;
|
||||
return {
|
||||
...item,
|
||||
type: type === 'text' ? toTextBlockType(role) : type,
|
||||
text,
|
||||
};
|
||||
}
|
||||
|
||||
if (type === 'input_image' || type === 'image_url') {
|
||||
const imageUrl = normalizeImageUrlValue(item.image_url ?? item.url);
|
||||
if (!imageUrl) return null;
|
||||
return {
|
||||
...item,
|
||||
type: 'input_image',
|
||||
image_url: imageUrl,
|
||||
};
|
||||
}
|
||||
|
||||
if (type === 'input_audio') {
|
||||
const inputAudio = normalizeAudioInputValue(item.input_audio);
|
||||
if (!inputAudio) return null;
|
||||
return {
|
||||
...item,
|
||||
type: 'input_audio',
|
||||
input_audio: inputAudio,
|
||||
};
|
||||
}
|
||||
|
||||
if (type === 'function_call' || type === 'function_call_output') {
|
||||
return item;
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
export function normalizeResponsesMessageContent(content: unknown, role: string): unknown {
|
||||
if (Array.isArray(content)) {
|
||||
const normalized = content
|
||||
.map((item) => normalizeResponsesContentItem(item, role))
|
||||
.filter((item): item is Record<string, unknown> => !!item);
|
||||
return normalized.length > 0 ? normalized : content;
|
||||
}
|
||||
|
||||
const single = normalizeResponsesContentItem(content, role);
|
||||
if (single) return [single];
|
||||
return content;
|
||||
}
|
||||
|
||||
function toResponsesInputMessageFromText(text: string): Record<string, unknown> {
|
||||
return {
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [{ type: 'input_text', text }],
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeResponsesMessageItem(item: Record<string, unknown>): Record<string, unknown> {
|
||||
const type = asTrimmedString(item.type).toLowerCase();
|
||||
if (type === 'function_call' || type === 'function_call_output') {
|
||||
return item;
|
||||
}
|
||||
|
||||
const role = asTrimmedString(item.role).toLowerCase() || 'user';
|
||||
const normalizedContent = normalizeResponsesMessageContent(item.content ?? item.text, role);
|
||||
|
||||
if (type === 'message') {
|
||||
return {
|
||||
...item,
|
||||
role,
|
||||
content: normalizedContent,
|
||||
};
|
||||
}
|
||||
|
||||
if (asTrimmedString(item.role)) {
|
||||
return {
|
||||
type: 'message',
|
||||
role,
|
||||
content: normalizedContent,
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof item.content === 'string') {
|
||||
const text = item.content.trim();
|
||||
return text ? toResponsesInputMessageFromText(text) : item;
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
export function normalizeResponsesInputForCompatibility(input: unknown): unknown {
|
||||
if (typeof input === 'string') {
|
||||
const normalized = input.trim();
|
||||
if (!normalized) return input;
|
||||
return [toResponsesInputMessageFromText(normalized)];
|
||||
}
|
||||
|
||||
if (Array.isArray(input)) {
|
||||
return input.map((item) => {
|
||||
if (typeof item === 'string') {
|
||||
const normalized = item.trim();
|
||||
return normalized ? toResponsesInputMessageFromText(normalized) : item;
|
||||
}
|
||||
if (!isRecord(item)) return item;
|
||||
return normalizeResponsesMessageItem(item);
|
||||
});
|
||||
}
|
||||
|
||||
if (isRecord(input)) {
|
||||
return [normalizeResponsesMessageItem(input)];
|
||||
}
|
||||
|
||||
return input;
|
||||
}
|
||||
|
||||
function extractTextFromResponsesContent(content: unknown): string {
|
||||
if (typeof content === 'string') return content.trim();
|
||||
if (!Array.isArray(content)) return '';
|
||||
return content
|
||||
.map((item) => {
|
||||
if (!isRecord(item)) return '';
|
||||
return asTrimmedString(item.text ?? item.content ?? item.output_text);
|
||||
})
|
||||
.filter((item) => item.length > 0)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function safeJsonStringify(value: unknown): string {
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeOpenAiToolArguments(raw: unknown): string {
|
||||
if (typeof raw === 'string') return raw;
|
||||
if (raw === undefined || raw === null) return '';
|
||||
if (typeof raw === 'number' || typeof raw === 'boolean') return String(raw);
|
||||
if (Array.isArray(raw) || isRecord(raw)) {
|
||||
return safeJsonStringify(raw);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function normalizeToolMessageContent(raw: unknown): string {
|
||||
if (raw === undefined || raw === null) return '';
|
||||
if (typeof raw === 'string') return raw;
|
||||
if (typeof raw === 'number' || typeof raw === 'boolean') return String(raw);
|
||||
if (Array.isArray(raw)) {
|
||||
const normalized = normalizeResponsesMessageContent(raw, 'user');
|
||||
const text = extractTextFromResponsesContent(normalized);
|
||||
return text || safeJsonStringify(raw);
|
||||
}
|
||||
if (isRecord(raw)) {
|
||||
const normalized = normalizeResponsesMessageContent(raw, 'user');
|
||||
const text = extractTextFromResponsesContent(normalized);
|
||||
return text || safeJsonStringify(raw);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function toFiniteNumber(value: unknown): number | null {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
const ALLOWED_RESPONSES_FIELDS = new Set([
|
||||
'model',
|
||||
'input',
|
||||
'instructions',
|
||||
'max_output_tokens',
|
||||
'max_completion_tokens',
|
||||
'temperature',
|
||||
'top_p',
|
||||
'truncation',
|
||||
'tools',
|
||||
'tool_choice',
|
||||
'parallel_tool_calls',
|
||||
'metadata',
|
||||
'reasoning',
|
||||
'store',
|
||||
'stream',
|
||||
'user',
|
||||
'previous_response_id',
|
||||
'text',
|
||||
'audio',
|
||||
'include',
|
||||
'response_format',
|
||||
'service_tier',
|
||||
'stop',
|
||||
'n',
|
||||
]);
|
||||
|
||||
export function convertOpenAiBodyToResponsesBody(
|
||||
openaiBody: Record<string, unknown>,
|
||||
modelName: string,
|
||||
stream: boolean,
|
||||
): Record<string, unknown> {
|
||||
const rawMessages = Array.isArray(openaiBody.messages) ? openaiBody.messages : [];
|
||||
const systemContents: string[] = [];
|
||||
const inputItems: Array<Record<string, unknown>> = [];
|
||||
|
||||
for (const item of rawMessages) {
|
||||
if (!isRecord(item)) continue;
|
||||
const role = asTrimmedString(item.role).toLowerCase() || 'user';
|
||||
|
||||
if (role === 'system' || role === 'developer') {
|
||||
const normalized = normalizeResponsesMessageContent(item.content, 'user');
|
||||
const content = extractTextFromResponsesContent(normalized).trim();
|
||||
if (content) systemContents.push(content);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (role === 'assistant') {
|
||||
const normalizedAssistantContent = normalizeResponsesMessageContent(item.content, 'assistant');
|
||||
if (Array.isArray(normalizedAssistantContent) && normalizedAssistantContent.length > 0) {
|
||||
inputItems.push({
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content: normalizedAssistantContent,
|
||||
});
|
||||
}
|
||||
|
||||
const rawToolCalls = Array.isArray(item.tool_calls) ? item.tool_calls : [];
|
||||
for (let index = 0; index < rawToolCalls.length; index += 1) {
|
||||
const toolCall = rawToolCalls[index];
|
||||
if (!isRecord(toolCall)) continue;
|
||||
const functionPart = isRecord(toolCall.function) ? toolCall.function : {};
|
||||
const callId = asTrimmedString(toolCall.id) || `call_${Date.now()}_${index}`;
|
||||
const name = (
|
||||
asTrimmedString(functionPart.name)
|
||||
|| asTrimmedString(toolCall.name)
|
||||
|| `tool_${index}`
|
||||
);
|
||||
const argumentsValue = normalizeOpenAiToolArguments(
|
||||
functionPart.arguments ?? toolCall.arguments,
|
||||
);
|
||||
|
||||
inputItems.push({
|
||||
type: 'function_call',
|
||||
call_id: callId,
|
||||
name,
|
||||
arguments: argumentsValue,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (role === 'tool') {
|
||||
const callId = asTrimmedString(item.tool_call_id) || asTrimmedString(item.id);
|
||||
if (!callId) continue;
|
||||
|
||||
inputItems.push({
|
||||
type: 'function_call_output',
|
||||
call_id: callId,
|
||||
output: normalizeToolMessageContent(item.content),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalizedUserContent = normalizeResponsesMessageContent(item.content, 'user');
|
||||
if (Array.isArray(normalizedUserContent) && normalizedUserContent.length > 0) {
|
||||
inputItems.push({
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: normalizedUserContent,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const maxOutputTokens = (
|
||||
toFiniteNumber(openaiBody.max_output_tokens)
|
||||
?? toFiniteNumber(openaiBody.max_completion_tokens)
|
||||
?? toFiniteNumber(openaiBody.max_tokens)
|
||||
?? 4096
|
||||
);
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
model: modelName,
|
||||
stream,
|
||||
max_output_tokens: maxOutputTokens,
|
||||
input: inputItems,
|
||||
};
|
||||
|
||||
if (systemContents.length > 0) {
|
||||
body.instructions = systemContents.join('\n\n');
|
||||
}
|
||||
|
||||
const temperature = toFiniteNumber(openaiBody.temperature);
|
||||
if (temperature !== null) body.temperature = temperature;
|
||||
|
||||
const topP = toFiniteNumber(openaiBody.top_p);
|
||||
if (topP !== null) body.top_p = topP;
|
||||
|
||||
if (openaiBody.metadata !== undefined) body.metadata = openaiBody.metadata;
|
||||
if (openaiBody.reasoning !== undefined) body.reasoning = openaiBody.reasoning;
|
||||
if (openaiBody.parallel_tool_calls !== undefined) body.parallel_tool_calls = openaiBody.parallel_tool_calls;
|
||||
if (openaiBody.tools !== undefined) body.tools = openaiBody.tools;
|
||||
if (openaiBody.tool_choice !== undefined) body.tool_choice = openaiBody.tool_choice;
|
||||
|
||||
return {
|
||||
...body,
|
||||
input: normalizeResponsesInputForCompatibility(body.input),
|
||||
};
|
||||
}
|
||||
|
||||
export function sanitizeResponsesBodyForProxy(
|
||||
body: Record<string, unknown>,
|
||||
modelName: string,
|
||||
stream: boolean,
|
||||
): Record<string, unknown> {
|
||||
let normalized: Record<string, unknown> = {
|
||||
...body,
|
||||
model: modelName,
|
||||
stream,
|
||||
};
|
||||
|
||||
if (normalized.input === undefined) {
|
||||
if (Array.isArray((normalized as Record<string, unknown>).messages)) {
|
||||
normalized = convertOpenAiBodyToResponsesBody(normalized, modelName, stream);
|
||||
} else {
|
||||
const prompt = asTrimmedString((normalized as Record<string, unknown>).prompt);
|
||||
if (prompt) {
|
||||
normalized = {
|
||||
...normalized,
|
||||
input: [toResponsesInputMessageFromText(prompt)],
|
||||
};
|
||||
}
|
||||
}
|
||||
} else {
|
||||
normalized = {
|
||||
...normalized,
|
||||
input: normalizeResponsesInputForCompatibility(normalized.input),
|
||||
};
|
||||
}
|
||||
|
||||
const sanitized: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(normalized)) {
|
||||
if (!ALLOWED_RESPONSES_FIELDS.has(key)) continue;
|
||||
if (key === 'max_completion_tokens') continue;
|
||||
sanitized[key] = value;
|
||||
}
|
||||
|
||||
const maxOutputTokens = toFiniteNumber(normalized.max_output_tokens);
|
||||
if (maxOutputTokens !== null && maxOutputTokens > 0) {
|
||||
sanitized.max_output_tokens = Math.trunc(maxOutputTokens);
|
||||
} else {
|
||||
const maxCompletionTokens = toFiniteNumber(normalized.max_completion_tokens);
|
||||
if (maxCompletionTokens !== null && maxCompletionTokens > 0) {
|
||||
sanitized.max_output_tokens = Math.trunc(maxCompletionTokens);
|
||||
}
|
||||
}
|
||||
|
||||
sanitized.model = modelName;
|
||||
sanitized.stream = stream;
|
||||
return sanitized;
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
convertOpenAiBodyToResponsesBody,
|
||||
convertResponsesBodyToOpenAiBody,
|
||||
sanitizeResponsesBodyForProxy,
|
||||
} from './conversion.js';
|
||||
|
||||
describe('sanitizeResponsesBodyForProxy', () => {
|
||||
it('preserves newer Responses request fields needed by the proxy', () => {
|
||||
const result = sanitizeResponsesBodyForProxy(
|
||||
{
|
||||
model: 'gpt-5',
|
||||
input: 'hello',
|
||||
safety_identifier: 'safe-user-1',
|
||||
max_tool_calls: 3,
|
||||
prompt_cache_key: 'cache-key',
|
||||
prompt_cache_retention: { scope: 'session' },
|
||||
stream_options: { include_obfuscation: true },
|
||||
background: true,
|
||||
text: { format: { type: 'text' }, verbosity: 'high' },
|
||||
},
|
||||
'gpt-5',
|
||||
true,
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
model: 'gpt-5',
|
||||
stream: true,
|
||||
safety_identifier: 'safe-user-1',
|
||||
max_tool_calls: 3,
|
||||
prompt_cache_key: 'cache-key',
|
||||
prompt_cache_retention: { scope: 'session' },
|
||||
stream_options: { include_obfuscation: true },
|
||||
background: true,
|
||||
text: { format: { type: 'text' }, verbosity: 'high' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertOpenAiBodyToResponsesBody', () => {
|
||||
it('maps extra request fields and preserves custom/image_generation tools', () => {
|
||||
const result = convertOpenAiBodyToResponsesBody(
|
||||
{
|
||||
model: 'gpt-5',
|
||||
messages: [{ role: 'user', content: 'draw a cat' }],
|
||||
safety_identifier: 'safe-user-2',
|
||||
max_tool_calls: 2,
|
||||
prompt_cache_key: 'prompt-key',
|
||||
prompt_cache_retention: { scope: 'workspace' },
|
||||
stream_options: { include_obfuscation: true },
|
||||
background: false,
|
||||
verbosity: 'low',
|
||||
tools: [
|
||||
{
|
||||
type: 'custom',
|
||||
name: 'browser',
|
||||
description: 'browse the web',
|
||||
format: { type: 'text' },
|
||||
},
|
||||
{
|
||||
type: 'image_generation',
|
||||
background: 'transparent',
|
||||
size: '1024x1024',
|
||||
},
|
||||
],
|
||||
},
|
||||
'gpt-5',
|
||||
false,
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
model: 'gpt-5',
|
||||
stream: false,
|
||||
safety_identifier: 'safe-user-2',
|
||||
max_tool_calls: 2,
|
||||
prompt_cache_key: 'prompt-key',
|
||||
prompt_cache_retention: { scope: 'workspace' },
|
||||
stream_options: { include_obfuscation: true },
|
||||
background: false,
|
||||
text: { verbosity: 'low' },
|
||||
tools: [
|
||||
{
|
||||
type: 'custom',
|
||||
name: 'browser',
|
||||
description: 'browse the web',
|
||||
format: { type: 'text' },
|
||||
},
|
||||
{
|
||||
type: 'image_generation',
|
||||
background: 'transparent',
|
||||
size: '1024x1024',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertResponsesBodyToOpenAiBody', () => {
|
||||
it('preserves richer Responses request fields back onto the OpenAI-compatible body', () => {
|
||||
const result = convertResponsesBodyToOpenAiBody(
|
||||
{
|
||||
model: 'gpt-5',
|
||||
input: [{ type: 'message', role: 'user', content: [{ type: 'input_text', text: 'hello' }] }],
|
||||
safety_identifier: 'safe-user-3',
|
||||
max_tool_calls: 4,
|
||||
prompt_cache_key: 'prompt-key-2',
|
||||
prompt_cache_retention: { scope: 'project' },
|
||||
stream_options: { include_obfuscation: true },
|
||||
background: true,
|
||||
text: { format: { type: 'json_object' }, verbosity: 'high' },
|
||||
tools: [
|
||||
{
|
||||
type: 'custom',
|
||||
name: 'browser',
|
||||
format: { type: 'grammar', syntax: 'lark' },
|
||||
},
|
||||
{
|
||||
type: 'image_generation',
|
||||
background: 'transparent',
|
||||
partial_images: 2,
|
||||
output_format: 'png',
|
||||
},
|
||||
],
|
||||
},
|
||||
'gpt-5',
|
||||
true,
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
model: 'gpt-5',
|
||||
stream: true,
|
||||
safety_identifier: 'safe-user-3',
|
||||
max_tool_calls: 4,
|
||||
prompt_cache_key: 'prompt-key-2',
|
||||
prompt_cache_retention: { scope: 'project' },
|
||||
stream_options: { include_obfuscation: true },
|
||||
background: true,
|
||||
verbosity: 'high',
|
||||
tools: [
|
||||
{
|
||||
type: 'custom',
|
||||
name: 'browser',
|
||||
format: { type: 'grammar', syntax: 'lark' },
|
||||
},
|
||||
{
|
||||
type: 'image_generation',
|
||||
background: 'transparent',
|
||||
partial_images: 2,
|
||||
output_format: 'png',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('converts custom tool calls and outputs into OpenAI-compatible tool messages', () => {
|
||||
const result = convertResponsesBodyToOpenAiBody(
|
||||
{
|
||||
model: 'gpt-5',
|
||||
input: [
|
||||
{
|
||||
type: 'custom_tool_call',
|
||||
id: 'ct_1',
|
||||
call_id: 'ct_1',
|
||||
name: 'browser',
|
||||
input: 'open example.com',
|
||||
},
|
||||
{
|
||||
type: 'custom_tool_call_output',
|
||||
call_id: 'ct_1',
|
||||
output: 'done',
|
||||
},
|
||||
],
|
||||
},
|
||||
'gpt-5',
|
||||
false,
|
||||
);
|
||||
|
||||
expect(result.messages).toEqual([
|
||||
{
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'ct_1',
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'browser',
|
||||
arguments: 'open example.com',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'tool',
|
||||
tool_call_id: 'ct_1',
|
||||
content: 'done',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('converts reasoning items back into assistant content instead of dropping them', () => {
|
||||
const result = convertResponsesBodyToOpenAiBody(
|
||||
{
|
||||
model: 'gpt-5',
|
||||
input: [
|
||||
{
|
||||
type: 'reasoning',
|
||||
id: 'rs_1',
|
||||
status: 'completed',
|
||||
summary: [
|
||||
{ type: 'summary_text', text: 'Think step by step' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
'gpt-5',
|
||||
false,
|
||||
);
|
||||
|
||||
expect(result.messages).toEqual([
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Think step by step',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves remaining request fields needed for OpenAI-compatible downstream fallback', () => {
|
||||
const result = convertResponsesBodyToOpenAiBody(
|
||||
{
|
||||
model: 'gpt-5',
|
||||
input: 'hello',
|
||||
user: 'user-123',
|
||||
include: ['reasoning.encrypted_content'],
|
||||
previous_response_id: 'resp_prev',
|
||||
truncation: 'auto',
|
||||
service_tier: 'priority',
|
||||
top_logprobs: 4,
|
||||
},
|
||||
'gpt-5',
|
||||
true,
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
model: 'gpt-5',
|
||||
stream: true,
|
||||
user: 'user-123',
|
||||
include: ['reasoning.encrypted_content'],
|
||||
previous_response_id: 'resp_prev',
|
||||
truncation: 'auto',
|
||||
service_tier: 'priority',
|
||||
top_logprobs: 4,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,698 @@
|
||||
import {
|
||||
normalizeResponsesInputForCompatibility as normalizeResponsesInputForCompatibilityViaCompatibility,
|
||||
normalizeResponsesMessageItem,
|
||||
} from './compatibility.js';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object';
|
||||
}
|
||||
|
||||
function asTrimmedString(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function toFiniteNumber(value: unknown): number | null {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
function safeJsonStringify(value: unknown): string {
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function cloneRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (!isRecord(value)) return null;
|
||||
return { ...value };
|
||||
}
|
||||
|
||||
function cloneJsonValue<T>(value: T): T {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => cloneJsonValue(item)) as T;
|
||||
}
|
||||
if (isRecord(value)) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, item]) => [key, cloneJsonValue(item)]),
|
||||
) as T;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseJsonString(raw: string): unknown {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return {};
|
||||
try {
|
||||
return JSON.parse(trimmed);
|
||||
} catch {
|
||||
return { value: raw };
|
||||
}
|
||||
}
|
||||
|
||||
export function extractTextContent(value: unknown): string {
|
||||
if (typeof value === 'string') return value;
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map((item) => extractTextContent(item))
|
||||
.filter((item) => item.length > 0)
|
||||
.join('\n');
|
||||
}
|
||||
if (!isRecord(value)) return '';
|
||||
|
||||
const direct = asTrimmedString(
|
||||
value.text
|
||||
?? value.content
|
||||
?? value.input_text
|
||||
?? value.output_text
|
||||
?? value.reasoning
|
||||
?? value.reasoning_content
|
||||
?? value.thinking,
|
||||
);
|
||||
if (direct) return direct;
|
||||
|
||||
if (Array.isArray(value.parts)) return extractTextContent(value.parts);
|
||||
if (Array.isArray(value.content)) return extractTextContent(value.content);
|
||||
if (Array.isArray(value.output)) return extractTextContent(value.output);
|
||||
return '';
|
||||
}
|
||||
|
||||
function normalizeOpenAiToolArguments(raw: unknown): string {
|
||||
if (typeof raw === 'string') return raw;
|
||||
if (raw === undefined || raw === null) return '';
|
||||
if (typeof raw === 'number' || typeof raw === 'boolean') return String(raw);
|
||||
if (Array.isArray(raw) || isRecord(raw)) return safeJsonStringify(raw);
|
||||
return '';
|
||||
}
|
||||
|
||||
function normalizeToolOutput(raw: unknown): string {
|
||||
const text = extractTextContent(raw).trim();
|
||||
if (text) return text;
|
||||
if (raw === undefined || raw === null) return '';
|
||||
if (typeof raw === 'string') return raw;
|
||||
if (typeof raw === 'number' || typeof raw === 'boolean') return String(raw);
|
||||
if (Array.isArray(raw) || isRecord(raw)) return safeJsonStringify(raw);
|
||||
return '';
|
||||
}
|
||||
|
||||
export function normalizeResponsesInputForCompatibility(input: unknown): unknown {
|
||||
return normalizeResponsesInputForCompatibilityViaCompatibility(input);
|
||||
}
|
||||
|
||||
function toResponsesInputMessageFromText(text: string): Record<string, unknown> {
|
||||
return {
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [{ type: 'input_text', text }],
|
||||
};
|
||||
}
|
||||
|
||||
function convertOpenAiToolsToResponses(rawTools: unknown): unknown {
|
||||
if (!Array.isArray(rawTools)) return rawTools;
|
||||
|
||||
const converted = rawTools
|
||||
.map((item) => {
|
||||
if (!isRecord(item)) return null;
|
||||
|
||||
const type = asTrimmedString(item.type).toLowerCase();
|
||||
if (type === 'function' && isRecord(item.function)) {
|
||||
const fn = item.function;
|
||||
const name = asTrimmedString(fn.name);
|
||||
if (!name) return null;
|
||||
|
||||
const mapped: Record<string, unknown> = {
|
||||
type: 'function',
|
||||
name,
|
||||
};
|
||||
const description = asTrimmedString(fn.description);
|
||||
if (description) mapped.description = description;
|
||||
if (fn.parameters !== undefined) mapped.parameters = fn.parameters;
|
||||
if (fn.strict !== undefined) mapped.strict = fn.strict;
|
||||
return mapped;
|
||||
}
|
||||
|
||||
if (type === 'function' && asTrimmedString(item.name)) {
|
||||
return item;
|
||||
}
|
||||
|
||||
if (type === 'image_generation') {
|
||||
return item;
|
||||
}
|
||||
|
||||
if (type === 'custom' && asTrimmedString(item.name)) {
|
||||
return item;
|
||||
}
|
||||
|
||||
return null;
|
||||
})
|
||||
.filter((item): item is Record<string, unknown> => !!item);
|
||||
|
||||
return converted.length > 0 ? converted : rawTools;
|
||||
}
|
||||
|
||||
function convertOpenAiToolChoiceToResponses(rawToolChoice: unknown): unknown {
|
||||
if (rawToolChoice === undefined) return undefined;
|
||||
if (typeof rawToolChoice === 'string') return rawToolChoice;
|
||||
if (!isRecord(rawToolChoice)) return rawToolChoice;
|
||||
|
||||
const type = asTrimmedString(rawToolChoice.type).toLowerCase();
|
||||
if (type === 'function' && isRecord(rawToolChoice.function)) {
|
||||
const name = asTrimmedString(rawToolChoice.function.name);
|
||||
if (!name) return 'required';
|
||||
return { type: 'function', name };
|
||||
}
|
||||
|
||||
return rawToolChoice;
|
||||
}
|
||||
|
||||
function normalizeResponsesBodyForCompatibility(
|
||||
body: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const nextInput = normalizeResponsesInputForCompatibility(body.input);
|
||||
if (nextInput === body.input) return body;
|
||||
return {
|
||||
...body,
|
||||
input: nextInput,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeResponsesMessageContent(role: string, content: unknown): Array<Record<string, unknown>> {
|
||||
const normalized = normalizeResponsesMessageItem({
|
||||
type: 'message',
|
||||
role,
|
||||
content,
|
||||
});
|
||||
|
||||
if (isRecord(normalized) && Array.isArray(normalized.content)) {
|
||||
return normalized.content.filter((item): item is Record<string, unknown> => isRecord(item));
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
const ALLOWED_RESPONSES_FIELDS = new Set([
|
||||
'model',
|
||||
'input',
|
||||
'instructions',
|
||||
'max_output_tokens',
|
||||
'max_completion_tokens',
|
||||
'temperature',
|
||||
'top_p',
|
||||
'truncation',
|
||||
'tools',
|
||||
'tool_choice',
|
||||
'parallel_tool_calls',
|
||||
'metadata',
|
||||
'reasoning',
|
||||
'store',
|
||||
'safety_identifier',
|
||||
'stream',
|
||||
'stream_options',
|
||||
'user',
|
||||
'max_tool_calls',
|
||||
'prompt_cache_key',
|
||||
'prompt_cache_retention',
|
||||
'background',
|
||||
'previous_response_id',
|
||||
'text',
|
||||
'audio',
|
||||
'include',
|
||||
'response_format',
|
||||
'service_tier',
|
||||
'stop',
|
||||
'n',
|
||||
]);
|
||||
|
||||
export function sanitizeResponsesBodyForProxy(
|
||||
body: Record<string, unknown>,
|
||||
modelName: string,
|
||||
stream: boolean,
|
||||
): Record<string, unknown> {
|
||||
let normalized = normalizeResponsesBodyForCompatibility({
|
||||
...body,
|
||||
model: modelName,
|
||||
stream,
|
||||
});
|
||||
|
||||
if (normalized.input === undefined) {
|
||||
if (Array.isArray((normalized as Record<string, unknown>).messages)) {
|
||||
normalized = normalizeResponsesBodyForCompatibility(
|
||||
convertOpenAiBodyToResponsesBody(normalized, modelName, stream),
|
||||
);
|
||||
} else {
|
||||
const prompt = asTrimmedString((normalized as Record<string, unknown>).prompt);
|
||||
if (prompt) {
|
||||
normalized = {
|
||||
...normalized,
|
||||
input: [toResponsesInputMessageFromText(prompt)],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sanitized: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(normalized)) {
|
||||
if (!ALLOWED_RESPONSES_FIELDS.has(key)) continue;
|
||||
if (key === 'max_completion_tokens') continue;
|
||||
sanitized[key] = value;
|
||||
}
|
||||
|
||||
const maxOutputTokens = toFiniteNumber(normalized.max_output_tokens);
|
||||
if (maxOutputTokens !== null && maxOutputTokens > 0) {
|
||||
sanitized.max_output_tokens = Math.trunc(maxOutputTokens);
|
||||
} else {
|
||||
const maxCompletionTokens = toFiniteNumber(normalized.max_completion_tokens);
|
||||
if (maxCompletionTokens !== null && maxCompletionTokens > 0) {
|
||||
sanitized.max_output_tokens = Math.trunc(maxCompletionTokens);
|
||||
}
|
||||
}
|
||||
|
||||
sanitized.model = modelName;
|
||||
sanitized.stream = stream;
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
export function convertOpenAiBodyToResponsesBody(
|
||||
openaiBody: Record<string, unknown>,
|
||||
modelName: string,
|
||||
stream: boolean,
|
||||
): Record<string, unknown> {
|
||||
const rawMessages = Array.isArray(openaiBody.messages) ? openaiBody.messages : [];
|
||||
const systemContents: string[] = [];
|
||||
const inputItems: Array<Record<string, unknown>> = [];
|
||||
|
||||
for (const item of rawMessages) {
|
||||
if (!isRecord(item)) continue;
|
||||
const role = asTrimmedString(item.role).toLowerCase() || 'user';
|
||||
|
||||
if (role === 'system' || role === 'developer') {
|
||||
const content = extractTextContent(item.content).trim();
|
||||
if (content) systemContents.push(content);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (role === 'assistant') {
|
||||
const normalizedContent = normalizeResponsesMessageContent('assistant', item.content);
|
||||
if (normalizedContent.length > 0) {
|
||||
inputItems.push({
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content: normalizedContent,
|
||||
});
|
||||
}
|
||||
|
||||
const rawToolCalls = Array.isArray(item.tool_calls) ? item.tool_calls : [];
|
||||
for (let index = 0; index < rawToolCalls.length; index += 1) {
|
||||
const toolCall = rawToolCalls[index];
|
||||
if (!isRecord(toolCall)) continue;
|
||||
const functionPart = isRecord(toolCall.function) ? toolCall.function : {};
|
||||
const callId = asTrimmedString(toolCall.id) || `call_${Date.now()}_${index}`;
|
||||
const name = (
|
||||
asTrimmedString(functionPart.name)
|
||||
|| asTrimmedString(toolCall.name)
|
||||
|| `tool_${index}`
|
||||
);
|
||||
const argumentsValue = normalizeOpenAiToolArguments(
|
||||
functionPart.arguments ?? toolCall.arguments,
|
||||
);
|
||||
|
||||
inputItems.push({
|
||||
type: 'function_call',
|
||||
call_id: callId,
|
||||
name,
|
||||
arguments: argumentsValue,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (role === 'tool') {
|
||||
const callId = asTrimmedString(item.tool_call_id) || asTrimmedString(item.id);
|
||||
if (!callId) continue;
|
||||
inputItems.push({
|
||||
type: 'function_call_output',
|
||||
call_id: callId,
|
||||
output: normalizeToolOutput(item.content),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalizedContent = normalizeResponsesMessageContent('user', item.content);
|
||||
if (normalizedContent.length <= 0) continue;
|
||||
inputItems.push({
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: normalizedContent,
|
||||
});
|
||||
}
|
||||
|
||||
const maxOutputTokens = (
|
||||
toFiniteNumber(openaiBody.max_output_tokens)
|
||||
?? toFiniteNumber(openaiBody.max_completion_tokens)
|
||||
?? toFiniteNumber(openaiBody.max_tokens)
|
||||
?? 4096
|
||||
);
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
model: modelName,
|
||||
stream,
|
||||
max_output_tokens: maxOutputTokens,
|
||||
input: inputItems,
|
||||
};
|
||||
|
||||
if (systemContents.length > 0) {
|
||||
body.instructions = systemContents.join('\n\n');
|
||||
}
|
||||
|
||||
const temperature = toFiniteNumber(openaiBody.temperature);
|
||||
if (temperature !== null) body.temperature = temperature;
|
||||
|
||||
const topP = toFiniteNumber(openaiBody.top_p);
|
||||
if (topP !== null) body.top_p = topP;
|
||||
|
||||
if (openaiBody.metadata !== undefined) body.metadata = openaiBody.metadata;
|
||||
if (openaiBody.reasoning !== undefined) body.reasoning = openaiBody.reasoning;
|
||||
if (openaiBody.parallel_tool_calls !== undefined) body.parallel_tool_calls = openaiBody.parallel_tool_calls;
|
||||
if (openaiBody.tools !== undefined) body.tools = convertOpenAiToolsToResponses(openaiBody.tools);
|
||||
if (openaiBody.safety_identifier !== undefined) body.safety_identifier = openaiBody.safety_identifier;
|
||||
if (openaiBody.max_tool_calls !== undefined) body.max_tool_calls = openaiBody.max_tool_calls;
|
||||
if (openaiBody.prompt_cache_key !== undefined) body.prompt_cache_key = openaiBody.prompt_cache_key;
|
||||
if (openaiBody.prompt_cache_retention !== undefined) {
|
||||
body.prompt_cache_retention = openaiBody.prompt_cache_retention;
|
||||
}
|
||||
if (openaiBody.background !== undefined) body.background = openaiBody.background;
|
||||
if (openaiBody.user !== undefined) body.user = openaiBody.user;
|
||||
if (openaiBody.include !== undefined) body.include = cloneJsonValue(openaiBody.include);
|
||||
if (openaiBody.previous_response_id !== undefined) body.previous_response_id = openaiBody.previous_response_id;
|
||||
if (openaiBody.truncation !== undefined) body.truncation = openaiBody.truncation;
|
||||
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 (Object.keys(textConfig).length > 0) {
|
||||
body.text = textConfig;
|
||||
}
|
||||
|
||||
const responsesToolChoice = convertOpenAiToolChoiceToResponses(openaiBody.tool_choice);
|
||||
if (responsesToolChoice !== undefined) body.tool_choice = responsesToolChoice;
|
||||
|
||||
return normalizeResponsesBodyForCompatibility(body);
|
||||
}
|
||||
|
||||
type OpenAiToolCall = {
|
||||
id: string;
|
||||
type: 'function';
|
||||
function: {
|
||||
name: string;
|
||||
arguments: string;
|
||||
};
|
||||
};
|
||||
|
||||
function toOpenAiToolCall(item: Record<string, unknown>, fallbackIndex: number): OpenAiToolCall | null {
|
||||
const callId = (
|
||||
asTrimmedString(item.call_id)
|
||||
|| asTrimmedString(item.id)
|
||||
|| `call_${Date.now()}_${fallbackIndex}`
|
||||
);
|
||||
const name = asTrimmedString(item.name);
|
||||
if (!name) return null;
|
||||
|
||||
return {
|
||||
id: callId,
|
||||
type: 'function',
|
||||
function: {
|
||||
name,
|
||||
arguments: normalizeOpenAiToolArguments(item.arguments ?? item.input),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeOpenAiContentBlock(item: Record<string, unknown>): string | Record<string, unknown> | null {
|
||||
const type = asTrimmedString(item.type).toLowerCase();
|
||||
if (!type) {
|
||||
const text = extractTextContent(item).trim();
|
||||
return text ? { type: 'text', text } : null;
|
||||
}
|
||||
|
||||
if (
|
||||
type === 'input_text'
|
||||
|| type === 'output_text'
|
||||
|| type === 'text'
|
||||
|| type === 'summary_text'
|
||||
|| type === 'reasoning_text'
|
||||
) {
|
||||
const text = extractTextContent(item).trim();
|
||||
return text ? { type: 'text', text } : null;
|
||||
}
|
||||
|
||||
if (type === 'input_image') {
|
||||
const imageUrl = item.image_url ?? item.url;
|
||||
if (imageUrl === undefined) return null;
|
||||
return {
|
||||
type: 'image_url',
|
||||
image_url: imageUrl,
|
||||
};
|
||||
}
|
||||
|
||||
if (type === 'input_audio' && item.input_audio !== undefined) {
|
||||
return {
|
||||
type: 'input_audio',
|
||||
input_audio: item.input_audio,
|
||||
};
|
||||
}
|
||||
|
||||
if (type === 'reasoning' || type === 'thinking' || type === 'redacted_reasoning') {
|
||||
const text = extractTextContent(item).trim();
|
||||
return text ? { type: 'text', text } : null;
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
function toOpenAiMessageContent(content: unknown): string | Array<string | Record<string, unknown>> {
|
||||
if (typeof content === 'string') return content;
|
||||
if (!Array.isArray(content)) {
|
||||
if (isRecord(content)) {
|
||||
const single = normalizeOpenAiContentBlock(content);
|
||||
if (!single) return '';
|
||||
return typeof single === 'string' ? single : [single];
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
const blocks = content
|
||||
.map((item) => {
|
||||
if (typeof item === 'string') return item.trim() ? item : null;
|
||||
if (!isRecord(item)) return null;
|
||||
return normalizeOpenAiContentBlock(item);
|
||||
})
|
||||
.filter((item): item is string | Record<string, unknown> => !!item);
|
||||
|
||||
if (blocks.length === 1 && typeof blocks[0] === 'string') {
|
||||
return blocks[0];
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
function convertResponsesToolsToOpenAi(rawTools: unknown): unknown {
|
||||
if (!Array.isArray(rawTools)) return rawTools;
|
||||
|
||||
return rawTools.map((item) => {
|
||||
if (!isRecord(item)) return item;
|
||||
const type = asTrimmedString(item.type).toLowerCase();
|
||||
|
||||
if (type === 'custom' || type === 'image_generation') return item;
|
||||
if (type !== 'function') return item;
|
||||
if (isRecord(item.function) && asTrimmedString(item.function.name)) return item;
|
||||
|
||||
const name = asTrimmedString(item.name);
|
||||
if (!name) return item;
|
||||
|
||||
const fn: Record<string, unknown> = { name };
|
||||
const description = asTrimmedString(item.description);
|
||||
if (description) fn.description = description;
|
||||
if (item.parameters !== undefined) fn.parameters = item.parameters;
|
||||
if (item.strict !== undefined) fn.strict = item.strict;
|
||||
|
||||
return {
|
||||
type: 'function',
|
||||
function: fn,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function convertResponsesToolChoiceToOpenAi(rawToolChoice: unknown): unknown {
|
||||
if (rawToolChoice === undefined) return undefined;
|
||||
if (typeof rawToolChoice === 'string') return rawToolChoice;
|
||||
if (!isRecord(rawToolChoice)) return rawToolChoice;
|
||||
|
||||
const type = asTrimmedString(rawToolChoice.type).toLowerCase();
|
||||
if (type === 'function') {
|
||||
if (isRecord(rawToolChoice.function) && asTrimmedString(rawToolChoice.function.name)) {
|
||||
return rawToolChoice;
|
||||
}
|
||||
|
||||
const name = asTrimmedString(rawToolChoice.name);
|
||||
if (!name) return 'required';
|
||||
return {
|
||||
type: 'function',
|
||||
function: { name },
|
||||
};
|
||||
}
|
||||
|
||||
if (type === 'auto' || type === 'none' || type === 'required') {
|
||||
return type;
|
||||
}
|
||||
|
||||
return rawToolChoice;
|
||||
}
|
||||
|
||||
export function convertResponsesBodyToOpenAiBody(
|
||||
body: Record<string, unknown>,
|
||||
modelName: string,
|
||||
stream: boolean,
|
||||
): Record<string, unknown> {
|
||||
const messages: Array<Record<string, unknown>> = [];
|
||||
const input = body.input;
|
||||
let functionCallIndex = 0;
|
||||
let pendingToolCalls: OpenAiToolCall[] = [];
|
||||
|
||||
const flushPendingToolCalls = () => {
|
||||
if (pendingToolCalls.length <= 0) return;
|
||||
messages.push({
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
tool_calls: pendingToolCalls,
|
||||
});
|
||||
pendingToolCalls = [];
|
||||
};
|
||||
|
||||
const pushToolOutputMessage = (callIdRaw: unknown, outputRaw: unknown) => {
|
||||
const toolCallId = asTrimmedString(callIdRaw);
|
||||
if (!toolCallId) return;
|
||||
messages.push({
|
||||
role: 'tool',
|
||||
tool_call_id: toolCallId,
|
||||
content: normalizeToolOutput(outputRaw),
|
||||
});
|
||||
};
|
||||
|
||||
const processInputItem = (item: unknown) => {
|
||||
if (typeof item === 'string') {
|
||||
flushPendingToolCalls();
|
||||
const text = item.trim();
|
||||
if (text) messages.push({ role: 'user', content: text });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isRecord(item)) return;
|
||||
|
||||
const itemType = asTrimmedString(item.type).toLowerCase();
|
||||
if (itemType === 'function_call' || itemType === 'custom_tool_call') {
|
||||
const toolCall = toOpenAiToolCall(item, functionCallIndex);
|
||||
functionCallIndex += 1;
|
||||
if (toolCall) pendingToolCalls.push(toolCall);
|
||||
return;
|
||||
}
|
||||
|
||||
if (itemType === 'function_call_output' || itemType === 'custom_tool_call_output') {
|
||||
flushPendingToolCalls();
|
||||
pushToolOutputMessage(item.call_id ?? item.id, item.output ?? item.content);
|
||||
return;
|
||||
}
|
||||
|
||||
if (itemType === 'reasoning') {
|
||||
flushPendingToolCalls();
|
||||
const reasoningContent = toOpenAiMessageContent(item.summary ?? item.content ?? item);
|
||||
const hasReasoningContent = typeof reasoningContent === 'string'
|
||||
? reasoningContent.trim().length > 0
|
||||
: Array.isArray(reasoningContent) && reasoningContent.length > 0;
|
||||
if (!hasReasoningContent) return;
|
||||
|
||||
messages.push({
|
||||
role: 'assistant',
|
||||
content: reasoningContent,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
flushPendingToolCalls();
|
||||
const role = asTrimmedString(item.role).toLowerCase() || 'user';
|
||||
const normalizedRole = role === 'developer' ? 'system' : role;
|
||||
const content = toOpenAiMessageContent(item.content ?? item.input ?? item);
|
||||
|
||||
if (normalizedRole === 'tool') {
|
||||
pushToolOutputMessage(item.tool_call_id ?? item.call_id ?? item.id, item.content);
|
||||
return;
|
||||
}
|
||||
|
||||
const hasContent = typeof content === 'string'
|
||||
? content.trim().length > 0
|
||||
: Array.isArray(content) && content.length > 0;
|
||||
if (!hasContent) return;
|
||||
|
||||
messages.push({
|
||||
role: normalizedRole,
|
||||
content,
|
||||
});
|
||||
};
|
||||
|
||||
if (typeof input === 'string') {
|
||||
const text = input.trim();
|
||||
if (text) messages.push({ role: 'user', content: text });
|
||||
} else if (Array.isArray(input)) {
|
||||
for (const item of input) processInputItem(item);
|
||||
} else if (isRecord(input)) {
|
||||
processInputItem(input);
|
||||
}
|
||||
flushPendingToolCalls();
|
||||
|
||||
const instructions = asTrimmedString(body.instructions);
|
||||
if (instructions) {
|
||||
messages.unshift({ role: 'system', content: instructions });
|
||||
}
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
model: modelName,
|
||||
stream,
|
||||
messages,
|
||||
};
|
||||
|
||||
if (typeof body.temperature === 'number' && Number.isFinite(body.temperature)) {
|
||||
payload.temperature = body.temperature;
|
||||
}
|
||||
if (typeof body.top_p === 'number' && Number.isFinite(body.top_p)) {
|
||||
payload.top_p = body.top_p;
|
||||
}
|
||||
if (typeof body.max_output_tokens === 'number' && Number.isFinite(body.max_output_tokens)) {
|
||||
payload.max_tokens = body.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);
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { normalizeResponsesInputForCompatibility, normalizeResponsesMessageItem } from './compatibility.js';
|
||||
|
||||
export const openAiResponsesInbound = {
|
||||
normalizeInput: normalizeResponsesInputForCompatibility,
|
||||
normalizeMessage: normalizeResponsesMessageItem,
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
import { type StreamTransformContext } from '../../shared/normalized.js';
|
||||
import {
|
||||
convertOpenAiBodyToResponsesBody,
|
||||
convertResponsesBodyToOpenAiBody,
|
||||
normalizeResponsesInputForCompatibility,
|
||||
normalizeResponsesMessageContent,
|
||||
sanitizeResponsesBodyForProxy,
|
||||
} from './conversion.js';
|
||||
import { normalizeResponsesMessageItem } from './compatibility.js';
|
||||
import {
|
||||
type OpenAiResponsesAggregateState,
|
||||
completeResponsesStream,
|
||||
createOpenAiResponsesAggregateState,
|
||||
failResponsesStream,
|
||||
serializeConvertedResponsesEvents,
|
||||
} from './aggregator.js';
|
||||
import { openAiResponsesOutbound } from './outbound.js';
|
||||
import { openAiResponsesStream } from './stream.js';
|
||||
import { openAiResponsesUsage } from './usage.js';
|
||||
|
||||
export const openAiResponsesTransformer = {
|
||||
protocol: 'openai/responses' as const,
|
||||
inbound: {
|
||||
normalizeInput: normalizeResponsesInputForCompatibility,
|
||||
normalizeMessage: normalizeResponsesMessageItem,
|
||||
normalizeContent: normalizeResponsesMessageContent,
|
||||
sanitizeProxyBody: sanitizeResponsesBodyForProxy,
|
||||
fromOpenAiBody: convertOpenAiBodyToResponsesBody,
|
||||
toOpenAiBody: convertResponsesBodyToOpenAiBody,
|
||||
},
|
||||
outbound: openAiResponsesOutbound,
|
||||
stream: openAiResponsesStream,
|
||||
usage: openAiResponsesUsage,
|
||||
aggregator: {
|
||||
createState: createOpenAiResponsesAggregateState,
|
||||
serialize: serializeConvertedResponsesEvents,
|
||||
complete: completeResponsesStream,
|
||||
fail: failResponsesStream,
|
||||
},
|
||||
transformRequest(body: unknown) {
|
||||
return body;
|
||||
},
|
||||
createStreamContext(modelName: string): StreamTransformContext {
|
||||
return openAiResponsesStream.createContext(modelName);
|
||||
},
|
||||
transformFinalResponse(payload: unknown, modelName: string, fallbackText = '') {
|
||||
return openAiResponsesOutbound.normalizeFinal(payload, modelName, fallbackText);
|
||||
},
|
||||
transformStreamEvent(payload: unknown, context: StreamTransformContext, modelName: string) {
|
||||
return openAiResponsesStream.normalizeEvent(payload, context, modelName);
|
||||
},
|
||||
pullSseEvents(buffer: string) {
|
||||
return openAiResponsesStream.pullSseEvents(buffer);
|
||||
},
|
||||
};
|
||||
|
||||
export type OpenAiResponsesTransformer = typeof openAiResponsesTransformer;
|
||||
export type OpenAiResponsesAggregate = OpenAiResponsesAggregateState;
|
||||
export {
|
||||
convertOpenAiBodyToResponsesBody,
|
||||
convertResponsesBodyToOpenAiBody,
|
||||
normalizeResponsesInputForCompatibility,
|
||||
normalizeResponsesMessageContent,
|
||||
normalizeResponsesMessageItem,
|
||||
sanitizeResponsesBodyForProxy,
|
||||
};
|
||||
@@ -0,0 +1,182 @@
|
||||
import {
|
||||
normalizeUpstreamFinalResponse,
|
||||
type NormalizedFinalResponse,
|
||||
} from '../../shared/normalized.js';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object';
|
||||
}
|
||||
|
||||
function asTrimmedString(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function ensureResponseId(rawId: string): string {
|
||||
const trimmed = rawId.trim() || `resp_${Date.now()}`;
|
||||
return trimmed.startsWith('resp_') ? trimmed : `resp_${trimmed}`;
|
||||
}
|
||||
|
||||
function ensureMessageId(rawId: string): string {
|
||||
const trimmed = rawId.trim() || `msg_${Date.now()}`;
|
||||
return trimmed.startsWith('msg_') ? trimmed : `msg_${trimmed}`;
|
||||
}
|
||||
|
||||
function ensureFunctionCallId(rawId: string): string {
|
||||
const trimmed = rawId.trim();
|
||||
if (!trimmed) return `call_${Date.now()}`;
|
||||
return trimmed.startsWith('call_') ? trimmed : `call_${trimmed}`;
|
||||
}
|
||||
|
||||
function stringifyToolInput(input: unknown): string {
|
||||
if (typeof input === 'string') return input;
|
||||
if (!input || typeof input !== 'object') return '';
|
||||
try {
|
||||
return JSON.stringify(input);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export type ResponsesUsageSummary = {
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
};
|
||||
|
||||
export type ResponsesToolCall = {
|
||||
id: string;
|
||||
name: string;
|
||||
arguments: string;
|
||||
};
|
||||
|
||||
function extractToolCallsFromUpstream(payload: unknown): ResponsesToolCall[] {
|
||||
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 toolCalls = Array.isArray((message as any).tool_calls) ? (message as any).tool_calls : [];
|
||||
return toolCalls
|
||||
.map((item: unknown, index: number) => {
|
||||
if (!isRecord(item)) return null;
|
||||
const fn = isRecord(item.function) ? item.function : {};
|
||||
const id = typeof item.id === 'string' && item.id.trim().length > 0
|
||||
? item.id
|
||||
: `call_${index}`;
|
||||
const name = typeof fn.name === 'string' ? fn.name : '';
|
||||
const args = typeof fn.arguments === 'string' ? fn.arguments : '';
|
||||
return {
|
||||
id: ensureFunctionCallId(id),
|
||||
name,
|
||||
arguments: args,
|
||||
};
|
||||
})
|
||||
.filter((item): item is ResponsesToolCall => !!item);
|
||||
}
|
||||
|
||||
if (payload.type === 'message' && Array.isArray(payload.content)) {
|
||||
return payload.content
|
||||
.map((item: unknown, index: number) => {
|
||||
if (!isRecord(item) || item.type !== 'tool_use') return null;
|
||||
const id = typeof item.id === 'string' && item.id.trim().length > 0
|
||||
? item.id
|
||||
: `call_${index}`;
|
||||
const name = typeof item.name === 'string' ? item.name : '';
|
||||
const args = stringifyToolInput(item.input);
|
||||
return {
|
||||
id: ensureFunctionCallId(id),
|
||||
name,
|
||||
arguments: args,
|
||||
};
|
||||
})
|
||||
.filter((item): item is ResponsesToolCall => !!item);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
export function normalizeResponsesFinalPayload(
|
||||
payload: unknown,
|
||||
modelName: string,
|
||||
fallbackText = '',
|
||||
): NormalizedFinalResponse {
|
||||
return normalizeUpstreamFinalResponse(payload, modelName, fallbackText);
|
||||
}
|
||||
|
||||
export function serializeResponsesFinalPayload(input: {
|
||||
upstreamPayload: unknown;
|
||||
normalized: NormalizedFinalResponse;
|
||||
usage: ResponsesUsageSummary;
|
||||
}): Record<string, unknown> {
|
||||
const { upstreamPayload, normalized, usage } = input;
|
||||
if (isRecord(upstreamPayload) && upstreamPayload.object === 'response') {
|
||||
return upstreamPayload;
|
||||
}
|
||||
|
||||
const normalizedId = typeof normalized.id === 'string' && normalized.id.trim()
|
||||
? normalized.id.trim()
|
||||
: `resp_${Date.now()}`;
|
||||
const responseId = ensureResponseId(normalizedId);
|
||||
const messageId = ensureMessageId(normalizedId);
|
||||
const toolCalls = extractToolCallsFromUpstream(upstreamPayload);
|
||||
|
||||
const output: Array<Record<string, unknown>> = [];
|
||||
if (normalized.reasoningContent) {
|
||||
output.push({
|
||||
id: ensureMessageId(`${normalizedId}_reasoning`),
|
||||
type: 'reasoning',
|
||||
status: 'completed',
|
||||
summary: [{
|
||||
type: 'summary_text',
|
||||
text: normalized.reasoningContent,
|
||||
}],
|
||||
});
|
||||
}
|
||||
if (normalized.content || toolCalls.length === 0) {
|
||||
output.push({
|
||||
id: messageId,
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
status: 'completed',
|
||||
content: [{
|
||||
type: 'output_text',
|
||||
text: normalized.content || '',
|
||||
}],
|
||||
});
|
||||
}
|
||||
|
||||
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 {
|
||||
id: responseId,
|
||||
object: 'response',
|
||||
created: normalized.created,
|
||||
status: 'completed',
|
||||
model: normalized.model,
|
||||
output,
|
||||
output_text: normalized.content || '',
|
||||
usage: {
|
||||
input_tokens: usage.promptTokens,
|
||||
output_tokens: usage.completionTokens,
|
||||
total_tokens: usage.totalTokens,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const openAiResponsesOutbound = {
|
||||
normalizeFinal: normalizeResponsesFinalPayload,
|
||||
serializeFinal: serializeResponsesFinalPayload,
|
||||
};
|
||||
|
||||
export {
|
||||
serializeResponsesFinalPayload as toResponsesPayload,
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
createStreamTransformContext,
|
||||
normalizeUpstreamStreamEvent,
|
||||
pullSseEventsWithDone,
|
||||
type NormalizedStreamEvent,
|
||||
type StreamTransformContext,
|
||||
} from '../../shared/normalized.js';
|
||||
import {
|
||||
completeResponsesStream,
|
||||
createOpenAiResponsesAggregateState,
|
||||
failResponsesStream,
|
||||
serializeConvertedResponsesEvents,
|
||||
type OpenAiResponsesAggregateState,
|
||||
} from './aggregator.js';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object';
|
||||
}
|
||||
|
||||
export type OpenAiResponsesStreamEvent = NormalizedStreamEvent & {
|
||||
responsesEventType?: string;
|
||||
responsesPayload?: Record<string, unknown>;
|
||||
usagePayload?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export const openAiResponsesStream = {
|
||||
eventNames: [
|
||||
'response.created',
|
||||
'response.in_progress',
|
||||
'response.output_item.added',
|
||||
'response.output_item.done',
|
||||
'response.content_part.added',
|
||||
'response.content_part.done',
|
||||
'response.output_text.delta',
|
||||
'response.output_text.done',
|
||||
'response.function_call_arguments.delta',
|
||||
'response.function_call_arguments.done',
|
||||
'response.custom_tool_call_input.delta',
|
||||
'response.custom_tool_call_input.done',
|
||||
'response.reasoning_summary_part.added',
|
||||
'response.reasoning_summary_part.done',
|
||||
'response.reasoning_summary_text.delta',
|
||||
'response.reasoning_summary_text.done',
|
||||
'response.image_generation_call.generating',
|
||||
'response.image_generation_call.in_progress',
|
||||
'response.image_generation_call.partial_image',
|
||||
'response.image_generation_call.completed',
|
||||
'response.completed',
|
||||
'response.failed',
|
||||
] as const,
|
||||
createContext(modelName: string): StreamTransformContext {
|
||||
return createStreamTransformContext(modelName);
|
||||
},
|
||||
normalizeEvent(payload: unknown, context: StreamTransformContext, modelName: string): OpenAiResponsesStreamEvent {
|
||||
const normalized = normalizeUpstreamStreamEvent(payload, context, modelName) as OpenAiResponsesStreamEvent;
|
||||
if (isRecord(payload) && typeof payload.type === 'string' && payload.type.startsWith('response.')) {
|
||||
normalized.responsesEventType = payload.type;
|
||||
normalized.responsesPayload = payload;
|
||||
}
|
||||
if (isRecord(payload) && isRecord(payload.usage)) {
|
||||
normalized.usagePayload = payload.usage;
|
||||
}
|
||||
return normalized;
|
||||
},
|
||||
pullSseEvents(buffer: string) {
|
||||
return pullSseEventsWithDone(buffer);
|
||||
},
|
||||
};
|
||||
|
||||
export type ResponsesStreamState = OpenAiResponsesAggregateState;
|
||||
|
||||
export function createResponsesStreamState(modelName: string): ResponsesStreamState {
|
||||
return createOpenAiResponsesAggregateState(modelName);
|
||||
}
|
||||
|
||||
export {
|
||||
completeResponsesStream,
|
||||
failResponsesStream,
|
||||
serializeConvertedResponsesEvents,
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import { createEmptyNormalizedUsage, mergeNormalizedUsage } from '../../shared/normalized.js';
|
||||
|
||||
export const openAiResponsesUsage = {
|
||||
empty: createEmptyNormalizedUsage,
|
||||
merge: mergeNormalizedUsage,
|
||||
};
|
||||
Reference in New Issue
Block a user