Add comprehensive tests for Anthropics and Gemini transformers

- Implement tests for anthropicMessagesTransformer including serialization and event handling in stream.test.ts.
- Add usage extraction tests for anthropicMessagesUsage in usage.test.ts.
- Create extensive tests for Gemini generate content aggregation and outbound usage in aggregator-outbound-usage.test.ts.
- Introduce tests for Gemini stream handling and payload parsing in stream.test.ts.
- Enhance OpenAI response serialization tests to ensure proper mapping of usage details and preservation of annotations in outbound.test.ts.
This commit is contained in:
cita
2026-03-08 16:42:41 +08:00
parent 7081be14c7
commit ede8998244
48 changed files with 6357 additions and 1110 deletions
+110 -52
View File
@@ -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 } : {}),
};
}
+235 -12
View File
@@ -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 },
});
});
});
+17 -2
View File
@@ -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;
+53 -11
View File
@@ -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;
+101 -11
View File
@@ -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)) {
+70 -1
View File
@@ -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) }
: {}),
},
};
}