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,