Add comprehensive tests for Anthropics and Gemini transformers
- Implement tests for anthropicMessagesTransformer including serialization and event handling in stream.test.ts. - Add usage extraction tests for anthropicMessagesUsage in usage.test.ts. - Create extensive tests for Gemini generate content aggregation and outbound usage in aggregator-outbound-usage.test.ts. - Introduce tests for Gemini stream handling and payload parsing in stream.test.ts. - Enhance OpenAI response serialization tests to ensure proper mapping of usage details and preservation of annotations in outbound.test.ts.
This commit is contained in:
@@ -1,9 +1,12 @@
|
||||
import { type NormalizedFinalResponse, type NormalizedStreamEvent } from '../../shared/normalized.js';
|
||||
import type {
|
||||
OpenAiChatChoice,
|
||||
OpenAiChatChoiceDelta,
|
||||
OpenAiChatNormalizedFinalResponse,
|
||||
OpenAiChatNormalizedStreamEvent,
|
||||
OpenAiChatUsageDetails,
|
||||
} from './model.js';
|
||||
import { mergeChatUsageDetails } from './helpers.js';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object';
|
||||
@@ -22,18 +25,9 @@ function pushUniqueAnnotation(
|
||||
annotations.push(candidate);
|
||||
}
|
||||
|
||||
function mergeUsageDetails(
|
||||
target: OpenAiChatUsageDetails | undefined,
|
||||
next: OpenAiChatUsageDetails | undefined,
|
||||
): OpenAiChatUsageDetails | undefined {
|
||||
if (!target && !next) return undefined;
|
||||
return {
|
||||
...(target || {}),
|
||||
...(next || {}),
|
||||
};
|
||||
}
|
||||
|
||||
export type OpenAiChatAggregateState = {
|
||||
type ChoiceAggregate = {
|
||||
index: number;
|
||||
role?: 'assistant';
|
||||
content: string[];
|
||||
reasoning: string[];
|
||||
toolCalls: Array<{ id: string; name: string; arguments: string }>;
|
||||
@@ -41,11 +35,18 @@ export type OpenAiChatAggregateState = {
|
||||
annotations: Array<Record<string, unknown>>;
|
||||
annotationUrls: Set<string>;
|
||||
citations: Set<string>;
|
||||
};
|
||||
|
||||
export type OpenAiChatAggregateState = {
|
||||
choices: Map<number, ChoiceAggregate>;
|
||||
citations: Set<string>;
|
||||
usageDetails?: OpenAiChatUsageDetails;
|
||||
};
|
||||
|
||||
export function createOpenAiChatAggregateState(): OpenAiChatAggregateState {
|
||||
function createChoiceAggregate(index: number): ChoiceAggregate {
|
||||
return {
|
||||
index,
|
||||
role: undefined,
|
||||
content: [],
|
||||
reasoning: [],
|
||||
toolCalls: [],
|
||||
@@ -53,6 +54,53 @@ export function createOpenAiChatAggregateState(): OpenAiChatAggregateState {
|
||||
annotations: [],
|
||||
annotationUrls: new Set<string>(),
|
||||
citations: new Set<string>(),
|
||||
};
|
||||
}
|
||||
|
||||
function getChoiceAggregate(state: OpenAiChatAggregateState, index: number): ChoiceAggregate {
|
||||
const existing = state.choices.get(index);
|
||||
if (existing) return existing;
|
||||
const created = createChoiceAggregate(index);
|
||||
state.choices.set(index, created);
|
||||
return created;
|
||||
}
|
||||
|
||||
function applyChoiceDelta(choice: ChoiceAggregate, event: OpenAiChatChoiceDelta) {
|
||||
if (event.role) choice.role = event.role;
|
||||
if (event.contentDelta) choice.content.push(event.contentDelta);
|
||||
if (event.reasoningDelta) choice.reasoning.push(event.reasoningDelta);
|
||||
if (event.finishReason !== undefined) choice.finishReason = event.finishReason ?? null;
|
||||
if (Array.isArray(event.toolCallDeltas)) {
|
||||
for (const delta of event.toolCallDeltas) {
|
||||
if (!delta.id && !delta.name && !delta.argumentsDelta) continue;
|
||||
const index = Number.isFinite(delta.index) ? Math.max(0, Math.trunc(delta.index)) : choice.toolCalls.length;
|
||||
while (choice.toolCalls.length <= index) {
|
||||
choice.toolCalls.push({ id: '', name: '', arguments: '' });
|
||||
}
|
||||
const existing = choice.toolCalls[index];
|
||||
if (delta.id) existing.id = delta.id;
|
||||
if (delta.name) existing.name = delta.name;
|
||||
if (delta.argumentsDelta) existing.arguments += delta.argumentsDelta;
|
||||
}
|
||||
}
|
||||
if (Array.isArray(event.annotations)) {
|
||||
for (const annotation of event.annotations) {
|
||||
pushUniqueAnnotation(choice.annotations, choice.annotationUrls, annotation);
|
||||
}
|
||||
}
|
||||
if (Array.isArray(event.citations)) {
|
||||
for (const citation of event.citations) {
|
||||
if (typeof citation === 'string' && citation.trim()) {
|
||||
choice.citations.add(citation.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createOpenAiChatAggregateState(): OpenAiChatAggregateState {
|
||||
return {
|
||||
choices: new Map<number, ChoiceAggregate>(),
|
||||
citations: new Set<string>(),
|
||||
usageDetails: undefined,
|
||||
};
|
||||
}
|
||||
@@ -61,35 +109,34 @@ export function applyOpenAiChatStreamEvent(
|
||||
state: OpenAiChatAggregateState,
|
||||
event: OpenAiChatNormalizedStreamEvent,
|
||||
): OpenAiChatAggregateState {
|
||||
if (event.contentDelta) state.content.push(event.contentDelta);
|
||||
if (event.reasoningDelta) state.reasoning.push(event.reasoningDelta);
|
||||
if (event.finishReason !== undefined) state.finishReason = event.finishReason ?? null;
|
||||
if (Array.isArray(event.toolCallDeltas)) {
|
||||
for (const delta of event.toolCallDeltas) {
|
||||
if (!delta.id && !delta.name && !delta.argumentsDelta) continue;
|
||||
const index = Number.isFinite(delta.index) ? Math.max(0, Math.trunc(delta.index)) : state.toolCalls.length;
|
||||
while (state.toolCalls.length <= index) {
|
||||
state.toolCalls.push({ id: '', name: '', arguments: '' });
|
||||
}
|
||||
const existing = state.toolCalls[index];
|
||||
if (delta.id) existing.id = delta.id;
|
||||
if (delta.name) existing.name = delta.name;
|
||||
if (delta.argumentsDelta) existing.arguments += delta.argumentsDelta;
|
||||
}
|
||||
}
|
||||
if (Array.isArray(event.annotations)) {
|
||||
for (const annotation of event.annotations) {
|
||||
pushUniqueAnnotation(state.annotations, state.annotationUrls, annotation);
|
||||
}
|
||||
const choiceEvents = Array.isArray(event.choiceEvents) && event.choiceEvents.length > 0
|
||||
? event.choiceEvents
|
||||
: [{
|
||||
index: Number.isFinite(event.choiceIndex) ? Math.max(0, Math.trunc(event.choiceIndex!)) : 0,
|
||||
role: event.role,
|
||||
contentDelta: event.contentDelta,
|
||||
reasoningDelta: event.reasoningDelta,
|
||||
toolCallDeltas: event.toolCallDeltas,
|
||||
finishReason: event.finishReason,
|
||||
annotations: event.annotations,
|
||||
citations: event.citations,
|
||||
} satisfies OpenAiChatChoiceDelta];
|
||||
|
||||
for (const choiceEvent of choiceEvents) {
|
||||
const choice = getChoiceAggregate(state, choiceEvent.index);
|
||||
applyChoiceDelta(choice, choiceEvent);
|
||||
}
|
||||
|
||||
if (Array.isArray(event.citations)) {
|
||||
for (const citation of event.citations) {
|
||||
if (typeof citation === 'string' && citation.trim()) {
|
||||
state.citations.add(citation.trim());
|
||||
}
|
||||
if (typeof citation === 'string' && citation.trim()) state.citations.add(citation.trim());
|
||||
}
|
||||
}
|
||||
state.usageDetails = mergeUsageDetails(state.usageDetails, event.usageDetails);
|
||||
for (const choice of state.choices.values()) {
|
||||
for (const citation of choice.citations) state.citations.add(citation);
|
||||
}
|
||||
|
||||
state.usageDetails = mergeChatUsageDetails(state.usageDetails, event.usageDetails);
|
||||
return state;
|
||||
}
|
||||
|
||||
@@ -97,25 +144,36 @@ export function finalizeOpenAiChatAggregate(
|
||||
state: OpenAiChatAggregateState,
|
||||
normalized: OpenAiChatNormalizedFinalResponse,
|
||||
): OpenAiChatNormalizedFinalResponse {
|
||||
const mergedToolCalls = state.toolCalls.filter((item) => item.id || item.name || item.arguments);
|
||||
if (
|
||||
state.content.length === 0
|
||||
&& state.reasoning.length === 0
|
||||
&& mergedToolCalls.length === 0
|
||||
&& state.annotations.length === 0
|
||||
&& state.citations.size === 0
|
||||
&& !state.usageDetails
|
||||
) {
|
||||
if (state.choices.size === 0 && state.citations.size === 0 && !state.usageDetails) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const finalizedChoices: OpenAiChatChoice[] = Array.from(state.choices.values())
|
||||
.sort((left, right) => left.index - right.index)
|
||||
.map((choice) => ({
|
||||
index: choice.index,
|
||||
...(choice.role ? { role: choice.role } : {}),
|
||||
content: choice.content.join(''),
|
||||
reasoningContent: choice.reasoning.join(''),
|
||||
toolCalls: choice.toolCalls.filter((item) => item.id || item.name || item.arguments),
|
||||
finishReason: choice.finishReason || (choice.toolCalls.length > 0 ? 'tool_calls' : 'stop'),
|
||||
...(choice.annotations.length > 0 ? { annotations: choice.annotations } : {}),
|
||||
...(choice.citations.size > 0 ? { citations: Array.from(choice.citations).sort() } : {}),
|
||||
}));
|
||||
|
||||
const primaryChoice = finalizedChoices[0];
|
||||
const normalizedChoices = Array.isArray(normalized.choices) ? normalized.choices : [];
|
||||
const mergedChoices = finalizedChoices.length > 0 ? finalizedChoices : normalizedChoices;
|
||||
|
||||
return {
|
||||
...normalized,
|
||||
content: state.content.join('') || normalized.content,
|
||||
reasoningContent: state.reasoning.join('') || normalized.reasoningContent,
|
||||
finishReason: state.finishReason ?? normalized.finishReason,
|
||||
toolCalls: mergedToolCalls.length > 0 ? mergedToolCalls : normalized.toolCalls,
|
||||
annotations: state.annotations.length > 0 ? state.annotations : normalized.annotations,
|
||||
content: primaryChoice?.content || normalized.content,
|
||||
reasoningContent: primaryChoice?.reasoningContent || normalized.reasoningContent,
|
||||
finishReason: primaryChoice?.finishReason || normalized.finishReason,
|
||||
toolCalls: primaryChoice?.toolCalls?.length ? primaryChoice.toolCalls : normalized.toolCalls,
|
||||
annotations: primaryChoice?.annotations?.length ? primaryChoice.annotations : normalized.annotations,
|
||||
citations: state.citations.size > 0 ? Array.from(state.citations).sort() : normalized.citations,
|
||||
usageDetails: mergeUsageDetails(normalized.usageDetails, state.usageDetails),
|
||||
usageDetails: mergeChatUsageDetails(normalized.usageDetails, state.usageDetails),
|
||||
...(mergedChoices.length > 0 ? { choices: mergedChoices } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import type {
|
||||
OpenAiChatChoice,
|
||||
OpenAiChatChoiceDelta,
|
||||
OpenAiChatNormalizedFinalResponse,
|
||||
OpenAiChatNormalizedStreamEvent,
|
||||
OpenAiChatRequestMetadata,
|
||||
OpenAiChatToolCall,
|
||||
OpenAiChatUsageDetails,
|
||||
} from './model.js';
|
||||
|
||||
@@ -31,11 +34,20 @@ function toStringArray(value: unknown): string[] {
|
||||
.filter((item) => item.length > 0);
|
||||
}
|
||||
|
||||
function toFiniteNumber(value: unknown): number | undefined {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
if (typeof value === 'string' && value.trim().length > 0) {
|
||||
const numeric = Number(value);
|
||||
if (Number.isFinite(numeric)) return numeric;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function toNumericRecord(value: unknown): Record<string, number> | undefined {
|
||||
if (!isRecord(value)) return undefined;
|
||||
const entries = Object.entries(value)
|
||||
.map(([key, raw]) => {
|
||||
const numeric = typeof raw === 'number' ? raw : Number(raw);
|
||||
const numeric = toFiniteNumber(raw);
|
||||
if (!Number.isFinite(numeric)) return null;
|
||||
return [key, numeric] as const;
|
||||
})
|
||||
@@ -64,6 +76,112 @@ function annotationUrl(annotation: Record<string, unknown>): string {
|
||||
return (direct || nested).trim();
|
||||
}
|
||||
|
||||
function extractTextPart(value: unknown): string {
|
||||
if (typeof value === 'string') return value;
|
||||
if (!isRecord(value)) return '';
|
||||
|
||||
if (typeof value.text === 'string') return value.text;
|
||||
if (typeof value.output_text === 'string') return value.output_text;
|
||||
if (typeof value.content === 'string') return value.content;
|
||||
if (typeof value.reasoning_content === 'string') return value.reasoning_content;
|
||||
if (typeof value.reasoning === 'string') return value.reasoning;
|
||||
|
||||
if (Array.isArray(value.content)) {
|
||||
return value.content.map((item) => extractTextPart(item)).join('');
|
||||
}
|
||||
|
||||
if (Array.isArray(value.parts)) {
|
||||
return value.parts.map((item) => extractTextPart(item)).join('');
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function extractTextAndReasoning(value: unknown): { content: string; reasoning: string } {
|
||||
if (typeof value === 'string') return { content: value, reasoning: '' };
|
||||
if (Array.isArray(value)) {
|
||||
const contentParts: string[] = [];
|
||||
const reasoningParts: string[] = [];
|
||||
|
||||
for (const item of value) {
|
||||
if (!isRecord(item)) {
|
||||
if (typeof item === 'string') contentParts.push(item);
|
||||
continue;
|
||||
}
|
||||
|
||||
const type = typeof item.type === 'string' ? item.type : '';
|
||||
if ((type === 'reasoning' || type === 'thinking') && typeof item.text === 'string') {
|
||||
reasoningParts.push(item.text);
|
||||
continue;
|
||||
}
|
||||
if ((type === 'reasoning' || type === 'thinking') && typeof item.reasoning === 'string') {
|
||||
reasoningParts.push(item.reasoning);
|
||||
continue;
|
||||
}
|
||||
const text = extractTextPart(item);
|
||||
if (text) contentParts.push(text);
|
||||
}
|
||||
|
||||
return {
|
||||
content: contentParts.join(''),
|
||||
reasoning: reasoningParts.join(''),
|
||||
};
|
||||
}
|
||||
|
||||
if (!isRecord(value)) return { content: '', reasoning: '' };
|
||||
return {
|
||||
content: extractTextPart(value.content ?? value),
|
||||
reasoning: typeof value.reasoning_content === 'string'
|
||||
? value.reasoning_content
|
||||
: typeof value.reasoning === 'string'
|
||||
? value.reasoning
|
||||
: '',
|
||||
};
|
||||
}
|
||||
|
||||
function collectToolCalls(value: unknown): OpenAiChatToolCall[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const toolCalls: OpenAiChatToolCall[] = [];
|
||||
|
||||
for (const item of value) {
|
||||
if (!isRecord(item)) continue;
|
||||
const functionPart = isRecord(item.function) ? item.function : null;
|
||||
if (!functionPart) continue;
|
||||
const name = typeof functionPart.name === 'string' ? functionPart.name : '';
|
||||
const id = typeof item.id === 'string' ? item.id : '';
|
||||
const args = typeof functionPart.arguments === 'string' ? functionPart.arguments : '';
|
||||
if (!id && !name && !args) continue;
|
||||
toolCalls.push({
|
||||
id,
|
||||
name,
|
||||
arguments: args,
|
||||
});
|
||||
}
|
||||
|
||||
return toolCalls;
|
||||
}
|
||||
|
||||
function collectToolCallDeltas(value: unknown): OpenAiChatChoiceDelta['toolCallDeltas'] {
|
||||
if (!Array.isArray(value)) return undefined;
|
||||
const deltas = value
|
||||
.map((item, itemIndex) => {
|
||||
if (!isRecord(item)) return null;
|
||||
const functionPart = isRecord(item.function) ? item.function : {};
|
||||
const index = toFiniteNumber(item.index);
|
||||
const delta = {
|
||||
index: index !== undefined ? Math.max(0, Math.trunc(index)) : itemIndex,
|
||||
id: typeof item.id === 'string' && item.id.trim() ? item.id : undefined,
|
||||
name: typeof functionPart.name === 'string' && functionPart.name.trim() ? functionPart.name : undefined,
|
||||
argumentsDelta: typeof functionPart.arguments === 'string' ? functionPart.arguments : undefined,
|
||||
};
|
||||
if (!delta.id && !delta.name && !delta.argumentsDelta) return null;
|
||||
return delta;
|
||||
})
|
||||
.filter((item): item is NonNullable<typeof item> => !!item);
|
||||
|
||||
return deltas.length > 0 ? deltas : undefined;
|
||||
}
|
||||
|
||||
export function dedupeAnnotations(input: unknown): Array<Record<string, unknown>> {
|
||||
if (!Array.isArray(input)) return [];
|
||||
const seen = new Set<string>();
|
||||
@@ -98,6 +216,104 @@ export function dedupeCitations(input: unknown): string[] {
|
||||
return Array.from(citations);
|
||||
}
|
||||
|
||||
export function extractChoiceAnnotations(choice: unknown): Array<Record<string, unknown>> {
|
||||
if (!isRecord(choice)) return [];
|
||||
const choiceMessage = asRecord(choice.message);
|
||||
const choiceDelta = asRecord(choice.delta);
|
||||
return dedupeAnnotations([
|
||||
...(Array.isArray(choice.annotations) ? choice.annotations : []),
|
||||
...(Array.isArray(choiceMessage?.annotations) ? choiceMessage.annotations : []),
|
||||
...(Array.isArray(choiceDelta?.annotations) ? choiceDelta.annotations : []),
|
||||
]);
|
||||
}
|
||||
|
||||
export function extractChoiceCitations(
|
||||
choice: unknown,
|
||||
sharedCitations: Iterable<string> = [],
|
||||
): string[] {
|
||||
const citations = new Set<string>(sharedCitations);
|
||||
for (const annotation of extractChoiceAnnotations(choice)) {
|
||||
const url = annotationUrl(annotation);
|
||||
if (url) citations.add(url);
|
||||
}
|
||||
if (isRecord(choice)) {
|
||||
for (const citation of dedupeCitations(choice.citations)) citations.add(citation);
|
||||
}
|
||||
return Array.from(citations);
|
||||
}
|
||||
|
||||
export function extractChatChoices(payload: unknown): OpenAiChatChoice[] {
|
||||
const record = asRecord(payload);
|
||||
if (!record || !Array.isArray(record.choices)) return [];
|
||||
|
||||
const sharedCitations = dedupeCitations(record.citations);
|
||||
const choices: OpenAiChatChoice[] = [];
|
||||
|
||||
for (const [index, choice] of record.choices.entries()) {
|
||||
const choiceRecord = asRecord(choice);
|
||||
if (!choiceRecord) continue;
|
||||
const message = asRecord(choiceRecord.message) ?? asRecord(choiceRecord.delta) ?? {};
|
||||
const parsed = extractTextAndReasoning(message.content ?? message);
|
||||
const toolCalls = collectToolCalls(message.tool_calls);
|
||||
const annotations = extractChoiceAnnotations(choiceRecord);
|
||||
const citations = extractChoiceCitations(choiceRecord, sharedCitations);
|
||||
const finishReason = typeof choiceRecord.finish_reason === 'string' && choiceRecord.finish_reason.length > 0
|
||||
? choiceRecord.finish_reason
|
||||
: (toolCalls.length > 0 ? 'tool_calls' : 'stop');
|
||||
|
||||
choices.push({
|
||||
index: toFiniteNumber(choiceRecord.index) !== undefined ? Math.max(0, Math.trunc(toFiniteNumber(choiceRecord.index)!)) : index,
|
||||
...(message.role === 'assistant' ? { role: 'assistant' as const } : {}),
|
||||
content: parsed.content || (toolCalls.length > 0 ? '' : ''),
|
||||
reasoningContent: typeof message.reasoning_content === 'string'
|
||||
? message.reasoning_content
|
||||
: parsed.reasoning,
|
||||
toolCalls,
|
||||
finishReason,
|
||||
...(annotations.length > 0 ? { annotations } : {}),
|
||||
...(citations.length > 0 ? { citations } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
return choices.sort((left, right) => left.index - right.index);
|
||||
}
|
||||
|
||||
export function extractChatChoiceEvents(payload: unknown): OpenAiChatChoiceDelta[] {
|
||||
const record = asRecord(payload);
|
||||
if (!record || !Array.isArray(record.choices)) return [];
|
||||
|
||||
const sharedCitations = dedupeCitations(record.citations);
|
||||
const choiceEvents: OpenAiChatChoiceDelta[] = [];
|
||||
|
||||
for (const [index, choice] of record.choices.entries()) {
|
||||
const choiceRecord = asRecord(choice);
|
||||
if (!choiceRecord) continue;
|
||||
const delta = asRecord(choiceRecord.delta) ?? {};
|
||||
const parsed = extractTextAndReasoning(delta.content ?? delta);
|
||||
const toolCallDeltas = collectToolCallDeltas(delta.tool_calls);
|
||||
const annotations = extractChoiceAnnotations(choiceRecord);
|
||||
const citations = extractChoiceCitations(choiceRecord, sharedCitations);
|
||||
const choiceIndex = toFiniteNumber(choiceRecord.index) !== undefined
|
||||
? Math.max(0, Math.trunc(toFiniteNumber(choiceRecord.index)!))
|
||||
: index;
|
||||
|
||||
choiceEvents.push({
|
||||
index: choiceIndex,
|
||||
...(delta.role === 'assistant' ? { role: 'assistant' as const } : {}),
|
||||
...(parsed.content ? { contentDelta: parsed.content } : {}),
|
||||
...((typeof delta.reasoning_content === 'string' ? delta.reasoning_content : parsed.reasoning)
|
||||
? { reasoningDelta: (typeof delta.reasoning_content === 'string' ? delta.reasoning_content : parsed.reasoning) }
|
||||
: {}),
|
||||
...(toolCallDeltas ? { toolCallDeltas } : {}),
|
||||
finishReason: typeof choiceRecord.finish_reason === 'string' ? choiceRecord.finish_reason : null,
|
||||
...(annotations.length > 0 ? { annotations } : {}),
|
||||
...(citations.length > 0 ? { citations } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
return choiceEvents;
|
||||
}
|
||||
|
||||
function extractAnnotationsFromPayload(payload: Record<string, unknown>): Array<Record<string, unknown>> {
|
||||
const collected: Array<Record<string, unknown>> = [];
|
||||
const append = (value: unknown) => {
|
||||
@@ -216,18 +432,25 @@ export function extractChatRequestMetadata(body: unknown): OpenAiChatRequestMeta
|
||||
if (!raw) return undefined;
|
||||
|
||||
const streamOptions = asRecord(raw.stream_options) ?? asRecord(raw.streamOptions);
|
||||
const modalities = Array.isArray(raw.modalities)
|
||||
? raw.modalities.filter((item): item is string => typeof item === 'string' && item.trim().length > 0)
|
||||
: undefined;
|
||||
const audio = asRecord(raw.audio) as OpenAiChatRequestMetadata['audio'] | null;
|
||||
const reasoningBudget = toFiniteNumber(raw.reasoning_budget);
|
||||
const topLogprobs = toFiniteNumber(raw.top_logprobs);
|
||||
const logitBias = toNumericRecord(raw.logit_bias);
|
||||
const metadata: OpenAiChatRequestMetadata = {
|
||||
...(raw.modalities !== undefined ? { modalities: raw.modalities } : {}),
|
||||
...(raw.audio !== undefined ? { audio: raw.audio } : {}),
|
||||
...(raw.reasoning_effort !== undefined ? { reasoningEffort: raw.reasoning_effort } : {}),
|
||||
...(raw.reasoning_budget !== undefined ? { reasoningBudget: raw.reasoning_budget } : {}),
|
||||
...(raw.reasoning_summary !== undefined ? { reasoningSummary: raw.reasoning_summary } : {}),
|
||||
...(raw.service_tier !== undefined ? { serviceTier: raw.service_tier } : {}),
|
||||
...(raw.top_logprobs !== undefined ? { topLogprobs: raw.top_logprobs } : {}),
|
||||
...(raw.logit_bias !== undefined ? { logitBias: raw.logit_bias } : {}),
|
||||
...(raw.prompt_cache_key !== undefined ? { promptCacheKey: raw.prompt_cache_key } : {}),
|
||||
...(raw.safety_identifier !== undefined ? { safetyIdentifier: raw.safety_identifier } : {}),
|
||||
...(raw.verbosity !== undefined ? { verbosity: raw.verbosity } : {}),
|
||||
...(modalities && modalities.length > 0 ? { modalities } : {}),
|
||||
...(audio ? { audio } : {}),
|
||||
...(typeof raw.reasoning_effort === 'string' ? { reasoningEffort: raw.reasoning_effort } : {}),
|
||||
...(reasoningBudget !== undefined ? { reasoningBudget } : {}),
|
||||
...(typeof raw.reasoning_summary === 'string' ? { reasoningSummary: raw.reasoning_summary } : {}),
|
||||
...(typeof raw.service_tier === 'string' ? { serviceTier: raw.service_tier } : {}),
|
||||
...(topLogprobs !== undefined ? { topLogprobs } : {}),
|
||||
...(logitBias ? { logitBias } : {}),
|
||||
...(typeof raw.prompt_cache_key === 'string' ? { promptCacheKey: raw.prompt_cache_key } : {}),
|
||||
...(typeof raw.safety_identifier === 'string' ? { safetyIdentifier: raw.safety_identifier } : {}),
|
||||
...(typeof raw.verbosity === 'string' ? { verbosity: raw.verbosity } : {}),
|
||||
...(raw.response_format !== undefined ? { responseFormat: raw.response_format } : {}),
|
||||
...(streamOptions && streamOptions.include_usage !== undefined
|
||||
? { streamOptionsIncludeUsage: Boolean(streamOptions.include_usage) }
|
||||
|
||||
@@ -61,6 +61,48 @@ describe('openAiChatTransformer.inbound', () => {
|
||||
streamOptionsIncludeUsage: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes typed inbound metadata without mutating upstream body', () => {
|
||||
const result = openAiChatTransformer.transformRequest({
|
||||
model: 'gpt-5',
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
modalities: ['text', 42, 'audio', '', null],
|
||||
audio: { voice: 'alloy', format: 'wav' },
|
||||
reasoning_effort: 'medium',
|
||||
reasoning_budget: '2048',
|
||||
reasoning_summary: 'concise',
|
||||
service_tier: 'priority',
|
||||
top_logprobs: '5',
|
||||
logit_bias: { '42': '7', invalid: 'oops' },
|
||||
prompt_cache_key: 'cache-key',
|
||||
safety_identifier: 'safety-id',
|
||||
verbosity: 'high',
|
||||
stream_options: { include_usage: 1 },
|
||||
});
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.value?.upstreamBody).toMatchObject({
|
||||
modalities: ['text', 42, 'audio', '', null],
|
||||
reasoning_budget: '2048',
|
||||
top_logprobs: '5',
|
||||
logit_bias: { '42': '7', invalid: 'oops' },
|
||||
stream_options: { include_usage: 1 },
|
||||
});
|
||||
expect((result.value as any)?.requestMetadata).toEqual({
|
||||
modalities: ['text', 'audio'],
|
||||
audio: { voice: 'alloy', format: 'wav' },
|
||||
reasoningEffort: 'medium',
|
||||
reasoningBudget: 2048,
|
||||
reasoningSummary: 'concise',
|
||||
serviceTier: 'priority',
|
||||
topLogprobs: 5,
|
||||
logitBias: { '42': 7 },
|
||||
promptCacheKey: 'cache-key',
|
||||
safetyIdentifier: 'safety-id',
|
||||
verbosity: 'high',
|
||||
streamOptionsIncludeUsage: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('openAiChatTransformer.outbound', () => {
|
||||
@@ -111,6 +153,99 @@ describe('openAiChatTransformer.outbound', () => {
|
||||
completion_tokens_details: { reasoning_tokens: 2 },
|
||||
});
|
||||
});
|
||||
|
||||
it('serializes multi-choice chat responses with per-choice annotations, reasoning, tool calls, and shared citations', () => {
|
||||
const normalized = openAiChatTransformer.transformFinalResponse({
|
||||
id: 'chatcmpl-multi',
|
||||
model: 'gpt-5',
|
||||
created: 123,
|
||||
citations: ['https://shared.example', 'https://shared.example'],
|
||||
usage: {
|
||||
prompt_tokens: 21,
|
||||
completion_tokens: 9,
|
||||
total_tokens: 30,
|
||||
prompt_tokens_details: { cached_tokens: 4, audio_tokens: 1 },
|
||||
completion_tokens_details: { reasoning_tokens: 3, audio_tokens: 2 },
|
||||
},
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
finish_reason: 'stop',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: 'choice-0',
|
||||
reasoning_content: 'reason-0',
|
||||
annotations: [
|
||||
{ type: 'url_citation', url_citation: { url: 'https://a.example' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
index: 1,
|
||||
finish_reason: 'tool_calls',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
reasoning_content: 'reason-1',
|
||||
tool_calls: [{
|
||||
id: 'call_1',
|
||||
type: 'function',
|
||||
function: { name: 'search', arguments: '{"q":"cat"}' },
|
||||
}],
|
||||
annotations: [
|
||||
{ type: 'url_citation', url_citation: { url: 'https://b.example' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}, 'gpt-5');
|
||||
|
||||
const payload = openAiChatTransformer.serializeFinalResponse(normalized, {
|
||||
promptTokens: 21,
|
||||
completionTokens: 9,
|
||||
totalTokens: 30,
|
||||
}) as any;
|
||||
|
||||
expect(payload.choices).toHaveLength(2);
|
||||
expect(payload.choices[0]).toMatchObject({
|
||||
index: 0,
|
||||
finish_reason: 'stop',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: 'choice-0',
|
||||
reasoning_content: 'reason-0',
|
||||
annotations: [
|
||||
{ type: 'url_citation', url_citation: { url: 'https://a.example' } },
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(payload.choices[1]).toMatchObject({
|
||||
index: 1,
|
||||
finish_reason: 'tool_calls',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
reasoning_content: 'reason-1',
|
||||
tool_calls: [{
|
||||
id: 'call_1',
|
||||
type: 'function',
|
||||
function: { name: 'search', arguments: '{"q":"cat"}' },
|
||||
}],
|
||||
annotations: [
|
||||
{ type: 'url_citation', url_citation: { url: 'https://b.example' } },
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(payload.citations).toEqual([
|
||||
'https://shared.example',
|
||||
'https://a.example',
|
||||
'https://b.example',
|
||||
]);
|
||||
expect(payload.usage).toMatchObject({
|
||||
prompt_tokens_details: { cached_tokens: 4, audio_tokens: 1 },
|
||||
completion_tokens_details: { reasoning_tokens: 3, audio_tokens: 2 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('openAiChatTransformer.stream', () => {
|
||||
@@ -157,6 +292,89 @@ describe('openAiChatTransformer.stream', () => {
|
||||
{ type: 'url_citation', url_citation: { url: 'https://a.example' } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('serializes multi-choice stream chunks without collapsing choice-specific deltas', () => {
|
||||
const context = openAiChatTransformer.createStreamContext('gpt-5');
|
||||
const event = openAiChatTransformer.transformStreamEvent({
|
||||
id: 'chatcmpl-stream-multi',
|
||||
model: 'gpt-5',
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
finish_reason: null,
|
||||
delta: {
|
||||
role: 'assistant',
|
||||
content: 'choice-0',
|
||||
reasoning_content: 'reason-0',
|
||||
annotations: [
|
||||
{ type: 'url_citation', url_citation: { url: 'https://a.example' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
index: 1,
|
||||
finish_reason: null,
|
||||
delta: {
|
||||
role: 'assistant',
|
||||
content: 'choice-1',
|
||||
tool_calls: [{
|
||||
index: 0,
|
||||
id: 'call_1',
|
||||
type: 'function',
|
||||
function: { name: 'search', arguments: '{"q":"dog"}' },
|
||||
}],
|
||||
annotations: [
|
||||
{ type: 'url_citation', url_citation: { url: 'https://b.example' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
citations: ['https://shared.example'],
|
||||
usage: {
|
||||
prompt_tokens: 11,
|
||||
completion_tokens: 7,
|
||||
total_tokens: 18,
|
||||
prompt_tokens_details: { cached_tokens: 3 },
|
||||
},
|
||||
}, context, 'gpt-5');
|
||||
|
||||
const payloads = parseSsePayloads(
|
||||
openAiChatTransformer.serializeStreamEvent(event, context, createClaudeDownstreamContext()),
|
||||
);
|
||||
|
||||
expect(payloads[0].choices).toHaveLength(2);
|
||||
expect((payloads[0] as any).choices[0]).toMatchObject({
|
||||
index: 0,
|
||||
delta: {
|
||||
role: 'assistant',
|
||||
content: 'choice-0',
|
||||
reasoning_content: 'reason-0',
|
||||
annotations: [
|
||||
{ type: 'url_citation', url_citation: { url: 'https://a.example' } },
|
||||
],
|
||||
},
|
||||
});
|
||||
expect((payloads[0] as any).choices[1]).toMatchObject({
|
||||
index: 1,
|
||||
delta: {
|
||||
role: 'assistant',
|
||||
content: 'choice-1',
|
||||
tool_calls: [{
|
||||
index: 0,
|
||||
id: 'call_1',
|
||||
function: { name: 'search', arguments: '{"q":"dog"}' },
|
||||
}],
|
||||
annotations: [
|
||||
{ type: 'url_citation', url_citation: { url: 'https://b.example' } },
|
||||
],
|
||||
},
|
||||
});
|
||||
expect((payloads[0] as any).citations).toEqual([
|
||||
'https://shared.example',
|
||||
'https://a.example',
|
||||
'https://b.example',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('openAiChatTransformer.aggregator', () => {
|
||||
@@ -231,4 +449,85 @@ describe('openAiChatTransformer.aggregator', () => {
|
||||
{ type: 'url_citation', url_citation: { url: 'https://b.example' } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('aggregates stream events by choice index and preserves per-choice annotations, citations, tools, reasoning, and usage details', () => {
|
||||
const state = openAiChatTransformer.aggregator.createState();
|
||||
|
||||
openAiChatTransformer.aggregator.applyEvent(state, {
|
||||
choiceIndex: 0,
|
||||
role: 'assistant',
|
||||
contentDelta: 'hello',
|
||||
reasoningDelta: 'why-0',
|
||||
annotations: [
|
||||
{ type: 'url_citation', url_citation: { url: 'https://a.example' } },
|
||||
],
|
||||
citations: ['https://shared.example'],
|
||||
usageDetails: {
|
||||
prompt_tokens_details: { cached_tokens: 2 },
|
||||
},
|
||||
} as any);
|
||||
|
||||
openAiChatTransformer.aggregator.applyEvent(state, {
|
||||
choiceIndex: 1,
|
||||
role: 'assistant',
|
||||
contentDelta: 'tool-choice',
|
||||
reasoningDelta: 'why-1',
|
||||
toolCallDeltas: [{
|
||||
index: 0,
|
||||
id: 'call_1',
|
||||
name: 'search',
|
||||
argumentsDelta: '{"q":"bird"}',
|
||||
}],
|
||||
annotations: [
|
||||
{ type: 'url_citation', url_citation: { url: 'https://b.example' } },
|
||||
],
|
||||
citations: ['https://other.example'],
|
||||
usageDetails: {
|
||||
completion_tokens_details: { reasoning_tokens: 4 },
|
||||
},
|
||||
finishReason: 'tool_calls',
|
||||
} as any);
|
||||
|
||||
const normalized = openAiChatTransformer.aggregator.finalize(state, {
|
||||
id: 'chatcmpl-multi',
|
||||
model: 'gpt-5',
|
||||
created: 123,
|
||||
content: '',
|
||||
reasoningContent: '',
|
||||
finishReason: 'stop',
|
||||
toolCalls: [],
|
||||
}) as any;
|
||||
|
||||
expect(normalized.choices).toHaveLength(2);
|
||||
expect(normalized.choices[0]).toMatchObject({
|
||||
index: 0,
|
||||
content: 'hello',
|
||||
reasoningContent: 'why-0',
|
||||
finishReason: 'stop',
|
||||
citations: ['https://shared.example'],
|
||||
annotations: [
|
||||
{ type: 'url_citation', url_citation: { url: 'https://a.example' } },
|
||||
],
|
||||
});
|
||||
expect(normalized.choices[1]).toMatchObject({
|
||||
index: 1,
|
||||
content: 'tool-choice',
|
||||
reasoningContent: 'why-1',
|
||||
finishReason: 'tool_calls',
|
||||
toolCalls: [{
|
||||
id: 'call_1',
|
||||
name: 'search',
|
||||
arguments: '{"q":"bird"}',
|
||||
}],
|
||||
citations: ['https://other.example'],
|
||||
annotations: [
|
||||
{ type: 'url_citation', url_citation: { url: 'https://b.example' } },
|
||||
],
|
||||
});
|
||||
expect(normalized.citations).toEqual(['https://other.example', 'https://shared.example']);
|
||||
expect(normalized.usageDetails).toEqual({
|
||||
prompt_tokens_details: { cached_tokens: 2 },
|
||||
completion_tokens_details: { reasoning_tokens: 4 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { type NormalizedFinalResponse, type NormalizedStreamEvent, type ParsedDownstreamChatRequest, type StreamTransformContext } from '../../shared/normalized.js';
|
||||
import { type NormalizedFinalResponse, type NormalizedStreamEvent, type StreamTransformContext } from '../../shared/normalized.js';
|
||||
import { openAiChatInbound } from './inbound.js';
|
||||
import { openAiChatOutbound } from './outbound.js';
|
||||
import { openAiChatStream } from './stream.js';
|
||||
import { openAiChatUsage } from './usage.js';
|
||||
import { createOpenAiChatAggregateState, applyOpenAiChatStreamEvent, finalizeOpenAiChatAggregate } from './aggregator.js';
|
||||
import type { OpenAiChatParsedRequest as OpenAiChatParsedRequestModel } from './model.js';
|
||||
|
||||
export const openAiChatTransformer = {
|
||||
protocol: 'openai/chat' as const,
|
||||
@@ -47,6 +48,20 @@ export const openAiChatTransformer = {
|
||||
) {
|
||||
return openAiChatOutbound.serializeFinal(normalized, usage);
|
||||
},
|
||||
serializeUpstreamFinalAsStream(
|
||||
payload: unknown,
|
||||
modelName: string,
|
||||
fallbackText: string,
|
||||
streamContext: StreamTransformContext,
|
||||
) {
|
||||
const normalizedFinal = openAiChatOutbound.normalizeFinal(payload, modelName, fallbackText);
|
||||
streamContext.id = normalizedFinal.id;
|
||||
streamContext.model = normalizedFinal.model;
|
||||
streamContext.created = normalizedFinal.created;
|
||||
return openAiChatOutbound
|
||||
.buildSyntheticChunks(normalizedFinal)
|
||||
.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`);
|
||||
},
|
||||
buildSyntheticChunks(normalized: NormalizedFinalResponse) {
|
||||
return openAiChatOutbound.buildSyntheticChunks(normalized);
|
||||
},
|
||||
@@ -56,4 +71,4 @@ export const openAiChatTransformer = {
|
||||
};
|
||||
|
||||
export type OpenAiChatTransformer = typeof openAiChatTransformer;
|
||||
export type OpenAiChatParsedRequest = ParsedDownstreamChatRequest;
|
||||
export type OpenAiChatParsedRequest = OpenAiChatParsedRequestModel;
|
||||
|
||||
@@ -4,18 +4,24 @@ import type {
|
||||
ParsedDownstreamChatRequest,
|
||||
} from '../../shared/normalized.js';
|
||||
|
||||
export type OpenAiChatAudioRequest = {
|
||||
format?: string;
|
||||
voice?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type OpenAiChatRequestMetadata = {
|
||||
modalities?: unknown;
|
||||
audio?: unknown;
|
||||
reasoningEffort?: unknown;
|
||||
reasoningBudget?: unknown;
|
||||
reasoningSummary?: unknown;
|
||||
serviceTier?: unknown;
|
||||
topLogprobs?: unknown;
|
||||
logitBias?: unknown;
|
||||
promptCacheKey?: unknown;
|
||||
safetyIdentifier?: unknown;
|
||||
verbosity?: unknown;
|
||||
modalities?: string[];
|
||||
audio?: OpenAiChatAudioRequest;
|
||||
reasoningEffort?: string;
|
||||
reasoningBudget?: number;
|
||||
reasoningSummary?: string;
|
||||
serviceTier?: string;
|
||||
topLogprobs?: number;
|
||||
logitBias?: Record<string, number>;
|
||||
promptCacheKey?: string;
|
||||
safetyIdentifier?: string;
|
||||
verbosity?: string;
|
||||
responseFormat?: unknown;
|
||||
streamOptionsIncludeUsage?: boolean | null;
|
||||
};
|
||||
@@ -29,14 +35,50 @@ export type OpenAiChatParsedRequest = ParsedDownstreamChatRequest & {
|
||||
requestMetadata?: OpenAiChatRequestMetadata;
|
||||
};
|
||||
|
||||
export type OpenAiChatToolCall = {
|
||||
id: string;
|
||||
name: string;
|
||||
arguments: string;
|
||||
};
|
||||
|
||||
export type OpenAiChatChoice = {
|
||||
index: number;
|
||||
role?: 'assistant';
|
||||
content: string;
|
||||
reasoningContent: string;
|
||||
toolCalls: OpenAiChatToolCall[];
|
||||
finishReason: string;
|
||||
annotations?: Array<Record<string, unknown>>;
|
||||
citations?: string[];
|
||||
};
|
||||
|
||||
export type OpenAiChatNormalizedFinalResponse = NormalizedFinalResponse & {
|
||||
choices?: OpenAiChatChoice[];
|
||||
annotations?: Array<Record<string, unknown>>;
|
||||
citations?: string[];
|
||||
usageDetails?: OpenAiChatUsageDetails;
|
||||
usagePayload?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type OpenAiChatChoiceDelta = {
|
||||
index: number;
|
||||
role?: 'assistant';
|
||||
contentDelta?: string;
|
||||
reasoningDelta?: string;
|
||||
toolCallDeltas?: Array<{
|
||||
index: number;
|
||||
id?: string;
|
||||
name?: string;
|
||||
argumentsDelta?: string;
|
||||
}>;
|
||||
finishReason?: string | null;
|
||||
annotations?: Array<Record<string, unknown>>;
|
||||
citations?: string[];
|
||||
};
|
||||
|
||||
export type OpenAiChatNormalizedStreamEvent = NormalizedStreamEvent & {
|
||||
choiceIndex?: number;
|
||||
choiceEvents?: OpenAiChatChoiceDelta[];
|
||||
annotations?: Array<Record<string, unknown>>;
|
||||
citations?: string[];
|
||||
usageDetails?: OpenAiChatUsageDetails;
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
serializeFinalResponse,
|
||||
type NormalizedFinalResponse,
|
||||
} from '../../shared/normalized.js';
|
||||
import { extractChatResponseExtras } from './helpers.js';
|
||||
import { extractChatChoices, extractChatResponseExtras } from './helpers.js';
|
||||
import type { OpenAiChatNormalizedFinalResponse } from './model.js';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
@@ -39,8 +39,13 @@ function mergeUsagePayload(
|
||||
|
||||
export const openAiChatOutbound = {
|
||||
normalizeFinal(payload: unknown, modelName: string, fallbackText = ''): OpenAiChatNormalizedFinalResponse {
|
||||
const choices = extractChatChoices(payload);
|
||||
const primaryChoice = choices[0];
|
||||
return {
|
||||
...normalizeUpstreamFinalResponse(payload, modelName, fallbackText),
|
||||
...(choices.length > 0 ? { choices } : {}),
|
||||
...(primaryChoice?.annotations?.length ? { annotations: primaryChoice.annotations } : {}),
|
||||
...(primaryChoice?.citations?.length ? { citations: primaryChoice.citations } : {}),
|
||||
...extractChatResponseExtras(payload),
|
||||
};
|
||||
},
|
||||
@@ -52,18 +57,54 @@ export const openAiChatOutbound = {
|
||||
usage as { promptTokens: number; completionTokens: number; totalTokens: number },
|
||||
) as Record<string, unknown>;
|
||||
|
||||
const choices = Array.isArray(chatNormalized.choices) && chatNormalized.choices.length > 0
|
||||
? chatNormalized.choices
|
||||
: [{
|
||||
index: 0,
|
||||
content: normalized.content,
|
||||
reasoningContent: normalized.reasoningContent,
|
||||
toolCalls: normalized.toolCalls,
|
||||
finishReason: normalized.toolCalls.length > 0 ? 'tool_calls' : normalized.finishReason,
|
||||
annotations: chatNormalized.annotations,
|
||||
citations: chatNormalized.citations,
|
||||
}];
|
||||
|
||||
payload.choices = choices.map((choice) => {
|
||||
const message: Record<string, unknown> = {
|
||||
role: choice.role || 'assistant',
|
||||
content: choice.content,
|
||||
};
|
||||
if (choice.reasoningContent) {
|
||||
message.reasoning_content = choice.reasoningContent;
|
||||
}
|
||||
if (Array.isArray(choice.toolCalls) && choice.toolCalls.length > 0) {
|
||||
message.tool_calls = choice.toolCalls.map((toolCall) => ({
|
||||
id: toolCall.id,
|
||||
type: 'function',
|
||||
function: {
|
||||
name: toolCall.name,
|
||||
arguments: toolCall.arguments,
|
||||
},
|
||||
}));
|
||||
if (!choice.content) {
|
||||
message.content = '';
|
||||
}
|
||||
}
|
||||
if (Array.isArray(choice.annotations) && choice.annotations.length > 0) {
|
||||
message.annotations = choice.annotations;
|
||||
}
|
||||
|
||||
return {
|
||||
index: choice.index,
|
||||
message,
|
||||
finish_reason: choice.finishReason || (choice.toolCalls?.length ? 'tool_calls' : 'stop'),
|
||||
};
|
||||
}).sort((left, right) => left.index - right.index);
|
||||
|
||||
if (Array.isArray(chatNormalized.citations) && chatNormalized.citations.length > 0) {
|
||||
payload.citations = chatNormalized.citations;
|
||||
}
|
||||
|
||||
if (Array.isArray(chatNormalized.annotations) && chatNormalized.annotations.length > 0) {
|
||||
const choices = Array.isArray(payload.choices) ? payload.choices : [];
|
||||
const firstChoice = choices[0];
|
||||
if (isRecord(firstChoice) && isRecord(firstChoice.message)) {
|
||||
firstChoice.message.annotations = chatNormalized.annotations;
|
||||
}
|
||||
}
|
||||
|
||||
const mergedUsage = mergeUsagePayload(payload.usage, chatNormalized);
|
||||
if (mergedUsage) {
|
||||
payload.usage = {
|
||||
@@ -75,8 +116,57 @@ export const openAiChatOutbound = {
|
||||
return payload;
|
||||
},
|
||||
buildSyntheticChunks(normalized: NormalizedFinalResponse) {
|
||||
const chunks = buildSyntheticOpenAiChunks(normalized);
|
||||
const chatNormalized = normalized as OpenAiChatNormalizedFinalResponse;
|
||||
const choices = Array.isArray(chatNormalized.choices) && chatNormalized.choices.length > 0
|
||||
? chatNormalized.choices
|
||||
: undefined;
|
||||
const chunks = choices && choices.length > 1
|
||||
? [
|
||||
{
|
||||
id: normalized.id,
|
||||
object: 'chat.completion.chunk',
|
||||
created: normalized.created,
|
||||
model: normalized.model,
|
||||
choices: choices.map((choice) => {
|
||||
const delta: Record<string, unknown> = {
|
||||
role: choice.role || 'assistant',
|
||||
content: choice.content || '',
|
||||
};
|
||||
if (choice.reasoningContent) delta.reasoning_content = choice.reasoningContent;
|
||||
if (choice.toolCalls.length > 0) {
|
||||
delta.tool_calls = choice.toolCalls.map((toolCall, toolIndex) => ({
|
||||
index: toolIndex,
|
||||
id: toolCall.id,
|
||||
type: 'function',
|
||||
function: {
|
||||
name: toolCall.name,
|
||||
arguments: toolCall.arguments,
|
||||
},
|
||||
}));
|
||||
}
|
||||
if (Array.isArray(choice.annotations) && choice.annotations.length > 0) {
|
||||
delta.annotations = choice.annotations;
|
||||
}
|
||||
return {
|
||||
index: choice.index,
|
||||
delta,
|
||||
finish_reason: null,
|
||||
};
|
||||
}).sort((left, right) => left.index - right.index),
|
||||
},
|
||||
{
|
||||
id: normalized.id,
|
||||
object: 'chat.completion.chunk',
|
||||
created: normalized.created,
|
||||
model: normalized.model,
|
||||
choices: choices.map((choice) => ({
|
||||
index: choice.index,
|
||||
delta: {},
|
||||
finish_reason: choice.finishReason || (choice.toolCalls.length > 0 ? 'tool_calls' : 'stop'),
|
||||
})).sort((left, right) => left.index - right.index),
|
||||
},
|
||||
]
|
||||
: buildSyntheticOpenAiChunks(normalized);
|
||||
if (chunks.length <= 0) return chunks;
|
||||
|
||||
if (Array.isArray(chatNormalized.citations) && chatNormalized.citations.length > 0) {
|
||||
@@ -86,7 +176,7 @@ export const openAiChatOutbound = {
|
||||
};
|
||||
}
|
||||
|
||||
if (Array.isArray(chatNormalized.annotations) && chatNormalized.annotations.length > 0) {
|
||||
if (!choices && Array.isArray(chatNormalized.annotations) && chatNormalized.annotations.length > 0) {
|
||||
const firstChunk = isRecord(chunks[0]) ? chunks[0] : null;
|
||||
const firstChoice = firstChunk && Array.isArray(firstChunk.choices) ? firstChunk.choices[0] : null;
|
||||
if (isRecord(firstChoice) && isRecord(firstChoice.delta)) {
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
type ClaudeDownstreamContext,
|
||||
type StreamTransformContext,
|
||||
} from '../../shared/normalized.js';
|
||||
import { extractChatResponseExtras } from './helpers.js';
|
||||
import { extractChatChoiceEvents, extractChatResponseExtras } from './helpers.js';
|
||||
import type { OpenAiChatNormalizedStreamEvent } from './model.js';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
@@ -36,8 +36,23 @@ export const openAiChatStream = {
|
||||
return createStreamTransformContext(modelName);
|
||||
},
|
||||
normalizeEvent(payload: unknown, context: StreamTransformContext, modelName: string): OpenAiChatNormalizedStreamEvent {
|
||||
const choiceEvents = extractChatChoiceEvents(payload);
|
||||
const primaryChoice = choiceEvents[0];
|
||||
return {
|
||||
...normalizeUpstreamStreamEvent(payload, context, modelName),
|
||||
...(primaryChoice
|
||||
? {
|
||||
choiceIndex: primaryChoice.index,
|
||||
role: primaryChoice.role,
|
||||
contentDelta: primaryChoice.contentDelta,
|
||||
reasoningDelta: primaryChoice.reasoningDelta,
|
||||
toolCallDeltas: primaryChoice.toolCallDeltas,
|
||||
finishReason: primaryChoice.finishReason,
|
||||
annotations: primaryChoice.annotations,
|
||||
citations: primaryChoice.citations,
|
||||
}
|
||||
: {}),
|
||||
...(choiceEvents.length > 0 ? { choiceEvents } : {}),
|
||||
...extractChatResponseExtras(payload),
|
||||
};
|
||||
},
|
||||
@@ -46,6 +61,60 @@ export const openAiChatStream = {
|
||||
context: StreamTransformContext,
|
||||
downstreamContext?: ClaudeDownstreamContext,
|
||||
): string[] {
|
||||
if (
|
||||
Array.isArray(event.choiceEvents)
|
||||
&& event.choiceEvents.length > 0
|
||||
&& (event.choiceEvents.length > 1 || event.choiceEvents[0]?.index !== 0)
|
||||
) {
|
||||
const usagePayload = event.usagePayload
|
||||
? {
|
||||
...(isRecord(event.usagePayload) ? event.usagePayload : {}),
|
||||
...(event.usageDetails?.prompt_tokens_details
|
||||
? { prompt_tokens_details: event.usageDetails.prompt_tokens_details }
|
||||
: {}),
|
||||
...(event.usageDetails?.completion_tokens_details
|
||||
? { completion_tokens_details: event.usageDetails.completion_tokens_details }
|
||||
: {}),
|
||||
}
|
||||
: undefined;
|
||||
return [`data: ${JSON.stringify({
|
||||
id: context.id || `chatcmpl-${Date.now()}`,
|
||||
object: 'chat.completion.chunk',
|
||||
created: context.created,
|
||||
model: context.model,
|
||||
choices: event.choiceEvents
|
||||
.map((choiceEvent) => {
|
||||
const delta: Record<string, unknown> = {};
|
||||
if (choiceEvent.role) delta.role = choiceEvent.role;
|
||||
if (choiceEvent.contentDelta !== undefined) delta.content = choiceEvent.contentDelta;
|
||||
if (choiceEvent.reasoningDelta) delta.reasoning_content = choiceEvent.reasoningDelta;
|
||||
if (Array.isArray(choiceEvent.toolCallDeltas) && choiceEvent.toolCallDeltas.length > 0) {
|
||||
delta.tool_calls = choiceEvent.toolCallDeltas.map((toolCall) => ({
|
||||
index: toolCall.index,
|
||||
...(toolCall.id ? { id: toolCall.id } : {}),
|
||||
type: 'function',
|
||||
function: {
|
||||
...(toolCall.name ? { name: toolCall.name } : {}),
|
||||
...(toolCall.argumentsDelta !== undefined ? { arguments: toolCall.argumentsDelta } : {}),
|
||||
},
|
||||
}));
|
||||
}
|
||||
if (Array.isArray(choiceEvent.annotations) && choiceEvent.annotations.length > 0) {
|
||||
delta.annotations = choiceEvent.annotations;
|
||||
}
|
||||
|
||||
return {
|
||||
index: choiceEvent.index,
|
||||
delta,
|
||||
finish_reason: choiceEvent.finishReason ?? null,
|
||||
};
|
||||
})
|
||||
.sort((left, right) => left.index - right.index),
|
||||
...(Array.isArray(event.citations) && event.citations.length > 0 ? { citations: event.citations } : {}),
|
||||
...(usagePayload && Object.keys(usagePayload).length > 0 ? { usage: usagePayload } : {}),
|
||||
})}\n\n`];
|
||||
}
|
||||
|
||||
const lines = serializeNormalizedStreamEvent(
|
||||
'openai',
|
||||
event,
|
||||
|
||||
Reference in New Issue
Block a user