feat: Add normalized types and utility functions for handling chat formats
- Introduced `NormalizedContentBlock`, `NormalizedUsage`, and related types for structured data handling. - Implemented utility functions for creating and merging normalized usage statistics. - Added a new module for shared transformers to manage chat formats. feat: Implement app local state management - Created functions to clear application installation state and manage local storage keys. - Added constants for theme and user profile storage keys. test: Add tests for BrandIcon component rendering - Implemented tests to ensure BrandIcon does not force a colored background for recognized brands. test: Enhance ModelAnalysisPanel tests - Added tests for rendering token summaries with compact units in ModelAnalysisPanel. test: Add tests for ModernSelect component - Implemented tests to verify icon rendering for selected options in ModernSelect. test: Add number formatting tests - Created tests for `formatCompactTokenMetric` to validate token formatting logic. feat: Implement account creation intent handling - Added logic to handle session and API key creation intents in the Accounts page. test: Add tests for dashboard performance card - Implemented tests to verify rendering of performance metrics in the dashboard. feat: Implement default connection segment resolution - Added logic to determine initial connection segments based on platform types. test: Add tests for settings factory reset functionality - Implemented tests to verify the factory reset process and local state clearing. test: Add tests for site creation redirect logic - Implemented tests to verify redirection based on site capabilities during creation. test: Add tests for token routes refresh decision action - Implemented tests to verify refresh decision actions in TokenRoutes. test: Add tests for token routes snapshot load - Implemented tests to verify loading of cached snapshot data in TokenRoutes.
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
import { type NormalizedFinalResponse, type NormalizedStreamEvent } from '../../shared/normalized.js';
|
||||
import type {
|
||||
OpenAiChatNormalizedFinalResponse,
|
||||
OpenAiChatNormalizedStreamEvent,
|
||||
OpenAiChatUsageDetails,
|
||||
} from './model.js';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object';
|
||||
}
|
||||
|
||||
function pushUniqueAnnotation(
|
||||
annotations: Array<Record<string, unknown>>,
|
||||
seenUrls: Set<string>,
|
||||
candidate: unknown,
|
||||
) {
|
||||
if (!isRecord(candidate)) return;
|
||||
const citation = isRecord(candidate.url_citation) ? candidate.url_citation : null;
|
||||
const url = citation && typeof citation.url === 'string' ? citation.url.trim() : '';
|
||||
if (!url || seenUrls.has(url)) return;
|
||||
seenUrls.add(url);
|
||||
annotations.push(candidate);
|
||||
}
|
||||
|
||||
function mergeUsageDetails(
|
||||
target: OpenAiChatUsageDetails | undefined,
|
||||
next: OpenAiChatUsageDetails | undefined,
|
||||
): OpenAiChatUsageDetails | undefined {
|
||||
if (!target && !next) return undefined;
|
||||
return {
|
||||
...(target || {}),
|
||||
...(next || {}),
|
||||
};
|
||||
}
|
||||
|
||||
export type OpenAiChatAggregateState = {
|
||||
content: string[];
|
||||
reasoning: string[];
|
||||
toolCalls: Array<{ id: string; name: string; arguments: string }>;
|
||||
finishReason: string | null;
|
||||
annotations: Array<Record<string, unknown>>;
|
||||
annotationUrls: Set<string>;
|
||||
citations: Set<string>;
|
||||
usageDetails?: OpenAiChatUsageDetails;
|
||||
};
|
||||
|
||||
export function createOpenAiChatAggregateState(): OpenAiChatAggregateState {
|
||||
return {
|
||||
content: [],
|
||||
reasoning: [],
|
||||
toolCalls: [],
|
||||
finishReason: null,
|
||||
annotations: [],
|
||||
annotationUrls: new Set<string>(),
|
||||
citations: new Set<string>(),
|
||||
usageDetails: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
if (Array.isArray(event.citations)) {
|
||||
for (const citation of event.citations) {
|
||||
if (typeof citation === 'string' && citation.trim()) {
|
||||
state.citations.add(citation.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
state.usageDetails = mergeUsageDetails(state.usageDetails, event.usageDetails);
|
||||
return state;
|
||||
}
|
||||
|
||||
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
|
||||
) {
|
||||
return normalized;
|
||||
}
|
||||
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,
|
||||
citations: state.citations.size > 0 ? Array.from(state.citations).sort() : normalized.citations,
|
||||
usageDetails: mergeUsageDetails(normalized.usageDetails, state.usageDetails),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import type {
|
||||
OpenAiChatNormalizedFinalResponse,
|
||||
OpenAiChatNormalizedStreamEvent,
|
||||
OpenAiChatRequestMetadata,
|
||||
OpenAiChatUsageDetails,
|
||||
} from './model.js';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return isRecord(value) ? value : null;
|
||||
}
|
||||
|
||||
function toStringArray(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value
|
||||
.map((item) => {
|
||||
if (typeof item === 'string') return item.trim();
|
||||
if (isRecord(item)) {
|
||||
const direct = typeof item.url === 'string'
|
||||
? item.url
|
||||
: typeof item.uri === 'string'
|
||||
? item.uri
|
||||
: '';
|
||||
return direct.trim();
|
||||
}
|
||||
return '';
|
||||
})
|
||||
.filter((item) => item.length > 0);
|
||||
}
|
||||
|
||||
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);
|
||||
if (!Number.isFinite(numeric)) return null;
|
||||
return [key, numeric] as const;
|
||||
})
|
||||
.filter((entry): entry is readonly [string, number] => !!entry);
|
||||
if (entries.length <= 0) return undefined;
|
||||
return Object.fromEntries(entries);
|
||||
}
|
||||
|
||||
function stableKey(value: unknown): string {
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.map((item) => stableKey(item)).join(',')}]`;
|
||||
}
|
||||
if (isRecord(value)) {
|
||||
const entries = Object.entries(value)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, nested]) => `${JSON.stringify(key)}:${stableKey(nested)}`);
|
||||
return `{${entries.join(',')}}`;
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function annotationUrl(annotation: Record<string, unknown>): string {
|
||||
const urlCitation = asRecord(annotation.url_citation) ?? asRecord(annotation.urlCitation);
|
||||
const direct = typeof annotation.url === 'string' ? annotation.url : '';
|
||||
const nested = urlCitation && typeof urlCitation.url === 'string' ? urlCitation.url : '';
|
||||
return (direct || nested).trim();
|
||||
}
|
||||
|
||||
export function dedupeAnnotations(input: unknown): Array<Record<string, unknown>> {
|
||||
if (!Array.isArray(input)) return [];
|
||||
const seen = new Set<string>();
|
||||
const result: Array<Record<string, unknown>> = [];
|
||||
|
||||
for (const item of input) {
|
||||
if (!isRecord(item)) continue;
|
||||
const key = annotationUrl(item) || stableKey(item);
|
||||
if (!key || seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
result.push(item);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function dedupeCitations(input: unknown): string[] {
|
||||
const citations = new Set<string>();
|
||||
|
||||
for (const citation of toStringArray(input)) {
|
||||
citations.add(citation);
|
||||
}
|
||||
|
||||
if (Array.isArray(input)) {
|
||||
for (const item of input) {
|
||||
if (!isRecord(item)) continue;
|
||||
const nestedUrl = annotationUrl(item);
|
||||
if (nestedUrl) citations.add(nestedUrl);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(citations);
|
||||
}
|
||||
|
||||
function extractAnnotationsFromPayload(payload: Record<string, unknown>): Array<Record<string, unknown>> {
|
||||
const collected: Array<Record<string, unknown>> = [];
|
||||
const append = (value: unknown) => {
|
||||
for (const item of dedupeAnnotations(value)) {
|
||||
collected.push(item);
|
||||
}
|
||||
};
|
||||
|
||||
append(payload.annotations);
|
||||
|
||||
const choices = Array.isArray(payload.choices) ? payload.choices : [];
|
||||
for (const choice of choices) {
|
||||
const choiceRecord = asRecord(choice);
|
||||
if (!choiceRecord) continue;
|
||||
append(asRecord(choiceRecord.message)?.annotations);
|
||||
append(asRecord(choiceRecord.delta)?.annotations);
|
||||
}
|
||||
|
||||
return dedupeAnnotations(collected);
|
||||
}
|
||||
|
||||
function extractCitationsFromPayload(
|
||||
payload: Record<string, unknown>,
|
||||
annotations: Array<Record<string, unknown>>,
|
||||
): string[] {
|
||||
const citations = new Set<string>();
|
||||
|
||||
for (const citation of dedupeCitations(payload.citations)) {
|
||||
citations.add(citation);
|
||||
}
|
||||
|
||||
const transformerMetadata = asRecord(payload.transformer_metadata) ?? asRecord(payload.transformerMetadata);
|
||||
if (transformerMetadata) {
|
||||
for (const citation of dedupeCitations(transformerMetadata.citations)) {
|
||||
citations.add(citation);
|
||||
}
|
||||
}
|
||||
|
||||
for (const annotation of annotations) {
|
||||
const url = annotationUrl(annotation);
|
||||
if (url) citations.add(url);
|
||||
}
|
||||
|
||||
return Array.from(citations);
|
||||
}
|
||||
|
||||
export function extractChatUsageDetails(payload: Record<string, unknown>): OpenAiChatUsageDetails | undefined {
|
||||
const usage = asRecord(payload.usage);
|
||||
if (!usage) return undefined;
|
||||
|
||||
const promptDetails = toNumericRecord(usage.prompt_tokens_details ?? usage.promptTokensDetails);
|
||||
const completionDetails = toNumericRecord(usage.completion_tokens_details ?? usage.completionTokensDetails);
|
||||
if (!promptDetails && !completionDetails) return undefined;
|
||||
|
||||
return {
|
||||
...(promptDetails ? { prompt_tokens_details: promptDetails } : {}),
|
||||
...(completionDetails ? { completion_tokens_details: completionDetails } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function mergeChatUsageDetails(
|
||||
base: OpenAiChatUsageDetails | undefined,
|
||||
next: OpenAiChatUsageDetails | undefined,
|
||||
): OpenAiChatUsageDetails | undefined {
|
||||
if (!base && !next) return undefined;
|
||||
|
||||
const mergeNumericMaps = (
|
||||
left: Record<string, number> | undefined,
|
||||
right: Record<string, number> | undefined,
|
||||
): Record<string, number> | undefined => {
|
||||
if (!left && !right) return undefined;
|
||||
return Object.fromEntries(
|
||||
Array.from(new Set([...Object.keys(left || {}), ...Object.keys(right || {})]))
|
||||
.map((key) => [key, Math.max(left?.[key] || 0, right?.[key] || 0)]),
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
...(mergeNumericMaps(base?.prompt_tokens_details, next?.prompt_tokens_details)
|
||||
? { prompt_tokens_details: mergeNumericMaps(base?.prompt_tokens_details, next?.prompt_tokens_details)! }
|
||||
: {}),
|
||||
...(mergeNumericMaps(base?.completion_tokens_details, next?.completion_tokens_details)
|
||||
? { completion_tokens_details: mergeNumericMaps(base?.completion_tokens_details, next?.completion_tokens_details)! }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function extractUsagePayload(payload: Record<string, unknown>): Record<string, unknown> | undefined {
|
||||
const usage = asRecord(payload.usage);
|
||||
if (!usage) return undefined;
|
||||
return usage;
|
||||
}
|
||||
|
||||
export function extractChatResponseExtras(payload: unknown): Pick<
|
||||
OpenAiChatNormalizedFinalResponse,
|
||||
'annotations' | 'citations' | 'usageDetails' | 'usagePayload'
|
||||
> & Pick<OpenAiChatNormalizedStreamEvent, 'annotations' | 'citations' | 'usageDetails' | 'usagePayload'> {
|
||||
const record = asRecord(payload);
|
||||
if (!record) return {};
|
||||
|
||||
const annotations = extractAnnotationsFromPayload(record);
|
||||
const citations = extractCitationsFromPayload(record, annotations);
|
||||
const usageDetails = extractChatUsageDetails(record);
|
||||
const usagePayload = extractUsagePayload(record);
|
||||
|
||||
return {
|
||||
...(annotations.length > 0 ? { annotations } : {}),
|
||||
...(citations.length > 0 ? { citations } : {}),
|
||||
...(usageDetails ? { usageDetails } : {}),
|
||||
...(usagePayload ? { usagePayload } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function extractChatRequestMetadata(body: unknown): OpenAiChatRequestMetadata | undefined {
|
||||
const raw = asRecord(body);
|
||||
if (!raw) return undefined;
|
||||
|
||||
const streamOptions = asRecord(raw.stream_options) ?? asRecord(raw.streamOptions);
|
||||
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 } : {}),
|
||||
...(raw.response_format !== undefined ? { responseFormat: raw.response_format } : {}),
|
||||
...(streamOptions && streamOptions.include_usage !== undefined
|
||||
? { streamOptionsIncludeUsage: Boolean(streamOptions.include_usage) }
|
||||
: {}),
|
||||
};
|
||||
|
||||
return Object.keys(metadata).length > 0 ? metadata : undefined;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { parseDownstreamChatRequest } from '../../shared/normalized.js';
|
||||
import { extractChatRequestMetadata } from './helpers.js';
|
||||
import type { OpenAiChatParsedRequest } from './model.js';
|
||||
|
||||
export const openAiChatInbound = {
|
||||
parse(body: unknown): { value?: OpenAiChatParsedRequest; error?: { statusCode: number; payload: unknown } } {
|
||||
const parsed = parseDownstreamChatRequest(body, 'openai') as {
|
||||
value?: OpenAiChatParsedRequest;
|
||||
error?: { statusCode: number; payload: unknown };
|
||||
};
|
||||
if (!parsed.value) return parsed;
|
||||
|
||||
parsed.value = {
|
||||
...parsed.value,
|
||||
requestMetadata: extractChatRequestMetadata(body),
|
||||
};
|
||||
return parsed;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,234 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { createClaudeDownstreamContext } from '../../shared/normalized.js';
|
||||
import { openAiChatTransformer } from './index.js';
|
||||
|
||||
function parseSsePayloads(lines: string[]): Array<Record<string, unknown>> {
|
||||
return lines
|
||||
.filter((line) => line.startsWith('data: ') && line.trim() !== 'data: [DONE]')
|
||||
.map((line) => JSON.parse(line.slice(6)) as Record<string, unknown>);
|
||||
}
|
||||
|
||||
describe('openAiChatTransformer.inbound', () => {
|
||||
it('captures chat request metadata fields without changing upstream body', () => {
|
||||
const result = openAiChatTransformer.transformRequest({
|
||||
model: 'gpt-5',
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
modalities: ['text', 'audio'],
|
||||
audio: { voice: 'alloy', format: 'mp3' },
|
||||
reasoning_effort: 'high',
|
||||
reasoning_budget: 1024,
|
||||
reasoning_summary: 'detailed',
|
||||
service_tier: 'priority',
|
||||
top_logprobs: 3,
|
||||
logit_bias: { '42': 5 },
|
||||
prompt_cache_key: 'cache-key',
|
||||
safety_identifier: 'safety-id',
|
||||
verbosity: 'low',
|
||||
response_format: { type: 'json_object' },
|
||||
stream_options: { include_usage: true },
|
||||
});
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.value?.upstreamBody).toMatchObject({
|
||||
modalities: ['text', 'audio'],
|
||||
audio: { voice: 'alloy', format: 'mp3' },
|
||||
reasoning_effort: 'high',
|
||||
reasoning_budget: 1024,
|
||||
reasoning_summary: 'detailed',
|
||||
service_tier: 'priority',
|
||||
top_logprobs: 3,
|
||||
logit_bias: { '42': 5 },
|
||||
prompt_cache_key: 'cache-key',
|
||||
safety_identifier: 'safety-id',
|
||||
verbosity: 'low',
|
||||
response_format: { type: 'json_object' },
|
||||
stream_options: { include_usage: true },
|
||||
});
|
||||
expect((result.value as any)?.requestMetadata).toEqual({
|
||||
modalities: ['text', 'audio'],
|
||||
audio: { voice: 'alloy', format: 'mp3' },
|
||||
reasoningEffort: 'high',
|
||||
reasoningBudget: 1024,
|
||||
reasoningSummary: 'detailed',
|
||||
serviceTier: 'priority',
|
||||
topLogprobs: 3,
|
||||
logitBias: { '42': 5 },
|
||||
promptCacheKey: 'cache-key',
|
||||
safetyIdentifier: 'safety-id',
|
||||
verbosity: 'low',
|
||||
responseFormat: { type: 'json_object' },
|
||||
streamOptionsIncludeUsage: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('openAiChatTransformer.outbound', () => {
|
||||
it('carries annotations, citations, and detailed usage through final serialization', () => {
|
||||
const normalized = openAiChatTransformer.transformFinalResponse({
|
||||
id: 'chatcmpl-1',
|
||||
model: 'gpt-5',
|
||||
created: 123,
|
||||
choices: [{
|
||||
index: 0,
|
||||
finish_reason: 'stop',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: 'hello',
|
||||
reasoning_content: 'think',
|
||||
annotations: [
|
||||
{ type: 'url_citation', url_citation: { url: 'https://a.example' } },
|
||||
{ type: 'url_citation', url_citation: { url: 'https://a.example' } },
|
||||
],
|
||||
},
|
||||
}],
|
||||
citations: ['https://c.example', 'https://c.example'],
|
||||
usage: {
|
||||
prompt_tokens: 11,
|
||||
completion_tokens: 7,
|
||||
total_tokens: 18,
|
||||
prompt_tokens_details: { cached_tokens: 3 },
|
||||
completion_tokens_details: { reasoning_tokens: 2 },
|
||||
},
|
||||
}, 'gpt-5');
|
||||
|
||||
const payload = openAiChatTransformer.serializeFinalResponse(normalized, {
|
||||
promptTokens: 11,
|
||||
completionTokens: 7,
|
||||
totalTokens: 18,
|
||||
});
|
||||
|
||||
const choice = (payload as any).choices[0];
|
||||
expect(choice.message.annotations).toEqual([
|
||||
{ type: 'url_citation', url_citation: { url: 'https://a.example' } },
|
||||
]);
|
||||
expect((payload as any).citations).toEqual(['https://c.example', 'https://a.example']);
|
||||
expect((payload as any).usage).toMatchObject({
|
||||
prompt_tokens: 11,
|
||||
completion_tokens: 7,
|
||||
total_tokens: 18,
|
||||
prompt_tokens_details: { cached_tokens: 3 },
|
||||
completion_tokens_details: { reasoning_tokens: 2 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('openAiChatTransformer.stream', () => {
|
||||
it('preserves annotations, citations, and usage payload on serialized stream chunks', () => {
|
||||
const context = openAiChatTransformer.createStreamContext('gpt-5');
|
||||
const event = openAiChatTransformer.transformStreamEvent({
|
||||
id: 'chatcmpl-1',
|
||||
model: 'gpt-5',
|
||||
choices: [{
|
||||
index: 0,
|
||||
finish_reason: null,
|
||||
delta: {
|
||||
role: 'assistant',
|
||||
content: 'hello',
|
||||
reasoning_content: 'why',
|
||||
annotations: [
|
||||
{ type: 'url_citation', url_citation: { url: 'https://a.example' } },
|
||||
],
|
||||
},
|
||||
}],
|
||||
citations: ['https://c.example', 'https://a.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]).toMatchObject({
|
||||
citations: ['https://c.example', 'https://a.example'],
|
||||
usage: {
|
||||
prompt_tokens: 11,
|
||||
completion_tokens: 7,
|
||||
total_tokens: 18,
|
||||
prompt_tokens_details: { cached_tokens: 3 },
|
||||
},
|
||||
});
|
||||
expect(((payloads[0] as any).choices[0] as any).delta.annotations).toEqual([
|
||||
{ type: 'url_citation', url_citation: { url: 'https://a.example' } },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('openAiChatTransformer.aggregator', () => {
|
||||
it('deduplicates annotations/citations while aggregating reasoning, tool calls, and usage details', () => {
|
||||
const state = openAiChatTransformer.aggregator.createState();
|
||||
|
||||
openAiChatTransformer.aggregator.applyEvent(state, {
|
||||
contentDelta: 'hel',
|
||||
reasoningDelta: 'why',
|
||||
toolCallDeltas: [{
|
||||
index: 0,
|
||||
id: 'call_1',
|
||||
name: 'search',
|
||||
argumentsDelta: '{"q":"cat"',
|
||||
}],
|
||||
annotations: [
|
||||
{ type: 'url_citation', url_citation: { url: 'https://a.example' } },
|
||||
],
|
||||
citations: ['https://c.example'],
|
||||
usageDetails: {
|
||||
prompt_tokens_details: { cached_tokens: 2 },
|
||||
},
|
||||
} as any);
|
||||
|
||||
openAiChatTransformer.aggregator.applyEvent(state, {
|
||||
contentDelta: 'lo',
|
||||
reasoningDelta: ' now',
|
||||
toolCallDeltas: [{
|
||||
index: 0,
|
||||
id: 'call_1',
|
||||
name: 'search',
|
||||
argumentsDelta: ',"k":1}',
|
||||
}],
|
||||
annotations: [
|
||||
{ type: 'url_citation', url_citation: { url: 'https://a.example' } },
|
||||
{ type: 'url_citation', url_citation: { url: 'https://b.example' } },
|
||||
],
|
||||
citations: ['https://c.example', 'https://d.example'],
|
||||
usageDetails: {
|
||||
completion_tokens_details: { reasoning_tokens: 4 },
|
||||
},
|
||||
finishReason: 'tool_calls',
|
||||
} as any);
|
||||
|
||||
const normalized = openAiChatTransformer.aggregator.finalize(state, {
|
||||
id: 'chatcmpl-1',
|
||||
model: 'gpt-5',
|
||||
created: 123,
|
||||
content: '',
|
||||
reasoningContent: '',
|
||||
finishReason: 'stop',
|
||||
toolCalls: [],
|
||||
});
|
||||
|
||||
expect(normalized).toMatchObject({
|
||||
content: 'hello',
|
||||
reasoningContent: 'why now',
|
||||
finishReason: 'tool_calls',
|
||||
toolCalls: [{
|
||||
id: 'call_1',
|
||||
name: 'search',
|
||||
arguments: '{"q":"cat","k":1}',
|
||||
}],
|
||||
citations: ['https://c.example', 'https://d.example'],
|
||||
usageDetails: {
|
||||
prompt_tokens_details: { cached_tokens: 2 },
|
||||
completion_tokens_details: { reasoning_tokens: 4 },
|
||||
},
|
||||
});
|
||||
expect((normalized as any).annotations).toEqual([
|
||||
{ type: 'url_citation', url_citation: { url: 'https://a.example' } },
|
||||
{ type: 'url_citation', url_citation: { url: 'https://b.example' } },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { type NormalizedFinalResponse, type NormalizedStreamEvent, type ParsedDownstreamChatRequest, 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';
|
||||
|
||||
export const openAiChatTransformer = {
|
||||
protocol: 'openai/chat' as const,
|
||||
inbound: openAiChatInbound,
|
||||
outbound: openAiChatOutbound,
|
||||
stream: openAiChatStream,
|
||||
usage: openAiChatUsage,
|
||||
aggregator: {
|
||||
createState: createOpenAiChatAggregateState,
|
||||
applyEvent: applyOpenAiChatStreamEvent,
|
||||
finalize: finalizeOpenAiChatAggregate,
|
||||
},
|
||||
transformRequest(body: unknown): ReturnType<typeof openAiChatInbound.parse> {
|
||||
return openAiChatInbound.parse(body);
|
||||
},
|
||||
createStreamContext(modelName: string): StreamTransformContext {
|
||||
return openAiChatStream.createContext(modelName);
|
||||
},
|
||||
transformFinalResponse(payload: unknown, modelName: string, fallbackText = ''): NormalizedFinalResponse {
|
||||
return openAiChatOutbound.normalizeFinal(payload, modelName, fallbackText);
|
||||
},
|
||||
transformStreamEvent(payload: unknown, context: StreamTransformContext, modelName: string): NormalizedStreamEvent {
|
||||
return openAiChatStream.normalizeEvent(payload, context, modelName);
|
||||
},
|
||||
serializeStreamEvent(
|
||||
event: NormalizedStreamEvent,
|
||||
context: StreamTransformContext,
|
||||
claudeContext: Parameters<typeof openAiChatStream.serializeEvent>[2],
|
||||
): string[] {
|
||||
return openAiChatStream.serializeEvent(event, context, claudeContext);
|
||||
},
|
||||
serializeDone(
|
||||
context: StreamTransformContext,
|
||||
claudeContext: Parameters<typeof openAiChatStream.serializeDone>[1],
|
||||
): string[] {
|
||||
return openAiChatStream.serializeDone(context, claudeContext);
|
||||
},
|
||||
serializeFinalResponse(
|
||||
normalized: NormalizedFinalResponse,
|
||||
usage: Parameters<typeof openAiChatOutbound.serializeFinal>[1],
|
||||
) {
|
||||
return openAiChatOutbound.serializeFinal(normalized, usage);
|
||||
},
|
||||
buildSyntheticChunks(normalized: NormalizedFinalResponse) {
|
||||
return openAiChatOutbound.buildSyntheticChunks(normalized);
|
||||
},
|
||||
pullSseEvents(buffer: string) {
|
||||
return openAiChatStream.pullSseEvents(buffer);
|
||||
},
|
||||
};
|
||||
|
||||
export type OpenAiChatTransformer = typeof openAiChatTransformer;
|
||||
export type OpenAiChatParsedRequest = ParsedDownstreamChatRequest;
|
||||
@@ -0,0 +1,44 @@
|
||||
import type {
|
||||
NormalizedFinalResponse,
|
||||
NormalizedStreamEvent,
|
||||
ParsedDownstreamChatRequest,
|
||||
} from '../../shared/normalized.js';
|
||||
|
||||
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;
|
||||
responseFormat?: unknown;
|
||||
streamOptionsIncludeUsage?: boolean | null;
|
||||
};
|
||||
|
||||
export type OpenAiChatUsageDetails = {
|
||||
prompt_tokens_details?: Record<string, number>;
|
||||
completion_tokens_details?: Record<string, number>;
|
||||
};
|
||||
|
||||
export type OpenAiChatParsedRequest = ParsedDownstreamChatRequest & {
|
||||
requestMetadata?: OpenAiChatRequestMetadata;
|
||||
};
|
||||
|
||||
export type OpenAiChatNormalizedFinalResponse = NormalizedFinalResponse & {
|
||||
annotations?: Array<Record<string, unknown>>;
|
||||
citations?: string[];
|
||||
usageDetails?: OpenAiChatUsageDetails;
|
||||
usagePayload?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type OpenAiChatNormalizedStreamEvent = NormalizedStreamEvent & {
|
||||
annotations?: Array<Record<string, unknown>>;
|
||||
citations?: string[];
|
||||
usageDetails?: OpenAiChatUsageDetails;
|
||||
usagePayload?: Record<string, unknown>;
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
import {
|
||||
buildSyntheticOpenAiChunks,
|
||||
normalizeUpstreamFinalResponse,
|
||||
serializeFinalResponse,
|
||||
type NormalizedFinalResponse,
|
||||
} from '../../shared/normalized.js';
|
||||
import { extractChatResponseExtras } from './helpers.js';
|
||||
import type { OpenAiChatNormalizedFinalResponse } from './model.js';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function mergeUsagePayload(
|
||||
usage: unknown,
|
||||
normalized: OpenAiChatNormalizedFinalResponse,
|
||||
): Record<string, unknown> | undefined {
|
||||
const merged = isRecord(usage) ? { ...usage } : {};
|
||||
const usagePayload = normalized.usagePayload && isRecord(normalized.usagePayload)
|
||||
? normalized.usagePayload
|
||||
: null;
|
||||
|
||||
if (usagePayload) {
|
||||
for (const [key, value] of Object.entries(usagePayload)) {
|
||||
if (key === 'prompt_tokens' || key === 'completion_tokens' || key === 'total_tokens') continue;
|
||||
if (merged[key] === undefined) merged[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
if (normalized.usageDetails?.prompt_tokens_details) {
|
||||
merged.prompt_tokens_details = normalized.usageDetails.prompt_tokens_details;
|
||||
}
|
||||
if (normalized.usageDetails?.completion_tokens_details) {
|
||||
merged.completion_tokens_details = normalized.usageDetails.completion_tokens_details;
|
||||
}
|
||||
|
||||
return Object.keys(merged).length > 0 ? merged : undefined;
|
||||
}
|
||||
|
||||
export const openAiChatOutbound = {
|
||||
normalizeFinal(payload: unknown, modelName: string, fallbackText = ''): OpenAiChatNormalizedFinalResponse {
|
||||
return {
|
||||
...normalizeUpstreamFinalResponse(payload, modelName, fallbackText),
|
||||
...extractChatResponseExtras(payload),
|
||||
};
|
||||
},
|
||||
serializeFinal(normalized: NormalizedFinalResponse, usage?: unknown) {
|
||||
const chatNormalized = normalized as OpenAiChatNormalizedFinalResponse;
|
||||
const payload = serializeFinalResponse(
|
||||
'openai',
|
||||
normalized,
|
||||
usage as { promptTokens: number; completionTokens: number; totalTokens: number },
|
||||
) as Record<string, unknown>;
|
||||
|
||||
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 = {
|
||||
...(isRecord(payload.usage) ? payload.usage : {}),
|
||||
...mergedUsage,
|
||||
};
|
||||
}
|
||||
|
||||
return payload;
|
||||
},
|
||||
buildSyntheticChunks(normalized: NormalizedFinalResponse) {
|
||||
const chunks = buildSyntheticOpenAiChunks(normalized);
|
||||
const chatNormalized = normalized as OpenAiChatNormalizedFinalResponse;
|
||||
if (chunks.length <= 0) return chunks;
|
||||
|
||||
if (Array.isArray(chatNormalized.citations) && chatNormalized.citations.length > 0) {
|
||||
chunks[0] = {
|
||||
...chunks[0],
|
||||
citations: chatNormalized.citations,
|
||||
};
|
||||
}
|
||||
|
||||
if (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)) {
|
||||
firstChoice.delta.annotations = chatNormalized.annotations;
|
||||
}
|
||||
}
|
||||
|
||||
return chunks;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,109 @@
|
||||
import {
|
||||
createClaudeDownstreamContext,
|
||||
createStreamTransformContext,
|
||||
normalizeUpstreamStreamEvent,
|
||||
pullSseEventsWithDone,
|
||||
serializeNormalizedStreamEvent,
|
||||
serializeStreamDone,
|
||||
type ClaudeDownstreamContext,
|
||||
type StreamTransformContext,
|
||||
} from '../../shared/normalized.js';
|
||||
import { extractChatResponseExtras } from './helpers.js';
|
||||
import type { OpenAiChatNormalizedStreamEvent } from './model.js';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function parseSerializedSse(lines: string[]): Array<{ index: number; payload: Record<string, unknown> }> {
|
||||
return lines
|
||||
.map((line, index) => {
|
||||
if (!line.startsWith('data: ') || line.trim() === 'data: [DONE]') return null;
|
||||
try {
|
||||
return {
|
||||
index,
|
||||
payload: JSON.parse(line.slice(6)) as Record<string, unknown>,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter((item): item is { index: number; payload: Record<string, unknown> } => !!item);
|
||||
}
|
||||
|
||||
export const openAiChatStream = {
|
||||
createContext(modelName: string): StreamTransformContext {
|
||||
return createStreamTransformContext(modelName);
|
||||
},
|
||||
normalizeEvent(payload: unknown, context: StreamTransformContext, modelName: string): OpenAiChatNormalizedStreamEvent {
|
||||
return {
|
||||
...normalizeUpstreamStreamEvent(payload, context, modelName),
|
||||
...extractChatResponseExtras(payload),
|
||||
};
|
||||
},
|
||||
serializeEvent(
|
||||
event: OpenAiChatNormalizedStreamEvent,
|
||||
context: StreamTransformContext,
|
||||
downstreamContext?: ClaudeDownstreamContext,
|
||||
): string[] {
|
||||
const lines = serializeNormalizedStreamEvent(
|
||||
'openai',
|
||||
event,
|
||||
context,
|
||||
downstreamContext ?? createClaudeDownstreamContext(),
|
||||
);
|
||||
|
||||
if (
|
||||
(!Array.isArray(event.annotations) || event.annotations.length <= 0)
|
||||
&& (!Array.isArray(event.citations) || event.citations.length <= 0)
|
||||
&& !event.usagePayload
|
||||
&& !event.usageDetails
|
||||
) {
|
||||
return lines;
|
||||
}
|
||||
|
||||
const parsedEvents = parseSerializedSse(lines);
|
||||
if (parsedEvents.length <= 0) return lines;
|
||||
|
||||
for (const parsed of parsedEvents) {
|
||||
const payload = parsed.payload;
|
||||
if (Array.isArray(event.citations) && event.citations.length > 0) {
|
||||
payload.citations = event.citations;
|
||||
}
|
||||
|
||||
const firstChoice = Array.isArray(payload.choices) ? payload.choices[0] : null;
|
||||
if (Array.isArray(event.annotations) && event.annotations.length > 0 && isRecord(firstChoice) && isRecord(firstChoice.delta)) {
|
||||
firstChoice.delta.annotations = event.annotations;
|
||||
}
|
||||
|
||||
if (event.usagePayload || event.usageDetails) {
|
||||
payload.usage = {
|
||||
...(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 }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
lines[parsed.index] = `data: ${JSON.stringify(payload)}\n\n`;
|
||||
}
|
||||
|
||||
return lines;
|
||||
},
|
||||
serializeDone(
|
||||
context: StreamTransformContext,
|
||||
downstreamContext?: ClaudeDownstreamContext,
|
||||
): string[] {
|
||||
return serializeStreamDone(
|
||||
'openai',
|
||||
context,
|
||||
downstreamContext ?? createClaudeDownstreamContext(),
|
||||
);
|
||||
},
|
||||
pullSseEvents(buffer: string) {
|
||||
return pullSseEventsWithDone(buffer);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import { createEmptyNormalizedUsage, mergeNormalizedUsage } from '../../shared/normalized.js';
|
||||
|
||||
export const openAiChatUsage = {
|
||||
empty: createEmptyNormalizedUsage,
|
||||
merge: mergeNormalizedUsage,
|
||||
};
|
||||
Reference in New Issue
Block a user