Add comprehensive tests for Anthropics and Gemini transformers
- Implement tests for anthropicMessagesTransformer including serialization and event handling in stream.test.ts. - Add usage extraction tests for anthropicMessagesUsage in usage.test.ts. - Create extensive tests for Gemini generate content aggregation and outbound usage in aggregator-outbound-usage.test.ts. - Introduce tests for Gemini stream handling and payload parsing in stream.test.ts. - Enhance OpenAI response serialization tests to ensure proper mapping of usage details and preservation of annotations in outbound.test.ts.
This commit is contained in:
@@ -1,9 +1,12 @@
|
||||
import { type NormalizedFinalResponse, type NormalizedStreamEvent } from '../../shared/normalized.js';
|
||||
import type {
|
||||
OpenAiChatChoice,
|
||||
OpenAiChatChoiceDelta,
|
||||
OpenAiChatNormalizedFinalResponse,
|
||||
OpenAiChatNormalizedStreamEvent,
|
||||
OpenAiChatUsageDetails,
|
||||
} from './model.js';
|
||||
import { mergeChatUsageDetails } from './helpers.js';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object';
|
||||
@@ -22,18 +25,9 @@ function pushUniqueAnnotation(
|
||||
annotations.push(candidate);
|
||||
}
|
||||
|
||||
function mergeUsageDetails(
|
||||
target: OpenAiChatUsageDetails | undefined,
|
||||
next: OpenAiChatUsageDetails | undefined,
|
||||
): OpenAiChatUsageDetails | undefined {
|
||||
if (!target && !next) return undefined;
|
||||
return {
|
||||
...(target || {}),
|
||||
...(next || {}),
|
||||
};
|
||||
}
|
||||
|
||||
export type OpenAiChatAggregateState = {
|
||||
type ChoiceAggregate = {
|
||||
index: number;
|
||||
role?: 'assistant';
|
||||
content: string[];
|
||||
reasoning: string[];
|
||||
toolCalls: Array<{ id: string; name: string; arguments: string }>;
|
||||
@@ -41,11 +35,18 @@ export type OpenAiChatAggregateState = {
|
||||
annotations: Array<Record<string, unknown>>;
|
||||
annotationUrls: Set<string>;
|
||||
citations: Set<string>;
|
||||
};
|
||||
|
||||
export type OpenAiChatAggregateState = {
|
||||
choices: Map<number, ChoiceAggregate>;
|
||||
citations: Set<string>;
|
||||
usageDetails?: OpenAiChatUsageDetails;
|
||||
};
|
||||
|
||||
export function createOpenAiChatAggregateState(): OpenAiChatAggregateState {
|
||||
function createChoiceAggregate(index: number): ChoiceAggregate {
|
||||
return {
|
||||
index,
|
||||
role: undefined,
|
||||
content: [],
|
||||
reasoning: [],
|
||||
toolCalls: [],
|
||||
@@ -53,6 +54,53 @@ export function createOpenAiChatAggregateState(): OpenAiChatAggregateState {
|
||||
annotations: [],
|
||||
annotationUrls: new Set<string>(),
|
||||
citations: new Set<string>(),
|
||||
};
|
||||
}
|
||||
|
||||
function getChoiceAggregate(state: OpenAiChatAggregateState, index: number): ChoiceAggregate {
|
||||
const existing = state.choices.get(index);
|
||||
if (existing) return existing;
|
||||
const created = createChoiceAggregate(index);
|
||||
state.choices.set(index, created);
|
||||
return created;
|
||||
}
|
||||
|
||||
function applyChoiceDelta(choice: ChoiceAggregate, event: OpenAiChatChoiceDelta) {
|
||||
if (event.role) choice.role = event.role;
|
||||
if (event.contentDelta) choice.content.push(event.contentDelta);
|
||||
if (event.reasoningDelta) choice.reasoning.push(event.reasoningDelta);
|
||||
if (event.finishReason !== undefined) choice.finishReason = event.finishReason ?? null;
|
||||
if (Array.isArray(event.toolCallDeltas)) {
|
||||
for (const delta of event.toolCallDeltas) {
|
||||
if (!delta.id && !delta.name && !delta.argumentsDelta) continue;
|
||||
const index = Number.isFinite(delta.index) ? Math.max(0, Math.trunc(delta.index)) : choice.toolCalls.length;
|
||||
while (choice.toolCalls.length <= index) {
|
||||
choice.toolCalls.push({ id: '', name: '', arguments: '' });
|
||||
}
|
||||
const existing = choice.toolCalls[index];
|
||||
if (delta.id) existing.id = delta.id;
|
||||
if (delta.name) existing.name = delta.name;
|
||||
if (delta.argumentsDelta) existing.arguments += delta.argumentsDelta;
|
||||
}
|
||||
}
|
||||
if (Array.isArray(event.annotations)) {
|
||||
for (const annotation of event.annotations) {
|
||||
pushUniqueAnnotation(choice.annotations, choice.annotationUrls, annotation);
|
||||
}
|
||||
}
|
||||
if (Array.isArray(event.citations)) {
|
||||
for (const citation of event.citations) {
|
||||
if (typeof citation === 'string' && citation.trim()) {
|
||||
choice.citations.add(citation.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createOpenAiChatAggregateState(): OpenAiChatAggregateState {
|
||||
return {
|
||||
choices: new Map<number, ChoiceAggregate>(),
|
||||
citations: new Set<string>(),
|
||||
usageDetails: undefined,
|
||||
};
|
||||
}
|
||||
@@ -61,35 +109,34 @@ export function applyOpenAiChatStreamEvent(
|
||||
state: OpenAiChatAggregateState,
|
||||
event: OpenAiChatNormalizedStreamEvent,
|
||||
): OpenAiChatAggregateState {
|
||||
if (event.contentDelta) state.content.push(event.contentDelta);
|
||||
if (event.reasoningDelta) state.reasoning.push(event.reasoningDelta);
|
||||
if (event.finishReason !== undefined) state.finishReason = event.finishReason ?? null;
|
||||
if (Array.isArray(event.toolCallDeltas)) {
|
||||
for (const delta of event.toolCallDeltas) {
|
||||
if (!delta.id && !delta.name && !delta.argumentsDelta) continue;
|
||||
const index = Number.isFinite(delta.index) ? Math.max(0, Math.trunc(delta.index)) : state.toolCalls.length;
|
||||
while (state.toolCalls.length <= index) {
|
||||
state.toolCalls.push({ id: '', name: '', arguments: '' });
|
||||
}
|
||||
const existing = state.toolCalls[index];
|
||||
if (delta.id) existing.id = delta.id;
|
||||
if (delta.name) existing.name = delta.name;
|
||||
if (delta.argumentsDelta) existing.arguments += delta.argumentsDelta;
|
||||
}
|
||||
}
|
||||
if (Array.isArray(event.annotations)) {
|
||||
for (const annotation of event.annotations) {
|
||||
pushUniqueAnnotation(state.annotations, state.annotationUrls, annotation);
|
||||
}
|
||||
const choiceEvents = Array.isArray(event.choiceEvents) && event.choiceEvents.length > 0
|
||||
? event.choiceEvents
|
||||
: [{
|
||||
index: Number.isFinite(event.choiceIndex) ? Math.max(0, Math.trunc(event.choiceIndex!)) : 0,
|
||||
role: event.role,
|
||||
contentDelta: event.contentDelta,
|
||||
reasoningDelta: event.reasoningDelta,
|
||||
toolCallDeltas: event.toolCallDeltas,
|
||||
finishReason: event.finishReason,
|
||||
annotations: event.annotations,
|
||||
citations: event.citations,
|
||||
} satisfies OpenAiChatChoiceDelta];
|
||||
|
||||
for (const choiceEvent of choiceEvents) {
|
||||
const choice = getChoiceAggregate(state, choiceEvent.index);
|
||||
applyChoiceDelta(choice, choiceEvent);
|
||||
}
|
||||
|
||||
if (Array.isArray(event.citations)) {
|
||||
for (const citation of event.citations) {
|
||||
if (typeof citation === 'string' && citation.trim()) {
|
||||
state.citations.add(citation.trim());
|
||||
}
|
||||
if (typeof citation === 'string' && citation.trim()) state.citations.add(citation.trim());
|
||||
}
|
||||
}
|
||||
state.usageDetails = mergeUsageDetails(state.usageDetails, event.usageDetails);
|
||||
for (const choice of state.choices.values()) {
|
||||
for (const citation of choice.citations) state.citations.add(citation);
|
||||
}
|
||||
|
||||
state.usageDetails = mergeChatUsageDetails(state.usageDetails, event.usageDetails);
|
||||
return state;
|
||||
}
|
||||
|
||||
@@ -97,25 +144,36 @@ export function finalizeOpenAiChatAggregate(
|
||||
state: OpenAiChatAggregateState,
|
||||
normalized: OpenAiChatNormalizedFinalResponse,
|
||||
): OpenAiChatNormalizedFinalResponse {
|
||||
const mergedToolCalls = state.toolCalls.filter((item) => item.id || item.name || item.arguments);
|
||||
if (
|
||||
state.content.length === 0
|
||||
&& state.reasoning.length === 0
|
||||
&& mergedToolCalls.length === 0
|
||||
&& state.annotations.length === 0
|
||||
&& state.citations.size === 0
|
||||
&& !state.usageDetails
|
||||
) {
|
||||
if (state.choices.size === 0 && state.citations.size === 0 && !state.usageDetails) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const finalizedChoices: OpenAiChatChoice[] = Array.from(state.choices.values())
|
||||
.sort((left, right) => left.index - right.index)
|
||||
.map((choice) => ({
|
||||
index: choice.index,
|
||||
...(choice.role ? { role: choice.role } : {}),
|
||||
content: choice.content.join(''),
|
||||
reasoningContent: choice.reasoning.join(''),
|
||||
toolCalls: choice.toolCalls.filter((item) => item.id || item.name || item.arguments),
|
||||
finishReason: choice.finishReason || (choice.toolCalls.length > 0 ? 'tool_calls' : 'stop'),
|
||||
...(choice.annotations.length > 0 ? { annotations: choice.annotations } : {}),
|
||||
...(choice.citations.size > 0 ? { citations: Array.from(choice.citations).sort() } : {}),
|
||||
}));
|
||||
|
||||
const primaryChoice = finalizedChoices[0];
|
||||
const normalizedChoices = Array.isArray(normalized.choices) ? normalized.choices : [];
|
||||
const mergedChoices = finalizedChoices.length > 0 ? finalizedChoices : normalizedChoices;
|
||||
|
||||
return {
|
||||
...normalized,
|
||||
content: state.content.join('') || normalized.content,
|
||||
reasoningContent: state.reasoning.join('') || normalized.reasoningContent,
|
||||
finishReason: state.finishReason ?? normalized.finishReason,
|
||||
toolCalls: mergedToolCalls.length > 0 ? mergedToolCalls : normalized.toolCalls,
|
||||
annotations: state.annotations.length > 0 ? state.annotations : normalized.annotations,
|
||||
content: primaryChoice?.content || normalized.content,
|
||||
reasoningContent: primaryChoice?.reasoningContent || normalized.reasoningContent,
|
||||
finishReason: primaryChoice?.finishReason || normalized.finishReason,
|
||||
toolCalls: primaryChoice?.toolCalls?.length ? primaryChoice.toolCalls : normalized.toolCalls,
|
||||
annotations: primaryChoice?.annotations?.length ? primaryChoice.annotations : normalized.annotations,
|
||||
citations: state.citations.size > 0 ? Array.from(state.citations).sort() : normalized.citations,
|
||||
usageDetails: mergeUsageDetails(normalized.usageDetails, state.usageDetails),
|
||||
usageDetails: mergeChatUsageDetails(normalized.usageDetails, state.usageDetails),
|
||||
...(mergedChoices.length > 0 ? { choices: mergedChoices } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import type {
|
||||
OpenAiChatChoice,
|
||||
OpenAiChatChoiceDelta,
|
||||
OpenAiChatNormalizedFinalResponse,
|
||||
OpenAiChatNormalizedStreamEvent,
|
||||
OpenAiChatRequestMetadata,
|
||||
OpenAiChatToolCall,
|
||||
OpenAiChatUsageDetails,
|
||||
} from './model.js';
|
||||
|
||||
@@ -31,11 +34,20 @@ function toStringArray(value: unknown): string[] {
|
||||
.filter((item) => item.length > 0);
|
||||
}
|
||||
|
||||
function toFiniteNumber(value: unknown): number | undefined {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
if (typeof value === 'string' && value.trim().length > 0) {
|
||||
const numeric = Number(value);
|
||||
if (Number.isFinite(numeric)) return numeric;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function toNumericRecord(value: unknown): Record<string, number> | undefined {
|
||||
if (!isRecord(value)) return undefined;
|
||||
const entries = Object.entries(value)
|
||||
.map(([key, raw]) => {
|
||||
const numeric = typeof raw === 'number' ? raw : Number(raw);
|
||||
const numeric = toFiniteNumber(raw);
|
||||
if (!Number.isFinite(numeric)) return null;
|
||||
return [key, numeric] as const;
|
||||
})
|
||||
@@ -64,6 +76,112 @@ function annotationUrl(annotation: Record<string, unknown>): string {
|
||||
return (direct || nested).trim();
|
||||
}
|
||||
|
||||
function extractTextPart(value: unknown): string {
|
||||
if (typeof value === 'string') return value;
|
||||
if (!isRecord(value)) return '';
|
||||
|
||||
if (typeof value.text === 'string') return value.text;
|
||||
if (typeof value.output_text === 'string') return value.output_text;
|
||||
if (typeof value.content === 'string') return value.content;
|
||||
if (typeof value.reasoning_content === 'string') return value.reasoning_content;
|
||||
if (typeof value.reasoning === 'string') return value.reasoning;
|
||||
|
||||
if (Array.isArray(value.content)) {
|
||||
return value.content.map((item) => extractTextPart(item)).join('');
|
||||
}
|
||||
|
||||
if (Array.isArray(value.parts)) {
|
||||
return value.parts.map((item) => extractTextPart(item)).join('');
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function extractTextAndReasoning(value: unknown): { content: string; reasoning: string } {
|
||||
if (typeof value === 'string') return { content: value, reasoning: '' };
|
||||
if (Array.isArray(value)) {
|
||||
const contentParts: string[] = [];
|
||||
const reasoningParts: string[] = [];
|
||||
|
||||
for (const item of value) {
|
||||
if (!isRecord(item)) {
|
||||
if (typeof item === 'string') contentParts.push(item);
|
||||
continue;
|
||||
}
|
||||
|
||||
const type = typeof item.type === 'string' ? item.type : '';
|
||||
if ((type === 'reasoning' || type === 'thinking') && typeof item.text === 'string') {
|
||||
reasoningParts.push(item.text);
|
||||
continue;
|
||||
}
|
||||
if ((type === 'reasoning' || type === 'thinking') && typeof item.reasoning === 'string') {
|
||||
reasoningParts.push(item.reasoning);
|
||||
continue;
|
||||
}
|
||||
const text = extractTextPart(item);
|
||||
if (text) contentParts.push(text);
|
||||
}
|
||||
|
||||
return {
|
||||
content: contentParts.join(''),
|
||||
reasoning: reasoningParts.join(''),
|
||||
};
|
||||
}
|
||||
|
||||
if (!isRecord(value)) return { content: '', reasoning: '' };
|
||||
return {
|
||||
content: extractTextPart(value.content ?? value),
|
||||
reasoning: typeof value.reasoning_content === 'string'
|
||||
? value.reasoning_content
|
||||
: typeof value.reasoning === 'string'
|
||||
? value.reasoning
|
||||
: '',
|
||||
};
|
||||
}
|
||||
|
||||
function collectToolCalls(value: unknown): OpenAiChatToolCall[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const toolCalls: OpenAiChatToolCall[] = [];
|
||||
|
||||
for (const item of value) {
|
||||
if (!isRecord(item)) continue;
|
||||
const functionPart = isRecord(item.function) ? item.function : null;
|
||||
if (!functionPart) continue;
|
||||
const name = typeof functionPart.name === 'string' ? functionPart.name : '';
|
||||
const id = typeof item.id === 'string' ? item.id : '';
|
||||
const args = typeof functionPart.arguments === 'string' ? functionPart.arguments : '';
|
||||
if (!id && !name && !args) continue;
|
||||
toolCalls.push({
|
||||
id,
|
||||
name,
|
||||
arguments: args,
|
||||
});
|
||||
}
|
||||
|
||||
return toolCalls;
|
||||
}
|
||||
|
||||
function collectToolCallDeltas(value: unknown): OpenAiChatChoiceDelta['toolCallDeltas'] {
|
||||
if (!Array.isArray(value)) return undefined;
|
||||
const deltas = value
|
||||
.map((item, itemIndex) => {
|
||||
if (!isRecord(item)) return null;
|
||||
const functionPart = isRecord(item.function) ? item.function : {};
|
||||
const index = toFiniteNumber(item.index);
|
||||
const delta = {
|
||||
index: index !== undefined ? Math.max(0, Math.trunc(index)) : itemIndex,
|
||||
id: typeof item.id === 'string' && item.id.trim() ? item.id : undefined,
|
||||
name: typeof functionPart.name === 'string' && functionPart.name.trim() ? functionPart.name : undefined,
|
||||
argumentsDelta: typeof functionPart.arguments === 'string' ? functionPart.arguments : undefined,
|
||||
};
|
||||
if (!delta.id && !delta.name && !delta.argumentsDelta) return null;
|
||||
return delta;
|
||||
})
|
||||
.filter((item): item is NonNullable<typeof item> => !!item);
|
||||
|
||||
return deltas.length > 0 ? deltas : undefined;
|
||||
}
|
||||
|
||||
export function dedupeAnnotations(input: unknown): Array<Record<string, unknown>> {
|
||||
if (!Array.isArray(input)) return [];
|
||||
const seen = new Set<string>();
|
||||
@@ -98,6 +216,104 @@ export function dedupeCitations(input: unknown): string[] {
|
||||
return Array.from(citations);
|
||||
}
|
||||
|
||||
export function extractChoiceAnnotations(choice: unknown): Array<Record<string, unknown>> {
|
||||
if (!isRecord(choice)) return [];
|
||||
const choiceMessage = asRecord(choice.message);
|
||||
const choiceDelta = asRecord(choice.delta);
|
||||
return dedupeAnnotations([
|
||||
...(Array.isArray(choice.annotations) ? choice.annotations : []),
|
||||
...(Array.isArray(choiceMessage?.annotations) ? choiceMessage.annotations : []),
|
||||
...(Array.isArray(choiceDelta?.annotations) ? choiceDelta.annotations : []),
|
||||
]);
|
||||
}
|
||||
|
||||
export function extractChoiceCitations(
|
||||
choice: unknown,
|
||||
sharedCitations: Iterable<string> = [],
|
||||
): string[] {
|
||||
const citations = new Set<string>(sharedCitations);
|
||||
for (const annotation of extractChoiceAnnotations(choice)) {
|
||||
const url = annotationUrl(annotation);
|
||||
if (url) citations.add(url);
|
||||
}
|
||||
if (isRecord(choice)) {
|
||||
for (const citation of dedupeCitations(choice.citations)) citations.add(citation);
|
||||
}
|
||||
return Array.from(citations);
|
||||
}
|
||||
|
||||
export function extractChatChoices(payload: unknown): OpenAiChatChoice[] {
|
||||
const record = asRecord(payload);
|
||||
if (!record || !Array.isArray(record.choices)) return [];
|
||||
|
||||
const sharedCitations = dedupeCitations(record.citations);
|
||||
const choices: OpenAiChatChoice[] = [];
|
||||
|
||||
for (const [index, choice] of record.choices.entries()) {
|
||||
const choiceRecord = asRecord(choice);
|
||||
if (!choiceRecord) continue;
|
||||
const message = asRecord(choiceRecord.message) ?? asRecord(choiceRecord.delta) ?? {};
|
||||
const parsed = extractTextAndReasoning(message.content ?? message);
|
||||
const toolCalls = collectToolCalls(message.tool_calls);
|
||||
const annotations = extractChoiceAnnotations(choiceRecord);
|
||||
const citations = extractChoiceCitations(choiceRecord, sharedCitations);
|
||||
const finishReason = typeof choiceRecord.finish_reason === 'string' && choiceRecord.finish_reason.length > 0
|
||||
? choiceRecord.finish_reason
|
||||
: (toolCalls.length > 0 ? 'tool_calls' : 'stop');
|
||||
|
||||
choices.push({
|
||||
index: toFiniteNumber(choiceRecord.index) !== undefined ? Math.max(0, Math.trunc(toFiniteNumber(choiceRecord.index)!)) : index,
|
||||
...(message.role === 'assistant' ? { role: 'assistant' as const } : {}),
|
||||
content: parsed.content || (toolCalls.length > 0 ? '' : ''),
|
||||
reasoningContent: typeof message.reasoning_content === 'string'
|
||||
? message.reasoning_content
|
||||
: parsed.reasoning,
|
||||
toolCalls,
|
||||
finishReason,
|
||||
...(annotations.length > 0 ? { annotations } : {}),
|
||||
...(citations.length > 0 ? { citations } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
return choices.sort((left, right) => left.index - right.index);
|
||||
}
|
||||
|
||||
export function extractChatChoiceEvents(payload: unknown): OpenAiChatChoiceDelta[] {
|
||||
const record = asRecord(payload);
|
||||
if (!record || !Array.isArray(record.choices)) return [];
|
||||
|
||||
const sharedCitations = dedupeCitations(record.citations);
|
||||
const choiceEvents: OpenAiChatChoiceDelta[] = [];
|
||||
|
||||
for (const [index, choice] of record.choices.entries()) {
|
||||
const choiceRecord = asRecord(choice);
|
||||
if (!choiceRecord) continue;
|
||||
const delta = asRecord(choiceRecord.delta) ?? {};
|
||||
const parsed = extractTextAndReasoning(delta.content ?? delta);
|
||||
const toolCallDeltas = collectToolCallDeltas(delta.tool_calls);
|
||||
const annotations = extractChoiceAnnotations(choiceRecord);
|
||||
const citations = extractChoiceCitations(choiceRecord, sharedCitations);
|
||||
const choiceIndex = toFiniteNumber(choiceRecord.index) !== undefined
|
||||
? Math.max(0, Math.trunc(toFiniteNumber(choiceRecord.index)!))
|
||||
: index;
|
||||
|
||||
choiceEvents.push({
|
||||
index: choiceIndex,
|
||||
...(delta.role === 'assistant' ? { role: 'assistant' as const } : {}),
|
||||
...(parsed.content ? { contentDelta: parsed.content } : {}),
|
||||
...((typeof delta.reasoning_content === 'string' ? delta.reasoning_content : parsed.reasoning)
|
||||
? { reasoningDelta: (typeof delta.reasoning_content === 'string' ? delta.reasoning_content : parsed.reasoning) }
|
||||
: {}),
|
||||
...(toolCallDeltas ? { toolCallDeltas } : {}),
|
||||
finishReason: typeof choiceRecord.finish_reason === 'string' ? choiceRecord.finish_reason : null,
|
||||
...(annotations.length > 0 ? { annotations } : {}),
|
||||
...(citations.length > 0 ? { citations } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
return choiceEvents;
|
||||
}
|
||||
|
||||
function extractAnnotationsFromPayload(payload: Record<string, unknown>): Array<Record<string, unknown>> {
|
||||
const collected: Array<Record<string, unknown>> = [];
|
||||
const append = (value: unknown) => {
|
||||
@@ -216,18 +432,25 @@ export function extractChatRequestMetadata(body: unknown): OpenAiChatRequestMeta
|
||||
if (!raw) return undefined;
|
||||
|
||||
const streamOptions = asRecord(raw.stream_options) ?? asRecord(raw.streamOptions);
|
||||
const modalities = Array.isArray(raw.modalities)
|
||||
? raw.modalities.filter((item): item is string => typeof item === 'string' && item.trim().length > 0)
|
||||
: undefined;
|
||||
const audio = asRecord(raw.audio) as OpenAiChatRequestMetadata['audio'] | null;
|
||||
const reasoningBudget = toFiniteNumber(raw.reasoning_budget);
|
||||
const topLogprobs = toFiniteNumber(raw.top_logprobs);
|
||||
const logitBias = toNumericRecord(raw.logit_bias);
|
||||
const metadata: OpenAiChatRequestMetadata = {
|
||||
...(raw.modalities !== undefined ? { modalities: raw.modalities } : {}),
|
||||
...(raw.audio !== undefined ? { audio: raw.audio } : {}),
|
||||
...(raw.reasoning_effort !== undefined ? { reasoningEffort: raw.reasoning_effort } : {}),
|
||||
...(raw.reasoning_budget !== undefined ? { reasoningBudget: raw.reasoning_budget } : {}),
|
||||
...(raw.reasoning_summary !== undefined ? { reasoningSummary: raw.reasoning_summary } : {}),
|
||||
...(raw.service_tier !== undefined ? { serviceTier: raw.service_tier } : {}),
|
||||
...(raw.top_logprobs !== undefined ? { topLogprobs: raw.top_logprobs } : {}),
|
||||
...(raw.logit_bias !== undefined ? { logitBias: raw.logit_bias } : {}),
|
||||
...(raw.prompt_cache_key !== undefined ? { promptCacheKey: raw.prompt_cache_key } : {}),
|
||||
...(raw.safety_identifier !== undefined ? { safetyIdentifier: raw.safety_identifier } : {}),
|
||||
...(raw.verbosity !== undefined ? { verbosity: raw.verbosity } : {}),
|
||||
...(modalities && modalities.length > 0 ? { modalities } : {}),
|
||||
...(audio ? { audio } : {}),
|
||||
...(typeof raw.reasoning_effort === 'string' ? { reasoningEffort: raw.reasoning_effort } : {}),
|
||||
...(reasoningBudget !== undefined ? { reasoningBudget } : {}),
|
||||
...(typeof raw.reasoning_summary === 'string' ? { reasoningSummary: raw.reasoning_summary } : {}),
|
||||
...(typeof raw.service_tier === 'string' ? { serviceTier: raw.service_tier } : {}),
|
||||
...(topLogprobs !== undefined ? { topLogprobs } : {}),
|
||||
...(logitBias ? { logitBias } : {}),
|
||||
...(typeof raw.prompt_cache_key === 'string' ? { promptCacheKey: raw.prompt_cache_key } : {}),
|
||||
...(typeof raw.safety_identifier === 'string' ? { safetyIdentifier: raw.safety_identifier } : {}),
|
||||
...(typeof raw.verbosity === 'string' ? { verbosity: raw.verbosity } : {}),
|
||||
...(raw.response_format !== undefined ? { responseFormat: raw.response_format } : {}),
|
||||
...(streamOptions && streamOptions.include_usage !== undefined
|
||||
? { streamOptionsIncludeUsage: Boolean(streamOptions.include_usage) }
|
||||
|
||||
@@ -61,6 +61,48 @@ describe('openAiChatTransformer.inbound', () => {
|
||||
streamOptionsIncludeUsage: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes typed inbound metadata without mutating upstream body', () => {
|
||||
const result = openAiChatTransformer.transformRequest({
|
||||
model: 'gpt-5',
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
modalities: ['text', 42, 'audio', '', null],
|
||||
audio: { voice: 'alloy', format: 'wav' },
|
||||
reasoning_effort: 'medium',
|
||||
reasoning_budget: '2048',
|
||||
reasoning_summary: 'concise',
|
||||
service_tier: 'priority',
|
||||
top_logprobs: '5',
|
||||
logit_bias: { '42': '7', invalid: 'oops' },
|
||||
prompt_cache_key: 'cache-key',
|
||||
safety_identifier: 'safety-id',
|
||||
verbosity: 'high',
|
||||
stream_options: { include_usage: 1 },
|
||||
});
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.value?.upstreamBody).toMatchObject({
|
||||
modalities: ['text', 42, 'audio', '', null],
|
||||
reasoning_budget: '2048',
|
||||
top_logprobs: '5',
|
||||
logit_bias: { '42': '7', invalid: 'oops' },
|
||||
stream_options: { include_usage: 1 },
|
||||
});
|
||||
expect((result.value as any)?.requestMetadata).toEqual({
|
||||
modalities: ['text', 'audio'],
|
||||
audio: { voice: 'alloy', format: 'wav' },
|
||||
reasoningEffort: 'medium',
|
||||
reasoningBudget: 2048,
|
||||
reasoningSummary: 'concise',
|
||||
serviceTier: 'priority',
|
||||
topLogprobs: 5,
|
||||
logitBias: { '42': 7 },
|
||||
promptCacheKey: 'cache-key',
|
||||
safetyIdentifier: 'safety-id',
|
||||
verbosity: 'high',
|
||||
streamOptionsIncludeUsage: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('openAiChatTransformer.outbound', () => {
|
||||
@@ -111,6 +153,99 @@ describe('openAiChatTransformer.outbound', () => {
|
||||
completion_tokens_details: { reasoning_tokens: 2 },
|
||||
});
|
||||
});
|
||||
|
||||
it('serializes multi-choice chat responses with per-choice annotations, reasoning, tool calls, and shared citations', () => {
|
||||
const normalized = openAiChatTransformer.transformFinalResponse({
|
||||
id: 'chatcmpl-multi',
|
||||
model: 'gpt-5',
|
||||
created: 123,
|
||||
citations: ['https://shared.example', 'https://shared.example'],
|
||||
usage: {
|
||||
prompt_tokens: 21,
|
||||
completion_tokens: 9,
|
||||
total_tokens: 30,
|
||||
prompt_tokens_details: { cached_tokens: 4, audio_tokens: 1 },
|
||||
completion_tokens_details: { reasoning_tokens: 3, audio_tokens: 2 },
|
||||
},
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
finish_reason: 'stop',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: 'choice-0',
|
||||
reasoning_content: 'reason-0',
|
||||
annotations: [
|
||||
{ type: 'url_citation', url_citation: { url: 'https://a.example' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
index: 1,
|
||||
finish_reason: 'tool_calls',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
reasoning_content: 'reason-1',
|
||||
tool_calls: [{
|
||||
id: 'call_1',
|
||||
type: 'function',
|
||||
function: { name: 'search', arguments: '{"q":"cat"}' },
|
||||
}],
|
||||
annotations: [
|
||||
{ type: 'url_citation', url_citation: { url: 'https://b.example' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}, 'gpt-5');
|
||||
|
||||
const payload = openAiChatTransformer.serializeFinalResponse(normalized, {
|
||||
promptTokens: 21,
|
||||
completionTokens: 9,
|
||||
totalTokens: 30,
|
||||
}) as any;
|
||||
|
||||
expect(payload.choices).toHaveLength(2);
|
||||
expect(payload.choices[0]).toMatchObject({
|
||||
index: 0,
|
||||
finish_reason: 'stop',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: 'choice-0',
|
||||
reasoning_content: 'reason-0',
|
||||
annotations: [
|
||||
{ type: 'url_citation', url_citation: { url: 'https://a.example' } },
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(payload.choices[1]).toMatchObject({
|
||||
index: 1,
|
||||
finish_reason: 'tool_calls',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
reasoning_content: 'reason-1',
|
||||
tool_calls: [{
|
||||
id: 'call_1',
|
||||
type: 'function',
|
||||
function: { name: 'search', arguments: '{"q":"cat"}' },
|
||||
}],
|
||||
annotations: [
|
||||
{ type: 'url_citation', url_citation: { url: 'https://b.example' } },
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(payload.citations).toEqual([
|
||||
'https://shared.example',
|
||||
'https://a.example',
|
||||
'https://b.example',
|
||||
]);
|
||||
expect(payload.usage).toMatchObject({
|
||||
prompt_tokens_details: { cached_tokens: 4, audio_tokens: 1 },
|
||||
completion_tokens_details: { reasoning_tokens: 3, audio_tokens: 2 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('openAiChatTransformer.stream', () => {
|
||||
@@ -157,6 +292,89 @@ describe('openAiChatTransformer.stream', () => {
|
||||
{ type: 'url_citation', url_citation: { url: 'https://a.example' } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('serializes multi-choice stream chunks without collapsing choice-specific deltas', () => {
|
||||
const context = openAiChatTransformer.createStreamContext('gpt-5');
|
||||
const event = openAiChatTransformer.transformStreamEvent({
|
||||
id: 'chatcmpl-stream-multi',
|
||||
model: 'gpt-5',
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
finish_reason: null,
|
||||
delta: {
|
||||
role: 'assistant',
|
||||
content: 'choice-0',
|
||||
reasoning_content: 'reason-0',
|
||||
annotations: [
|
||||
{ type: 'url_citation', url_citation: { url: 'https://a.example' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
index: 1,
|
||||
finish_reason: null,
|
||||
delta: {
|
||||
role: 'assistant',
|
||||
content: 'choice-1',
|
||||
tool_calls: [{
|
||||
index: 0,
|
||||
id: 'call_1',
|
||||
type: 'function',
|
||||
function: { name: 'search', arguments: '{"q":"dog"}' },
|
||||
}],
|
||||
annotations: [
|
||||
{ type: 'url_citation', url_citation: { url: 'https://b.example' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
citations: ['https://shared.example'],
|
||||
usage: {
|
||||
prompt_tokens: 11,
|
||||
completion_tokens: 7,
|
||||
total_tokens: 18,
|
||||
prompt_tokens_details: { cached_tokens: 3 },
|
||||
},
|
||||
}, context, 'gpt-5');
|
||||
|
||||
const payloads = parseSsePayloads(
|
||||
openAiChatTransformer.serializeStreamEvent(event, context, createClaudeDownstreamContext()),
|
||||
);
|
||||
|
||||
expect(payloads[0].choices).toHaveLength(2);
|
||||
expect((payloads[0] as any).choices[0]).toMatchObject({
|
||||
index: 0,
|
||||
delta: {
|
||||
role: 'assistant',
|
||||
content: 'choice-0',
|
||||
reasoning_content: 'reason-0',
|
||||
annotations: [
|
||||
{ type: 'url_citation', url_citation: { url: 'https://a.example' } },
|
||||
],
|
||||
},
|
||||
});
|
||||
expect((payloads[0] as any).choices[1]).toMatchObject({
|
||||
index: 1,
|
||||
delta: {
|
||||
role: 'assistant',
|
||||
content: 'choice-1',
|
||||
tool_calls: [{
|
||||
index: 0,
|
||||
id: 'call_1',
|
||||
function: { name: 'search', arguments: '{"q":"dog"}' },
|
||||
}],
|
||||
annotations: [
|
||||
{ type: 'url_citation', url_citation: { url: 'https://b.example' } },
|
||||
],
|
||||
},
|
||||
});
|
||||
expect((payloads[0] as any).citations).toEqual([
|
||||
'https://shared.example',
|
||||
'https://a.example',
|
||||
'https://b.example',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('openAiChatTransformer.aggregator', () => {
|
||||
@@ -231,4 +449,85 @@ describe('openAiChatTransformer.aggregator', () => {
|
||||
{ type: 'url_citation', url_citation: { url: 'https://b.example' } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('aggregates stream events by choice index and preserves per-choice annotations, citations, tools, reasoning, and usage details', () => {
|
||||
const state = openAiChatTransformer.aggregator.createState();
|
||||
|
||||
openAiChatTransformer.aggregator.applyEvent(state, {
|
||||
choiceIndex: 0,
|
||||
role: 'assistant',
|
||||
contentDelta: 'hello',
|
||||
reasoningDelta: 'why-0',
|
||||
annotations: [
|
||||
{ type: 'url_citation', url_citation: { url: 'https://a.example' } },
|
||||
],
|
||||
citations: ['https://shared.example'],
|
||||
usageDetails: {
|
||||
prompt_tokens_details: { cached_tokens: 2 },
|
||||
},
|
||||
} as any);
|
||||
|
||||
openAiChatTransformer.aggregator.applyEvent(state, {
|
||||
choiceIndex: 1,
|
||||
role: 'assistant',
|
||||
contentDelta: 'tool-choice',
|
||||
reasoningDelta: 'why-1',
|
||||
toolCallDeltas: [{
|
||||
index: 0,
|
||||
id: 'call_1',
|
||||
name: 'search',
|
||||
argumentsDelta: '{"q":"bird"}',
|
||||
}],
|
||||
annotations: [
|
||||
{ type: 'url_citation', url_citation: { url: 'https://b.example' } },
|
||||
],
|
||||
citations: ['https://other.example'],
|
||||
usageDetails: {
|
||||
completion_tokens_details: { reasoning_tokens: 4 },
|
||||
},
|
||||
finishReason: 'tool_calls',
|
||||
} as any);
|
||||
|
||||
const normalized = openAiChatTransformer.aggregator.finalize(state, {
|
||||
id: 'chatcmpl-multi',
|
||||
model: 'gpt-5',
|
||||
created: 123,
|
||||
content: '',
|
||||
reasoningContent: '',
|
||||
finishReason: 'stop',
|
||||
toolCalls: [],
|
||||
}) as any;
|
||||
|
||||
expect(normalized.choices).toHaveLength(2);
|
||||
expect(normalized.choices[0]).toMatchObject({
|
||||
index: 0,
|
||||
content: 'hello',
|
||||
reasoningContent: 'why-0',
|
||||
finishReason: 'stop',
|
||||
citations: ['https://shared.example'],
|
||||
annotations: [
|
||||
{ type: 'url_citation', url_citation: { url: 'https://a.example' } },
|
||||
],
|
||||
});
|
||||
expect(normalized.choices[1]).toMatchObject({
|
||||
index: 1,
|
||||
content: 'tool-choice',
|
||||
reasoningContent: 'why-1',
|
||||
finishReason: 'tool_calls',
|
||||
toolCalls: [{
|
||||
id: 'call_1',
|
||||
name: 'search',
|
||||
arguments: '{"q":"bird"}',
|
||||
}],
|
||||
citations: ['https://other.example'],
|
||||
annotations: [
|
||||
{ type: 'url_citation', url_citation: { url: 'https://b.example' } },
|
||||
],
|
||||
});
|
||||
expect(normalized.citations).toEqual(['https://other.example', 'https://shared.example']);
|
||||
expect(normalized.usageDetails).toEqual({
|
||||
prompt_tokens_details: { cached_tokens: 2 },
|
||||
completion_tokens_details: { reasoning_tokens: 4 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { type NormalizedFinalResponse, type NormalizedStreamEvent, type ParsedDownstreamChatRequest, type StreamTransformContext } from '../../shared/normalized.js';
|
||||
import { type NormalizedFinalResponse, type NormalizedStreamEvent, type StreamTransformContext } from '../../shared/normalized.js';
|
||||
import { openAiChatInbound } from './inbound.js';
|
||||
import { openAiChatOutbound } from './outbound.js';
|
||||
import { openAiChatStream } from './stream.js';
|
||||
import { openAiChatUsage } from './usage.js';
|
||||
import { createOpenAiChatAggregateState, applyOpenAiChatStreamEvent, finalizeOpenAiChatAggregate } from './aggregator.js';
|
||||
import type { OpenAiChatParsedRequest as OpenAiChatParsedRequestModel } from './model.js';
|
||||
|
||||
export const openAiChatTransformer = {
|
||||
protocol: 'openai/chat' as const,
|
||||
@@ -47,6 +48,20 @@ export const openAiChatTransformer = {
|
||||
) {
|
||||
return openAiChatOutbound.serializeFinal(normalized, usage);
|
||||
},
|
||||
serializeUpstreamFinalAsStream(
|
||||
payload: unknown,
|
||||
modelName: string,
|
||||
fallbackText: string,
|
||||
streamContext: StreamTransformContext,
|
||||
) {
|
||||
const normalizedFinal = openAiChatOutbound.normalizeFinal(payload, modelName, fallbackText);
|
||||
streamContext.id = normalizedFinal.id;
|
||||
streamContext.model = normalizedFinal.model;
|
||||
streamContext.created = normalizedFinal.created;
|
||||
return openAiChatOutbound
|
||||
.buildSyntheticChunks(normalizedFinal)
|
||||
.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`);
|
||||
},
|
||||
buildSyntheticChunks(normalized: NormalizedFinalResponse) {
|
||||
return openAiChatOutbound.buildSyntheticChunks(normalized);
|
||||
},
|
||||
@@ -56,4 +71,4 @@ export const openAiChatTransformer = {
|
||||
};
|
||||
|
||||
export type OpenAiChatTransformer = typeof openAiChatTransformer;
|
||||
export type OpenAiChatParsedRequest = ParsedDownstreamChatRequest;
|
||||
export type OpenAiChatParsedRequest = OpenAiChatParsedRequestModel;
|
||||
|
||||
@@ -4,18 +4,24 @@ import type {
|
||||
ParsedDownstreamChatRequest,
|
||||
} from '../../shared/normalized.js';
|
||||
|
||||
export type OpenAiChatAudioRequest = {
|
||||
format?: string;
|
||||
voice?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type OpenAiChatRequestMetadata = {
|
||||
modalities?: unknown;
|
||||
audio?: unknown;
|
||||
reasoningEffort?: unknown;
|
||||
reasoningBudget?: unknown;
|
||||
reasoningSummary?: unknown;
|
||||
serviceTier?: unknown;
|
||||
topLogprobs?: unknown;
|
||||
logitBias?: unknown;
|
||||
promptCacheKey?: unknown;
|
||||
safetyIdentifier?: unknown;
|
||||
verbosity?: unknown;
|
||||
modalities?: string[];
|
||||
audio?: OpenAiChatAudioRequest;
|
||||
reasoningEffort?: string;
|
||||
reasoningBudget?: number;
|
||||
reasoningSummary?: string;
|
||||
serviceTier?: string;
|
||||
topLogprobs?: number;
|
||||
logitBias?: Record<string, number>;
|
||||
promptCacheKey?: string;
|
||||
safetyIdentifier?: string;
|
||||
verbosity?: string;
|
||||
responseFormat?: unknown;
|
||||
streamOptionsIncludeUsage?: boolean | null;
|
||||
};
|
||||
@@ -29,14 +35,50 @@ export type OpenAiChatParsedRequest = ParsedDownstreamChatRequest & {
|
||||
requestMetadata?: OpenAiChatRequestMetadata;
|
||||
};
|
||||
|
||||
export type OpenAiChatToolCall = {
|
||||
id: string;
|
||||
name: string;
|
||||
arguments: string;
|
||||
};
|
||||
|
||||
export type OpenAiChatChoice = {
|
||||
index: number;
|
||||
role?: 'assistant';
|
||||
content: string;
|
||||
reasoningContent: string;
|
||||
toolCalls: OpenAiChatToolCall[];
|
||||
finishReason: string;
|
||||
annotations?: Array<Record<string, unknown>>;
|
||||
citations?: string[];
|
||||
};
|
||||
|
||||
export type OpenAiChatNormalizedFinalResponse = NormalizedFinalResponse & {
|
||||
choices?: OpenAiChatChoice[];
|
||||
annotations?: Array<Record<string, unknown>>;
|
||||
citations?: string[];
|
||||
usageDetails?: OpenAiChatUsageDetails;
|
||||
usagePayload?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type OpenAiChatChoiceDelta = {
|
||||
index: number;
|
||||
role?: 'assistant';
|
||||
contentDelta?: string;
|
||||
reasoningDelta?: string;
|
||||
toolCallDeltas?: Array<{
|
||||
index: number;
|
||||
id?: string;
|
||||
name?: string;
|
||||
argumentsDelta?: string;
|
||||
}>;
|
||||
finishReason?: string | null;
|
||||
annotations?: Array<Record<string, unknown>>;
|
||||
citations?: string[];
|
||||
};
|
||||
|
||||
export type OpenAiChatNormalizedStreamEvent = NormalizedStreamEvent & {
|
||||
choiceIndex?: number;
|
||||
choiceEvents?: OpenAiChatChoiceDelta[];
|
||||
annotations?: Array<Record<string, unknown>>;
|
||||
citations?: string[];
|
||||
usageDetails?: OpenAiChatUsageDetails;
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
serializeFinalResponse,
|
||||
type NormalizedFinalResponse,
|
||||
} from '../../shared/normalized.js';
|
||||
import { extractChatResponseExtras } from './helpers.js';
|
||||
import { extractChatChoices, extractChatResponseExtras } from './helpers.js';
|
||||
import type { OpenAiChatNormalizedFinalResponse } from './model.js';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
@@ -39,8 +39,13 @@ function mergeUsagePayload(
|
||||
|
||||
export const openAiChatOutbound = {
|
||||
normalizeFinal(payload: unknown, modelName: string, fallbackText = ''): OpenAiChatNormalizedFinalResponse {
|
||||
const choices = extractChatChoices(payload);
|
||||
const primaryChoice = choices[0];
|
||||
return {
|
||||
...normalizeUpstreamFinalResponse(payload, modelName, fallbackText),
|
||||
...(choices.length > 0 ? { choices } : {}),
|
||||
...(primaryChoice?.annotations?.length ? { annotations: primaryChoice.annotations } : {}),
|
||||
...(primaryChoice?.citations?.length ? { citations: primaryChoice.citations } : {}),
|
||||
...extractChatResponseExtras(payload),
|
||||
};
|
||||
},
|
||||
@@ -52,18 +57,54 @@ export const openAiChatOutbound = {
|
||||
usage as { promptTokens: number; completionTokens: number; totalTokens: number },
|
||||
) as Record<string, unknown>;
|
||||
|
||||
const choices = Array.isArray(chatNormalized.choices) && chatNormalized.choices.length > 0
|
||||
? chatNormalized.choices
|
||||
: [{
|
||||
index: 0,
|
||||
content: normalized.content,
|
||||
reasoningContent: normalized.reasoningContent,
|
||||
toolCalls: normalized.toolCalls,
|
||||
finishReason: normalized.toolCalls.length > 0 ? 'tool_calls' : normalized.finishReason,
|
||||
annotations: chatNormalized.annotations,
|
||||
citations: chatNormalized.citations,
|
||||
}];
|
||||
|
||||
payload.choices = choices.map((choice) => {
|
||||
const message: Record<string, unknown> = {
|
||||
role: choice.role || 'assistant',
|
||||
content: choice.content,
|
||||
};
|
||||
if (choice.reasoningContent) {
|
||||
message.reasoning_content = choice.reasoningContent;
|
||||
}
|
||||
if (Array.isArray(choice.toolCalls) && choice.toolCalls.length > 0) {
|
||||
message.tool_calls = choice.toolCalls.map((toolCall) => ({
|
||||
id: toolCall.id,
|
||||
type: 'function',
|
||||
function: {
|
||||
name: toolCall.name,
|
||||
arguments: toolCall.arguments,
|
||||
},
|
||||
}));
|
||||
if (!choice.content) {
|
||||
message.content = '';
|
||||
}
|
||||
}
|
||||
if (Array.isArray(choice.annotations) && choice.annotations.length > 0) {
|
||||
message.annotations = choice.annotations;
|
||||
}
|
||||
|
||||
return {
|
||||
index: choice.index,
|
||||
message,
|
||||
finish_reason: choice.finishReason || (choice.toolCalls?.length ? 'tool_calls' : 'stop'),
|
||||
};
|
||||
}).sort((left, right) => left.index - right.index);
|
||||
|
||||
if (Array.isArray(chatNormalized.citations) && chatNormalized.citations.length > 0) {
|
||||
payload.citations = chatNormalized.citations;
|
||||
}
|
||||
|
||||
if (Array.isArray(chatNormalized.annotations) && chatNormalized.annotations.length > 0) {
|
||||
const choices = Array.isArray(payload.choices) ? payload.choices : [];
|
||||
const firstChoice = choices[0];
|
||||
if (isRecord(firstChoice) && isRecord(firstChoice.message)) {
|
||||
firstChoice.message.annotations = chatNormalized.annotations;
|
||||
}
|
||||
}
|
||||
|
||||
const mergedUsage = mergeUsagePayload(payload.usage, chatNormalized);
|
||||
if (mergedUsage) {
|
||||
payload.usage = {
|
||||
@@ -75,8 +116,57 @@ export const openAiChatOutbound = {
|
||||
return payload;
|
||||
},
|
||||
buildSyntheticChunks(normalized: NormalizedFinalResponse) {
|
||||
const chunks = buildSyntheticOpenAiChunks(normalized);
|
||||
const chatNormalized = normalized as OpenAiChatNormalizedFinalResponse;
|
||||
const choices = Array.isArray(chatNormalized.choices) && chatNormalized.choices.length > 0
|
||||
? chatNormalized.choices
|
||||
: undefined;
|
||||
const chunks = choices && choices.length > 1
|
||||
? [
|
||||
{
|
||||
id: normalized.id,
|
||||
object: 'chat.completion.chunk',
|
||||
created: normalized.created,
|
||||
model: normalized.model,
|
||||
choices: choices.map((choice) => {
|
||||
const delta: Record<string, unknown> = {
|
||||
role: choice.role || 'assistant',
|
||||
content: choice.content || '',
|
||||
};
|
||||
if (choice.reasoningContent) delta.reasoning_content = choice.reasoningContent;
|
||||
if (choice.toolCalls.length > 0) {
|
||||
delta.tool_calls = choice.toolCalls.map((toolCall, toolIndex) => ({
|
||||
index: toolIndex,
|
||||
id: toolCall.id,
|
||||
type: 'function',
|
||||
function: {
|
||||
name: toolCall.name,
|
||||
arguments: toolCall.arguments,
|
||||
},
|
||||
}));
|
||||
}
|
||||
if (Array.isArray(choice.annotations) && choice.annotations.length > 0) {
|
||||
delta.annotations = choice.annotations;
|
||||
}
|
||||
return {
|
||||
index: choice.index,
|
||||
delta,
|
||||
finish_reason: null,
|
||||
};
|
||||
}).sort((left, right) => left.index - right.index),
|
||||
},
|
||||
{
|
||||
id: normalized.id,
|
||||
object: 'chat.completion.chunk',
|
||||
created: normalized.created,
|
||||
model: normalized.model,
|
||||
choices: choices.map((choice) => ({
|
||||
index: choice.index,
|
||||
delta: {},
|
||||
finish_reason: choice.finishReason || (choice.toolCalls.length > 0 ? 'tool_calls' : 'stop'),
|
||||
})).sort((left, right) => left.index - right.index),
|
||||
},
|
||||
]
|
||||
: buildSyntheticOpenAiChunks(normalized);
|
||||
if (chunks.length <= 0) return chunks;
|
||||
|
||||
if (Array.isArray(chatNormalized.citations) && chatNormalized.citations.length > 0) {
|
||||
@@ -86,7 +176,7 @@ export const openAiChatOutbound = {
|
||||
};
|
||||
}
|
||||
|
||||
if (Array.isArray(chatNormalized.annotations) && chatNormalized.annotations.length > 0) {
|
||||
if (!choices && Array.isArray(chatNormalized.annotations) && chatNormalized.annotations.length > 0) {
|
||||
const firstChunk = isRecord(chunks[0]) ? chunks[0] : null;
|
||||
const firstChoice = firstChunk && Array.isArray(firstChunk.choices) ? firstChunk.choices[0] : null;
|
||||
if (isRecord(firstChoice) && isRecord(firstChoice.delta)) {
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
type ClaudeDownstreamContext,
|
||||
type StreamTransformContext,
|
||||
} from '../../shared/normalized.js';
|
||||
import { extractChatResponseExtras } from './helpers.js';
|
||||
import { extractChatChoiceEvents, extractChatResponseExtras } from './helpers.js';
|
||||
import type { OpenAiChatNormalizedStreamEvent } from './model.js';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
@@ -36,8 +36,23 @@ export const openAiChatStream = {
|
||||
return createStreamTransformContext(modelName);
|
||||
},
|
||||
normalizeEvent(payload: unknown, context: StreamTransformContext, modelName: string): OpenAiChatNormalizedStreamEvent {
|
||||
const choiceEvents = extractChatChoiceEvents(payload);
|
||||
const primaryChoice = choiceEvents[0];
|
||||
return {
|
||||
...normalizeUpstreamStreamEvent(payload, context, modelName),
|
||||
...(primaryChoice
|
||||
? {
|
||||
choiceIndex: primaryChoice.index,
|
||||
role: primaryChoice.role,
|
||||
contentDelta: primaryChoice.contentDelta,
|
||||
reasoningDelta: primaryChoice.reasoningDelta,
|
||||
toolCallDeltas: primaryChoice.toolCallDeltas,
|
||||
finishReason: primaryChoice.finishReason,
|
||||
annotations: primaryChoice.annotations,
|
||||
citations: primaryChoice.citations,
|
||||
}
|
||||
: {}),
|
||||
...(choiceEvents.length > 0 ? { choiceEvents } : {}),
|
||||
...extractChatResponseExtras(payload),
|
||||
};
|
||||
},
|
||||
@@ -46,6 +61,60 @@ export const openAiChatStream = {
|
||||
context: StreamTransformContext,
|
||||
downstreamContext?: ClaudeDownstreamContext,
|
||||
): string[] {
|
||||
if (
|
||||
Array.isArray(event.choiceEvents)
|
||||
&& event.choiceEvents.length > 0
|
||||
&& (event.choiceEvents.length > 1 || event.choiceEvents[0]?.index !== 0)
|
||||
) {
|
||||
const usagePayload = event.usagePayload
|
||||
? {
|
||||
...(isRecord(event.usagePayload) ? event.usagePayload : {}),
|
||||
...(event.usageDetails?.prompt_tokens_details
|
||||
? { prompt_tokens_details: event.usageDetails.prompt_tokens_details }
|
||||
: {}),
|
||||
...(event.usageDetails?.completion_tokens_details
|
||||
? { completion_tokens_details: event.usageDetails.completion_tokens_details }
|
||||
: {}),
|
||||
}
|
||||
: undefined;
|
||||
return [`data: ${JSON.stringify({
|
||||
id: context.id || `chatcmpl-${Date.now()}`,
|
||||
object: 'chat.completion.chunk',
|
||||
created: context.created,
|
||||
model: context.model,
|
||||
choices: event.choiceEvents
|
||||
.map((choiceEvent) => {
|
||||
const delta: Record<string, unknown> = {};
|
||||
if (choiceEvent.role) delta.role = choiceEvent.role;
|
||||
if (choiceEvent.contentDelta !== undefined) delta.content = choiceEvent.contentDelta;
|
||||
if (choiceEvent.reasoningDelta) delta.reasoning_content = choiceEvent.reasoningDelta;
|
||||
if (Array.isArray(choiceEvent.toolCallDeltas) && choiceEvent.toolCallDeltas.length > 0) {
|
||||
delta.tool_calls = choiceEvent.toolCallDeltas.map((toolCall) => ({
|
||||
index: toolCall.index,
|
||||
...(toolCall.id ? { id: toolCall.id } : {}),
|
||||
type: 'function',
|
||||
function: {
|
||||
...(toolCall.name ? { name: toolCall.name } : {}),
|
||||
...(toolCall.argumentsDelta !== undefined ? { arguments: toolCall.argumentsDelta } : {}),
|
||||
},
|
||||
}));
|
||||
}
|
||||
if (Array.isArray(choiceEvent.annotations) && choiceEvent.annotations.length > 0) {
|
||||
delta.annotations = choiceEvent.annotations;
|
||||
}
|
||||
|
||||
return {
|
||||
index: choiceEvent.index,
|
||||
delta,
|
||||
finish_reason: choiceEvent.finishReason ?? null,
|
||||
};
|
||||
})
|
||||
.sort((left, right) => left.index - right.index),
|
||||
...(Array.isArray(event.citations) && event.citations.length > 0 ? { citations: event.citations } : {}),
|
||||
...(usagePayload && Object.keys(usagePayload).length > 0 ? { usage: usagePayload } : {}),
|
||||
})}\n\n`];
|
||||
}
|
||||
|
||||
const lines = serializeNormalizedStreamEvent(
|
||||
'openai',
|
||||
event,
|
||||
|
||||
@@ -1,336 +1,180 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { createStreamTransformContext } from '../../shared/normalized.js';
|
||||
import {
|
||||
createOpenAiResponsesAggregateState,
|
||||
serializeConvertedResponsesEvents,
|
||||
type OpenAiResponsesAggregateState,
|
||||
completeResponsesStream,
|
||||
} from './aggregator.js';
|
||||
import { openAiResponsesStream } from './stream.js';
|
||||
|
||||
function collectSerializedEvents(input: string[]): Array<{ event: string; data: any }> {
|
||||
const events: Array<{ event: string; data: any }> = [];
|
||||
for (const chunk of input) {
|
||||
const blocks = chunk.split('\n\n').filter((item) => item.trim().length > 0);
|
||||
for (const block of blocks) {
|
||||
const lines = block.split('\n');
|
||||
const eventLine = lines.find((line) => line.startsWith('event: '));
|
||||
const dataLine = lines.find((line) => line.startsWith('data: '));
|
||||
if (!dataLine) continue;
|
||||
const rawData = dataLine.slice('data: '.length);
|
||||
if (rawData === '[DONE]') {
|
||||
events.push({ event: eventLine?.slice('event: '.length) || 'done', data: '[DONE]' });
|
||||
continue;
|
||||
function parseSsePayloads(lines: string[]): Array<Record<string, unknown>> {
|
||||
return lines
|
||||
.flatMap((line) => line.split('\n\n').filter((block) => block.trim().length > 0))
|
||||
.map((block) => {
|
||||
const dataLine = block
|
||||
.split('\n')
|
||||
.find((line) => line.startsWith('data: '));
|
||||
if (!dataLine) return null;
|
||||
try {
|
||||
return JSON.parse(dataLine.slice('data: '.length)) as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
events.push({
|
||||
event: eventLine?.slice('event: '.length) || '',
|
||||
data: JSON.parse(rawData),
|
||||
});
|
||||
}
|
||||
}
|
||||
return events;
|
||||
})
|
||||
.filter((item): item is Record<string, unknown> => !!item);
|
||||
}
|
||||
|
||||
function feedResponsesEvent(
|
||||
state: OpenAiResponsesAggregateState,
|
||||
payload: Record<string, unknown>,
|
||||
): Array<{ event: string; data: any }> {
|
||||
const context = openAiResponsesStream.createContext('gpt-5');
|
||||
const normalizedEvent = openAiResponsesStream.normalizeEvent(payload, context, 'gpt-5');
|
||||
return collectSerializedEvents(
|
||||
describe('serializeConvertedResponsesEvents', () => {
|
||||
it('aggregates reasoning summary events into the completed response payload', () => {
|
||||
const state = createOpenAiResponsesAggregateState('gpt-5');
|
||||
const streamContext = createStreamTransformContext('gpt-5');
|
||||
const usage = {
|
||||
promptTokens: 1,
|
||||
completionTokens: 2,
|
||||
totalTokens: 3,
|
||||
};
|
||||
|
||||
serializeConvertedResponsesEvents({
|
||||
state,
|
||||
streamContext: context,
|
||||
event: normalizedEvent,
|
||||
usage: {
|
||||
promptTokens: 0,
|
||||
completionTokens: 0,
|
||||
totalTokens: 0,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
describe('openAiResponsesStream / aggregator', () => {
|
||||
it('keeps reasoning separate from assistant text for normalized events and merges usage details', () => {
|
||||
const state = createOpenAiResponsesAggregateState('gpt-5');
|
||||
const context = openAiResponsesStream.createContext('gpt-5');
|
||||
|
||||
const contentEvent = {
|
||||
role: 'assistant',
|
||||
contentDelta: 'hello',
|
||||
};
|
||||
const reasoningEvent = {
|
||||
reasoningDelta: 'plan first',
|
||||
responsesPayload: {
|
||||
usage: {
|
||||
input_tokens: 5,
|
||||
output_tokens: 3,
|
||||
total_tokens: 8,
|
||||
output_tokens_details: { reasoning_tokens: 2 },
|
||||
streamContext,
|
||||
usage,
|
||||
event: {
|
||||
responsesEventType: 'response.reasoning_summary_part.added',
|
||||
responsesPayload: {
|
||||
type: 'response.reasoning_summary_part.added',
|
||||
item_id: 'rs_1',
|
||||
output_index: 0,
|
||||
summary_index: 0,
|
||||
part: {
|
||||
type: 'summary_text',
|
||||
text: '',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
collectSerializedEvents(
|
||||
serializeConvertedResponsesEvents({
|
||||
state,
|
||||
streamContext: context,
|
||||
event: contentEvent as any,
|
||||
usage: { promptTokens: 5, completionTokens: 3, totalTokens: 8 },
|
||||
}),
|
||||
);
|
||||
|
||||
const reasoningEmitted = collectSerializedEvents(
|
||||
serializeConvertedResponsesEvents({
|
||||
state,
|
||||
streamContext: context,
|
||||
event: reasoningEvent as any,
|
||||
usage: { promptTokens: 5, completionTokens: 3, totalTokens: 8 },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(reasoningEmitted.some((item) => item.event === 'response.reasoning_summary_part.added')).toBe(true);
|
||||
expect(reasoningEmitted.some((item) => item.event === 'response.reasoning_summary_text.delta')).toBe(true);
|
||||
|
||||
const completed = collectSerializedEvents(
|
||||
completeResponsesStream(state, context, { promptTokens: 5, completionTokens: 3, totalTokens: 8 }),
|
||||
).find((item) => item.event === 'response.completed');
|
||||
|
||||
expect(completed?.data.response.output).toEqual([
|
||||
{
|
||||
id: expect.any(String),
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
status: 'completed',
|
||||
content: [
|
||||
{
|
||||
type: 'output_text',
|
||||
text: 'hello',
|
||||
},
|
||||
],
|
||||
serializeConvertedResponsesEvents({
|
||||
state,
|
||||
streamContext,
|
||||
usage,
|
||||
event: {
|
||||
responsesEventType: 'response.reasoning_summary_text.delta',
|
||||
responsesPayload: {
|
||||
type: 'response.reasoning_summary_text.delta',
|
||||
item_id: 'rs_1',
|
||||
output_index: 0,
|
||||
summary_index: 0,
|
||||
delta: 'Think ',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: expect.any(String),
|
||||
type: 'reasoning',
|
||||
status: 'completed',
|
||||
summary: [
|
||||
{
|
||||
type: 'summary_text',
|
||||
text: 'plan first',
|
||||
});
|
||||
|
||||
const completedLines = serializeConvertedResponsesEvents({
|
||||
state,
|
||||
streamContext,
|
||||
usage,
|
||||
event: {
|
||||
responsesEventType: 'response.completed',
|
||||
responsesPayload: {
|
||||
type: 'response.completed',
|
||||
response: {
|
||||
id: 'resp_1',
|
||||
model: 'gpt-5',
|
||||
usage: {
|
||||
input_tokens: 1,
|
||||
output_tokens: 2,
|
||||
total_tokens: 3,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(completed?.data.response.usage).toMatchObject({
|
||||
input_tokens: 5,
|
||||
output_tokens: 3,
|
||||
total_tokens: 8,
|
||||
output_tokens_details: { reasoning_tokens: 2 },
|
||||
});
|
||||
|
||||
const payloads = parseSsePayloads(completedLines);
|
||||
const completed = payloads.find((item) => item.type === 'response.completed');
|
||||
expect(completed).toBeTruthy();
|
||||
expect(completed?.response).toMatchObject({
|
||||
id: 'resp_1',
|
||||
output: [
|
||||
{
|
||||
id: 'rs_1',
|
||||
type: 'reasoning',
|
||||
summary: [
|
||||
{
|
||||
type: 'summary_text',
|
||||
text: 'Think ',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('tracks reasoning summary events into completed response output', () => {
|
||||
it('preserves richer image_generation_call fields while aggregating progress events', () => {
|
||||
const state = createOpenAiResponsesAggregateState('gpt-5');
|
||||
const streamContext = createStreamTransformContext('gpt-5');
|
||||
const usage = {
|
||||
promptTokens: 1,
|
||||
completionTokens: 2,
|
||||
totalTokens: 3,
|
||||
};
|
||||
|
||||
const emitted = [
|
||||
...feedResponsesEvent(state, {
|
||||
type: 'response.output_item.added',
|
||||
output_index: 0,
|
||||
item: {
|
||||
id: 'rs_1',
|
||||
type: 'reasoning',
|
||||
status: 'in_progress',
|
||||
summary: [],
|
||||
serializeConvertedResponsesEvents({
|
||||
state,
|
||||
streamContext,
|
||||
usage,
|
||||
event: {
|
||||
responsesEventType: 'response.image_generation_call.partial_image',
|
||||
responsesPayload: {
|
||||
type: 'response.image_generation_call.partial_image',
|
||||
item_id: 'img_1',
|
||||
output_index: 0,
|
||||
partial_image_index: 0,
|
||||
partial_image_b64: 'partial',
|
||||
background: 'transparent',
|
||||
output_format: 'png',
|
||||
quality: 'high',
|
||||
size: '1024x1024',
|
||||
},
|
||||
}),
|
||||
...feedResponsesEvent(state, {
|
||||
type: 'response.reasoning_summary_part.added',
|
||||
item_id: 'rs_1',
|
||||
output_index: 0,
|
||||
summary_index: 0,
|
||||
part: {
|
||||
type: 'summary_text',
|
||||
text: '',
|
||||
},
|
||||
}),
|
||||
...feedResponsesEvent(state, {
|
||||
type: 'response.reasoning_summary_text.delta',
|
||||
item_id: 'rs_1',
|
||||
output_index: 0,
|
||||
summary_index: 0,
|
||||
delta: 'Plan',
|
||||
}),
|
||||
...feedResponsesEvent(state, {
|
||||
type: 'response.reasoning_summary_text.done',
|
||||
item_id: 'rs_1',
|
||||
output_index: 0,
|
||||
summary_index: 0,
|
||||
text: 'Plan it out',
|
||||
}),
|
||||
...feedResponsesEvent(state, {
|
||||
type: 'response.reasoning_summary_part.done',
|
||||
item_id: 'rs_1',
|
||||
output_index: 0,
|
||||
summary_index: 0,
|
||||
part: {
|
||||
type: 'summary_text',
|
||||
text: 'Plan it out',
|
||||
},
|
||||
}),
|
||||
...feedResponsesEvent(state, {
|
||||
type: 'response.output_item.done',
|
||||
output_index: 0,
|
||||
item: {
|
||||
id: 'rs_1',
|
||||
type: 'reasoning',
|
||||
status: 'completed',
|
||||
summary: [],
|
||||
},
|
||||
}),
|
||||
...feedResponsesEvent(state, {
|
||||
type: 'response.completed',
|
||||
response: {
|
||||
id: 'resp_reasoning',
|
||||
object: 'response',
|
||||
created_at: 1700000000,
|
||||
status: 'completed',
|
||||
model: 'gpt-5',
|
||||
output: [],
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
expect(emitted.some((item) => item.event === 'response.reasoning_summary_part.added')).toBe(true);
|
||||
expect(emitted.some((item) => item.event === 'response.reasoning_summary_text.done')).toBe(true);
|
||||
|
||||
const completed = emitted.find((item) => item.event === 'response.completed');
|
||||
expect(completed?.data.response.output).toEqual([
|
||||
{
|
||||
id: 'rs_1',
|
||||
type: 'reasoning',
|
||||
status: 'completed',
|
||||
summary: [
|
||||
{
|
||||
type: 'summary_text',
|
||||
text: 'Plan it out',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it('tracks custom tool input and image generation results', () => {
|
||||
const state = createOpenAiResponsesAggregateState('gpt-5');
|
||||
|
||||
const emitted = [
|
||||
...feedResponsesEvent(state, {
|
||||
type: 'response.output_item.added',
|
||||
output_index: 0,
|
||||
item: {
|
||||
id: 'ct_1',
|
||||
type: 'custom_tool_call',
|
||||
status: 'in_progress',
|
||||
call_id: 'ct_1',
|
||||
name: 'browser',
|
||||
input: '',
|
||||
const completedLines = serializeConvertedResponsesEvents({
|
||||
state,
|
||||
streamContext,
|
||||
usage,
|
||||
event: {
|
||||
responsesEventType: 'response.image_generation_call.completed',
|
||||
responsesPayload: {
|
||||
type: 'response.image_generation_call.completed',
|
||||
item_id: 'img_1',
|
||||
output_index: 0,
|
||||
result: 'final-image',
|
||||
background: 'transparent',
|
||||
output_format: 'png',
|
||||
quality: 'high',
|
||||
size: '1024x1024',
|
||||
},
|
||||
}),
|
||||
...feedResponsesEvent(state, {
|
||||
type: 'response.custom_tool_call_input.delta',
|
||||
item_id: 'ct_1',
|
||||
output_index: 0,
|
||||
delta: 'open ',
|
||||
}),
|
||||
...feedResponsesEvent(state, {
|
||||
type: 'response.custom_tool_call_input.done',
|
||||
item_id: 'ct_1',
|
||||
output_index: 0,
|
||||
input: 'open example.com',
|
||||
}),
|
||||
...feedResponsesEvent(state, {
|
||||
type: 'response.output_item.done',
|
||||
output_index: 0,
|
||||
item: {
|
||||
id: 'ct_1',
|
||||
type: 'custom_tool_call',
|
||||
status: 'completed',
|
||||
call_id: 'ct_1',
|
||||
name: 'browser',
|
||||
input: 'open example.com',
|
||||
},
|
||||
}),
|
||||
...feedResponsesEvent(state, {
|
||||
type: 'response.output_item.added',
|
||||
output_index: 1,
|
||||
item: {
|
||||
id: 'img_1',
|
||||
type: 'image_generation_call',
|
||||
status: 'in_progress',
|
||||
result: null,
|
||||
},
|
||||
}),
|
||||
...feedResponsesEvent(state, {
|
||||
type: 'response.image_generation_call.partial_image',
|
||||
item_id: 'img_1',
|
||||
output_index: 1,
|
||||
partial_image_index: 0,
|
||||
partial_image_b64: 'abc123',
|
||||
}),
|
||||
...feedResponsesEvent(state, {
|
||||
type: 'response.image_generation_call.completed',
|
||||
item_id: 'img_1',
|
||||
output_index: 1,
|
||||
result: 'data:image/png;base64,final',
|
||||
}),
|
||||
...feedResponsesEvent(state, {
|
||||
type: 'response.output_item.done',
|
||||
output_index: 1,
|
||||
item: {
|
||||
id: 'img_1',
|
||||
type: 'image_generation_call',
|
||||
status: 'completed',
|
||||
result: 'data:image/png;base64,final',
|
||||
},
|
||||
}),
|
||||
...feedResponsesEvent(state, {
|
||||
type: 'response.completed',
|
||||
response: {
|
||||
id: 'resp_tools',
|
||||
object: 'response',
|
||||
created_at: 1700000001,
|
||||
status: 'completed',
|
||||
model: 'gpt-5',
|
||||
output: [],
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
expect(emitted.some((item) => item.event === 'response.custom_tool_call_input.done')).toBe(true);
|
||||
expect(emitted.some((item) => item.event === 'response.image_generation_call.completed')).toBe(true);
|
||||
|
||||
const completed = emitted.find((item) => item.event === 'response.completed');
|
||||
expect(completed?.data.response.output).toEqual([
|
||||
{
|
||||
id: 'ct_1',
|
||||
type: 'custom_tool_call',
|
||||
status: 'completed',
|
||||
call_id: 'ct_1',
|
||||
name: 'browser',
|
||||
input: 'open example.com',
|
||||
},
|
||||
{
|
||||
id: 'img_1',
|
||||
type: 'image_generation_call',
|
||||
status: 'completed',
|
||||
result: 'data:image/png;base64,final',
|
||||
partial_images: [
|
||||
{
|
||||
partial_image_index: 0,
|
||||
partial_image_b64: 'abc123',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
const payloads = parseSsePayloads(completedLines);
|
||||
const imageEvent = payloads.find((item) => item.type === 'response.image_generation_call.completed');
|
||||
expect(imageEvent).toBeTruthy();
|
||||
expect(state.outputItems[0]).toMatchObject({
|
||||
id: 'img_1',
|
||||
type: 'image_generation_call',
|
||||
status: 'completed',
|
||||
result: 'final-image',
|
||||
background: 'transparent',
|
||||
output_format: 'png',
|
||||
quality: 'high',
|
||||
size: '1024x1024',
|
||||
partial_images: [
|
||||
{
|
||||
partial_image_index: 0,
|
||||
partial_image_b64: 'partial',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -407,6 +407,26 @@ function serializeOriginalResponsesEvent(eventType: string, payload: Record<stri
|
||||
return [serializeSse(eventType, payload)];
|
||||
}
|
||||
|
||||
function mergeImageGenerationFields(
|
||||
item: AggregateOutputItem,
|
||||
payload: Record<string, unknown>,
|
||||
): void {
|
||||
const passthroughKeys = [
|
||||
'background',
|
||||
'output_format',
|
||||
'quality',
|
||||
'size',
|
||||
'revised_prompt',
|
||||
'mime_type',
|
||||
] as const;
|
||||
|
||||
for (const key of passthroughKeys) {
|
||||
if (payload[key] !== undefined) {
|
||||
item[key] = cloneJson(payload[key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function computeNovelDelta(existingText: string, incomingDelta: string): string {
|
||||
if (!incomingDelta) return '';
|
||||
if (!existingText) return incomingDelta;
|
||||
@@ -555,6 +575,7 @@ function applyOriginalResponsesPayload(
|
||||
});
|
||||
entry.item.partial_images = partialImages;
|
||||
}
|
||||
mergeImageGenerationFields(entry.item, payload);
|
||||
if (payload.result !== undefined) {
|
||||
entry.item.result = payload.result;
|
||||
}
|
||||
|
||||
@@ -165,6 +165,117 @@ export function normalizeResponsesInputForCompatibility(input: unknown): unknown
|
||||
return input;
|
||||
}
|
||||
|
||||
export function buildResponsesCompatibilityBodies(
|
||||
body: Record<string, unknown>,
|
||||
): Record<string, unknown>[] {
|
||||
const candidates: Record<string, unknown>[] = [];
|
||||
const seen = new Set<string>();
|
||||
try {
|
||||
const originalKey = JSON.stringify(body);
|
||||
if (originalKey) seen.add(originalKey);
|
||||
} catch {
|
||||
// ignore non-serializable bodies
|
||||
}
|
||||
|
||||
const push = (next: Record<string, unknown> | null) => {
|
||||
if (!next) return;
|
||||
let key = '';
|
||||
try {
|
||||
key = JSON.stringify(next);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!key || seen.has(key)) return;
|
||||
seen.add(key);
|
||||
candidates.push(next);
|
||||
};
|
||||
|
||||
push(stripResponsesMetadata(body));
|
||||
push(buildCoreResponsesBody(body));
|
||||
push(buildStrictResponsesBody(body));
|
||||
return candidates;
|
||||
}
|
||||
|
||||
export function buildResponsesCompatibilityHeaderCandidates(
|
||||
headers: Record<string, string>,
|
||||
stream: boolean,
|
||||
): Record<string, string>[] {
|
||||
const candidates: Record<string, string>[] = [];
|
||||
const seen = new Set<string>();
|
||||
const push = (next: Record<string, string>) => {
|
||||
const normalizedEntries = Object.entries(next)
|
||||
.filter(([, value]) => typeof value === 'string' && value.trim().length > 0)
|
||||
.map(([key, value]) => [key.toLowerCase(), value] as const)
|
||||
.sort(([a], [b]) => a.localeCompare(b));
|
||||
const key = JSON.stringify(normalizedEntries);
|
||||
if (!key || seen.has(key)) return;
|
||||
seen.add(key);
|
||||
candidates.push(Object.fromEntries(normalizedEntries));
|
||||
};
|
||||
|
||||
push(headers);
|
||||
|
||||
const minimal: Record<string, string> = {};
|
||||
for (const [rawKey, rawValue] of Object.entries(headers)) {
|
||||
const key = rawKey.toLowerCase();
|
||||
if (
|
||||
key === 'authorization'
|
||||
|| key === 'x-api-key'
|
||||
|| key === 'content-type'
|
||||
|| key === 'accept'
|
||||
) {
|
||||
minimal[key] = rawValue;
|
||||
}
|
||||
}
|
||||
if (!minimal['content-type']) minimal['content-type'] = 'application/json';
|
||||
if (stream && !minimal.accept) minimal.accept = 'text/event-stream';
|
||||
push(minimal);
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
export function shouldRetryResponsesCompatibility(input: {
|
||||
endpoint: string;
|
||||
status: number;
|
||||
rawErrText: string;
|
||||
}): boolean {
|
||||
if (input.endpoint !== 'responses') return false;
|
||||
if (input.status !== 400) return false;
|
||||
const parsedError = parseUpstreamErrorShape(input.rawErrText);
|
||||
const type = parsedError.type.trim().toLowerCase();
|
||||
const code = parsedError.code.trim().toLowerCase();
|
||||
const message = parsedError.message.trim().toLowerCase();
|
||||
const compact = `${type} ${code} ${message}`.trim();
|
||||
const rawCompact = (input.rawErrText || '').toLowerCase();
|
||||
|
||||
if (
|
||||
compact.includes('invalid_api_key')
|
||||
|| compact.includes('authentication')
|
||||
|| compact.includes('unauthorized')
|
||||
|| compact.includes('forbidden')
|
||||
|| compact.includes('insufficient_quota')
|
||||
|| compact.includes('rate_limit')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (type === 'upstream_error' || code === 'upstream_error') return true;
|
||||
if (message === 'upstream_error' || message === 'upstream request failed') return true;
|
||||
if (rawCompact.includes('upstream_error')) return true;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function shouldDowngradeResponsesChatToMessages(
|
||||
endpointPath: string,
|
||||
status: number,
|
||||
upstreamErrorText: string,
|
||||
): boolean {
|
||||
if (!endpointPath.includes('/chat/completions')) return false;
|
||||
if (status < 400 || status >= 500) return false;
|
||||
return /messages\s+is\s+required/i.test(upstreamErrorText);
|
||||
}
|
||||
|
||||
function extractTextFromResponsesContent(content: unknown): string {
|
||||
if (typeof content === 'string') return content.trim();
|
||||
if (!Array.isArray(content)) return '';
|
||||
@@ -216,6 +327,79 @@ function toFiniteNumber(value: unknown): number | null {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
function parseUpstreamErrorShape(rawText: string): {
|
||||
type: string;
|
||||
code: string;
|
||||
message: string;
|
||||
} {
|
||||
try {
|
||||
const parsed = JSON.parse(rawText) as Record<string, unknown>;
|
||||
const error = (parsed.error && typeof parsed.error === 'object')
|
||||
? parsed.error as Record<string, unknown>
|
||||
: parsed;
|
||||
return {
|
||||
type: typeof error.type === 'string' ? error.type.trim().toLowerCase() : '',
|
||||
code: typeof error.code === 'string' ? error.code.trim().toLowerCase() : '',
|
||||
message: typeof error.message === 'string' ? error.message.trim() : '',
|
||||
};
|
||||
} catch {
|
||||
return { type: '', code: '', message: '' };
|
||||
}
|
||||
}
|
||||
|
||||
function stripResponsesMetadata(
|
||||
body: Record<string, unknown>,
|
||||
): Record<string, unknown> | null {
|
||||
if (!Object.prototype.hasOwnProperty.call(body, 'metadata')) return null;
|
||||
const next = { ...body };
|
||||
delete next.metadata;
|
||||
return next;
|
||||
}
|
||||
|
||||
function buildCoreResponsesBody(
|
||||
body: Record<string, unknown>,
|
||||
): Record<string, unknown> | null {
|
||||
const model = typeof body.model === 'string' ? body.model.trim() : '';
|
||||
if (!model) return null;
|
||||
if (body.input === undefined) return null;
|
||||
|
||||
const core: Record<string, unknown> = {
|
||||
model,
|
||||
input: body.input,
|
||||
stream: body.stream === true,
|
||||
};
|
||||
|
||||
const maxOutputTokens = toFiniteNumber(body.max_output_tokens);
|
||||
if (maxOutputTokens !== null && maxOutputTokens > 0) {
|
||||
core.max_output_tokens = Math.trunc(maxOutputTokens);
|
||||
}
|
||||
|
||||
const temperature = toFiniteNumber(body.temperature);
|
||||
if (temperature !== null) core.temperature = temperature;
|
||||
|
||||
const topP = toFiniteNumber(body.top_p);
|
||||
if (topP !== null) core.top_p = topP;
|
||||
|
||||
const instructions = typeof body.instructions === 'string' ? body.instructions.trim() : '';
|
||||
if (instructions) core.instructions = instructions;
|
||||
|
||||
return core;
|
||||
}
|
||||
|
||||
function buildStrictResponsesBody(
|
||||
body: Record<string, unknown>,
|
||||
): Record<string, unknown> | null {
|
||||
const model = typeof body.model === 'string' ? body.model.trim() : '';
|
||||
if (!model) return null;
|
||||
if (body.input === undefined) return null;
|
||||
|
||||
return {
|
||||
model,
|
||||
input: body.input,
|
||||
stream: body.stream === true,
|
||||
};
|
||||
}
|
||||
|
||||
const ALLOWED_RESPONSES_FIELDS = new Set([
|
||||
'model',
|
||||
'input',
|
||||
|
||||
@@ -19,6 +19,7 @@ describe('sanitizeResponsesBodyForProxy', () => {
|
||||
stream_options: { include_obfuscation: true },
|
||||
background: true,
|
||||
text: { format: { type: 'text' }, verbosity: 'high' },
|
||||
top_logprobs: 2,
|
||||
},
|
||||
'gpt-5',
|
||||
true,
|
||||
@@ -34,6 +35,48 @@ describe('sanitizeResponsesBodyForProxy', () => {
|
||||
stream_options: { include_obfuscation: true },
|
||||
background: true,
|
||||
text: { format: { type: 'text' }, verbosity: 'high' },
|
||||
top_logprobs: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes current Responses inbound parity fields', () => {
|
||||
const result = sanitizeResponsesBodyForProxy(
|
||||
{
|
||||
input: 'hello',
|
||||
safety_identifier: ' safe-user-4 ',
|
||||
max_tool_calls: '5',
|
||||
prompt_cache_key: ' cache-key-2 ',
|
||||
prompt_cache_retention: ' 24h ',
|
||||
stream_options: { include_obfuscation: 'true', extra: 'keep-me' },
|
||||
background: 'false',
|
||||
text: { format: { type: 'text' }, verbosity: ' high ' },
|
||||
truncation: ' auto ',
|
||||
previous_response_id: ' resp_prev_2 ',
|
||||
include: [' reasoning.encrypted_content ', '', 123, 'message.input_image.image_url'],
|
||||
top_logprobs: '7',
|
||||
user: ' user-456 ',
|
||||
service_tier: ' priority ',
|
||||
},
|
||||
'gpt-5',
|
||||
false,
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
model: 'gpt-5',
|
||||
stream: false,
|
||||
safety_identifier: 'safe-user-4',
|
||||
max_tool_calls: 5,
|
||||
prompt_cache_key: 'cache-key-2',
|
||||
prompt_cache_retention: '24h',
|
||||
stream_options: { include_obfuscation: true, extra: 'keep-me' },
|
||||
background: false,
|
||||
text: { format: { type: 'text' }, verbosity: 'high' },
|
||||
truncation: 'auto',
|
||||
previous_response_id: 'resp_prev_2',
|
||||
include: ['reasoning.encrypted_content', 'message.input_image.image_url'],
|
||||
top_logprobs: 7,
|
||||
user: 'user-456',
|
||||
service_tier: 'priority',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -94,6 +137,92 @@ describe('convertOpenAiBodyToResponsesBody', () => {
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('maps OpenAI response_format into Responses text.format while preserving verbosity', () => {
|
||||
const result = convertOpenAiBodyToResponsesBody(
|
||||
{
|
||||
model: 'gpt-5',
|
||||
messages: [{ role: 'user', content: 'return structured data' }],
|
||||
response_format: {
|
||||
type: 'json_schema',
|
||||
json_schema: {
|
||||
name: 'payload',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string' },
|
||||
},
|
||||
required: ['name'],
|
||||
},
|
||||
},
|
||||
},
|
||||
verbosity: 'high',
|
||||
},
|
||||
'gpt-5',
|
||||
false,
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
text: {
|
||||
format: {
|
||||
type: 'json_schema',
|
||||
json_schema: {
|
||||
name: 'payload',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string' },
|
||||
},
|
||||
required: ['name'],
|
||||
},
|
||||
},
|
||||
},
|
||||
verbosity: 'high',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes and preserves field parity when converting from OpenAI-compatible input', () => {
|
||||
const result = convertOpenAiBodyToResponsesBody(
|
||||
{
|
||||
model: 'gpt-5',
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
safety_identifier: ' safe-user-5 ',
|
||||
max_tool_calls: '6',
|
||||
prompt_cache_key: ' cache-key-3 ',
|
||||
prompt_cache_retention: ' in-memory ',
|
||||
stream_options: { include_obfuscation: 'false' },
|
||||
background: 'true',
|
||||
verbosity: ' medium ',
|
||||
truncation: ' disabled ',
|
||||
previous_response_id: ' resp_prev_3 ',
|
||||
include: 'reasoning.encrypted_content',
|
||||
top_logprobs: '4',
|
||||
user: ' user-789 ',
|
||||
service_tier: ' flex ',
|
||||
},
|
||||
'gpt-5',
|
||||
true,
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
model: 'gpt-5',
|
||||
stream: true,
|
||||
safety_identifier: 'safe-user-5',
|
||||
max_tool_calls: 6,
|
||||
prompt_cache_key: 'cache-key-3',
|
||||
prompt_cache_retention: 'in-memory',
|
||||
stream_options: { include_obfuscation: false },
|
||||
background: true,
|
||||
text: { verbosity: 'medium' },
|
||||
truncation: 'disabled',
|
||||
previous_response_id: 'resp_prev_3',
|
||||
include: ['reasoning.encrypted_content'],
|
||||
top_logprobs: 4,
|
||||
user: 'user-789',
|
||||
service_tier: 'flex',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertResponsesBodyToOpenAiBody', () => {
|
||||
@@ -258,4 +387,88 @@ describe('convertResponsesBodyToOpenAiBody', () => {
|
||||
top_logprobs: 4,
|
||||
});
|
||||
});
|
||||
|
||||
it('maps Responses text.format back into OpenAI response_format', () => {
|
||||
const result = convertResponsesBodyToOpenAiBody(
|
||||
{
|
||||
model: 'gpt-5',
|
||||
input: 'hello',
|
||||
text: {
|
||||
format: {
|
||||
type: 'json_schema',
|
||||
json_schema: {
|
||||
name: 'payload',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
value: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
verbosity: 'medium',
|
||||
},
|
||||
},
|
||||
'gpt-5',
|
||||
false,
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
response_format: {
|
||||
type: 'json_schema',
|
||||
json_schema: {
|
||||
name: 'payload',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
value: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
verbosity: 'medium',
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes field parity when converting Responses input back to OpenAI-compatible input', () => {
|
||||
const result = convertResponsesBodyToOpenAiBody(
|
||||
{
|
||||
model: 'gpt-5',
|
||||
input: 'hello',
|
||||
safety_identifier: ' safe-user-6 ',
|
||||
max_tool_calls: '8',
|
||||
prompt_cache_key: ' cache-key-4 ',
|
||||
prompt_cache_retention: ' 24h ',
|
||||
stream_options: { include_obfuscation: 'true' },
|
||||
background: 'false',
|
||||
text: { verbosity: ' low ' },
|
||||
truncation: ' auto ',
|
||||
previous_response_id: ' resp_prev_4 ',
|
||||
include: [' reasoning.encrypted_content ', ''],
|
||||
top_logprobs: '9',
|
||||
user: ' user-999 ',
|
||||
service_tier: ' default ',
|
||||
},
|
||||
'gpt-5',
|
||||
false,
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
model: 'gpt-5',
|
||||
stream: false,
|
||||
safety_identifier: 'safe-user-6',
|
||||
max_tool_calls: 8,
|
||||
prompt_cache_key: 'cache-key-4',
|
||||
prompt_cache_retention: '24h',
|
||||
stream_options: { include_obfuscation: true },
|
||||
background: false,
|
||||
verbosity: 'low',
|
||||
truncation: 'auto',
|
||||
previous_response_id: 'resp_prev_4',
|
||||
include: ['reasoning.encrypted_content'],
|
||||
top_logprobs: 9,
|
||||
user: 'user-999',
|
||||
service_tier: 'default',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,29 @@ function toFiniteNumber(value: unknown): number | null {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
function toFiniteIntegerLike(value: unknown): number | null {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return Math.trunc(value);
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
const parsed = Number(trimmed);
|
||||
return Number.isFinite(parsed) ? Math.trunc(parsed) : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function toBooleanLike(value: unknown): boolean | undefined {
|
||||
if (typeof value === 'boolean') return value;
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim().toLowerCase();
|
||||
if (trimmed === 'true') return true;
|
||||
if (trimmed === 'false') return false;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function safeJsonStringify(value: unknown): string {
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
@@ -40,6 +63,103 @@ function cloneJsonValue<T>(value: T): T {
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeOptionalTrimmedString(value: unknown): string | undefined {
|
||||
const trimmed = asTrimmedString(value);
|
||||
return trimmed || undefined;
|
||||
}
|
||||
|
||||
function normalizeIncludeList(value: unknown): unknown {
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? [trimmed] : [];
|
||||
}
|
||||
|
||||
if (!Array.isArray(value)) return value;
|
||||
|
||||
return value
|
||||
.map((item) => (typeof item === 'string' ? item.trim() : ''))
|
||||
.filter((item) => item.length > 0);
|
||||
}
|
||||
|
||||
function normalizeTextConfig(
|
||||
rawText: unknown,
|
||||
fallbackVerbosity?: unknown,
|
||||
): Record<string, unknown> | undefined {
|
||||
const textConfig = cloneRecord(rawText) || {};
|
||||
const verbosity = (
|
||||
normalizeOptionalTrimmedString(textConfig.verbosity)
|
||||
?? normalizeOptionalTrimmedString(fallbackVerbosity)
|
||||
);
|
||||
if (verbosity) {
|
||||
textConfig.verbosity = verbosity;
|
||||
}
|
||||
return Object.keys(textConfig).length > 0 ? textConfig : undefined;
|
||||
}
|
||||
|
||||
function normalizeStreamOptions(rawStreamOptions: unknown): unknown {
|
||||
const streamOptions = cloneRecord(rawStreamOptions);
|
||||
if (!streamOptions) return rawStreamOptions;
|
||||
|
||||
const includeObfuscation = toBooleanLike(streamOptions.include_obfuscation);
|
||||
if (includeObfuscation !== undefined) {
|
||||
streamOptions.include_obfuscation = includeObfuscation;
|
||||
}
|
||||
|
||||
return streamOptions;
|
||||
}
|
||||
|
||||
function normalizeResponsesRequestFieldParity(
|
||||
body: Record<string, unknown>,
|
||||
options?: { verbositySource?: unknown },
|
||||
): Record<string, unknown> {
|
||||
const normalized: Record<string, unknown> = { ...body };
|
||||
|
||||
const safetyIdentifier = normalizeOptionalTrimmedString(normalized.safety_identifier);
|
||||
if (safetyIdentifier) normalized.safety_identifier = safetyIdentifier;
|
||||
|
||||
const maxToolCalls = toFiniteIntegerLike(normalized.max_tool_calls);
|
||||
if (maxToolCalls !== null) normalized.max_tool_calls = maxToolCalls;
|
||||
|
||||
const promptCacheKey = normalizeOptionalTrimmedString(normalized.prompt_cache_key);
|
||||
if (promptCacheKey) normalized.prompt_cache_key = promptCacheKey;
|
||||
|
||||
const promptCacheRetention = normalizeOptionalTrimmedString(normalized.prompt_cache_retention);
|
||||
if (promptCacheRetention) normalized.prompt_cache_retention = promptCacheRetention;
|
||||
|
||||
const background = toBooleanLike(normalized.background);
|
||||
if (background !== undefined) normalized.background = background;
|
||||
|
||||
const user = normalizeOptionalTrimmedString(normalized.user);
|
||||
if (user) normalized.user = user;
|
||||
|
||||
const previousResponseId = normalizeOptionalTrimmedString(normalized.previous_response_id);
|
||||
if (previousResponseId) normalized.previous_response_id = previousResponseId;
|
||||
|
||||
const truncation = normalizeOptionalTrimmedString(normalized.truncation);
|
||||
if (truncation) normalized.truncation = truncation;
|
||||
|
||||
const serviceTier = normalizeOptionalTrimmedString(normalized.service_tier);
|
||||
if (serviceTier) normalized.service_tier = serviceTier;
|
||||
|
||||
const topLogprobs = toFiniteIntegerLike(normalized.top_logprobs);
|
||||
if (topLogprobs !== null) normalized.top_logprobs = topLogprobs;
|
||||
|
||||
if (normalized.include !== undefined) {
|
||||
normalized.include = normalizeIncludeList(normalized.include);
|
||||
}
|
||||
|
||||
if (normalized.stream_options !== undefined) {
|
||||
normalized.stream_options = normalizeStreamOptions(normalized.stream_options);
|
||||
}
|
||||
|
||||
const textConfig = normalizeTextConfig(normalized.text, options?.verbositySource);
|
||||
if (textConfig) {
|
||||
normalized.text = textConfig;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function parseJsonString(raw: string): unknown {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return {};
|
||||
@@ -219,6 +339,7 @@ const ALLOWED_RESPONSES_FIELDS = new Set([
|
||||
'include',
|
||||
'response_format',
|
||||
'service_tier',
|
||||
'top_logprobs',
|
||||
'stop',
|
||||
'n',
|
||||
]);
|
||||
@@ -250,6 +371,10 @@ export function sanitizeResponsesBodyForProxy(
|
||||
}
|
||||
}
|
||||
|
||||
normalized = normalizeResponsesRequestFieldParity(normalized, {
|
||||
verbositySource: body.verbosity,
|
||||
});
|
||||
|
||||
const sanitized: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(normalized)) {
|
||||
if (!ALLOWED_RESPONSES_FIELDS.has(key)) continue;
|
||||
@@ -388,23 +513,23 @@ export function convertOpenAiBodyToResponsesBody(
|
||||
if (openaiBody.service_tier !== undefined) body.service_tier = openaiBody.service_tier;
|
||||
if (openaiBody.top_logprobs !== undefined) body.top_logprobs = openaiBody.top_logprobs;
|
||||
if (openaiBody.stream_options !== undefined) body.stream_options = openaiBody.stream_options;
|
||||
|
||||
const textConfig = cloneRecord(openaiBody.text) || {};
|
||||
const verbosity = (
|
||||
asTrimmedString(openaiBody.verbosity)
|
||||
|| asTrimmedString(isRecord(openaiBody.text) ? openaiBody.text.verbosity : undefined)
|
||||
);
|
||||
if (verbosity) {
|
||||
textConfig.verbosity = verbosity;
|
||||
if (openaiBody.response_format !== undefined) {
|
||||
const existingTextConfig = cloneRecord(body.text) || {};
|
||||
existingTextConfig.format = cloneJsonValue(openaiBody.response_format);
|
||||
body.text = existingTextConfig;
|
||||
}
|
||||
if (Object.keys(textConfig).length > 0) {
|
||||
|
||||
const textConfig = normalizeTextConfig(body.text, openaiBody.verbosity);
|
||||
if (textConfig) {
|
||||
body.text = textConfig;
|
||||
}
|
||||
|
||||
const responsesToolChoice = convertOpenAiToolChoiceToResponses(openaiBody.tool_choice);
|
||||
if (responsesToolChoice !== undefined) body.tool_choice = responsesToolChoice;
|
||||
|
||||
return normalizeResponsesBodyForCompatibility(body);
|
||||
return normalizeResponsesBodyForCompatibility(
|
||||
normalizeResponsesRequestFieldParity(body, { verbositySource: openaiBody.verbosity }),
|
||||
);
|
||||
}
|
||||
|
||||
type OpenAiToolCall = {
|
||||
@@ -560,8 +685,9 @@ export function convertResponsesBodyToOpenAiBody(
|
||||
modelName: string,
|
||||
stream: boolean,
|
||||
): Record<string, unknown> {
|
||||
const normalizedBody = normalizeResponsesRequestFieldParity(body);
|
||||
const messages: Array<Record<string, unknown>> = [];
|
||||
const input = body.input;
|
||||
const input = normalizedBody.input;
|
||||
let functionCallIndex = 0;
|
||||
let pendingToolCalls: OpenAiToolCall[] = [];
|
||||
|
||||
@@ -655,7 +781,7 @@ export function convertResponsesBodyToOpenAiBody(
|
||||
}
|
||||
flushPendingToolCalls();
|
||||
|
||||
const instructions = asTrimmedString(body.instructions);
|
||||
const instructions = asTrimmedString(normalizedBody.instructions);
|
||||
if (instructions) {
|
||||
messages.unshift({ role: 'system', content: instructions });
|
||||
}
|
||||
@@ -666,32 +792,35 @@ export function convertResponsesBodyToOpenAiBody(
|
||||
messages,
|
||||
};
|
||||
|
||||
if (typeof body.temperature === 'number' && Number.isFinite(body.temperature)) {
|
||||
payload.temperature = body.temperature;
|
||||
if (typeof normalizedBody.temperature === 'number' && Number.isFinite(normalizedBody.temperature)) {
|
||||
payload.temperature = normalizedBody.temperature;
|
||||
}
|
||||
if (typeof body.top_p === 'number' && Number.isFinite(body.top_p)) {
|
||||
payload.top_p = body.top_p;
|
||||
if (typeof normalizedBody.top_p === 'number' && Number.isFinite(normalizedBody.top_p)) {
|
||||
payload.top_p = normalizedBody.top_p;
|
||||
}
|
||||
if (typeof body.max_output_tokens === 'number' && Number.isFinite(body.max_output_tokens)) {
|
||||
payload.max_tokens = body.max_output_tokens;
|
||||
if (typeof normalizedBody.max_output_tokens === 'number' && Number.isFinite(normalizedBody.max_output_tokens)) {
|
||||
payload.max_tokens = normalizedBody.max_output_tokens;
|
||||
}
|
||||
if (body.parallel_tool_calls !== undefined) payload.parallel_tool_calls = body.parallel_tool_calls;
|
||||
if (body.tools !== undefined) payload.tools = convertResponsesToolsToOpenAi(body.tools);
|
||||
if (body.tool_choice !== undefined) payload.tool_choice = convertResponsesToolChoiceToOpenAi(body.tool_choice);
|
||||
if (body.safety_identifier !== undefined) payload.safety_identifier = body.safety_identifier;
|
||||
if (body.max_tool_calls !== undefined) payload.max_tool_calls = body.max_tool_calls;
|
||||
if (body.prompt_cache_key !== undefined) payload.prompt_cache_key = body.prompt_cache_key;
|
||||
if (body.prompt_cache_retention !== undefined) payload.prompt_cache_retention = body.prompt_cache_retention;
|
||||
if (body.background !== undefined) payload.background = body.background;
|
||||
if (body.user !== undefined) payload.user = body.user;
|
||||
if (body.include !== undefined) payload.include = cloneJsonValue(body.include);
|
||||
if (body.previous_response_id !== undefined) payload.previous_response_id = body.previous_response_id;
|
||||
if (body.truncation !== undefined) payload.truncation = body.truncation;
|
||||
if (body.service_tier !== undefined) payload.service_tier = body.service_tier;
|
||||
if (body.top_logprobs !== undefined) payload.top_logprobs = body.top_logprobs;
|
||||
if (body.stream_options !== undefined) payload.stream_options = body.stream_options;
|
||||
if (isRecord(body.text) && asTrimmedString(body.text.verbosity)) {
|
||||
payload.verbosity = asTrimmedString(body.text.verbosity);
|
||||
if (normalizedBody.parallel_tool_calls !== undefined) payload.parallel_tool_calls = normalizedBody.parallel_tool_calls;
|
||||
if (normalizedBody.tools !== undefined) payload.tools = convertResponsesToolsToOpenAi(normalizedBody.tools);
|
||||
if (normalizedBody.tool_choice !== undefined) payload.tool_choice = convertResponsesToolChoiceToOpenAi(normalizedBody.tool_choice);
|
||||
if (normalizedBody.safety_identifier !== undefined) payload.safety_identifier = normalizedBody.safety_identifier;
|
||||
if (normalizedBody.max_tool_calls !== undefined) payload.max_tool_calls = normalizedBody.max_tool_calls;
|
||||
if (normalizedBody.prompt_cache_key !== undefined) payload.prompt_cache_key = normalizedBody.prompt_cache_key;
|
||||
if (normalizedBody.prompt_cache_retention !== undefined) payload.prompt_cache_retention = normalizedBody.prompt_cache_retention;
|
||||
if (normalizedBody.background !== undefined) payload.background = normalizedBody.background;
|
||||
if (normalizedBody.user !== undefined) payload.user = normalizedBody.user;
|
||||
if (normalizedBody.include !== undefined) payload.include = cloneJsonValue(normalizedBody.include);
|
||||
if (normalizedBody.previous_response_id !== undefined) payload.previous_response_id = normalizedBody.previous_response_id;
|
||||
if (normalizedBody.truncation !== undefined) payload.truncation = normalizedBody.truncation;
|
||||
if (normalizedBody.service_tier !== undefined) payload.service_tier = normalizedBody.service_tier;
|
||||
if (normalizedBody.top_logprobs !== undefined) payload.top_logprobs = normalizedBody.top_logprobs;
|
||||
if (normalizedBody.stream_options !== undefined) payload.stream_options = normalizedBody.stream_options;
|
||||
if (isRecord(normalizedBody.text) && normalizedBody.text.format !== undefined) {
|
||||
payload.response_format = cloneJsonValue(normalizedBody.text.format);
|
||||
}
|
||||
if (isRecord(normalizedBody.text) && asTrimmedString(normalizedBody.text.verbosity)) {
|
||||
payload.verbosity = asTrimmedString(normalizedBody.text.verbosity);
|
||||
}
|
||||
|
||||
return payload;
|
||||
|
||||
@@ -2,11 +2,17 @@ import { type StreamTransformContext } from '../../shared/normalized.js';
|
||||
import {
|
||||
convertOpenAiBodyToResponsesBody,
|
||||
convertResponsesBodyToOpenAiBody,
|
||||
normalizeResponsesInputForCompatibility,
|
||||
normalizeResponsesMessageContent,
|
||||
sanitizeResponsesBodyForProxy,
|
||||
} from './conversion.js';
|
||||
import { normalizeResponsesMessageItem } from './compatibility.js';
|
||||
import {
|
||||
buildResponsesCompatibilityBodies as buildRetryBodies,
|
||||
buildResponsesCompatibilityHeaderCandidates as buildRetryHeaders,
|
||||
normalizeResponsesInputForCompatibility,
|
||||
normalizeResponsesMessageContent,
|
||||
normalizeResponsesMessageItem,
|
||||
shouldDowngradeResponsesChatToMessages as shouldDowngradeChatToMessages,
|
||||
shouldRetryResponsesCompatibility as shouldRetry,
|
||||
} from './compatibility.js';
|
||||
import {
|
||||
type OpenAiResponsesAggregateState,
|
||||
completeResponsesStream,
|
||||
@@ -31,6 +37,12 @@ export const openAiResponsesTransformer = {
|
||||
outbound: openAiResponsesOutbound,
|
||||
stream: openAiResponsesStream,
|
||||
usage: openAiResponsesUsage,
|
||||
compatibility: {
|
||||
buildRetryBodies,
|
||||
buildRetryHeaders,
|
||||
shouldRetry,
|
||||
shouldDowngradeChatToMessages,
|
||||
},
|
||||
aggregator: {
|
||||
createState: createOpenAiResponsesAggregateState,
|
||||
serialize: serializeConvertedResponsesEvents,
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { serializeResponsesFinalPayload } from './outbound.js';
|
||||
|
||||
describe('serializeResponsesFinalPayload', () => {
|
||||
it('preserves top-level chat annotations on synthetic assistant messages', () => {
|
||||
const payload = serializeResponsesFinalPayload({
|
||||
upstreamPayload: {
|
||||
id: 'chatcmpl_1',
|
||||
object: 'chat.completion',
|
||||
created: 1700000000,
|
||||
model: 'gpt-5',
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
finish_reason: 'stop',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: 'hello',
|
||||
annotations: [
|
||||
{
|
||||
type: 'url_citation',
|
||||
url: 'https://example.com',
|
||||
title: 'Example',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
normalized: {
|
||||
id: 'chatcmpl_1',
|
||||
model: 'gpt-5',
|
||||
created: 1700000000,
|
||||
content: 'hello',
|
||||
reasoningContent: '',
|
||||
finishReason: 'stop',
|
||||
toolCalls: [],
|
||||
},
|
||||
usage: {
|
||||
promptTokens: 1,
|
||||
completionTokens: 2,
|
||||
totalTokens: 3,
|
||||
},
|
||||
});
|
||||
|
||||
expect(payload.output).toEqual([
|
||||
{
|
||||
id: 'msg_chatcmpl_1',
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
status: 'completed',
|
||||
content: [
|
||||
{
|
||||
type: 'output_text',
|
||||
text: 'hello',
|
||||
annotations: [
|
||||
{
|
||||
type: 'url_citation',
|
||||
url: 'https://example.com',
|
||||
title: 'Example',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('falls back to normalized tool calls when upstream payload no longer exposes them directly', () => {
|
||||
const payload = serializeResponsesFinalPayload({
|
||||
upstreamPayload: {
|
||||
id: 'opaque_1',
|
||||
model: 'gpt-5',
|
||||
},
|
||||
normalized: {
|
||||
id: 'opaque_1',
|
||||
model: 'gpt-5',
|
||||
created: 1700000000,
|
||||
content: '',
|
||||
reasoningContent: '',
|
||||
finishReason: 'tool_calls',
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'call_1',
|
||||
name: 'browser',
|
||||
arguments: '{"url":"https://example.com"}',
|
||||
},
|
||||
],
|
||||
},
|
||||
usage: {
|
||||
promptTokens: 1,
|
||||
completionTokens: 2,
|
||||
totalTokens: 3,
|
||||
},
|
||||
});
|
||||
|
||||
expect(payload.output).toEqual([
|
||||
{
|
||||
id: 'call_1',
|
||||
type: 'function_call',
|
||||
status: 'completed',
|
||||
call_id: 'call_1',
|
||||
name: 'browser',
|
||||
arguments: '{"url":"https://example.com"}',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves response-like custom tool and image generation items when synthesizing object=response payloads', () => {
|
||||
const payload = serializeResponsesFinalPayload({
|
||||
upstreamPayload: {
|
||||
id: 'resp_like_1',
|
||||
model: 'gpt-5',
|
||||
output: [
|
||||
{
|
||||
id: 'ct_1',
|
||||
type: 'custom_tool_call',
|
||||
status: 'completed',
|
||||
call_id: 'ct_1',
|
||||
name: 'browser',
|
||||
input: 'open example.com',
|
||||
},
|
||||
{
|
||||
id: 'img_1',
|
||||
type: 'image_generation_call',
|
||||
status: 'completed',
|
||||
result: 'data:image/png;base64,final',
|
||||
background: 'transparent',
|
||||
output_format: 'png',
|
||||
quality: 'high',
|
||||
size: '1024x1024',
|
||||
partial_images: [
|
||||
{
|
||||
partial_image_index: 0,
|
||||
partial_image_b64: 'partial',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
normalized: {
|
||||
id: 'resp_like_1',
|
||||
model: 'gpt-5',
|
||||
created: 1700000000,
|
||||
content: '',
|
||||
reasoningContent: '',
|
||||
finishReason: 'stop',
|
||||
toolCalls: [],
|
||||
},
|
||||
usage: {
|
||||
promptTokens: 1,
|
||||
completionTokens: 2,
|
||||
totalTokens: 3,
|
||||
},
|
||||
});
|
||||
|
||||
expect(payload.output).toEqual([
|
||||
{
|
||||
id: 'ct_1',
|
||||
type: 'custom_tool_call',
|
||||
status: 'completed',
|
||||
call_id: 'ct_1',
|
||||
name: 'browser',
|
||||
input: 'open example.com',
|
||||
},
|
||||
{
|
||||
id: 'img_1',
|
||||
type: 'image_generation_call',
|
||||
status: 'completed',
|
||||
result: 'data:image/png;base64,final',
|
||||
background: 'transparent',
|
||||
output_format: 'png',
|
||||
quality: 'high',
|
||||
size: '1024x1024',
|
||||
partial_images: [
|
||||
{
|
||||
partial_image_index: 0,
|
||||
partial_image_b64: 'partial',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('maps upstream chat-completion usage details into Responses usage details', () => {
|
||||
const payload = serializeResponsesFinalPayload({
|
||||
upstreamPayload: {
|
||||
id: 'chatcmpl_usage',
|
||||
object: 'chat.completion',
|
||||
created: 1700000000,
|
||||
model: 'gpt-5',
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
finish_reason: 'stop',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: 'hello',
|
||||
},
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
prompt_tokens: 11,
|
||||
completion_tokens: 7,
|
||||
total_tokens: 18,
|
||||
prompt_tokens_details: {
|
||||
cached_tokens: 5,
|
||||
audio_tokens: 2,
|
||||
},
|
||||
completion_tokens_details: {
|
||||
reasoning_tokens: 3,
|
||||
audio_tokens: 1,
|
||||
accepted_prediction_tokens: 4,
|
||||
rejected_prediction_tokens: 6,
|
||||
},
|
||||
},
|
||||
},
|
||||
normalized: {
|
||||
id: 'chatcmpl_usage',
|
||||
model: 'gpt-5',
|
||||
created: 1700000000,
|
||||
content: 'hello',
|
||||
reasoningContent: '',
|
||||
finishReason: 'stop',
|
||||
toolCalls: [],
|
||||
},
|
||||
usage: {
|
||||
promptTokens: 11,
|
||||
completionTokens: 7,
|
||||
totalTokens: 18,
|
||||
},
|
||||
});
|
||||
|
||||
expect(payload.usage).toMatchObject({
|
||||
input_tokens: 11,
|
||||
output_tokens: 7,
|
||||
total_tokens: 18,
|
||||
input_tokens_details: {
|
||||
cached_tokens: 5,
|
||||
audio_tokens: 2,
|
||||
},
|
||||
output_tokens_details: {
|
||||
reasoning_tokens: 3,
|
||||
audio_tokens: 1,
|
||||
accepted_prediction_tokens: 4,
|
||||
rejected_prediction_tokens: 6,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,18 @@ function asTrimmedString(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function cloneJson<T>(value: T): T {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => cloneJson(item)) as T;
|
||||
}
|
||||
if (isRecord(value)) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, item]) => [key, cloneJson(item)]),
|
||||
) as T;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function ensureResponseId(rawId: string): string {
|
||||
const trimmed = rawId.trim() || `resp_${Date.now()}`;
|
||||
return trimmed.startsWith('resp_') ? trimmed : `resp_${trimmed}`;
|
||||
@@ -41,6 +53,8 @@ export type ResponsesUsageSummary = {
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
inputTokensDetails?: Record<string, unknown>;
|
||||
outputTokensDetails?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type ResponsesToolCall = {
|
||||
@@ -49,6 +63,8 @@ export type ResponsesToolCall = {
|
||||
arguments: string;
|
||||
};
|
||||
|
||||
type ResponsesOutputItem = Record<string, unknown>;
|
||||
|
||||
function extractToolCallsFromUpstream(payload: unknown): ResponsesToolCall[] {
|
||||
if (!isRecord(payload)) return [];
|
||||
|
||||
@@ -95,6 +111,166 @@ function extractToolCallsFromUpstream(payload: unknown): ResponsesToolCall[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
function extractToolCallsFromNormalized(normalized: NormalizedFinalResponse): ResponsesToolCall[] {
|
||||
return Array.isArray(normalized.toolCalls)
|
||||
? normalized.toolCalls
|
||||
.map((item) => {
|
||||
const id = ensureFunctionCallId(asTrimmedString(item.id));
|
||||
const name = asTrimmedString(item.name);
|
||||
if (!name) return null;
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
arguments: typeof item.arguments === 'string' ? item.arguments : '',
|
||||
};
|
||||
})
|
||||
.filter((item): item is ResponsesToolCall => !!item)
|
||||
: [];
|
||||
}
|
||||
|
||||
function extractAnnotationsFromUpstream(payload: unknown): unknown[] {
|
||||
if (!isRecord(payload)) return [];
|
||||
|
||||
if (Array.isArray(payload.choices)) {
|
||||
const choice = payload.choices[0];
|
||||
const message = isRecord((choice as any)?.message) ? (choice as any).message : {};
|
||||
const annotations = Array.isArray((message as any).annotations) ? (message as any).annotations : [];
|
||||
return annotations.map((item) => cloneJson(item));
|
||||
}
|
||||
|
||||
if (payload.type === 'message' && Array.isArray(payload.content)) {
|
||||
const annotations = payload.content
|
||||
.filter((item): item is Record<string, unknown> => isRecord(item))
|
||||
.flatMap((item) => (Array.isArray(item.annotations) ? item.annotations : []));
|
||||
return annotations.map((item) => cloneJson(item));
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function extractSyntheticOutputItemsFromUpstream(payload: unknown): ResponsesOutputItem[] {
|
||||
if (!isRecord(payload) || !Array.isArray(payload.output)) return [];
|
||||
|
||||
return payload.output
|
||||
.map((rawItem): ResponsesOutputItem | null => {
|
||||
if (!isRecord(rawItem)) return null;
|
||||
const itemType = asTrimmedString(rawItem.type).toLowerCase();
|
||||
if (!itemType) return null;
|
||||
|
||||
if (itemType === 'message') {
|
||||
return {
|
||||
id: asTrimmedString(rawItem.id) || ensureMessageId(''),
|
||||
type: 'message',
|
||||
role: asTrimmedString(rawItem.role) || 'assistant',
|
||||
status: asTrimmedString(rawItem.status) || 'completed',
|
||||
content: Array.isArray(rawItem.content) ? cloneJson(rawItem.content) : [],
|
||||
};
|
||||
}
|
||||
|
||||
if (itemType === 'reasoning') {
|
||||
return {
|
||||
id: asTrimmedString(rawItem.id) || ensureMessageId(''),
|
||||
type: 'reasoning',
|
||||
status: asTrimmedString(rawItem.status) || 'completed',
|
||||
encrypted_content: rawItem.encrypted_content,
|
||||
summary: Array.isArray(rawItem.summary) ? cloneJson(rawItem.summary) : [],
|
||||
};
|
||||
}
|
||||
|
||||
if (itemType === 'function_call') {
|
||||
return {
|
||||
id: asTrimmedString(rawItem.id) || ensureFunctionCallId(''),
|
||||
type: 'function_call',
|
||||
status: asTrimmedString(rawItem.status) || 'completed',
|
||||
call_id: asTrimmedString(rawItem.call_id) || ensureFunctionCallId(asTrimmedString(rawItem.id)),
|
||||
name: asTrimmedString(rawItem.name),
|
||||
arguments: typeof rawItem.arguments === 'string' ? rawItem.arguments : '',
|
||||
};
|
||||
}
|
||||
|
||||
if (itemType === 'custom_tool_call') {
|
||||
return {
|
||||
id: asTrimmedString(rawItem.id) || ensureFunctionCallId(''),
|
||||
type: 'custom_tool_call',
|
||||
status: asTrimmedString(rawItem.status) || 'completed',
|
||||
call_id: asTrimmedString(rawItem.call_id) || ensureFunctionCallId(asTrimmedString(rawItem.id)),
|
||||
name: asTrimmedString(rawItem.name),
|
||||
input: typeof rawItem.input === 'string' ? rawItem.input : '',
|
||||
};
|
||||
}
|
||||
|
||||
if (itemType === 'function_call_output' || itemType === 'custom_tool_call_output') {
|
||||
return {
|
||||
id: asTrimmedString(rawItem.id) || ensureMessageId(''),
|
||||
type: itemType,
|
||||
status: asTrimmedString(rawItem.status) || 'completed',
|
||||
call_id: asTrimmedString(rawItem.call_id),
|
||||
output: cloneJson(rawItem.output),
|
||||
};
|
||||
}
|
||||
|
||||
if (itemType === 'image_generation_call') {
|
||||
return {
|
||||
id: asTrimmedString(rawItem.id) || ensureMessageId(''),
|
||||
type: 'image_generation_call',
|
||||
status: asTrimmedString(rawItem.status) || 'completed',
|
||||
result: cloneJson(rawItem.result),
|
||||
partial_images: Array.isArray(rawItem.partial_images) ? cloneJson(rawItem.partial_images) : [],
|
||||
...(rawItem.background !== undefined ? { background: cloneJson(rawItem.background) } : {}),
|
||||
...(rawItem.output_format !== undefined ? { output_format: cloneJson(rawItem.output_format) } : {}),
|
||||
...(rawItem.quality !== undefined ? { quality: cloneJson(rawItem.quality) } : {}),
|
||||
...(rawItem.size !== undefined ? { size: cloneJson(rawItem.size) } : {}),
|
||||
...(rawItem.revised_prompt !== undefined ? { revised_prompt: cloneJson(rawItem.revised_prompt) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
})
|
||||
.filter((item): item is ResponsesOutputItem => item !== null);
|
||||
}
|
||||
|
||||
function collectOutputTextFromItems(items: ResponsesOutputItem[]): string {
|
||||
const textParts: string[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
if (asTrimmedString(item.type).toLowerCase() !== 'message') continue;
|
||||
const content = Array.isArray(item.content) ? item.content : [];
|
||||
for (const part of content) {
|
||||
if (!isRecord(part)) continue;
|
||||
const partType = asTrimmedString(part.type).toLowerCase();
|
||||
if ((partType === 'output_text' || partType === 'text') && typeof part.text === 'string') {
|
||||
textParts.push(part.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return textParts.join('');
|
||||
}
|
||||
|
||||
function extractSyntheticUsageDetails(upstreamPayload: unknown): {
|
||||
inputTokensDetails?: Record<string, unknown>;
|
||||
outputTokensDetails?: Record<string, unknown>;
|
||||
} {
|
||||
if (!isRecord(upstreamPayload) || !isRecord(upstreamPayload.usage)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const usage = upstreamPayload.usage;
|
||||
const promptDetails = (
|
||||
(isRecord(usage.input_tokens_details) ? usage.input_tokens_details : null)
|
||||
?? (isRecord(usage.prompt_tokens_details) ? usage.prompt_tokens_details : null)
|
||||
);
|
||||
const completionDetails = (
|
||||
(isRecord(usage.output_tokens_details) ? usage.output_tokens_details : null)
|
||||
?? (isRecord(usage.completion_tokens_details) ? usage.completion_tokens_details : null)
|
||||
);
|
||||
|
||||
return {
|
||||
...(promptDetails ? { inputTokensDetails: cloneJson(promptDetails) } : {}),
|
||||
...(completionDetails ? { outputTokensDetails: cloneJson(completionDetails) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeResponsesFinalPayload(
|
||||
payload: unknown,
|
||||
modelName: string,
|
||||
@@ -118,10 +294,30 @@ export function serializeResponsesFinalPayload(input: {
|
||||
: `resp_${Date.now()}`;
|
||||
const responseId = ensureResponseId(normalizedId);
|
||||
const messageId = ensureMessageId(normalizedId);
|
||||
const toolCalls = extractToolCallsFromUpstream(upstreamPayload);
|
||||
const syntheticOutput = extractSyntheticOutputItemsFromUpstream(upstreamPayload);
|
||||
const syntheticUsageDetails = extractSyntheticUsageDetails(upstreamPayload);
|
||||
const toolCalls = (() => {
|
||||
const extracted = extractToolCallsFromUpstream(upstreamPayload);
|
||||
if (extracted.length > 0) return extracted;
|
||||
return extractToolCallsFromNormalized(normalized);
|
||||
})();
|
||||
const annotations = extractAnnotationsFromUpstream(upstreamPayload);
|
||||
|
||||
const output: Array<Record<string, unknown>> = [];
|
||||
if (normalized.reasoningContent) {
|
||||
const output: Array<Record<string, unknown>> = syntheticOutput.map((item) => cloneJson(item));
|
||||
const hasReasoningItem = output.some((item) => asTrimmedString(item.type).toLowerCase() === 'reasoning');
|
||||
const hasMessageItem = output.some((item) => asTrimmedString(item.type).toLowerCase() === 'message');
|
||||
const hasToolLikeItem = output.some((item) => {
|
||||
const itemType = asTrimmedString(item.type).toLowerCase();
|
||||
return (
|
||||
itemType === 'function_call'
|
||||
|| itemType === 'custom_tool_call'
|
||||
|| itemType === 'function_call_output'
|
||||
|| itemType === 'custom_tool_call_output'
|
||||
|| itemType === 'image_generation_call'
|
||||
);
|
||||
});
|
||||
|
||||
if (normalized.reasoningContent && !hasReasoningItem) {
|
||||
output.push({
|
||||
id: ensureMessageId(`${normalizedId}_reasoning`),
|
||||
type: 'reasoning',
|
||||
@@ -132,28 +328,35 @@ export function serializeResponsesFinalPayload(input: {
|
||||
}],
|
||||
});
|
||||
}
|
||||
if (normalized.content || toolCalls.length === 0) {
|
||||
|
||||
if ((normalized.content || (!hasToolLikeItem && output.length === 0 && toolCalls.length === 0)) && !hasMessageItem) {
|
||||
const textPart: Record<string, unknown> = {
|
||||
type: 'output_text',
|
||||
text: normalized.content || '',
|
||||
};
|
||||
if (annotations.length > 0) {
|
||||
textPart.annotations = annotations;
|
||||
}
|
||||
output.push({
|
||||
id: messageId,
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
status: 'completed',
|
||||
content: [{
|
||||
type: 'output_text',
|
||||
text: normalized.content || '',
|
||||
}],
|
||||
content: [textPart],
|
||||
});
|
||||
}
|
||||
|
||||
for (const toolCall of toolCalls) {
|
||||
output.push({
|
||||
id: toolCall.id,
|
||||
type: 'function_call',
|
||||
status: 'completed',
|
||||
call_id: toolCall.id,
|
||||
name: toolCall.name,
|
||||
arguments: toolCall.arguments,
|
||||
});
|
||||
if (toolCalls.length > 0 && !hasToolLikeItem) {
|
||||
for (const toolCall of toolCalls) {
|
||||
output.push({
|
||||
id: toolCall.id,
|
||||
type: 'function_call',
|
||||
status: 'completed',
|
||||
call_id: toolCall.id,
|
||||
name: toolCall.name,
|
||||
arguments: toolCall.arguments,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -163,11 +366,17 @@ export function serializeResponsesFinalPayload(input: {
|
||||
status: 'completed',
|
||||
model: normalized.model,
|
||||
output,
|
||||
output_text: normalized.content || '',
|
||||
output_text: normalized.content || collectOutputTextFromItems(output),
|
||||
usage: {
|
||||
input_tokens: usage.promptTokens,
|
||||
output_tokens: usage.completionTokens,
|
||||
total_tokens: usage.totalTokens,
|
||||
...(usage.inputTokensDetails ?? syntheticUsageDetails.inputTokensDetails
|
||||
? { input_tokens_details: cloneJson(usage.inputTokensDetails ?? syntheticUsageDetails.inputTokensDetails) }
|
||||
: {}),
|
||||
...(usage.outputTokensDetails ?? syntheticUsageDetails.outputTokensDetails
|
||||
? { output_tokens_details: cloneJson(usage.outputTokensDetails ?? syntheticUsageDetails.outputTokensDetails) }
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user