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,179 @@
|
||||
import { type NormalizedStreamEvent } from '../../shared/normalized.js';
|
||||
|
||||
export type AnthropicStreamExtension = {
|
||||
signatureDelta?: string;
|
||||
redactedThinkingData?: string;
|
||||
startBlock?: {
|
||||
kind: 'thinking' | 'text' | 'tool_use' | 'redacted_thinking';
|
||||
index?: number;
|
||||
};
|
||||
stopBlockIndex?: number | null;
|
||||
};
|
||||
|
||||
export type AnthropicExtendedStreamEvent = NormalizedStreamEvent & {
|
||||
anthropic?: AnthropicStreamExtension;
|
||||
};
|
||||
|
||||
export type AnthropicBlockLifecycleEntry = {
|
||||
kind: 'thinking' | 'redacted_thinking';
|
||||
phase: 'start' | 'stop';
|
||||
index?: number;
|
||||
};
|
||||
|
||||
export type AnthropicMessagesAggregateState = {
|
||||
text: string[];
|
||||
reasoning: string[];
|
||||
redactedReasoning: string[];
|
||||
signatures: string[];
|
||||
toolCalls: Record<number, { id: string; name: string; arguments: string }>;
|
||||
finishReason: string | null;
|
||||
pendingSignature: string | null;
|
||||
thinkingBlockIndex: number | null;
|
||||
redactedBlockIndexes: number[];
|
||||
blockLifecycle: AnthropicBlockLifecycleEntry[];
|
||||
};
|
||||
|
||||
export function createAnthropicMessagesAggregateState(): AnthropicMessagesAggregateState {
|
||||
return {
|
||||
text: [],
|
||||
reasoning: [],
|
||||
redactedReasoning: [],
|
||||
signatures: [],
|
||||
toolCalls: {},
|
||||
finishReason: null,
|
||||
pendingSignature: null,
|
||||
thinkingBlockIndex: null,
|
||||
redactedBlockIndexes: [],
|
||||
blockLifecycle: [],
|
||||
};
|
||||
}
|
||||
|
||||
function ensureToolState(
|
||||
state: AnthropicMessagesAggregateState,
|
||||
index: number,
|
||||
): { id: string; name: string; arguments: string } {
|
||||
if (!state.toolCalls[index]) {
|
||||
state.toolCalls[index] = {
|
||||
id: `toolu_${index}`,
|
||||
name: '',
|
||||
arguments: '',
|
||||
};
|
||||
}
|
||||
return state.toolCalls[index];
|
||||
}
|
||||
|
||||
function flushPendingSignature(state: AnthropicMessagesAggregateState): void {
|
||||
if (!state.pendingSignature) return;
|
||||
state.signatures.push(state.pendingSignature);
|
||||
state.pendingSignature = null;
|
||||
}
|
||||
|
||||
function recordBlockStart(
|
||||
state: AnthropicMessagesAggregateState,
|
||||
kind: AnthropicBlockLifecycleEntry['kind'],
|
||||
index?: number,
|
||||
): void {
|
||||
state.blockLifecycle.push({ kind, phase: 'start', index });
|
||||
if (kind === 'thinking') {
|
||||
state.thinkingBlockIndex = typeof index === 'number' ? index : state.thinkingBlockIndex;
|
||||
return;
|
||||
}
|
||||
if (kind === 'redacted_thinking' && typeof index === 'number') {
|
||||
state.redactedBlockIndexes.push(index);
|
||||
}
|
||||
}
|
||||
|
||||
function recordBlockStop(state: AnthropicMessagesAggregateState, index: number): void {
|
||||
if (state.thinkingBlockIndex === index) {
|
||||
state.blockLifecycle.push({ kind: 'thinking', phase: 'stop', index });
|
||||
state.thinkingBlockIndex = null;
|
||||
flushPendingSignature(state);
|
||||
return;
|
||||
}
|
||||
|
||||
const redactedIndex = state.redactedBlockIndexes.indexOf(index);
|
||||
if (redactedIndex >= 0) {
|
||||
state.blockLifecycle.push({ kind: 'redacted_thinking', phase: 'stop', index });
|
||||
state.redactedBlockIndexes.splice(redactedIndex, 1);
|
||||
}
|
||||
}
|
||||
|
||||
function finalizeOpenBlocks(state: AnthropicMessagesAggregateState): void {
|
||||
if (state.thinkingBlockIndex !== null) {
|
||||
const thinkingIndex = state.thinkingBlockIndex;
|
||||
recordBlockStop(state, thinkingIndex);
|
||||
}
|
||||
|
||||
if (state.pendingSignature) {
|
||||
flushPendingSignature(state);
|
||||
}
|
||||
|
||||
const openRedactedIndexes = [...state.redactedBlockIndexes];
|
||||
for (const redactedIndex of openRedactedIndexes) {
|
||||
recordBlockStop(state, redactedIndex);
|
||||
}
|
||||
}
|
||||
|
||||
export function applyAnthropicMessagesAggregateEvent(
|
||||
state: AnthropicMessagesAggregateState,
|
||||
event: AnthropicExtendedStreamEvent,
|
||||
): AnthropicMessagesAggregateState {
|
||||
if (event.anthropic?.startBlock?.kind === 'thinking') {
|
||||
recordBlockStart(state, 'thinking', event.anthropic.startBlock.index);
|
||||
}
|
||||
if (event.anthropic?.startBlock?.kind === 'redacted_thinking') {
|
||||
if (state.thinkingBlockIndex !== null) {
|
||||
recordBlockStop(state, state.thinkingBlockIndex);
|
||||
}
|
||||
recordBlockStart(state, 'redacted_thinking', event.anthropic.startBlock.index);
|
||||
}
|
||||
|
||||
if (event.reasoningDelta) {
|
||||
state.reasoning.push(event.reasoningDelta);
|
||||
}
|
||||
|
||||
if (event.contentDelta) {
|
||||
state.text.push(event.contentDelta);
|
||||
}
|
||||
|
||||
if (Array.isArray(event.toolCallDeltas)) {
|
||||
for (const toolDelta of event.toolCallDeltas) {
|
||||
const toolState = ensureToolState(state, toolDelta.index);
|
||||
if (toolDelta.id) toolState.id = toolDelta.id;
|
||||
if (toolDelta.name) toolState.name = toolDelta.name;
|
||||
if (toolDelta.argumentsDelta) {
|
||||
toolState.arguments += toolDelta.argumentsDelta;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (event.anthropic?.signatureDelta) {
|
||||
if (state.thinkingBlockIndex !== null) {
|
||||
state.signatures.push(event.anthropic.signatureDelta);
|
||||
} else {
|
||||
state.pendingSignature = event.anthropic.signatureDelta;
|
||||
}
|
||||
}
|
||||
|
||||
if (event.anthropic?.redactedThinkingData) {
|
||||
state.redactedReasoning.push(event.anthropic.redactedThinkingData);
|
||||
}
|
||||
|
||||
if (
|
||||
event.anthropic?.stopBlockIndex !== undefined
|
||||
&& event.anthropic.stopBlockIndex !== null
|
||||
) {
|
||||
recordBlockStop(state, event.anthropic.stopBlockIndex);
|
||||
}
|
||||
|
||||
if (event.finishReason) {
|
||||
finalizeOpenBlocks(state);
|
||||
state.finishReason = event.finishReason;
|
||||
}
|
||||
|
||||
if (event.done) {
|
||||
finalizeOpenBlocks(state);
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
@@ -0,0 +1,666 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
convertOpenAiBodyToAnthropicMessagesBody,
|
||||
convertOpenAiToolChoiceToAnthropic,
|
||||
sanitizeAnthropicMessagesBody,
|
||||
} from './conversion.js';
|
||||
import { anthropicMessagesInbound } from './inbound.js';
|
||||
import { anthropicMessagesTransformer } from './index.js';
|
||||
import { extractAnthropicUsage, extractAnthropicUsageMetadata } from './usage.js';
|
||||
import { applyAnthropicMessagesAggregateEvent, createAnthropicMessagesAggregateState } from './aggregator.js';
|
||||
|
||||
describe('sanitizeAnthropicMessagesBody', () => {
|
||||
it('drops top_p when temperature is present and normalizes adaptive thinking', () => {
|
||||
const result = sanitizeAnthropicMessagesBody({
|
||||
model: 'claude-opus',
|
||||
temperature: 0.6,
|
||||
top_p: 0.9,
|
||||
thinking: {
|
||||
type: 'adaptive',
|
||||
budget_tokens: 512,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.top_p).toBeUndefined();
|
||||
expect(result.thinking).toEqual({
|
||||
type: 'enabled',
|
||||
budget_tokens: 512,
|
||||
});
|
||||
});
|
||||
|
||||
it('drops non-ephemeral cache_control markers while preserving valid tool block markers', () => {
|
||||
const result = sanitizeAnthropicMessagesBody({
|
||||
model: 'claude-opus-4-6',
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'thinking',
|
||||
thinking: 'internal',
|
||||
cache_control: { type: 'persistent' },
|
||||
},
|
||||
{
|
||||
type: 'tool_use',
|
||||
id: 'tool_1',
|
||||
name: 'lookup',
|
||||
input: { city: 'paris' },
|
||||
cache_control: { type: 'ephemeral' },
|
||||
},
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'tool_1',
|
||||
content: 'done',
|
||||
cache_control: { type: 'persistent' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.messages).toEqual([
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'thinking',
|
||||
thinking: 'internal',
|
||||
},
|
||||
{
|
||||
type: 'tool_use',
|
||||
id: 'tool_1',
|
||||
name: 'lookup',
|
||||
input: { city: 'paris' },
|
||||
},
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'tool_1',
|
||||
content: 'done',
|
||||
cache_control: { type: 'ephemeral' },
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertOpenAiBodyToAnthropicMessagesBody', () => {
|
||||
it('preserves image blocks, tool_use blocks, and tool_result chains', () => {
|
||||
const body = convertOpenAiBodyToAnthropicMessagesBody(
|
||||
{
|
||||
model: 'gpt-5',
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: 'system prompt',
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'look at this' },
|
||||
{ type: 'image_url', image_url: 'https://example.com/cat.png' },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'call_1',
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'lookup',
|
||||
arguments: '{"topic":"cat"}',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'tool',
|
||||
tool_call_id: 'call_1',
|
||||
content: '{"ok":true}',
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: 'thanks',
|
||||
},
|
||||
],
|
||||
},
|
||||
'claude-opus-4-6',
|
||||
false,
|
||||
);
|
||||
|
||||
expect(body.system).toBe('system prompt');
|
||||
expect(body.messages).toEqual([
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'look at this' },
|
||||
{
|
||||
type: 'image',
|
||||
source: {
|
||||
type: 'url',
|
||||
url: 'https://example.com/cat.png',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_use',
|
||||
id: 'call_1',
|
||||
name: 'lookup',
|
||||
input: { topic: 'cat' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'call_1',
|
||||
content: '{"ok":true}',
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: 'thanks',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('strips cache_control from thinking blocks and empty text blocks', () => {
|
||||
const body = convertOpenAiBodyToAnthropicMessagesBody(
|
||||
{
|
||||
model: 'gpt-5',
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'thinking',
|
||||
thinking: 'internal',
|
||||
cache_control: { type: 'ephemeral' },
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: ' ',
|
||||
cache_control: { type: 'ephemeral' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
'claude-opus-4-6',
|
||||
false,
|
||||
);
|
||||
|
||||
expect(body.messages).toEqual([
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'thinking',
|
||||
thinking: 'internal',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves cache_control on tool blocks while stripping unsupported thinking/text markers', () => {
|
||||
const result = sanitizeAnthropicMessagesBody({
|
||||
model: 'claude-opus-4-6',
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'thinking',
|
||||
thinking: 'internal',
|
||||
cache_control: { type: 'ephemeral' },
|
||||
},
|
||||
{
|
||||
type: 'tool_use',
|
||||
id: 'tool_1',
|
||||
name: 'lookup',
|
||||
input: { city: 'paris' },
|
||||
cache_control: { type: 'ephemeral' },
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: '',
|
||||
cache_control: { type: 'ephemeral' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.messages).toEqual([
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'thinking',
|
||||
thinking: 'internal',
|
||||
},
|
||||
{
|
||||
type: 'tool_use',
|
||||
id: 'tool_1',
|
||||
name: 'lookup',
|
||||
input: { city: 'paris' },
|
||||
cache_control: { type: 'ephemeral' },
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('rebuilds cache_control anchors on structural blocks and the last cacheable message block', () => {
|
||||
const result = sanitizeAnthropicMessagesBody({
|
||||
model: 'claude-opus-4-6',
|
||||
tools: [
|
||||
{ name: 'lookup', input_schema: { type: 'object' }, cache_control: { type: 'ephemeral' } },
|
||||
{ name: 'search', input_schema: { type: 'object' } },
|
||||
],
|
||||
system: [
|
||||
{ type: 'text', text: 'system 1' },
|
||||
{ type: 'text', text: 'system 2', cache_control: { type: 'ephemeral' } },
|
||||
],
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'prefix' },
|
||||
{ type: 'thinking', thinking: 'internal', cache_control: { type: 'ephemeral' } },
|
||||
{ type: 'tool_use', id: 'tool_1', name: 'lookup', input: { city: 'paris' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.tools).toEqual([
|
||||
{ name: 'lookup', input_schema: { type: 'object' } },
|
||||
{ name: 'search', input_schema: { type: 'object' }, cache_control: { type: 'ephemeral' } },
|
||||
]);
|
||||
expect(result.system).toEqual([
|
||||
{ type: 'text', text: 'system 1' },
|
||||
{ type: 'text', text: 'system 2', cache_control: { type: 'ephemeral' } },
|
||||
]);
|
||||
expect(result.messages).toEqual([
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'prefix' },
|
||||
{ type: 'thinking', thinking: 'internal' },
|
||||
{ type: 'tool_use', id: 'tool_1', name: 'lookup', input: { city: 'paris' }, cache_control: { type: 'ephemeral' } },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('adds a second message cache anchor around the 20-block window for long prompts', () => {
|
||||
const longContent = Array.from({ length: 25 }, (_, index) => ({
|
||||
type: 'text',
|
||||
text: `block-${index + 1}`,
|
||||
}));
|
||||
|
||||
const result = sanitizeAnthropicMessagesBody({
|
||||
model: 'claude-opus-4-6',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: longContent,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const content = (result.messages as Array<any>)[0].content as Array<any>;
|
||||
const anchoredIndexes = content
|
||||
.map((item, index) => item?.cache_control ? index : -1)
|
||||
.filter((index) => index >= 0);
|
||||
|
||||
expect(anchoredIndexes).toEqual([4, 24]);
|
||||
});
|
||||
|
||||
it('maps reasoning effort to anthropic output_config.effort with adaptive thinking', () => {
|
||||
const body = convertOpenAiBodyToAnthropicMessagesBody(
|
||||
{
|
||||
model: 'gpt-5',
|
||||
reasoning_effort: 'high',
|
||||
messages: [
|
||||
{ role: 'user', content: 'hello' },
|
||||
],
|
||||
},
|
||||
'claude-opus-4-6',
|
||||
true,
|
||||
);
|
||||
|
||||
expect(body.thinking).toEqual({ type: 'adaptive' });
|
||||
expect(body.output_config).toEqual({ effort: 'high' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertOpenAiToolChoiceToAnthropic', () => {
|
||||
it('maps required to any and function choice to tool', () => {
|
||||
expect(convertOpenAiToolChoiceToAnthropic('required')).toEqual({ type: 'any' });
|
||||
expect(convertOpenAiToolChoiceToAnthropic({
|
||||
type: 'function',
|
||||
function: { name: 'lookup' },
|
||||
})).toEqual({
|
||||
type: 'tool',
|
||||
name: 'lookup',
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes tool-style objects and strips unsupported name on non-tool variants', () => {
|
||||
expect(anthropicMessagesInbound.parse({
|
||||
model: 'claude-opus-4-6',
|
||||
max_tokens: 512,
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
tool_choice: { type: 'tool', tool: { name: 'lookup' } },
|
||||
}).value?.claudeOriginalBody?.tool_choice).toEqual({
|
||||
type: 'tool',
|
||||
name: 'lookup',
|
||||
});
|
||||
|
||||
expect(anthropicMessagesInbound.parse({
|
||||
model: 'claude-opus-4-6',
|
||||
max_tokens: 512,
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
tool_choice: { type: 'none', name: 'should-disappear' },
|
||||
}).value?.claudeOriginalBody?.tool_choice).toEqual({
|
||||
type: 'none',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('anthropicMessagesInbound', () => {
|
||||
it('rejects invalid tool_choice.type and invalid output_config.effort', () => {
|
||||
const invalidToolChoice = anthropicMessagesInbound.parse({
|
||||
model: 'claude-opus-4-6',
|
||||
max_tokens: 512,
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
tool_choice: { type: 'invalid' },
|
||||
});
|
||||
expect(invalidToolChoice.error?.statusCode).toBe(400);
|
||||
|
||||
const invalidEffort = anthropicMessagesInbound.parse({
|
||||
model: 'claude-opus-4-6',
|
||||
max_tokens: 512,
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
thinking: { type: 'adaptive' },
|
||||
output_config: { effort: 'turbo' },
|
||||
});
|
||||
expect(invalidEffort.error?.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects tool choice without a tool name', () => {
|
||||
const result = anthropicMessagesInbound.parse({
|
||||
model: 'claude-opus-4-6',
|
||||
max_tokens: 512,
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
tool_choice: { type: 'tool' },
|
||||
});
|
||||
|
||||
expect(result.error?.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('anthropicMessagesTransformer.stream', () => {
|
||||
it('serializes reasoning separately from text for Claude downstream', () => {
|
||||
const streamContext = anthropicMessagesTransformer.createStreamContext('claude-opus-4-6');
|
||||
const downstreamContext = anthropicMessagesTransformer.createDownstreamContext();
|
||||
|
||||
const chunks = [
|
||||
...anthropicMessagesTransformer.serializeStreamEvent({ role: 'assistant' }, streamContext, downstreamContext),
|
||||
...anthropicMessagesTransformer.serializeStreamEvent({ reasoningDelta: 'internal thought' }, streamContext, downstreamContext),
|
||||
...anthropicMessagesTransformer.serializeStreamEvent({ contentDelta: 'final answer' }, streamContext, downstreamContext),
|
||||
...anthropicMessagesTransformer.serializeStreamEvent({ finishReason: 'stop', done: true }, streamContext, downstreamContext),
|
||||
].join('');
|
||||
|
||||
expect(chunks).toContain('"type":"thinking_delta"');
|
||||
expect(chunks).toContain('"type":"text_delta"');
|
||||
expect(chunks).not.toContain('internal thought\\n\\nfinal answer');
|
||||
});
|
||||
|
||||
it('buffers signature_delta until a thinking block is closed', () => {
|
||||
const streamContext = anthropicMessagesTransformer.createStreamContext('claude-opus-4-6');
|
||||
const downstreamContext = anthropicMessagesTransformer.createDownstreamContext();
|
||||
|
||||
const signatureEvent = anthropicMessagesTransformer.transformStreamEvent({
|
||||
type: 'content_block_delta',
|
||||
index: 0,
|
||||
delta: { type: 'signature_delta', signature: 'sig-1' },
|
||||
}, streamContext, 'claude-opus-4-6');
|
||||
const thinkingStart = anthropicMessagesTransformer.transformStreamEvent({
|
||||
type: 'content_block_start',
|
||||
index: 0,
|
||||
content_block: { type: 'thinking', thinking: '' },
|
||||
}, streamContext, 'claude-opus-4-6');
|
||||
const thinkingDelta = anthropicMessagesTransformer.transformStreamEvent({
|
||||
type: 'content_block_delta',
|
||||
index: 0,
|
||||
delta: { type: 'thinking_delta', thinking: 'internal' },
|
||||
}, streamContext, 'claude-opus-4-6');
|
||||
const textDelta = anthropicMessagesTransformer.transformStreamEvent({
|
||||
type: 'content_block_delta',
|
||||
index: 1,
|
||||
delta: { type: 'text_delta', text: 'final' },
|
||||
}, streamContext, 'claude-opus-4-6');
|
||||
|
||||
const serialized = [
|
||||
...anthropicMessagesTransformer.serializeStreamEvent(signatureEvent, streamContext, downstreamContext),
|
||||
...anthropicMessagesTransformer.serializeStreamEvent(thinkingStart, streamContext, downstreamContext),
|
||||
...anthropicMessagesTransformer.serializeStreamEvent(thinkingDelta, streamContext, downstreamContext),
|
||||
...anthropicMessagesTransformer.serializeStreamEvent(textDelta, streamContext, downstreamContext),
|
||||
].join('');
|
||||
|
||||
expect(serialized).toContain('"type":"signature_delta"');
|
||||
expect(serialized.indexOf('"type":"signature_delta"')).toBeGreaterThan(serialized.indexOf('"type":"thinking_delta"'));
|
||||
expect(serialized.indexOf('"type":"signature_delta"')).toBeLessThan(serialized.indexOf('"type":"text_delta"'));
|
||||
});
|
||||
|
||||
it('emits redacted_thinking as its own lifecycle block', () => {
|
||||
const streamContext = anthropicMessagesTransformer.createStreamContext('claude-opus-4-6');
|
||||
const downstreamContext = anthropicMessagesTransformer.createDownstreamContext();
|
||||
|
||||
const redacted = anthropicMessagesTransformer.transformStreamEvent({
|
||||
type: 'content_block_start',
|
||||
index: 0,
|
||||
content_block: { type: 'redacted_thinking', data: 'ciphertext' },
|
||||
}, streamContext, 'claude-opus-4-6');
|
||||
|
||||
const serialized = anthropicMessagesTransformer.serializeStreamEvent(redacted, streamContext, downstreamContext).join('');
|
||||
expect(serialized).toContain('"type":"redacted_thinking"');
|
||||
expect(serialized).toContain('content_block_start');
|
||||
expect(serialized).toContain('content_block_stop');
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractAnthropicUsage', () => {
|
||||
it('maps cache read and cache creation tokens into normalized usage', () => {
|
||||
expect(extractAnthropicUsage({
|
||||
usage: {
|
||||
input_tokens: 100,
|
||||
output_tokens: 30,
|
||||
cache_read_input_tokens: 12,
|
||||
cache_creation_input_tokens: 8,
|
||||
},
|
||||
})).toEqual({
|
||||
promptTokens: 120,
|
||||
completionTokens: 30,
|
||||
totalTokens: 150,
|
||||
cachedTokens: 12,
|
||||
reasoningTokens: 0,
|
||||
audioInputTokens: 0,
|
||||
audioOutputTokens: 0,
|
||||
acceptedPredictionTokens: 0,
|
||||
rejectedPredictionTokens: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('exposes cache read/cache creation details for metadata consumers', () => {
|
||||
expect(extractAnthropicUsageMetadata({
|
||||
usage: {
|
||||
input_tokens: 100,
|
||||
output_tokens: 30,
|
||||
cache_read_input_tokens: 12,
|
||||
cache_creation_input_tokens: 8,
|
||||
cache_creation: {
|
||||
ephemeral_5m_input_tokens: 5,
|
||||
ephemeral_1h_input_tokens: 3,
|
||||
},
|
||||
},
|
||||
})).toEqual({
|
||||
cacheReadInputTokens: 12,
|
||||
cacheCreationInputTokens: 8,
|
||||
ephemeral5mInputTokens: 5,
|
||||
ephemeral1hInputTokens: 3,
|
||||
promptTokensIncludingCache: 120,
|
||||
});
|
||||
});
|
||||
|
||||
it('derives cache creation input tokens from nested cache_creation when the top-level field is missing', () => {
|
||||
expect(extractAnthropicUsageMetadata({
|
||||
usage: {
|
||||
input_tokens: 100,
|
||||
output_tokens: 30,
|
||||
cache_read_input_tokens: 12,
|
||||
cache_creation: {
|
||||
ephemeral_5m_input_tokens: 5,
|
||||
ephemeral_1h_input_tokens: 3,
|
||||
},
|
||||
},
|
||||
})).toEqual({
|
||||
cacheReadInputTokens: 12,
|
||||
cacheCreationInputTokens: 8,
|
||||
ephemeral5mInputTokens: 5,
|
||||
ephemeral1hInputTokens: 3,
|
||||
promptTokensIncludingCache: 120,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyAnthropicMessagesAggregateEvent', () => {
|
||||
it('tracks text, reasoning, redacted thinking, and tool call arguments', () => {
|
||||
const state = createAnthropicMessagesAggregateState();
|
||||
|
||||
applyAnthropicMessagesAggregateEvent(state, { reasoningDelta: 'think-1' });
|
||||
applyAnthropicMessagesAggregateEvent(state, { contentDelta: 'answer-1' });
|
||||
applyAnthropicMessagesAggregateEvent(state, {
|
||||
toolCallDeltas: [{ index: 0, id: 'call_1', name: 'lookup', argumentsDelta: '{"city":"par' }],
|
||||
});
|
||||
applyAnthropicMessagesAggregateEvent(state, {
|
||||
toolCallDeltas: [{ index: 0, argumentsDelta: 'is"}' }],
|
||||
});
|
||||
applyAnthropicMessagesAggregateEvent(state, {
|
||||
anthropic: {
|
||||
redactedThinkingData: 'cipher',
|
||||
},
|
||||
} as any);
|
||||
|
||||
expect(state.reasoning).toEqual(['think-1']);
|
||||
expect(state.text).toEqual(['answer-1']);
|
||||
expect(state.redactedReasoning).toEqual(['cipher']);
|
||||
expect(state.toolCalls[0]).toEqual({
|
||||
id: 'call_1',
|
||||
name: 'lookup',
|
||||
arguments: '{"city":"paris"}',
|
||||
});
|
||||
});
|
||||
|
||||
it('buffers signature until the thinking block closes and records lifecycle markers', () => {
|
||||
const state = createAnthropicMessagesAggregateState();
|
||||
|
||||
applyAnthropicMessagesAggregateEvent(state, {
|
||||
anthropic: {
|
||||
signatureDelta: 'sig-buffered',
|
||||
},
|
||||
} as any);
|
||||
applyAnthropicMessagesAggregateEvent(state, {
|
||||
anthropic: {
|
||||
startBlock: {
|
||||
kind: 'thinking',
|
||||
index: 2,
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
applyAnthropicMessagesAggregateEvent(state, { reasoningDelta: 'deliberating' });
|
||||
applyAnthropicMessagesAggregateEvent(state, {
|
||||
anthropic: {
|
||||
stopBlockIndex: 2,
|
||||
},
|
||||
} as any);
|
||||
applyAnthropicMessagesAggregateEvent(state, {
|
||||
anthropic: {
|
||||
startBlock: {
|
||||
kind: 'redacted_thinking',
|
||||
index: 3,
|
||||
},
|
||||
redactedThinkingData: 'cipher-2',
|
||||
},
|
||||
} as any);
|
||||
applyAnthropicMessagesAggregateEvent(state, {
|
||||
anthropic: {
|
||||
stopBlockIndex: 3,
|
||||
},
|
||||
} as any);
|
||||
|
||||
expect(state.pendingSignature).toBeNull();
|
||||
expect(state.signatures).toEqual(['sig-buffered']);
|
||||
expect(state.blockLifecycle).toEqual([
|
||||
{ kind: 'thinking', phase: 'start', index: 2 },
|
||||
{ kind: 'thinking', phase: 'stop', index: 2 },
|
||||
{ kind: 'redacted_thinking', phase: 'start', index: 3 },
|
||||
{ kind: 'redacted_thinking', phase: 'stop', index: 3 },
|
||||
]);
|
||||
expect(state.redactedReasoning).toEqual(['cipher-2']);
|
||||
});
|
||||
|
||||
it('flushes pending signatures and closes open thinking/redacted blocks on finish', () => {
|
||||
const state = createAnthropicMessagesAggregateState();
|
||||
|
||||
applyAnthropicMessagesAggregateEvent(state, {
|
||||
anthropic: {
|
||||
signatureDelta: 'sig-finish',
|
||||
},
|
||||
} as any);
|
||||
applyAnthropicMessagesAggregateEvent(state, {
|
||||
anthropic: {
|
||||
startBlock: {
|
||||
kind: 'thinking',
|
||||
index: 4,
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
applyAnthropicMessagesAggregateEvent(state, {
|
||||
anthropic: {
|
||||
startBlock: {
|
||||
kind: 'redacted_thinking',
|
||||
index: 5,
|
||||
},
|
||||
redactedThinkingData: 'cipher-finish',
|
||||
},
|
||||
} as any);
|
||||
applyAnthropicMessagesAggregateEvent(state, {
|
||||
finishReason: 'stop',
|
||||
done: true,
|
||||
});
|
||||
|
||||
expect(state.pendingSignature).toBeNull();
|
||||
expect(state.signatures).toEqual(['sig-finish']);
|
||||
expect(state.blockLifecycle).toEqual([
|
||||
{ kind: 'thinking', phase: 'start', index: 4 },
|
||||
{ kind: 'thinking', phase: 'stop', index: 4 },
|
||||
{ kind: 'redacted_thinking', phase: 'start', index: 5 },
|
||||
{ kind: 'redacted_thinking', phase: 'stop', index: 5 },
|
||||
]);
|
||||
expect(state.finishReason).toBe('stop');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,881 @@
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object';
|
||||
}
|
||||
|
||||
const VALID_ANTHROPIC_TOOL_CHOICE_TYPES = new Set(['auto', 'none', 'any', 'tool']);
|
||||
const VALID_ANTHROPIC_THINKING_TYPES = new Set(['enabled', 'disabled', 'adaptive']);
|
||||
const VALID_ANTHROPIC_EFFORTS = new Set(['low', 'medium', 'high', 'max']);
|
||||
|
||||
function asTrimmedString(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function toFiniteNumber(value: unknown): number | null {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
function safeJsonStringify(value: unknown): string {
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function parseJsonString(raw: string): unknown {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return {};
|
||||
try {
|
||||
return JSON.parse(trimmed);
|
||||
} catch {
|
||||
return { value: raw };
|
||||
}
|
||||
}
|
||||
|
||||
function toPositiveInteger(value: unknown): number | null {
|
||||
const numberValue = toFiniteNumber(value);
|
||||
if (numberValue === null || numberValue <= 0) return null;
|
||||
return Math.trunc(numberValue);
|
||||
}
|
||||
|
||||
function sanitizeCacheControl(value: unknown): Record<string, unknown> | undefined {
|
||||
if (!isRecord(value)) return undefined;
|
||||
const type = asTrimmedString(value.type).toLowerCase();
|
||||
if (type !== 'ephemeral') return undefined;
|
||||
return { type };
|
||||
}
|
||||
|
||||
function sanitizeAnthropicOutputConfig(
|
||||
value: unknown,
|
||||
): { value?: Record<string, unknown>; error?: string } {
|
||||
if (!isRecord(value)) return {};
|
||||
|
||||
const next: Record<string, unknown> = {};
|
||||
if ('effort' in value) {
|
||||
const effort = asTrimmedString(value.effort).toLowerCase();
|
||||
if (!effort) return { value: next };
|
||||
if (!VALID_ANTHROPIC_EFFORTS.has(effort)) {
|
||||
return { error: 'output_config.effort must be one of: low, medium, high, max' };
|
||||
}
|
||||
next.effort = effort;
|
||||
}
|
||||
|
||||
for (const [key, entry] of Object.entries(value)) {
|
||||
if (key === 'effort') continue;
|
||||
next[key] = entry;
|
||||
}
|
||||
|
||||
return Object.keys(next).length > 0 ? { value: next } : {};
|
||||
}
|
||||
|
||||
function sanitizeAnthropicToolChoiceValue(
|
||||
value: unknown,
|
||||
): { value?: Record<string, unknown>; error?: string } {
|
||||
if (value === undefined) return {};
|
||||
|
||||
if (typeof value === 'string') {
|
||||
const normalized = asTrimmedString(value).toLowerCase();
|
||||
if (!normalized) return {};
|
||||
if (normalized === 'required') return { value: { type: 'any' } };
|
||||
if (!VALID_ANTHROPIC_TOOL_CHOICE_TYPES.has(normalized)) {
|
||||
return { error: 'tool_choice.type must be one of: auto, none, any, tool' };
|
||||
}
|
||||
return { value: { type: normalized } };
|
||||
}
|
||||
|
||||
if (!isRecord(value)) {
|
||||
return { error: 'tool_choice must be an object or string' };
|
||||
}
|
||||
|
||||
const type = asTrimmedString(value.type).toLowerCase();
|
||||
if (!VALID_ANTHROPIC_TOOL_CHOICE_TYPES.has(type)) {
|
||||
return { error: 'tool_choice.type must be one of: auto, none, any, tool' };
|
||||
}
|
||||
|
||||
const next: Record<string, unknown> = { ...value, type };
|
||||
if (type === 'tool') {
|
||||
const name = asTrimmedString(value.name ?? (isRecord(value.tool) ? value.tool.name : undefined));
|
||||
if (!name) {
|
||||
return { error: 'tool_choice.name is required when type is tool' };
|
||||
}
|
||||
next.name = name;
|
||||
delete next.tool;
|
||||
} else {
|
||||
delete next.name;
|
||||
delete next.tool;
|
||||
}
|
||||
|
||||
return { value: next };
|
||||
}
|
||||
|
||||
function sanitizeAnthropicThinkingConfig(
|
||||
value: unknown,
|
||||
): { value?: Record<string, unknown>; error?: string } {
|
||||
if (!isRecord(value)) return {};
|
||||
|
||||
const type = asTrimmedString(value.type).toLowerCase();
|
||||
if (!type) return {};
|
||||
if (!VALID_ANTHROPIC_THINKING_TYPES.has(type)) {
|
||||
return { error: 'thinking.type must be one of: enabled, disabled, adaptive' };
|
||||
}
|
||||
|
||||
if (type === 'enabled') {
|
||||
const budgetTokens = toPositiveInteger(value.budget_tokens ?? value.budgetTokens);
|
||||
if (!budgetTokens) {
|
||||
return { error: 'budget_tokens is required and must be positive when thinking.type is enabled' };
|
||||
}
|
||||
return {
|
||||
value: {
|
||||
type,
|
||||
budget_tokens: budgetTokens,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (type === 'adaptive') {
|
||||
const budgetTokens = toPositiveInteger(value.budget_tokens ?? value.budgetTokens);
|
||||
if (budgetTokens) {
|
||||
return {
|
||||
value: {
|
||||
type: 'enabled',
|
||||
budget_tokens: budgetTokens,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { value: { type } };
|
||||
}
|
||||
|
||||
function normalizeOpenAiToolArguments(raw: unknown): string {
|
||||
if (typeof raw === 'string') return raw;
|
||||
if (raw === undefined || raw === null) return '';
|
||||
if (typeof raw === 'number' || typeof raw === 'boolean') return String(raw);
|
||||
if (Array.isArray(raw) || isRecord(raw)) {
|
||||
return safeJsonStringify(raw);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
export function normalizeAnthropicToolInput(raw: unknown): unknown {
|
||||
if (raw === undefined || raw === null) return {};
|
||||
if (isRecord(raw) || Array.isArray(raw)) return raw;
|
||||
if (typeof raw === 'string') return parseJsonString(raw);
|
||||
if (typeof raw === 'number' || typeof raw === 'boolean') return raw;
|
||||
return {};
|
||||
}
|
||||
|
||||
function normalizeTextCandidate(value: unknown): string {
|
||||
if (typeof value === 'string') return value;
|
||||
if (!isRecord(value)) return '';
|
||||
return asTrimmedString(value.text ?? value.content ?? value.output_text);
|
||||
}
|
||||
|
||||
function normalizeToolMessageContent(raw: unknown): string {
|
||||
if (raw === undefined || raw === null) return '';
|
||||
if (typeof raw === 'string') return raw;
|
||||
if (typeof raw === 'number' || typeof raw === 'boolean') return String(raw);
|
||||
|
||||
if (Array.isArray(raw)) {
|
||||
const textParts = raw
|
||||
.map((item) => {
|
||||
if (typeof item === 'string') return item;
|
||||
if (!isRecord(item)) return '';
|
||||
return normalizeTextCandidate(item);
|
||||
})
|
||||
.filter((item) => item.length > 0);
|
||||
if (textParts.length > 0) return textParts.join('\n');
|
||||
return safeJsonStringify(raw);
|
||||
}
|
||||
|
||||
if (isRecord(raw)) {
|
||||
const text = normalizeTextCandidate(raw);
|
||||
return text || safeJsonStringify(raw);
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function maybeParseDataUrlImage(url: string): Record<string, unknown> | null {
|
||||
const match = /^data:(image\/[a-zA-Z0-9.+-]+);base64,(.+)$/i.exec(url);
|
||||
if (!match) return null;
|
||||
return {
|
||||
type: 'image',
|
||||
source: {
|
||||
type: 'base64',
|
||||
media_type: match[1],
|
||||
data: match[2],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function toAnthropicImageBlock(item: Record<string, unknown>): Record<string, unknown> | null {
|
||||
const rawImageUrl = item.image_url ?? item.url;
|
||||
if (typeof rawImageUrl === 'string' && rawImageUrl.trim()) {
|
||||
const parsed = maybeParseDataUrlImage(rawImageUrl.trim());
|
||||
if (parsed) return parsed;
|
||||
return {
|
||||
type: 'image',
|
||||
source: {
|
||||
type: 'url',
|
||||
url: rawImageUrl.trim(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (isRecord(rawImageUrl)) {
|
||||
const nestedUrl = asTrimmedString(rawImageUrl.url ?? rawImageUrl.image_url);
|
||||
if (!nestedUrl) return null;
|
||||
const parsed = maybeParseDataUrlImage(nestedUrl);
|
||||
if (parsed) return parsed;
|
||||
return {
|
||||
type: 'image',
|
||||
source: {
|
||||
type: 'url',
|
||||
url: nestedUrl,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function toAnthropicTextBlock(text: string): Record<string, unknown> | null {
|
||||
const normalized = text.trim();
|
||||
return normalized ? { type: 'text', text: normalized } : null;
|
||||
}
|
||||
|
||||
function sanitizeAnthropicContentBlock(item: Record<string, unknown>): Record<string, unknown> | null {
|
||||
const type = asTrimmedString(item.type).toLowerCase();
|
||||
if (!type) {
|
||||
return toAnthropicTextBlock(normalizeTextCandidate(item));
|
||||
}
|
||||
|
||||
if (type === 'text' || type === 'input_text' || type === 'output_text') {
|
||||
const textBlock = toAnthropicTextBlock(normalizeTextCandidate(item));
|
||||
if (!textBlock) return null;
|
||||
const next: Record<string, unknown> = { ...item, ...textBlock, type: 'text' };
|
||||
const cacheControl = sanitizeCacheControl(item.cache_control);
|
||||
if (cacheControl) next.cache_control = cacheControl;
|
||||
else delete next.cache_control;
|
||||
return next;
|
||||
}
|
||||
|
||||
if (type === 'image_url' || type === 'input_image') {
|
||||
return toAnthropicImageBlock(item);
|
||||
}
|
||||
|
||||
if (type === 'thinking' || type === 'redacted_thinking' || type === 'reasoning') {
|
||||
const text = asTrimmedString(item.thinking ?? item.text ?? item.content ?? item.data);
|
||||
if (!text) return null;
|
||||
const next: Record<string, unknown> = { ...item };
|
||||
if (type === 'redacted_thinking') {
|
||||
next.type = 'redacted_thinking';
|
||||
next.data = text;
|
||||
delete next.thinking;
|
||||
} else {
|
||||
next.type = type === 'reasoning' ? 'thinking' : type;
|
||||
next.thinking = text;
|
||||
}
|
||||
delete next.cache_control;
|
||||
delete next.text;
|
||||
delete next.content;
|
||||
return next;
|
||||
}
|
||||
|
||||
if (type === 'tool_result') {
|
||||
const toolUseId = asTrimmedString(item.tool_use_id ?? item.toolUseId);
|
||||
const content = normalizeToolMessageContent(item.content ?? item.result);
|
||||
if (!toolUseId || !content) return null;
|
||||
const next: Record<string, unknown> = {
|
||||
type: 'tool_result',
|
||||
tool_use_id: toolUseId,
|
||||
content,
|
||||
};
|
||||
const cacheControl = sanitizeCacheControl(item.cache_control);
|
||||
if (cacheControl) next.cache_control = cacheControl;
|
||||
return next;
|
||||
}
|
||||
|
||||
if (type === 'tool_use') {
|
||||
const id = asTrimmedString(item.id);
|
||||
const name = asTrimmedString(item.name ?? item.toolName);
|
||||
if (!id || !name) return null;
|
||||
const next: Record<string, unknown> = {
|
||||
type: 'tool_use',
|
||||
id,
|
||||
name,
|
||||
input: normalizeAnthropicToolInput(item.input ?? item.arguments ?? item.argumentsText),
|
||||
};
|
||||
const cacheControl = sanitizeCacheControl(item.cache_control);
|
||||
if (cacheControl) next.cache_control = cacheControl;
|
||||
return next;
|
||||
}
|
||||
|
||||
return { ...item };
|
||||
}
|
||||
|
||||
function isCacheableAnthropicMessageBlock(item: Record<string, unknown>): boolean {
|
||||
const type = asTrimmedString(item.type).toLowerCase();
|
||||
if (type === 'thinking' || type === 'redacted_thinking') return false;
|
||||
if (type === 'text' || type === 'input_text' || type === 'output_text') {
|
||||
return normalizeTextCandidate(item).trim().length > 0;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function clearAnthropicCacheControls(body: Record<string, unknown>) {
|
||||
if (Array.isArray(body.tools)) {
|
||||
body.tools = body.tools.map((tool) => {
|
||||
if (!isRecord(tool)) return tool;
|
||||
const next = { ...tool };
|
||||
delete next.cache_control;
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
if (Array.isArray(body.system)) {
|
||||
body.system = body.system.map((entry) => {
|
||||
if (!isRecord(entry)) return entry;
|
||||
const next = { ...entry };
|
||||
delete next.cache_control;
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
const rawMessages = Array.isArray(body.messages) ? body.messages : [];
|
||||
body.messages = rawMessages.map((message) => {
|
||||
if (!isRecord(message)) return message;
|
||||
const next = { ...message };
|
||||
if (Array.isArray(next.content)) {
|
||||
next.content = next.content.map((item) => {
|
||||
if (!isRecord(item)) return item;
|
||||
const block = { ...item };
|
||||
delete block.cache_control;
|
||||
return block;
|
||||
});
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function ensureStructuralAnthropicCacheControls(body: Record<string, unknown>): number {
|
||||
let count = 0;
|
||||
|
||||
if (Array.isArray(body.tools) && body.tools.length > 0) {
|
||||
const lastIndex = body.tools.length - 1;
|
||||
const lastTool = body.tools[lastIndex];
|
||||
if (isRecord(lastTool)) {
|
||||
body.tools[lastIndex] = {
|
||||
...lastTool,
|
||||
cache_control: { type: 'ephemeral' },
|
||||
};
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(body.system) && body.system.length > 0) {
|
||||
const lastIndex = body.system.length - 1;
|
||||
const lastPrompt = body.system[lastIndex];
|
||||
if (isRecord(lastPrompt)) {
|
||||
body.system[lastIndex] = {
|
||||
...lastPrompt,
|
||||
cache_control: { type: 'ephemeral' },
|
||||
};
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
type AnthropicCacheRef = {
|
||||
get value(): Record<string, unknown>;
|
||||
set value(next: Record<string, unknown>);
|
||||
};
|
||||
|
||||
function collectAnthropicCacheableMessageRefs(body: Record<string, unknown>): AnthropicCacheRef[] {
|
||||
const refs: AnthropicCacheRef[] = [];
|
||||
const messages = Array.isArray(body.messages) ? body.messages : [];
|
||||
|
||||
messages.forEach((message, messageIndex) => {
|
||||
if (!isRecord(message) || !Array.isArray(message.content)) return;
|
||||
message.content.forEach((item, contentIndex) => {
|
||||
if (!isRecord(item) || !isCacheableAnthropicMessageBlock(item)) return;
|
||||
refs.push({
|
||||
get value() {
|
||||
const currentMessages = Array.isArray(body.messages) ? body.messages : [];
|
||||
const currentMessage = currentMessages[messageIndex];
|
||||
if (!isRecord(currentMessage) || !Array.isArray(currentMessage.content)) return item;
|
||||
const currentItem = currentMessage.content[contentIndex];
|
||||
return isRecord(currentItem) ? currentItem : item;
|
||||
},
|
||||
set value(next: Record<string, unknown>) {
|
||||
const currentMessages = Array.isArray(body.messages) ? [...body.messages] : [];
|
||||
const currentMessage = currentMessages[messageIndex];
|
||||
if (!isRecord(currentMessage) || !Array.isArray(currentMessage.content)) return;
|
||||
const nextContent = [...currentMessage.content];
|
||||
nextContent[contentIndex] = next;
|
||||
currentMessages[messageIndex] = {
|
||||
...currentMessage,
|
||||
content: nextContent,
|
||||
};
|
||||
body.messages = currentMessages;
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return refs;
|
||||
}
|
||||
|
||||
function addEphemeralCacheControl(item: Record<string, unknown>): Record<string, unknown> {
|
||||
return {
|
||||
...item,
|
||||
cache_control: { type: 'ephemeral' },
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeUnsupportedAnthropicCacheControls(body: Record<string, unknown>) {
|
||||
const rawMessages = Array.isArray(body.messages) ? body.messages : [];
|
||||
body.messages = rawMessages.map((message) => {
|
||||
if (!isRecord(message) || !Array.isArray(message.content)) return message;
|
||||
return {
|
||||
...message,
|
||||
content: message.content.map((item) => {
|
||||
if (!isRecord(item)) return item;
|
||||
if (isCacheableAnthropicMessageBlock(item)) return item;
|
||||
const next = { ...item };
|
||||
delete next.cache_control;
|
||||
return next;
|
||||
}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function optimizeAnthropicCacheControls(body: Record<string, unknown>) {
|
||||
clearAnthropicCacheControls(body);
|
||||
|
||||
const structuralAnchors = ensureStructuralAnthropicCacheControls(body);
|
||||
const remaining = Math.max(0, 4 - structuralAnchors);
|
||||
if (remaining <= 0) {
|
||||
sanitizeUnsupportedAnthropicCacheControls(body);
|
||||
return;
|
||||
}
|
||||
|
||||
const refs = collectAnthropicCacheableMessageRefs(body);
|
||||
if (refs.length <= 0) {
|
||||
sanitizeUnsupportedAnthropicCacheControls(body);
|
||||
return;
|
||||
}
|
||||
|
||||
const desiredMessageAnchors = refs.length >= 20 ? 2 : 1;
|
||||
const targetAnchors = Math.min(desiredMessageAnchors, remaining);
|
||||
const usedIndexes = new Set<number>();
|
||||
|
||||
const applyAnchor = (index: number) => {
|
||||
if (index < 0 || index >= refs.length || usedIndexes.has(index)) return;
|
||||
usedIndexes.add(index);
|
||||
refs[index].value = addEphemeralCacheControl(refs[index].value);
|
||||
};
|
||||
|
||||
applyAnchor(refs.length - 1);
|
||||
|
||||
if (targetAnchors > 1) {
|
||||
const targetIndex = Math.max(refs.length - 1 - 20, 0);
|
||||
let chosen = -1;
|
||||
for (let index = targetIndex; index >= 0; index -= 1) {
|
||||
if (!usedIndexes.has(index)) {
|
||||
chosen = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (chosen < 0) {
|
||||
for (let index = targetIndex + 1; index < refs.length; index += 1) {
|
||||
if (!usedIndexes.has(index)) {
|
||||
chosen = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
applyAnchor(chosen);
|
||||
}
|
||||
|
||||
sanitizeUnsupportedAnthropicCacheControls(body);
|
||||
}
|
||||
|
||||
function convertOpenAiContentToAnthropicBlocks(content: unknown): Array<Record<string, unknown>> {
|
||||
if (typeof content === 'string') {
|
||||
const textBlock = toAnthropicTextBlock(content);
|
||||
return textBlock ? [textBlock] : [];
|
||||
}
|
||||
|
||||
if (!Array.isArray(content)) {
|
||||
if (isRecord(content)) {
|
||||
const single = sanitizeAnthropicContentBlock(content);
|
||||
return single ? [single] : [];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
return content
|
||||
.map((item) => {
|
||||
if (typeof item === 'string') return toAnthropicTextBlock(item);
|
||||
if (!isRecord(item)) return null;
|
||||
return sanitizeAnthropicContentBlock(item);
|
||||
})
|
||||
.filter((item): item is Record<string, unknown> => !!item);
|
||||
}
|
||||
|
||||
export function sanitizeAnthropicMessagesBody(
|
||||
body: Record<string, unknown>,
|
||||
options: { autoOptimizeCacheControls?: boolean } = {},
|
||||
): Record<string, unknown> {
|
||||
const sanitized: Record<string, unknown> = { ...body };
|
||||
const autoOptimizeCacheControls = options.autoOptimizeCacheControls !== false;
|
||||
const hasTemperature = toFiniteNumber(sanitized.temperature) !== null;
|
||||
const hasTopP = toFiniteNumber(sanitized.top_p) !== null;
|
||||
if (hasTemperature && hasTopP) {
|
||||
delete sanitized.top_p;
|
||||
}
|
||||
|
||||
const thinkingResult = sanitizeAnthropicThinkingConfig(sanitized.thinking);
|
||||
if (thinkingResult.value) {
|
||||
sanitized.thinking = thinkingResult.value;
|
||||
} else {
|
||||
delete sanitized.thinking;
|
||||
}
|
||||
|
||||
const outputConfigResult = sanitizeAnthropicOutputConfig(sanitized.output_config ?? sanitized.outputConfig);
|
||||
if (outputConfigResult.value) {
|
||||
sanitized.output_config = outputConfigResult.value;
|
||||
} else {
|
||||
delete sanitized.output_config;
|
||||
}
|
||||
delete sanitized.outputConfig;
|
||||
|
||||
const toolChoiceResult = sanitizeAnthropicToolChoiceValue(sanitized.tool_choice ?? sanitized.toolChoice);
|
||||
if (toolChoiceResult.value) {
|
||||
sanitized.tool_choice = toolChoiceResult.value;
|
||||
} else {
|
||||
delete sanitized.tool_choice;
|
||||
}
|
||||
delete sanitized.toolChoice;
|
||||
|
||||
const rawMessages = Array.isArray(sanitized.messages) ? sanitized.messages : [];
|
||||
if (rawMessages.length > 0) {
|
||||
sanitized.messages = rawMessages.map((message) => {
|
||||
if (!isRecord(message)) return message;
|
||||
const next = { ...message };
|
||||
if (Array.isArray(next.content)) {
|
||||
next.content = next.content
|
||||
.map((item) => (isRecord(item) ? sanitizeAnthropicContentBlock(item) : item))
|
||||
.filter((item) => item !== null);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
if (autoOptimizeCacheControls) {
|
||||
optimizeAnthropicCacheControls(sanitized);
|
||||
} else {
|
||||
sanitizeUnsupportedAnthropicCacheControls(sanitized);
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
function resolveOpenAiReasoningSettings(
|
||||
openaiBody: Record<string, unknown>,
|
||||
): { thinking?: Record<string, unknown>; outputConfig?: Record<string, unknown> } {
|
||||
const explicitReasoning = isRecord(openaiBody.reasoning) ? openaiBody.reasoning : null;
|
||||
const reasoningEffort = asTrimmedString(
|
||||
openaiBody.reasoning_effort
|
||||
?? explicitReasoning?.effort
|
||||
?? explicitReasoning?.reasoning_effort,
|
||||
).toLowerCase();
|
||||
const reasoningBudget = toPositiveInteger(
|
||||
openaiBody.reasoning_budget
|
||||
?? explicitReasoning?.budget_tokens
|
||||
?? explicitReasoning?.max_tokens,
|
||||
);
|
||||
|
||||
const result: { thinking?: Record<string, unknown>; outputConfig?: Record<string, unknown> } = {};
|
||||
|
||||
if (reasoningBudget) {
|
||||
result.thinking = {
|
||||
type: 'enabled',
|
||||
budget_tokens: reasoningBudget,
|
||||
};
|
||||
} else if (reasoningEffort && VALID_ANTHROPIC_EFFORTS.has(reasoningEffort)) {
|
||||
result.thinking = { type: 'adaptive' };
|
||||
}
|
||||
|
||||
if (reasoningEffort && VALID_ANTHROPIC_EFFORTS.has(reasoningEffort)) {
|
||||
result.outputConfig = { effort: reasoningEffort };
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function convertOpenAiToolsToAnthropic(rawTools: unknown): unknown {
|
||||
if (!Array.isArray(rawTools)) return rawTools;
|
||||
|
||||
const converted = rawTools
|
||||
.map((item) => {
|
||||
if (!isRecord(item)) return null;
|
||||
|
||||
const type = asTrimmedString(item.type).toLowerCase();
|
||||
if (type === 'function' && isRecord(item.function)) {
|
||||
const fn = item.function;
|
||||
const name = asTrimmedString(fn.name);
|
||||
if (!name) return null;
|
||||
|
||||
const mapped: Record<string, unknown> = { name };
|
||||
const description = asTrimmedString(fn.description);
|
||||
if (description) mapped.description = description;
|
||||
if (fn.parameters !== undefined) mapped.input_schema = fn.parameters;
|
||||
if (item.cache_control !== undefined) mapped.cache_control = item.cache_control;
|
||||
return mapped;
|
||||
}
|
||||
|
||||
if (asTrimmedString(item.name) && item.input_schema !== undefined) {
|
||||
return item;
|
||||
}
|
||||
|
||||
return null;
|
||||
})
|
||||
.filter((item): item is Record<string, unknown> => !!item);
|
||||
|
||||
return converted.length > 0 ? converted : rawTools;
|
||||
}
|
||||
|
||||
export function convertOpenAiToolChoiceToAnthropic(rawToolChoice: unknown): unknown {
|
||||
if (rawToolChoice === undefined) return undefined;
|
||||
|
||||
if (typeof rawToolChoice === 'string') {
|
||||
const normalized = rawToolChoice.trim().toLowerCase();
|
||||
if (normalized === 'required') return { type: 'any' };
|
||||
if (normalized === 'none') return { type: 'none' };
|
||||
if (normalized === 'auto') return { type: 'auto' };
|
||||
if (normalized === 'any') return { type: 'any' };
|
||||
return rawToolChoice;
|
||||
}
|
||||
|
||||
if (!isRecord(rawToolChoice)) return rawToolChoice;
|
||||
|
||||
const type = asTrimmedString(rawToolChoice.type).toLowerCase();
|
||||
if (type === 'function' && isRecord(rawToolChoice.function)) {
|
||||
const name = asTrimmedString(rawToolChoice.function.name);
|
||||
return name ? { type: 'tool', name } : { type: 'any' };
|
||||
}
|
||||
|
||||
if (type === 'tool' || type === 'auto' || type === 'any' || type === 'none') {
|
||||
return rawToolChoice;
|
||||
}
|
||||
|
||||
return rawToolChoice;
|
||||
}
|
||||
|
||||
export function convertOpenAiBodyToAnthropicMessagesBody(
|
||||
openaiBody: Record<string, unknown>,
|
||||
modelName: string,
|
||||
stream: boolean,
|
||||
): Record<string, unknown> {
|
||||
const rawMessages = Array.isArray(openaiBody.messages) ? openaiBody.messages : [];
|
||||
const systemContents: string[] = [];
|
||||
const messages: Array<{
|
||||
role: 'user' | 'assistant';
|
||||
content: string | Array<Record<string, unknown>>;
|
||||
}> = [];
|
||||
|
||||
const appendAssistantMessage = (item: Record<string, unknown>) => {
|
||||
const role = asTrimmedString(item.role).toLowerCase() || 'assistant';
|
||||
if (role !== 'assistant') return;
|
||||
|
||||
const contentBlocks = convertOpenAiContentToAnthropicBlocks(item.content);
|
||||
|
||||
const rawToolCalls = Array.isArray(item.tool_calls) ? item.tool_calls : [];
|
||||
for (let index = 0; index < rawToolCalls.length; index += 1) {
|
||||
const toolCall = rawToolCalls[index];
|
||||
if (!isRecord(toolCall)) continue;
|
||||
const functionPart = isRecord(toolCall.function) ? toolCall.function : {};
|
||||
const id = asTrimmedString(toolCall.id) || `toolu_${Date.now()}_${index}`;
|
||||
const name = (
|
||||
asTrimmedString(functionPart.name)
|
||||
|| asTrimmedString(toolCall.name)
|
||||
|| `tool_${index}`
|
||||
);
|
||||
contentBlocks.push({
|
||||
type: 'tool_use',
|
||||
id,
|
||||
name,
|
||||
input: normalizeAnthropicToolInput(functionPart.arguments ?? toolCall.arguments),
|
||||
});
|
||||
}
|
||||
|
||||
if (contentBlocks.length <= 0) return;
|
||||
if (contentBlocks.length === 1 && contentBlocks[0].type === 'text') {
|
||||
const singleText = asTrimmedString(contentBlocks[0].text);
|
||||
if (singleText) {
|
||||
messages.push({ role: 'assistant', content: singleText });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
messages.push({
|
||||
role: 'assistant',
|
||||
content: contentBlocks,
|
||||
});
|
||||
};
|
||||
|
||||
for (let messageIndex = 0; messageIndex < rawMessages.length; messageIndex += 1) {
|
||||
const item = rawMessages[messageIndex];
|
||||
if (!isRecord(item)) continue;
|
||||
const role = asTrimmedString(item.role).toLowerCase() || 'user';
|
||||
|
||||
if (role === 'system' || role === 'developer') {
|
||||
const contentBlocks = convertOpenAiContentToAnthropicBlocks(item.content);
|
||||
const text = contentBlocks
|
||||
.filter((block) => block.type === 'text' && typeof block.text === 'string')
|
||||
.map((block) => block.text as string)
|
||||
.filter(Boolean)
|
||||
.join('\n\n');
|
||||
if (text) systemContents.push(text);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (role === 'tool') {
|
||||
const toolResultBlocks: Array<Record<string, unknown>> = [];
|
||||
let cursor = messageIndex;
|
||||
while (cursor < rawMessages.length) {
|
||||
const toolCandidate = rawMessages[cursor];
|
||||
if (!isRecord(toolCandidate)) break;
|
||||
const toolRole = asTrimmedString(toolCandidate.role).toLowerCase();
|
||||
if (toolRole !== 'tool') break;
|
||||
|
||||
const toolUseId = (
|
||||
asTrimmedString(toolCandidate.tool_call_id)
|
||||
|| asTrimmedString(toolCandidate.id)
|
||||
);
|
||||
const toolResultContent = normalizeToolMessageContent(toolCandidate.content).trim();
|
||||
if (toolUseId && toolResultContent) {
|
||||
toolResultBlocks.push({
|
||||
type: 'tool_result',
|
||||
tool_use_id: toolUseId,
|
||||
content: toolResultContent,
|
||||
});
|
||||
}
|
||||
cursor += 1;
|
||||
}
|
||||
|
||||
if (toolResultBlocks.length > 0 && cursor < rawMessages.length) {
|
||||
const nextItem = rawMessages[cursor];
|
||||
if (isRecord(nextItem) && asTrimmedString(nextItem.role).toLowerCase() === 'user') {
|
||||
const userBlocks = convertOpenAiContentToAnthropicBlocks(nextItem.content);
|
||||
toolResultBlocks.push(...userBlocks);
|
||||
cursor += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (toolResultBlocks.length > 0) {
|
||||
messages.push({
|
||||
role: 'user',
|
||||
content: toolResultBlocks,
|
||||
});
|
||||
}
|
||||
|
||||
messageIndex = cursor - 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (role === 'assistant') {
|
||||
appendAssistantMessage(item);
|
||||
continue;
|
||||
}
|
||||
|
||||
const contentBlocks = convertOpenAiContentToAnthropicBlocks(item.content);
|
||||
if (contentBlocks.length <= 0) continue;
|
||||
|
||||
if (contentBlocks.length === 1 && contentBlocks[0].type === 'text') {
|
||||
const singleText = asTrimmedString(contentBlocks[0].text);
|
||||
if (singleText) {
|
||||
messages.push({ role: 'user', content: singleText });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
messages.push({
|
||||
role: 'user',
|
||||
content: contentBlocks,
|
||||
});
|
||||
}
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
model: modelName,
|
||||
stream,
|
||||
messages,
|
||||
max_tokens: toFiniteNumber(openaiBody.max_tokens) ?? 4096,
|
||||
};
|
||||
|
||||
if (systemContents.length > 0) {
|
||||
body.system = systemContents.join('\n\n');
|
||||
}
|
||||
|
||||
const temperature = toFiniteNumber(openaiBody.temperature);
|
||||
if (temperature !== null) body.temperature = temperature;
|
||||
|
||||
const topP = toFiniteNumber(openaiBody.top_p);
|
||||
if (topP !== null) body.top_p = topP;
|
||||
|
||||
if (Array.isArray(openaiBody.stop) && openaiBody.stop.length > 0) {
|
||||
body.stop_sequences = openaiBody.stop;
|
||||
}
|
||||
|
||||
if (openaiBody.tools !== undefined) body.tools = convertOpenAiToolsToAnthropic(openaiBody.tools);
|
||||
|
||||
const anthropicToolChoice = convertOpenAiToolChoiceToAnthropic(openaiBody.tool_choice);
|
||||
if (anthropicToolChoice !== undefined) body.tool_choice = anthropicToolChoice;
|
||||
|
||||
const reasoningSettings = resolveOpenAiReasoningSettings(openaiBody);
|
||||
if (reasoningSettings.thinking) body.thinking = reasoningSettings.thinking;
|
||||
if (reasoningSettings.outputConfig) body.output_config = reasoningSettings.outputConfig;
|
||||
|
||||
return sanitizeAnthropicMessagesBody(body, { autoOptimizeCacheControls: false });
|
||||
}
|
||||
|
||||
export function validateAnthropicMessagesBody(
|
||||
body: Record<string, unknown>,
|
||||
): { sanitizedBody?: Record<string, unknown>; error?: { statusCode: number; payload: unknown } } {
|
||||
const thinkingResult = sanitizeAnthropicThinkingConfig(body.thinking);
|
||||
if (thinkingResult.error) {
|
||||
return {
|
||||
error: {
|
||||
statusCode: 400,
|
||||
payload: { error: { message: thinkingResult.error, type: 'invalid_request_error' } },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const outputConfigResult = sanitizeAnthropicOutputConfig(body.output_config ?? body.outputConfig);
|
||||
if (outputConfigResult.error) {
|
||||
return {
|
||||
error: {
|
||||
statusCode: 400,
|
||||
payload: { error: { message: outputConfigResult.error, type: 'invalid_request_error' } },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const toolChoiceResult = sanitizeAnthropicToolChoiceValue(body.tool_choice ?? body.toolChoice);
|
||||
if (toolChoiceResult.error) {
|
||||
return {
|
||||
error: {
|
||||
statusCode: 400,
|
||||
payload: { error: { message: toolChoiceResult.error, type: 'invalid_request_error' } },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { sanitizedBody: sanitizeAnthropicMessagesBody(body) };
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { parseDownstreamChatRequest, type ParsedDownstreamChatRequest } from '../../shared/normalized.js';
|
||||
import { validateAnthropicMessagesBody } from './conversion.js';
|
||||
|
||||
export const anthropicMessagesInbound = {
|
||||
parse(body: unknown): { value?: ParsedDownstreamChatRequest; error?: { statusCode: number; payload: unknown } } {
|
||||
const parsed = parseDownstreamChatRequest(body, 'claude');
|
||||
if (parsed.error || !parsed.value) return parsed;
|
||||
|
||||
const originalBody = parsed.value.claudeOriginalBody;
|
||||
if (originalBody) {
|
||||
const validation = validateAnthropicMessagesBody(originalBody);
|
||||
if (validation.error) {
|
||||
return { error: validation.error };
|
||||
}
|
||||
if (validation.sanitizedBody) {
|
||||
parsed.value.claudeOriginalBody = validation.sanitizedBody;
|
||||
}
|
||||
}
|
||||
|
||||
return parsed;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,95 @@
|
||||
import { type NormalizedFinalResponse, type NormalizedStreamEvent, type ParsedDownstreamChatRequest, type StreamTransformContext, type ClaudeDownstreamContext } from '../../shared/normalized.js';
|
||||
import { anthropicMessagesInbound } from './inbound.js';
|
||||
import { anthropicMessagesOutbound } from './outbound.js';
|
||||
import { anthropicMessagesStream, consumeAnthropicSseEvent } from './stream.js';
|
||||
import { anthropicMessagesUsage } from './usage.js';
|
||||
import { createAnthropicMessagesAggregateState } from './aggregator.js';
|
||||
export {
|
||||
ANTHROPIC_RAW_SSE_EVENT_NAMES,
|
||||
consumeAnthropicSseEvent,
|
||||
isAnthropicRawSseEventName,
|
||||
serializeAnthropicFinalAsStream,
|
||||
serializeAnthropicUpstreamFinalAsStream,
|
||||
serializeAnthropicRawSseEvent,
|
||||
syncAnthropicRawStreamStateFromEvent,
|
||||
} from './stream.js';
|
||||
|
||||
export const anthropicMessagesTransformer = {
|
||||
protocol: 'anthropic/messages' as const,
|
||||
inbound: anthropicMessagesInbound,
|
||||
outbound: anthropicMessagesOutbound,
|
||||
stream: anthropicMessagesStream,
|
||||
usage: anthropicMessagesUsage,
|
||||
aggregator: {
|
||||
createState: createAnthropicMessagesAggregateState,
|
||||
},
|
||||
transformRequest(body: unknown): ReturnType<typeof anthropicMessagesInbound.parse> {
|
||||
return anthropicMessagesInbound.parse(body);
|
||||
},
|
||||
createStreamContext(modelName: string): StreamTransformContext {
|
||||
return anthropicMessagesStream.createContext(modelName);
|
||||
},
|
||||
createDownstreamContext(): ClaudeDownstreamContext {
|
||||
return anthropicMessagesStream.createDownstreamContext();
|
||||
},
|
||||
transformFinalResponse(payload: unknown, modelName: string, fallbackText = ''): NormalizedFinalResponse {
|
||||
return anthropicMessagesOutbound.normalizeFinal(payload, modelName, fallbackText);
|
||||
},
|
||||
transformStreamEvent(payload: unknown, context: StreamTransformContext, modelName: string): NormalizedStreamEvent {
|
||||
return anthropicMessagesStream.normalizeEvent(payload, context, modelName);
|
||||
},
|
||||
serializeStreamEvent(
|
||||
event: NormalizedStreamEvent,
|
||||
context: StreamTransformContext,
|
||||
claudeContext: ClaudeDownstreamContext,
|
||||
): string[] {
|
||||
return anthropicMessagesStream.serializeEvent(event, context, claudeContext);
|
||||
},
|
||||
serializeDone(
|
||||
context: StreamTransformContext,
|
||||
claudeContext: ClaudeDownstreamContext,
|
||||
): string[] {
|
||||
return anthropicMessagesStream.serializeDone(context, claudeContext);
|
||||
},
|
||||
serializeFinalResponse(
|
||||
normalized: NormalizedFinalResponse,
|
||||
usage: Parameters<typeof anthropicMessagesOutbound.serializeFinal>[1],
|
||||
) {
|
||||
return anthropicMessagesOutbound.serializeFinal(normalized, usage);
|
||||
},
|
||||
serializeUpstreamFinalAsStream(
|
||||
payload: unknown,
|
||||
modelName: string,
|
||||
fallbackText: string,
|
||||
streamContext: StreamTransformContext,
|
||||
claudeContext: ClaudeDownstreamContext,
|
||||
) {
|
||||
return anthropicMessagesStream.serializeUpstreamFinalAsStream(
|
||||
payload,
|
||||
modelName,
|
||||
fallbackText,
|
||||
anthropicMessagesOutbound.normalizeFinal,
|
||||
streamContext,
|
||||
claudeContext,
|
||||
);
|
||||
},
|
||||
consumeSseEventBlock(
|
||||
eventBlock: { event: string; data: string },
|
||||
streamContext: StreamTransformContext,
|
||||
claudeContext: ClaudeDownstreamContext,
|
||||
modelName: string,
|
||||
) {
|
||||
return consumeAnthropicSseEvent(
|
||||
eventBlock,
|
||||
streamContext,
|
||||
claudeContext,
|
||||
modelName,
|
||||
);
|
||||
},
|
||||
pullSseEvents(buffer: string) {
|
||||
return anthropicMessagesStream.pullSseEvents(buffer);
|
||||
},
|
||||
};
|
||||
|
||||
export type AnthropicMessagesTransformer = typeof anthropicMessagesTransformer;
|
||||
export type AnthropicMessagesParsedRequest = ParsedDownstreamChatRequest;
|
||||
@@ -0,0 +1,10 @@
|
||||
import { normalizeUpstreamFinalResponse, serializeFinalResponse, type NormalizedFinalResponse } from '../../shared/normalized.js';
|
||||
|
||||
export const anthropicMessagesOutbound = {
|
||||
normalizeFinal(payload: unknown, modelName: string, fallbackText = ''): NormalizedFinalResponse {
|
||||
return normalizeUpstreamFinalResponse(payload, modelName, fallbackText);
|
||||
},
|
||||
serializeFinal(normalized: NormalizedFinalResponse, usage?: unknown) {
|
||||
return serializeFinalResponse('claude', normalized, usage as any);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,700 @@
|
||||
import {
|
||||
createClaudeDownstreamContext,
|
||||
createStreamTransformContext,
|
||||
normalizeStopReason,
|
||||
normalizeUpstreamStreamEvent,
|
||||
pullSseEventsWithDone,
|
||||
type ClaudeDownstreamContext,
|
||||
type NormalizedFinalResponse,
|
||||
type NormalizedStreamEvent,
|
||||
type StreamTransformContext,
|
||||
} from '../../shared/normalized.js';
|
||||
import { type AnthropicExtendedStreamEvent } from './aggregator.js';
|
||||
|
||||
type AnthropicStreamPayload = Record<string, unknown>;
|
||||
|
||||
type ExtendedClaudeDownstreamContext = ClaudeDownstreamContext & {
|
||||
thinkingBlockIndex?: number | null;
|
||||
pendingSignature?: string | null;
|
||||
};
|
||||
|
||||
export const ANTHROPIC_RAW_SSE_EVENT_NAMES = new Set([
|
||||
'message_start',
|
||||
'content_block_start',
|
||||
'content_block_delta',
|
||||
'content_block_stop',
|
||||
'message_delta',
|
||||
'message_stop',
|
||||
'ping',
|
||||
'error',
|
||||
]);
|
||||
|
||||
function isRecord(value: unknown): value is AnthropicStreamPayload {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function asTrimmedString(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function serializeSse(event: string, payload: Record<string, unknown>): string {
|
||||
return `event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`;
|
||||
}
|
||||
|
||||
export function isAnthropicRawSseEventName(value: unknown): value is string {
|
||||
return typeof value === 'string' && ANTHROPIC_RAW_SSE_EVENT_NAMES.has(value);
|
||||
}
|
||||
|
||||
export function serializeAnthropicRawSseEvent(event: string, data: string): string {
|
||||
const dataLines = data.split('\n').map((line) => `data: ${line}`).join('\n');
|
||||
if (event) {
|
||||
return `event: ${event}\n${dataLines}\n\n`;
|
||||
}
|
||||
return `${dataLines}\n\n`;
|
||||
}
|
||||
|
||||
function ensureContext(context: ClaudeDownstreamContext): ExtendedClaudeDownstreamContext {
|
||||
const extended = context as ExtendedClaudeDownstreamContext;
|
||||
if (extended.thinkingBlockIndex === undefined) extended.thinkingBlockIndex = null;
|
||||
if (extended.pendingSignature === undefined) extended.pendingSignature = null;
|
||||
return extended;
|
||||
}
|
||||
|
||||
export function syncAnthropicRawStreamStateFromEvent(
|
||||
eventName: string,
|
||||
parsedPayload: unknown,
|
||||
streamContext: StreamTransformContext,
|
||||
downstreamContext: ClaudeDownstreamContext,
|
||||
) {
|
||||
const context = ensureContext(downstreamContext);
|
||||
if (eventName === 'message_start') {
|
||||
context.messageStarted = true;
|
||||
if (isRecord(parsedPayload) && isRecord(parsedPayload.message)) {
|
||||
const message = parsedPayload.message;
|
||||
if (typeof message.id === 'string' && message.id.trim().length > 0) {
|
||||
streamContext.id = message.id;
|
||||
}
|
||||
if (typeof message.model === 'string' && message.model.trim().length > 0) {
|
||||
streamContext.model = message.model;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (eventName === 'content_block_start') {
|
||||
context.contentBlockStarted = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (eventName === 'content_block_stop') {
|
||||
context.contentBlockStarted = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (eventName === 'message_stop') {
|
||||
context.doneSent = true;
|
||||
}
|
||||
}
|
||||
|
||||
function buildClaudeMessageId(sourceId: string): string {
|
||||
if (sourceId.startsWith('msg_')) return sourceId;
|
||||
const sanitized = sourceId.replace(/[^A-Za-z0-9_-]/g, '_');
|
||||
return `msg_${sanitized || Date.now()}`;
|
||||
}
|
||||
|
||||
function ensureClaudeStartEvents(
|
||||
streamContext: StreamTransformContext,
|
||||
context: ExtendedClaudeDownstreamContext,
|
||||
): string[] {
|
||||
if (context.messageStarted) return [];
|
||||
context.messageStarted = true;
|
||||
return [
|
||||
serializeSse('message_start', {
|
||||
type: 'message_start',
|
||||
message: {
|
||||
id: buildClaudeMessageId(streamContext.id),
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
model: streamContext.model,
|
||||
content: [],
|
||||
stop_reason: null,
|
||||
stop_sequence: null,
|
||||
usage: {
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
function closeTextBlock(context: ExtendedClaudeDownstreamContext): string[] {
|
||||
if (context.textBlockIndex === null || context.textBlockIndex === undefined) return [];
|
||||
const index = context.textBlockIndex;
|
||||
context.textBlockIndex = null;
|
||||
context.contentBlockStarted = false;
|
||||
return [
|
||||
serializeSse('content_block_stop', {
|
||||
type: 'content_block_stop',
|
||||
index,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
function emitPendingSignature(context: ExtendedClaudeDownstreamContext): string[] {
|
||||
if (!context.pendingSignature || context.thinkingBlockIndex === null || context.thinkingBlockIndex === undefined) {
|
||||
return [];
|
||||
}
|
||||
const signature = context.pendingSignature;
|
||||
context.pendingSignature = null;
|
||||
return [
|
||||
serializeSse('content_block_delta', {
|
||||
type: 'content_block_delta',
|
||||
index: context.thinkingBlockIndex,
|
||||
delta: {
|
||||
type: 'signature_delta',
|
||||
signature,
|
||||
},
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
function closeThinkingBlock(context: ExtendedClaudeDownstreamContext): string[] {
|
||||
if (context.thinkingBlockIndex === null || context.thinkingBlockIndex === undefined) return [];
|
||||
const index = context.thinkingBlockIndex;
|
||||
const events = [
|
||||
...emitPendingSignature(context),
|
||||
serializeSse('content_block_stop', {
|
||||
type: 'content_block_stop',
|
||||
index,
|
||||
}),
|
||||
];
|
||||
context.thinkingBlockIndex = null;
|
||||
return events;
|
||||
}
|
||||
|
||||
function closeToolBlocks(context: ExtendedClaudeDownstreamContext): string[] {
|
||||
const openBlocks = Object.values(context.toolBlocks)
|
||||
.filter((item) => item.open)
|
||||
.sort((a, b) => a.contentIndex - b.contentIndex);
|
||||
|
||||
if (openBlocks.length <= 0) return [];
|
||||
|
||||
const events: string[] = [];
|
||||
for (const block of openBlocks) {
|
||||
block.open = false;
|
||||
events.push(serializeSse('content_block_stop', {
|
||||
type: 'content_block_stop',
|
||||
index: block.contentIndex,
|
||||
}));
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
function closeAllBlocks(context: ExtendedClaudeDownstreamContext): string[] {
|
||||
return [
|
||||
...closeTextBlock(context),
|
||||
...closeThinkingBlock(context),
|
||||
...closeToolBlocks(context),
|
||||
];
|
||||
}
|
||||
|
||||
function ensureTextBlockStart(context: ExtendedClaudeDownstreamContext): string[] {
|
||||
if (context.textBlockIndex !== null && context.textBlockIndex !== undefined) return [];
|
||||
const index = context.nextContentBlockIndex;
|
||||
context.nextContentBlockIndex += 1;
|
||||
context.textBlockIndex = index;
|
||||
context.contentBlockStarted = true;
|
||||
return [
|
||||
serializeSse('content_block_start', {
|
||||
type: 'content_block_start',
|
||||
index,
|
||||
content_block: {
|
||||
type: 'text',
|
||||
text: '',
|
||||
},
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
function ensureThinkingBlockStart(context: ExtendedClaudeDownstreamContext): string[] {
|
||||
if (context.thinkingBlockIndex !== null && context.thinkingBlockIndex !== undefined) return [];
|
||||
const index = context.nextContentBlockIndex;
|
||||
context.nextContentBlockIndex += 1;
|
||||
context.thinkingBlockIndex = index;
|
||||
return [
|
||||
serializeSse('content_block_start', {
|
||||
type: 'content_block_start',
|
||||
index,
|
||||
content_block: {
|
||||
type: 'thinking',
|
||||
thinking: '',
|
||||
},
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
function ensureToolBlockStart(
|
||||
context: ExtendedClaudeDownstreamContext,
|
||||
toolDelta: NonNullable<AnthropicExtendedStreamEvent['toolCallDeltas']>[number],
|
||||
): { events: string[]; contentIndex: number } {
|
||||
const toolSlot = Number.isFinite(toolDelta.index) ? Math.max(0, Math.trunc(toolDelta.index)) : 0;
|
||||
let state = context.toolBlocks[toolSlot];
|
||||
if (!state) {
|
||||
state = {
|
||||
contentIndex: context.nextContentBlockIndex,
|
||||
id: toolDelta.id || `toolu_${toolSlot}`,
|
||||
name: toolDelta.name || `tool_${toolSlot}`,
|
||||
open: false,
|
||||
};
|
||||
context.nextContentBlockIndex += 1;
|
||||
context.toolBlocks[toolSlot] = state;
|
||||
} else {
|
||||
if (toolDelta.id) state.id = toolDelta.id;
|
||||
if (toolDelta.name) state.name = toolDelta.name;
|
||||
}
|
||||
|
||||
const events: string[] = [];
|
||||
if (!state.open) {
|
||||
state.open = true;
|
||||
events.push(serializeSse('content_block_start', {
|
||||
type: 'content_block_start',
|
||||
index: state.contentIndex,
|
||||
content_block: {
|
||||
type: 'tool_use',
|
||||
id: state.id,
|
||||
name: state.name,
|
||||
input: {},
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
return { events, contentIndex: state.contentIndex };
|
||||
}
|
||||
|
||||
function buildDoneEvents(
|
||||
streamContext: StreamTransformContext,
|
||||
context: ExtendedClaudeDownstreamContext,
|
||||
finishReason?: string | null,
|
||||
): string[] {
|
||||
if (context.doneSent) return [];
|
||||
|
||||
const events = [
|
||||
...ensureClaudeStartEvents(streamContext, context),
|
||||
...closeAllBlocks(context),
|
||||
serializeSse('message_delta', {
|
||||
type: 'message_delta',
|
||||
delta: {
|
||||
stop_reason: toClaudeStopReason(finishReason),
|
||||
stop_sequence: null,
|
||||
},
|
||||
usage: {
|
||||
output_tokens: 0,
|
||||
},
|
||||
}),
|
||||
serializeSse('message_stop', {
|
||||
type: 'message_stop',
|
||||
}),
|
||||
];
|
||||
context.doneSent = true;
|
||||
return events;
|
||||
}
|
||||
|
||||
export function serializeAnthropicFinalAsStream(
|
||||
normalizedFinal: NormalizedFinalResponse,
|
||||
streamContext: StreamTransformContext,
|
||||
downstreamContext: ClaudeDownstreamContext,
|
||||
): string[] {
|
||||
streamContext.id = normalizedFinal.id;
|
||||
streamContext.model = normalizedFinal.model;
|
||||
streamContext.created = normalizedFinal.created;
|
||||
|
||||
const lines = [
|
||||
...anthropicMessagesStream.serializeEvent({ role: 'assistant' }, streamContext, downstreamContext),
|
||||
];
|
||||
|
||||
if (normalizedFinal.reasoningContent) {
|
||||
lines.push(
|
||||
...anthropicMessagesStream.serializeEvent(
|
||||
{ reasoningDelta: normalizedFinal.reasoningContent },
|
||||
streamContext,
|
||||
downstreamContext,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (normalizedFinal.content) {
|
||||
lines.push(
|
||||
...anthropicMessagesStream.serializeEvent(
|
||||
{ contentDelta: normalizedFinal.content },
|
||||
streamContext,
|
||||
downstreamContext,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
lines.push(
|
||||
...anthropicMessagesStream.serializeEvent(
|
||||
{ finishReason: normalizedFinal.finishReason },
|
||||
streamContext,
|
||||
downstreamContext,
|
||||
),
|
||||
);
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
export function serializeAnthropicUpstreamFinalAsStream(
|
||||
payload: unknown,
|
||||
modelName: string,
|
||||
fallbackText: string,
|
||||
normalizeFinal: (payload: unknown, modelName: string, fallbackText?: string) => NormalizedFinalResponse,
|
||||
streamContext: StreamTransformContext,
|
||||
downstreamContext: ClaudeDownstreamContext,
|
||||
): string[] {
|
||||
const normalizedFinal = normalizeFinal(payload, modelName, fallbackText);
|
||||
return serializeAnthropicFinalAsStream(normalizedFinal, streamContext, downstreamContext);
|
||||
}
|
||||
|
||||
function toClaudeStopReason(finishReason: string | null | undefined): string {
|
||||
const normalized = normalizeStopReason(finishReason);
|
||||
if (normalized === 'length') return 'max_tokens';
|
||||
if (normalized === 'tool_calls') return 'tool_use';
|
||||
return 'end_turn';
|
||||
}
|
||||
|
||||
function normalizeAnthropicRawEvent(
|
||||
payload: AnthropicStreamPayload,
|
||||
context: StreamTransformContext,
|
||||
fallbackModel: string,
|
||||
): AnthropicExtendedStreamEvent | null {
|
||||
const type = asTrimmedString(payload.type);
|
||||
if (!type) return null;
|
||||
|
||||
if (type === 'message_start' && isRecord(payload.message)) {
|
||||
const message = payload.message;
|
||||
if (asTrimmedString(message.id)) context.id = asTrimmedString(message.id);
|
||||
if (asTrimmedString(message.model)) context.model = asTrimmedString(message.model);
|
||||
else if (!context.model) context.model = fallbackModel;
|
||||
return { role: 'assistant' };
|
||||
}
|
||||
|
||||
if (type === 'content_block_start' && isRecord(payload.content_block)) {
|
||||
const contentBlock = payload.content_block;
|
||||
const blockType = asTrimmedString(contentBlock.type);
|
||||
const index = typeof payload.index === 'number' ? payload.index : undefined;
|
||||
|
||||
if (blockType === 'thinking') {
|
||||
return {
|
||||
anthropic: {
|
||||
startBlock: {
|
||||
kind: 'thinking',
|
||||
index,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (blockType === 'redacted_thinking') {
|
||||
return {
|
||||
anthropic: {
|
||||
startBlock: {
|
||||
kind: 'redacted_thinking',
|
||||
index,
|
||||
},
|
||||
redactedThinkingData: asTrimmedString(contentBlock.data),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (blockType === 'tool_use') {
|
||||
return {
|
||||
toolCallDeltas: [{
|
||||
index: typeof index === 'number' ? index : 0,
|
||||
id: asTrimmedString(contentBlock.id) || undefined,
|
||||
name: asTrimmedString(contentBlock.name) || undefined,
|
||||
}],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (type === 'content_block_delta' && isRecord(payload.delta)) {
|
||||
const delta = payload.delta;
|
||||
const deltaType = asTrimmedString(delta.type);
|
||||
const index = typeof payload.index === 'number' ? payload.index : 0;
|
||||
|
||||
if (deltaType === 'thinking_delta') {
|
||||
return {
|
||||
reasoningDelta: asTrimmedString(delta.thinking ?? delta.text) || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
if (deltaType === 'signature_delta') {
|
||||
return {
|
||||
anthropic: {
|
||||
signatureDelta: asTrimmedString(delta.signature) || undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (deltaType === 'text_delta') {
|
||||
return {
|
||||
contentDelta: asTrimmedString(delta.text) || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
if (deltaType === 'input_json_delta') {
|
||||
return {
|
||||
toolCallDeltas: [{
|
||||
index,
|
||||
argumentsDelta: asTrimmedString(delta.partial_json),
|
||||
}],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (type === 'content_block_stop') {
|
||||
return {
|
||||
anthropic: {
|
||||
stopBlockIndex: typeof payload.index === 'number' ? payload.index : null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (type === 'message_delta' && isRecord(payload.delta)) {
|
||||
return {
|
||||
finishReason: normalizeStopReason(payload.delta.stop_reason ?? payload.stop_reason),
|
||||
};
|
||||
}
|
||||
|
||||
if (type === 'message_stop') {
|
||||
return { done: true };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export type AnthropicConsumedSseEvent = {
|
||||
handled: boolean;
|
||||
lines: string[];
|
||||
done: boolean;
|
||||
parsedPayload: unknown | null;
|
||||
};
|
||||
|
||||
export function consumeAnthropicSseEvent(
|
||||
eventBlock: { event: string; data: string },
|
||||
streamContext: StreamTransformContext,
|
||||
downstreamContext: ClaudeDownstreamContext,
|
||||
fallbackModel: string,
|
||||
): AnthropicConsumedSseEvent {
|
||||
const context = ensureContext(downstreamContext);
|
||||
let parsedPayload: unknown = null;
|
||||
|
||||
try {
|
||||
parsedPayload = JSON.parse(eventBlock.data);
|
||||
} catch {
|
||||
parsedPayload = null;
|
||||
}
|
||||
|
||||
if (parsedPayload && isRecord(parsedPayload)) {
|
||||
const payloadType = typeof parsedPayload.type === 'string' ? parsedPayload.type : '';
|
||||
const claudeEventName = isAnthropicRawSseEventName(eventBlock.event)
|
||||
? eventBlock.event
|
||||
: (isAnthropicRawSseEventName(payloadType) ? payloadType : '');
|
||||
|
||||
if (claudeEventName) {
|
||||
syncAnthropicRawStreamStateFromEvent(
|
||||
claudeEventName,
|
||||
parsedPayload,
|
||||
streamContext,
|
||||
context,
|
||||
);
|
||||
return {
|
||||
handled: true,
|
||||
lines: [serializeAnthropicRawSseEvent(claudeEventName, eventBlock.data)],
|
||||
done: context.doneSent,
|
||||
parsedPayload,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
handled: false,
|
||||
lines: [],
|
||||
done: false,
|
||||
parsedPayload,
|
||||
};
|
||||
}
|
||||
|
||||
export const anthropicMessagesStream = {
|
||||
createContext(modelName: string): StreamTransformContext {
|
||||
return createStreamTransformContext(modelName);
|
||||
},
|
||||
createDownstreamContext(): ClaudeDownstreamContext {
|
||||
return ensureContext(createClaudeDownstreamContext());
|
||||
},
|
||||
normalizeEvent(payload: unknown, context: StreamTransformContext, modelName: string): AnthropicExtendedStreamEvent {
|
||||
if (isRecord(payload)) {
|
||||
const anthropicEvent = normalizeAnthropicRawEvent(payload, context, modelName);
|
||||
if (anthropicEvent) return anthropicEvent;
|
||||
}
|
||||
return normalizeUpstreamStreamEvent(payload, context, modelName) as AnthropicExtendedStreamEvent;
|
||||
},
|
||||
serializeEvent(
|
||||
event: NormalizedStreamEvent,
|
||||
streamContext: StreamTransformContext,
|
||||
downstreamContext: ClaudeDownstreamContext,
|
||||
): string[] {
|
||||
const context = ensureContext(downstreamContext);
|
||||
const anthropicEvent = event as AnthropicExtendedStreamEvent;
|
||||
const events: string[] = [];
|
||||
|
||||
const needsStart = (
|
||||
event.role === 'assistant'
|
||||
|| !!event.contentDelta
|
||||
|| !!event.reasoningDelta
|
||||
|| (Array.isArray(event.toolCallDeltas) && event.toolCallDeltas.length > 0)
|
||||
|| !!anthropicEvent.anthropic
|
||||
|| !!event.finishReason
|
||||
|| !!event.done
|
||||
);
|
||||
if (needsStart) {
|
||||
events.push(...ensureClaudeStartEvents(streamContext, context));
|
||||
}
|
||||
|
||||
if (anthropicEvent.anthropic?.signatureDelta) {
|
||||
if (context.thinkingBlockIndex !== null && context.thinkingBlockIndex !== undefined) {
|
||||
events.push(serializeSse('content_block_delta', {
|
||||
type: 'content_block_delta',
|
||||
index: context.thinkingBlockIndex,
|
||||
delta: {
|
||||
type: 'signature_delta',
|
||||
signature: anthropicEvent.anthropic.signatureDelta,
|
||||
},
|
||||
}));
|
||||
} else {
|
||||
context.pendingSignature = anthropicEvent.anthropic.signatureDelta;
|
||||
}
|
||||
}
|
||||
|
||||
if (anthropicEvent.anthropic?.redactedThinkingData) {
|
||||
events.push(...closeAllBlocks(context));
|
||||
const index = context.nextContentBlockIndex;
|
||||
context.nextContentBlockIndex += 1;
|
||||
events.push(serializeSse('content_block_start', {
|
||||
type: 'content_block_start',
|
||||
index,
|
||||
content_block: {
|
||||
type: 'redacted_thinking',
|
||||
data: anthropicEvent.anthropic.redactedThinkingData,
|
||||
},
|
||||
}));
|
||||
events.push(serializeSse('content_block_stop', {
|
||||
type: 'content_block_stop',
|
||||
index,
|
||||
}));
|
||||
}
|
||||
|
||||
if (event.reasoningDelta) {
|
||||
events.push(...closeToolBlocks(context));
|
||||
events.push(...closeTextBlock(context));
|
||||
events.push(...ensureThinkingBlockStart(context));
|
||||
events.push(serializeSse('content_block_delta', {
|
||||
type: 'content_block_delta',
|
||||
index: context.thinkingBlockIndex ?? 0,
|
||||
delta: {
|
||||
type: 'thinking_delta',
|
||||
thinking: event.reasoningDelta,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
if (Array.isArray(event.toolCallDeltas) && event.toolCallDeltas.length > 0) {
|
||||
events.push(...closeTextBlock(context));
|
||||
events.push(...closeThinkingBlock(context));
|
||||
for (const toolDelta of event.toolCallDeltas) {
|
||||
const toolBlock = ensureToolBlockStart(context, toolDelta);
|
||||
events.push(...toolBlock.events);
|
||||
if (toolDelta.argumentsDelta) {
|
||||
events.push(serializeSse('content_block_delta', {
|
||||
type: 'content_block_delta',
|
||||
index: toolBlock.contentIndex,
|
||||
delta: {
|
||||
type: 'input_json_delta',
|
||||
partial_json: toolDelta.argumentsDelta,
|
||||
},
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (event.contentDelta) {
|
||||
events.push(...closeToolBlocks(context));
|
||||
events.push(...closeThinkingBlock(context));
|
||||
events.push(...ensureTextBlockStart(context));
|
||||
events.push(serializeSse('content_block_delta', {
|
||||
type: 'content_block_delta',
|
||||
index: context.textBlockIndex ?? 0,
|
||||
delta: {
|
||||
type: 'text_delta',
|
||||
text: event.contentDelta,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
if (
|
||||
anthropicEvent.anthropic?.stopBlockIndex !== undefined
|
||||
&& anthropicEvent.anthropic.stopBlockIndex !== null
|
||||
) {
|
||||
const targetIndex = anthropicEvent.anthropic.stopBlockIndex;
|
||||
if (context.thinkingBlockIndex === targetIndex) {
|
||||
events.push(...closeThinkingBlock(context));
|
||||
}
|
||||
if (context.textBlockIndex === targetIndex) {
|
||||
events.push(...closeTextBlock(context));
|
||||
}
|
||||
const matchingToolBlock = Object.values(context.toolBlocks).find((item) => item.open && item.contentIndex === targetIndex);
|
||||
if (matchingToolBlock) {
|
||||
matchingToolBlock.open = false;
|
||||
events.push(serializeSse('content_block_stop', {
|
||||
type: 'content_block_stop',
|
||||
index: matchingToolBlock.contentIndex,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
if (event.finishReason || event.done) {
|
||||
events.push(...buildDoneEvents(streamContext, context, event.finishReason));
|
||||
}
|
||||
|
||||
return events;
|
||||
},
|
||||
serializeDone(
|
||||
streamContext: StreamTransformContext,
|
||||
downstreamContext: ClaudeDownstreamContext,
|
||||
): string[] {
|
||||
const context = ensureContext(downstreamContext);
|
||||
return buildDoneEvents(streamContext, context, 'stop');
|
||||
},
|
||||
pullSseEvents(buffer: string) {
|
||||
return pullSseEventsWithDone(buffer);
|
||||
},
|
||||
consumeAnthropicSseEvent,
|
||||
serializeUpstreamFinalAsStream(
|
||||
payload: unknown,
|
||||
modelName: string,
|
||||
fallbackText: string,
|
||||
normalizeFinal: (payload: unknown, modelName: string, fallbackText?: string) => NormalizedFinalResponse,
|
||||
streamContext: StreamTransformContext,
|
||||
downstreamContext: ClaudeDownstreamContext,
|
||||
) {
|
||||
return serializeAnthropicUpstreamFinalAsStream(
|
||||
payload,
|
||||
modelName,
|
||||
fallbackText,
|
||||
normalizeFinal,
|
||||
streamContext,
|
||||
downstreamContext,
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,113 @@
|
||||
import { createEmptyNormalizedUsage, mergeNormalizedUsage, type NormalizedUsage } from '../../shared/normalized.js';
|
||||
|
||||
type AnthropicRecord = Record<string, unknown>;
|
||||
|
||||
function isRecord(value: unknown): value is AnthropicRecord {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function toPositiveInt(value: unknown): number {
|
||||
const numberValue = typeof value === 'number' ? value : Number(value);
|
||||
if (!Number.isFinite(numberValue)) return 0;
|
||||
return Math.max(0, Math.trunc(numberValue));
|
||||
}
|
||||
|
||||
function resolveAnthropicUsageRecord(payload: unknown): AnthropicRecord | null {
|
||||
if (isRecord(payload) && isRecord(payload.usage)) return payload.usage;
|
||||
if (isRecord(payload)) return payload;
|
||||
return null;
|
||||
}
|
||||
|
||||
export type AnthropicUsageMetadata = {
|
||||
cacheReadInputTokens: number;
|
||||
cacheCreationInputTokens: number;
|
||||
ephemeral5mInputTokens: number;
|
||||
ephemeral1hInputTokens: number;
|
||||
promptTokensIncludingCache: number;
|
||||
};
|
||||
|
||||
export function extractAnthropicUsage(payload: unknown): NormalizedUsage {
|
||||
const usageRecord = resolveAnthropicUsageRecord(payload);
|
||||
if (!usageRecord) {
|
||||
return createEmptyNormalizedUsage();
|
||||
}
|
||||
|
||||
const inputTokens = toPositiveInt(usageRecord.input_tokens ?? usageRecord.inputTokens);
|
||||
const outputTokens = toPositiveInt(usageRecord.output_tokens ?? usageRecord.outputTokens);
|
||||
const cacheReadTokens = Math.max(
|
||||
toPositiveInt(usageRecord.cache_read_input_tokens ?? usageRecord.cacheReadInputTokens),
|
||||
toPositiveInt(usageRecord.cached_tokens ?? usageRecord.cachedTokens),
|
||||
);
|
||||
const cacheCreationTokens = Math.max(
|
||||
toPositiveInt(usageRecord.cache_creation_input_tokens ?? usageRecord.cacheCreationInputTokens),
|
||||
toPositiveInt((usageRecord.cache_creation as AnthropicRecord | undefined)?.ephemeral_5m_input_tokens)
|
||||
+ toPositiveInt((usageRecord.cache_creation as AnthropicRecord | undefined)?.ephemeral_1h_input_tokens),
|
||||
toPositiveInt((usageRecord.cacheCreation as AnthropicRecord | undefined)?.ephemeral5mInputTokens)
|
||||
+ toPositiveInt((usageRecord.cacheCreation as AnthropicRecord | undefined)?.ephemeral1hInputTokens),
|
||||
);
|
||||
|
||||
const promptTokens = inputTokens + cacheReadTokens + cacheCreationTokens;
|
||||
const completionTokens = outputTokens;
|
||||
const totalTokens = Math.max(
|
||||
toPositiveInt(usageRecord.total_tokens ?? usageRecord.totalTokens),
|
||||
promptTokens + completionTokens,
|
||||
);
|
||||
|
||||
return {
|
||||
promptTokens,
|
||||
completionTokens,
|
||||
totalTokens,
|
||||
cachedTokens: cacheReadTokens,
|
||||
reasoningTokens: 0,
|
||||
audioInputTokens: 0,
|
||||
audioOutputTokens: 0,
|
||||
acceptedPredictionTokens: 0,
|
||||
rejectedPredictionTokens: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function extractAnthropicUsageMetadata(payload: unknown): AnthropicUsageMetadata {
|
||||
const usageRecord = resolveAnthropicUsageRecord(payload);
|
||||
if (!usageRecord) {
|
||||
return {
|
||||
cacheReadInputTokens: 0,
|
||||
cacheCreationInputTokens: 0,
|
||||
ephemeral5mInputTokens: 0,
|
||||
ephemeral1hInputTokens: 0,
|
||||
promptTokensIncludingCache: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const inputTokens = toPositiveInt(usageRecord.input_tokens ?? usageRecord.inputTokens);
|
||||
const cacheReadInputTokens = Math.max(
|
||||
toPositiveInt(usageRecord.cache_read_input_tokens ?? usageRecord.cacheReadInputTokens),
|
||||
toPositiveInt(usageRecord.cached_tokens ?? usageRecord.cachedTokens),
|
||||
);
|
||||
const ephemeral5mInputTokens = Math.max(
|
||||
toPositiveInt((usageRecord.cache_creation as AnthropicRecord | undefined)?.ephemeral_5m_input_tokens),
|
||||
toPositiveInt((usageRecord.cacheCreation as AnthropicRecord | undefined)?.ephemeral5mInputTokens),
|
||||
);
|
||||
const ephemeral1hInputTokens = Math.max(
|
||||
toPositiveInt((usageRecord.cache_creation as AnthropicRecord | undefined)?.ephemeral_1h_input_tokens),
|
||||
toPositiveInt((usageRecord.cacheCreation as AnthropicRecord | undefined)?.ephemeral1hInputTokens),
|
||||
);
|
||||
const cacheCreationInputTokens = Math.max(
|
||||
toPositiveInt(usageRecord.cache_creation_input_tokens ?? usageRecord.cacheCreationInputTokens),
|
||||
ephemeral5mInputTokens + ephemeral1hInputTokens,
|
||||
);
|
||||
|
||||
return {
|
||||
cacheReadInputTokens,
|
||||
cacheCreationInputTokens,
|
||||
ephemeral5mInputTokens,
|
||||
ephemeral1hInputTokens,
|
||||
promptTokensIncludingCache: inputTokens + cacheReadInputTokens + cacheCreationInputTokens,
|
||||
};
|
||||
}
|
||||
|
||||
export const anthropicMessagesUsage = {
|
||||
empty: createEmptyNormalizedUsage,
|
||||
fromPayload: extractAnthropicUsage,
|
||||
metadataFromPayload: extractAnthropicUsageMetadata,
|
||||
merge: mergeNormalizedUsage,
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
function readWorkspaceFile(relativePath: string): string {
|
||||
return readFileSync(path.resolve(process.cwd(), relativePath), 'utf8');
|
||||
}
|
||||
|
||||
describe('final transformer hard-cut architecture', () => {
|
||||
it('keeps shared normalized helpers independent from route chatFormats', () => {
|
||||
const sharedNormalized = readWorkspaceFile('src/server/transformers/shared/normalized.ts');
|
||||
|
||||
expect(sharedNormalized).not.toContain("../../routes/proxy/chatFormats.js");
|
||||
expect(sharedNormalized).not.toContain("from '../../routes/proxy/chatFormats.js'");
|
||||
});
|
||||
|
||||
it('removes normalizeContentText from upstream endpoint compatibility path', () => {
|
||||
const upstreamEndpoint = readWorkspaceFile('src/server/routes/proxy/upstreamEndpoint.ts');
|
||||
|
||||
expect(upstreamEndpoint).not.toContain('function normalizeContentText(');
|
||||
expect(upstreamEndpoint).not.toContain('normalizeContentText(');
|
||||
});
|
||||
|
||||
it('keeps responses protocol shaping out of route-local helpers', () => {
|
||||
const responsesRoute = readWorkspaceFile('src/server/routes/proxy/responses.ts');
|
||||
|
||||
expect(responsesRoute).not.toContain('function toResponsesPayload(');
|
||||
expect(responsesRoute).not.toContain('function createResponsesStreamState(');
|
||||
});
|
||||
|
||||
it('replaces gemini passthrough placeholders with protocol-aware helpers', () => {
|
||||
const geminiInbound = readWorkspaceFile('src/server/transformers/gemini/generate-content/inbound.ts');
|
||||
const geminiStream = readWorkspaceFile('src/server/transformers/gemini/generate-content/stream.ts');
|
||||
const geminiAggregator = readWorkspaceFile('src/server/transformers/gemini/generate-content/aggregator.ts');
|
||||
|
||||
expect(geminiInbound).not.toContain('passthrough');
|
||||
expect(geminiStream).not.toContain('passthrough');
|
||||
expect(geminiAggregator).not.toContain('parts: unknown[]');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
type GeminiRecord = Record<string, unknown>;
|
||||
|
||||
function isRecord(value: unknown): value is GeminiRecord {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function cloneJsonValue<T>(value: T): T {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => cloneJsonValue(item)) as T;
|
||||
}
|
||||
if (isRecord(value)) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, item]) => [key, cloneJsonValue(item)]),
|
||||
) as T;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function pushUniqueJson(target: GeminiRecord[], incoming: unknown): void {
|
||||
if (!isRecord(incoming)) return;
|
||||
const serialized = JSON.stringify(incoming);
|
||||
if (target.some((item) => JSON.stringify(item) === serialized)) return;
|
||||
target.push(cloneJsonValue(incoming));
|
||||
}
|
||||
|
||||
function collectPartsFromPayload(payload: unknown): GeminiRecord[] {
|
||||
if (Array.isArray(payload)) {
|
||||
return payload.flatMap((item) => collectPartsFromPayload(item));
|
||||
}
|
||||
if (!isRecord(payload)) return [];
|
||||
|
||||
const candidates = Array.isArray(payload.candidates) ? payload.candidates : [];
|
||||
const parts: GeminiRecord[] = [];
|
||||
for (const candidate of candidates) {
|
||||
if (!isRecord(candidate)) continue;
|
||||
const content = isRecord(candidate.content) ? candidate.content : null;
|
||||
if (!content || !Array.isArray(content.parts)) continue;
|
||||
for (const part of content.parts) {
|
||||
if (isRecord(part)) parts.push(cloneJsonValue(part));
|
||||
}
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
function updateIfDefined<T extends keyof GeminiGenerateContentUsageSummary>(
|
||||
usage: GeminiGenerateContentUsageSummary,
|
||||
key: T,
|
||||
value: unknown,
|
||||
): void {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
usage[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
export type GeminiGenerateContentUsageSummary = {
|
||||
promptTokenCount?: number | null;
|
||||
candidatesTokenCount?: number | null;
|
||||
totalTokenCount?: number | null;
|
||||
cachedContentTokenCount?: number | null;
|
||||
thoughtsTokenCount?: number | null;
|
||||
};
|
||||
|
||||
export type GeminiGenerateContentCandidateAggregate = {
|
||||
index: number;
|
||||
finishReason: string | null;
|
||||
parts: GeminiRecord[];
|
||||
groundingMetadata?: GeminiRecord;
|
||||
citationMetadata?: GeminiRecord;
|
||||
};
|
||||
|
||||
export type GeminiGenerateContentAggregateState = {
|
||||
responseId: string | null;
|
||||
modelVersion: string | null;
|
||||
finishReason: string | null;
|
||||
parts: GeminiRecord[];
|
||||
citations: GeminiRecord[];
|
||||
groundingMetadata: GeminiRecord[];
|
||||
thoughtSignatures: string[];
|
||||
usage: GeminiGenerateContentUsageSummary;
|
||||
candidates: GeminiGenerateContentCandidateAggregate[];
|
||||
};
|
||||
|
||||
export function createGeminiGenerateContentAggregateState(): GeminiGenerateContentAggregateState {
|
||||
return {
|
||||
responseId: null,
|
||||
modelVersion: null,
|
||||
finishReason: null,
|
||||
parts: [],
|
||||
citations: [],
|
||||
groundingMetadata: [],
|
||||
thoughtSignatures: [],
|
||||
usage: {},
|
||||
candidates: [],
|
||||
};
|
||||
}
|
||||
|
||||
function ensureCandidateAggregate(
|
||||
state: GeminiGenerateContentAggregateState,
|
||||
rawIndex: unknown,
|
||||
): GeminiGenerateContentCandidateAggregate {
|
||||
const normalizedIndex = typeof rawIndex === 'number' && Number.isFinite(rawIndex)
|
||||
? Math.max(0, Math.trunc(rawIndex))
|
||||
: 0;
|
||||
let existing = state.candidates.find((candidate) => candidate.index === normalizedIndex);
|
||||
if (!existing) {
|
||||
existing = {
|
||||
index: normalizedIndex,
|
||||
finishReason: null,
|
||||
parts: [],
|
||||
};
|
||||
state.candidates.push(existing);
|
||||
state.candidates.sort((left, right) => left.index - right.index);
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
|
||||
export function applyGeminiGenerateContentAggregate(
|
||||
state: GeminiGenerateContentAggregateState,
|
||||
payload: unknown,
|
||||
): GeminiGenerateContentAggregateState {
|
||||
for (const part of collectPartsFromPayload(payload)) {
|
||||
state.parts.push(part);
|
||||
if (typeof part.thoughtSignature === 'string' && part.thoughtSignature.trim()) {
|
||||
if (!state.thoughtSignatures.includes(part.thoughtSignature)) {
|
||||
state.thoughtSignatures.push(part.thoughtSignature);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const payloads = Array.isArray(payload) ? payload : [payload];
|
||||
for (const item of payloads) {
|
||||
if (!isRecord(item)) continue;
|
||||
|
||||
if (typeof item.responseId === 'string' && item.responseId.trim()) {
|
||||
state.responseId = item.responseId.trim();
|
||||
}
|
||||
if (typeof item.modelVersion === 'string' && item.modelVersion.trim()) {
|
||||
state.modelVersion = item.modelVersion.trim();
|
||||
}
|
||||
|
||||
const candidates = Array.isArray(item.candidates) ? item.candidates : [];
|
||||
for (const candidate of candidates) {
|
||||
if (!isRecord(candidate)) continue;
|
||||
const candidateAggregate = ensureCandidateAggregate(state, candidate.index);
|
||||
if (candidate.groundingMetadata !== undefined) {
|
||||
pushUniqueJson(state.groundingMetadata, candidate.groundingMetadata);
|
||||
if (isRecord(candidate.groundingMetadata)) {
|
||||
candidateAggregate.groundingMetadata = cloneJsonValue(candidate.groundingMetadata);
|
||||
}
|
||||
}
|
||||
if (candidate.citationMetadata !== undefined) {
|
||||
pushUniqueJson(state.citations, candidate.citationMetadata);
|
||||
if (isRecord(candidate.citationMetadata)) {
|
||||
candidateAggregate.citationMetadata = cloneJsonValue(candidate.citationMetadata);
|
||||
}
|
||||
}
|
||||
if (typeof candidate.finishReason === 'string' && candidate.finishReason.trim()) {
|
||||
state.finishReason = candidate.finishReason.trim();
|
||||
candidateAggregate.finishReason = candidate.finishReason.trim();
|
||||
}
|
||||
|
||||
const content = isRecord(candidate.content) ? candidate.content : null;
|
||||
if (content && Array.isArray(content.parts)) {
|
||||
for (const part of content.parts) {
|
||||
if (!isRecord(part)) continue;
|
||||
candidateAggregate.parts.push(cloneJsonValue(part));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const usageMetadata = isRecord(item.usageMetadata) ? item.usageMetadata : null;
|
||||
if (usageMetadata) {
|
||||
updateIfDefined(state.usage, 'promptTokenCount', usageMetadata.promptTokenCount);
|
||||
updateIfDefined(state.usage, 'candidatesTokenCount', usageMetadata.candidatesTokenCount);
|
||||
updateIfDefined(state.usage, 'totalTokenCount', usageMetadata.totalTokenCount);
|
||||
updateIfDefined(state.usage, 'cachedContentTokenCount', usageMetadata.cachedContentTokenCount);
|
||||
updateIfDefined(state.usage, 'thoughtsTokenCount', usageMetadata.thoughtsTokenCount);
|
||||
}
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
export type GeminiReasoningEffort = 'none' | 'low' | 'medium' | 'high' | 'max';
|
||||
|
||||
type GeminiThinkingConfig = {
|
||||
thinkingBudget?: number;
|
||||
thinkingLevel?: string;
|
||||
};
|
||||
|
||||
function normalizeReasoningEffort(value: unknown): GeminiReasoningEffort {
|
||||
const normalized = typeof value === 'string' ? value.trim().toLowerCase() : '';
|
||||
if (
|
||||
normalized === 'none'
|
||||
|| normalized === 'low'
|
||||
|| normalized === 'medium'
|
||||
|| normalized === 'high'
|
||||
|| normalized === 'max'
|
||||
) {
|
||||
return normalized;
|
||||
}
|
||||
return 'medium';
|
||||
}
|
||||
|
||||
function normalizeThinkingLevel(value: unknown): GeminiReasoningEffort | null {
|
||||
const normalized = typeof value === 'string' ? value.trim().toLowerCase() : '';
|
||||
if (normalized === 'minimal') return 'low';
|
||||
if (
|
||||
normalized === 'none'
|
||||
|| normalized === 'low'
|
||||
|| normalized === 'medium'
|
||||
|| normalized === 'high'
|
||||
|| normalized === 'max'
|
||||
) {
|
||||
return normalized;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function thinkingBudgetToReasoningEffort(budget: number): GeminiReasoningEffort {
|
||||
if (budget <= 0) return 'none';
|
||||
if (budget <= 1024) return 'low';
|
||||
if (budget <= 8192) return 'medium';
|
||||
if (budget <= 32768) return 'high';
|
||||
return 'max';
|
||||
}
|
||||
|
||||
function shouldUseThinkingLevel(model: string, effort: GeminiReasoningEffort): boolean {
|
||||
if (!/gemini-3-/i.test(model)) return false;
|
||||
return effort === 'none' || effort === 'low' || effort === 'medium' || effort === 'high';
|
||||
}
|
||||
|
||||
function reasoningEffortToThinkingBudget(effort: GeminiReasoningEffort): number {
|
||||
switch (effort) {
|
||||
case 'none':
|
||||
return 0;
|
||||
case 'low':
|
||||
return 1024;
|
||||
case 'high':
|
||||
return 32768;
|
||||
case 'max':
|
||||
return 65536;
|
||||
case 'medium':
|
||||
default:
|
||||
return 8192;
|
||||
}
|
||||
}
|
||||
|
||||
export function reasoningEffortToGeminiThinkingConfig(
|
||||
model: string,
|
||||
reasoningEffort: unknown,
|
||||
): GeminiThinkingConfig {
|
||||
const effort = normalizeReasoningEffort(reasoningEffort);
|
||||
if (shouldUseThinkingLevel(model, effort)) {
|
||||
return { thinkingLevel: effort };
|
||||
}
|
||||
return { thinkingBudget: reasoningEffortToThinkingBudget(effort) };
|
||||
}
|
||||
|
||||
export function geminiThinkingConfigToReasoning(
|
||||
thinkingConfig: Record<string, unknown> | null | undefined,
|
||||
): { reasoningEffort: GeminiReasoningEffort; reasoningBudget: number } | null {
|
||||
if (!thinkingConfig || typeof thinkingConfig !== 'object') return null;
|
||||
|
||||
const level = normalizeThinkingLevel(thinkingConfig.thinkingLevel);
|
||||
if (level) {
|
||||
return {
|
||||
reasoningEffort: level,
|
||||
reasoningBudget: reasoningEffortToThinkingBudget(level),
|
||||
};
|
||||
}
|
||||
|
||||
const budget = typeof thinkingConfig.thinkingBudget === 'number' && Number.isFinite(thinkingConfig.thinkingBudget)
|
||||
? Math.max(0, Math.trunc(thinkingConfig.thinkingBudget))
|
||||
: null;
|
||||
if (budget === null) return null;
|
||||
|
||||
return {
|
||||
reasoningEffort: thinkingBudgetToReasoningEffort(budget),
|
||||
reasoningBudget: budget,
|
||||
};
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function toFiniteInt(value: unknown): number | null {
|
||||
const numeric = typeof value === 'number' ? value : Number(value);
|
||||
if (!Number.isFinite(numeric)) return null;
|
||||
return Math.max(0, Math.trunc(numeric));
|
||||
}
|
||||
|
||||
export function resolveGeminiThinkingConfigFromRequest(
|
||||
model: string,
|
||||
body: Record<string, unknown>,
|
||||
): GeminiThinkingConfig | null {
|
||||
const generationConfig = isRecord(body.generationConfig) ? body.generationConfig : null;
|
||||
const nativeThinkingConfig = isRecord(generationConfig?.thinkingConfig)
|
||||
? generationConfig!.thinkingConfig as Record<string, unknown>
|
||||
: null;
|
||||
|
||||
if (nativeThinkingConfig) {
|
||||
const nativeLevel = normalizeThinkingLevel(nativeThinkingConfig.thinkingLevel);
|
||||
const nativeBudget = toFiniteInt(nativeThinkingConfig.thinkingBudget);
|
||||
if (nativeLevel) {
|
||||
return { thinkingLevel: nativeLevel };
|
||||
}
|
||||
if (nativeBudget !== null) {
|
||||
return { thinkingBudget: nativeBudget };
|
||||
}
|
||||
}
|
||||
|
||||
const reasoning = isRecord(body.reasoning) ? body.reasoning : null;
|
||||
const reasoningEffort = (
|
||||
typeof body.reasoning_effort === 'string' ? body.reasoning_effort
|
||||
: (typeof reasoning?.effort === 'string' ? reasoning.effort : undefined)
|
||||
);
|
||||
const reasoningBudget = (
|
||||
body.reasoning_budget
|
||||
?? reasoning?.budget_tokens
|
||||
?? reasoning?.thinkingBudget
|
||||
?? reasoning?.max_tokens
|
||||
);
|
||||
|
||||
const parsedBudget = toFiniteInt(reasoningBudget);
|
||||
if (parsedBudget !== null) {
|
||||
return reasoningEffortToGeminiThinkingConfig(model, thinkingBudgetToReasoningEffort(parsedBudget));
|
||||
}
|
||||
|
||||
if (reasoningEffort !== undefined) {
|
||||
return reasoningEffortToGeminiThinkingConfig(model, reasoningEffort);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function extractReasoningMetadataFromGeminiRequest(
|
||||
body: Record<string, unknown> | null | undefined,
|
||||
): { reasoningEffort: GeminiReasoningEffort; reasoningBudget: number } | null {
|
||||
if (!body) return null;
|
||||
|
||||
const generationConfig = isRecord(body.generationConfig) ? body.generationConfig : null;
|
||||
const thinkingConfig = isRecord(generationConfig?.thinkingConfig)
|
||||
? generationConfig!.thinkingConfig as Record<string, unknown>
|
||||
: null;
|
||||
return geminiThinkingConfigToReasoning(thinkingConfig);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
type GeminiRecord = Record<string, unknown>;
|
||||
import { resolveGeminiThinkingConfigFromRequest } from './convert.js';
|
||||
|
||||
function isRecord(value: unknown): value is GeminiRecord {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function cloneJsonValue<T>(value: T): T {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => cloneJsonValue(item)) as T;
|
||||
}
|
||||
if (isRecord(value)) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, item]) => [key, cloneJsonValue(item)]),
|
||||
) as T;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function cloneContents(value: unknown): unknown[] | undefined {
|
||||
if (!Array.isArray(value)) return undefined;
|
||||
return value
|
||||
.filter((item) => isRecord(item))
|
||||
.map((item) => {
|
||||
const next: GeminiRecord = { ...item };
|
||||
if (Array.isArray(item.parts)) {
|
||||
next.parts = item.parts.map((part) => (isRecord(part) ? cloneJsonValue(part) : part));
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function cloneGenerationConfig(value: unknown): GeminiRecord | undefined {
|
||||
if (!isRecord(value)) return undefined;
|
||||
const allowedKeys = [
|
||||
'stopSequences',
|
||||
'responseModalities',
|
||||
'responseMimeType',
|
||||
'responseSchema',
|
||||
'candidateCount',
|
||||
'maxOutputTokens',
|
||||
'temperature',
|
||||
'topP',
|
||||
'topK',
|
||||
'presencePenalty',
|
||||
'frequencyPenalty',
|
||||
'seed',
|
||||
'responseLogprobs',
|
||||
'logprobs',
|
||||
'thinkingConfig',
|
||||
'imageConfig',
|
||||
];
|
||||
const next: GeminiRecord = {};
|
||||
for (const key of allowedKeys) {
|
||||
if (value[key] !== undefined) next[key] = cloneJsonValue(value[key]);
|
||||
}
|
||||
return Object.keys(next).length > 0 ? next : undefined;
|
||||
}
|
||||
|
||||
function cloneTools(value: unknown): unknown[] | undefined {
|
||||
if (!Array.isArray(value)) return undefined;
|
||||
return value
|
||||
.filter((item) => isRecord(item))
|
||||
.map((item) => {
|
||||
const next: GeminiRecord = {};
|
||||
if (item.functionDeclarations !== undefined) next.functionDeclarations = cloneJsonValue(item.functionDeclarations);
|
||||
if (item.googleSearch !== undefined) next.googleSearch = cloneJsonValue(item.googleSearch);
|
||||
if (item.urlContext !== undefined) next.urlContext = cloneJsonValue(item.urlContext);
|
||||
if (item.codeExecution !== undefined) next.codeExecution = cloneJsonValue(item.codeExecution);
|
||||
return Object.keys(next).length > 0 ? next : cloneJsonValue(item);
|
||||
});
|
||||
}
|
||||
|
||||
export type GeminiGenerateContentRequest = GeminiRecord;
|
||||
|
||||
export const geminiGenerateContentInbound = {
|
||||
normalizeRequest(body: unknown, modelName = ''): GeminiGenerateContentRequest {
|
||||
if (!isRecord(body)) return {};
|
||||
|
||||
const next: GeminiGenerateContentRequest = {};
|
||||
if (body.contents !== undefined) next.contents = cloneContents(body.contents) ?? cloneJsonValue(body.contents);
|
||||
if (body.systemInstruction !== undefined) next.systemInstruction = cloneJsonValue(body.systemInstruction);
|
||||
if (body.cachedContent !== undefined) next.cachedContent = cloneJsonValue(body.cachedContent);
|
||||
if (body.safetySettings !== undefined) next.safetySettings = cloneJsonValue(body.safetySettings);
|
||||
if (body.generationConfig !== undefined) {
|
||||
next.generationConfig = cloneGenerationConfig(body.generationConfig) ?? cloneJsonValue(body.generationConfig);
|
||||
}
|
||||
if (body.tools !== undefined) next.tools = cloneTools(body.tools) ?? cloneJsonValue(body.tools);
|
||||
if (body.toolConfig !== undefined) next.toolConfig = cloneJsonValue(body.toolConfig);
|
||||
|
||||
const derivedThinkingConfig = resolveGeminiThinkingConfigFromRequest(
|
||||
modelName || (typeof body.model === 'string' ? body.model : ''),
|
||||
body,
|
||||
);
|
||||
if (derivedThinkingConfig) {
|
||||
const generationConfig = isRecord(next.generationConfig)
|
||||
? { ...next.generationConfig }
|
||||
: {};
|
||||
if (!isRecord(generationConfig.thinkingConfig)) {
|
||||
generationConfig.thinkingConfig = derivedThinkingConfig;
|
||||
}
|
||||
next.generationConfig = generationConfig;
|
||||
}
|
||||
|
||||
const strippedKeys = new Set([
|
||||
'reasoning',
|
||||
'reasoning_effort',
|
||||
'reasoning_budget',
|
||||
]);
|
||||
|
||||
for (const [key, value] of Object.entries(body)) {
|
||||
if (strippedKeys.has(key)) continue;
|
||||
if (next[key] !== undefined) continue;
|
||||
next[key] = cloneJsonValue(value);
|
||||
}
|
||||
|
||||
return next;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,439 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
geminiGenerateContentTransformer,
|
||||
reasoningEffortToGeminiThinkingConfig,
|
||||
geminiThinkingConfigToReasoning,
|
||||
} from './index.js';
|
||||
import { extractGeminiUsage } from './usage.js';
|
||||
import { serializeGeminiAggregateResponse, extractResponseMetadata } from './outbound.js';
|
||||
|
||||
describe('geminiGenerateContentTransformer.inbound', () => {
|
||||
it('preserves native Gemini request fields through normalization', () => {
|
||||
const body = geminiGenerateContentTransformer.inbound.normalizeRequest({
|
||||
contents: [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: 'hello' }],
|
||||
},
|
||||
],
|
||||
systemInstruction: {
|
||||
role: 'system',
|
||||
parts: [{ text: 'system prompt' }],
|
||||
},
|
||||
cachedContent: 'cached/abc',
|
||||
generationConfig: {
|
||||
responseModalities: ['TEXT'],
|
||||
responseMimeType: 'application/json',
|
||||
responseSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
answer: { type: 'string' },
|
||||
},
|
||||
},
|
||||
thinkingConfig: {
|
||||
thinkingBudget: 512,
|
||||
},
|
||||
imageConfig: {
|
||||
aspectRatio: '1:1',
|
||||
},
|
||||
},
|
||||
tools: [
|
||||
{
|
||||
functionDeclarations: [
|
||||
{
|
||||
name: 'lookup',
|
||||
description: 'find facts',
|
||||
},
|
||||
],
|
||||
},
|
||||
{ googleSearch: {} },
|
||||
{ urlContext: {} },
|
||||
{ codeExecution: {} },
|
||||
],
|
||||
});
|
||||
|
||||
expect(body).toEqual({
|
||||
contents: [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: 'hello' }],
|
||||
},
|
||||
],
|
||||
systemInstruction: {
|
||||
role: 'system',
|
||||
parts: [{ text: 'system prompt' }],
|
||||
},
|
||||
cachedContent: 'cached/abc',
|
||||
generationConfig: {
|
||||
responseModalities: ['TEXT'],
|
||||
responseMimeType: 'application/json',
|
||||
responseSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
answer: { type: 'string' },
|
||||
},
|
||||
},
|
||||
thinkingConfig: {
|
||||
thinkingBudget: 512,
|
||||
},
|
||||
imageConfig: {
|
||||
aspectRatio: '1:1',
|
||||
},
|
||||
},
|
||||
tools: [
|
||||
{
|
||||
functionDeclarations: [
|
||||
{
|
||||
name: 'lookup',
|
||||
description: 'find facts',
|
||||
},
|
||||
],
|
||||
},
|
||||
{ googleSearch: {} },
|
||||
{ urlContext: {} },
|
||||
{ codeExecution: {} },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('geminiGenerateContentTransformer.aggregator', () => {
|
||||
it('collects grounding metadata, citations, thought signatures, and usage from streamed chunks', () => {
|
||||
const state = geminiGenerateContentTransformer.aggregator.createState();
|
||||
|
||||
geminiGenerateContentTransformer.aggregator.apply(state, [
|
||||
{
|
||||
candidates: [
|
||||
{
|
||||
groundingMetadata: { webSearchQueries: ['cat'] },
|
||||
citationMetadata: { citations: [{ uri: 'https://example.com' }] },
|
||||
content: {
|
||||
parts: [
|
||||
{ text: 'hello', thoughtSignature: 'sig-1' },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
usageMetadata: {
|
||||
promptTokenCount: 10,
|
||||
candidatesTokenCount: 4,
|
||||
totalTokenCount: 14,
|
||||
cachedContentTokenCount: 3,
|
||||
thoughtsTokenCount: 2,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
expect(state.groundingMetadata).toEqual([{ webSearchQueries: ['cat'] }]);
|
||||
expect(state.citations).toEqual([{ citations: [{ uri: 'https://example.com' }] }]);
|
||||
expect(state.thoughtSignatures).toEqual(['sig-1']);
|
||||
expect(state.usage).toEqual({
|
||||
promptTokenCount: 10,
|
||||
candidatesTokenCount: 4,
|
||||
totalTokenCount: 14,
|
||||
cachedContentTokenCount: 3,
|
||||
thoughtsTokenCount: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('serializes aggregate state back into Gemini response semantics', () => {
|
||||
const state = geminiGenerateContentTransformer.aggregator.createState();
|
||||
geminiGenerateContentTransformer.aggregator.apply(state, {
|
||||
responseId: 'resp-1',
|
||||
modelVersion: 'gemini-2.5-pro',
|
||||
candidates: [
|
||||
{
|
||||
groundingMetadata: { webSearchQueries: ['cat'] },
|
||||
citationMetadata: { citations: [{ uri: 'https://example.com' }] },
|
||||
content: {
|
||||
parts: [
|
||||
{ text: 'thinking', thought: true, thoughtSignature: 'sig-1' },
|
||||
{ text: 'answer' },
|
||||
],
|
||||
},
|
||||
finishReason: 'STOP',
|
||||
},
|
||||
],
|
||||
usageMetadata: {
|
||||
promptTokenCount: 10,
|
||||
candidatesTokenCount: 4,
|
||||
totalTokenCount: 14,
|
||||
cachedContentTokenCount: 3,
|
||||
thoughtsTokenCount: 2,
|
||||
},
|
||||
});
|
||||
|
||||
const response = serializeGeminiAggregateResponse(state);
|
||||
expect(response).toEqual({
|
||||
responseId: 'resp-1',
|
||||
modelVersion: 'gemini-2.5-pro',
|
||||
candidates: [
|
||||
{
|
||||
index: 0,
|
||||
finishReason: 'STOP',
|
||||
content: {
|
||||
role: 'model',
|
||||
parts: [
|
||||
{ text: 'thinking', thought: true, thoughtSignature: 'sig-1' },
|
||||
{ text: 'answer' },
|
||||
],
|
||||
},
|
||||
groundingMetadata: { webSearchQueries: ['cat'] },
|
||||
citationMetadata: { citations: [{ uri: 'https://example.com' }] },
|
||||
},
|
||||
],
|
||||
usageMetadata: {
|
||||
promptTokenCount: 10,
|
||||
candidatesTokenCount: 4,
|
||||
totalTokenCount: 14,
|
||||
cachedContentTokenCount: 3,
|
||||
thoughtsTokenCount: 2,
|
||||
},
|
||||
});
|
||||
|
||||
expect(extractResponseMetadata(state)).toEqual({
|
||||
citations: [{ citations: [{ uri: 'https://example.com' }] }],
|
||||
groundingMetadata: [{ webSearchQueries: ['cat'] }],
|
||||
thoughtSignature: 'sig-1',
|
||||
thoughtSignatures: ['sig-1'],
|
||||
usageMetadata: {
|
||||
promptTokenCount: 10,
|
||||
candidatesTokenCount: 4,
|
||||
totalTokenCount: 14,
|
||||
cachedContentTokenCount: 3,
|
||||
thoughtsTokenCount: 2,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves multiple candidate streams instead of collapsing them into one candidate', () => {
|
||||
const state = geminiGenerateContentTransformer.aggregator.createState();
|
||||
geminiGenerateContentTransformer.aggregator.apply(state, {
|
||||
responseId: 'resp-multi',
|
||||
modelVersion: 'gemini-2.5-pro',
|
||||
candidates: [
|
||||
{
|
||||
index: 0,
|
||||
content: {
|
||||
parts: [{ text: 'candidate-a' }],
|
||||
},
|
||||
finishReason: 'STOP',
|
||||
groundingMetadata: { webSearchQueries: ['a'] },
|
||||
citationMetadata: { citations: [{ uri: 'https://a.example.com' }] },
|
||||
},
|
||||
{
|
||||
index: 1,
|
||||
content: {
|
||||
parts: [{ text: 'candidate-b' }],
|
||||
},
|
||||
finishReason: 'MAX_TOKENS',
|
||||
groundingMetadata: { webSearchQueries: ['b'] },
|
||||
citationMetadata: { citations: [{ uri: 'https://b.example.com' }] },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(serializeGeminiAggregateResponse(state)).toEqual({
|
||||
responseId: 'resp-multi',
|
||||
modelVersion: 'gemini-2.5-pro',
|
||||
candidates: [
|
||||
{
|
||||
index: 0,
|
||||
finishReason: 'STOP',
|
||||
content: {
|
||||
role: 'model',
|
||||
parts: [{ text: 'candidate-a' }],
|
||||
},
|
||||
groundingMetadata: { webSearchQueries: ['a'] },
|
||||
citationMetadata: { citations: [{ uri: 'https://a.example.com' }] },
|
||||
},
|
||||
{
|
||||
index: 1,
|
||||
finishReason: 'MAX_TOKENS',
|
||||
content: {
|
||||
role: 'model',
|
||||
parts: [{ text: 'candidate-b' }],
|
||||
},
|
||||
groundingMetadata: { webSearchQueries: ['b'] },
|
||||
citationMetadata: { citations: [{ uri: 'https://b.example.com' }] },
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('merges preserved request semantics into extracted metadata when provided', () => {
|
||||
const metadata = extractResponseMetadata(
|
||||
{
|
||||
candidates: [
|
||||
{
|
||||
groundingMetadata: { webSearchQueries: ['cat'] },
|
||||
citationMetadata: { citations: [{ uri: 'https://example.com' }] },
|
||||
content: {
|
||||
parts: [{ text: 'answer', thoughtSignature: 'sig-1' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
systemInstruction: { role: 'system', parts: [{ text: 'system prompt' }] },
|
||||
cachedContent: 'cached/abc',
|
||||
generationConfig: {
|
||||
responseModalities: ['TEXT'],
|
||||
responseSchema: { type: 'object' },
|
||||
responseMimeType: 'application/json',
|
||||
},
|
||||
tools: [
|
||||
{ googleSearch: {} },
|
||||
{ urlContext: {} },
|
||||
{ codeExecution: {} },
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
expect(metadata.systemInstruction).toEqual({ role: 'system', parts: [{ text: 'system prompt' }] });
|
||||
expect(metadata.cachedContent).toBe('cached/abc');
|
||||
expect(metadata.responseModalities).toEqual(['TEXT']);
|
||||
expect(metadata.responseSchema).toEqual({ type: 'object' });
|
||||
expect(metadata.responseMimeType).toBe('application/json');
|
||||
expect(metadata.tools).toEqual([
|
||||
{ googleSearch: {} },
|
||||
{ urlContext: {} },
|
||||
{ codeExecution: {} },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractGeminiUsage', () => {
|
||||
it('maps cached and thought token counts into normalized usage', () => {
|
||||
expect(extractGeminiUsage({
|
||||
usageMetadata: {
|
||||
promptTokenCount: 11,
|
||||
candidatesTokenCount: 7,
|
||||
totalTokenCount: 18,
|
||||
cachedContentTokenCount: 5,
|
||||
thoughtsTokenCount: 3,
|
||||
},
|
||||
})).toEqual({
|
||||
promptTokens: 11,
|
||||
completionTokens: 10,
|
||||
totalTokens: 18,
|
||||
cachedTokens: 5,
|
||||
reasoningTokens: 3,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Gemini reasoning mapping', () => {
|
||||
it('supports explicit none reasoning effort and maps it back from thinking config', () => {
|
||||
expect(reasoningEffortToGeminiThinkingConfig('gemini-3-5m', 'none')).toEqual({
|
||||
thinkingLevel: 'none',
|
||||
});
|
||||
|
||||
expect(geminiThinkingConfigToReasoning({
|
||||
thinkingLevel: 'none',
|
||||
})).toEqual({
|
||||
reasoningEffort: 'none',
|
||||
reasoningBudget: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses thinkingLevel for Gemini 3 models when reasoning effort is provided', () => {
|
||||
expect(reasoningEffortToGeminiThinkingConfig('gemini-3-5m', 'high')).toEqual({
|
||||
thinkingLevel: 'high',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses thinkingBudget for non-Gemini 3 models when reasoning effort is provided', () => {
|
||||
expect(reasoningEffortToGeminiThinkingConfig('gemini-2.5-flash', 'medium')).toEqual({
|
||||
thinkingBudget: 8192,
|
||||
});
|
||||
});
|
||||
|
||||
it('maps Gemini thinking config back to normalized reasoning hints', () => {
|
||||
expect(geminiThinkingConfigToReasoning({
|
||||
thinkingLevel: 'medium',
|
||||
})).toEqual({
|
||||
reasoningEffort: 'medium',
|
||||
reasoningBudget: 8192,
|
||||
});
|
||||
|
||||
expect(geminiThinkingConfigToReasoning({
|
||||
thinkingBudget: 512,
|
||||
})).toEqual({
|
||||
reasoningEffort: 'low',
|
||||
reasoningBudget: 512,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('geminiGenerateContentTransformer.stream', () => {
|
||||
it('aggregates SSE payloads and JSON-array payloads to the same final semantics', () => {
|
||||
const chunks = [
|
||||
{
|
||||
responseId: 'resp-1',
|
||||
modelVersion: 'gemini-2.5-pro',
|
||||
candidates: [
|
||||
{
|
||||
groundingMetadata: { webSearchQueries: ['cat'] },
|
||||
citationMetadata: { citations: [{ uri: 'https://example.com' }] },
|
||||
content: {
|
||||
parts: [
|
||||
{ text: 'thinking', thought: true, thoughtSignature: 'sig-1' },
|
||||
],
|
||||
},
|
||||
finishReason: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
responseId: 'resp-1',
|
||||
modelVersion: 'gemini-2.5-pro',
|
||||
candidates: [
|
||||
{
|
||||
groundingMetadata: { webSearchQueries: ['cat'] },
|
||||
citationMetadata: { citations: [{ uri: 'https://example.com' }] },
|
||||
content: {
|
||||
parts: [
|
||||
{ text: 'answer' },
|
||||
],
|
||||
},
|
||||
finishReason: 'STOP',
|
||||
},
|
||||
],
|
||||
usageMetadata: {
|
||||
promptTokenCount: 10,
|
||||
candidatesTokenCount: 4,
|
||||
totalTokenCount: 14,
|
||||
cachedContentTokenCount: 3,
|
||||
thoughtsTokenCount: 2,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const ssePayload = chunks
|
||||
.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`)
|
||||
.join('');
|
||||
|
||||
const sseParsed = geminiGenerateContentTransformer.stream.parseSsePayloads(ssePayload);
|
||||
const sseState = geminiGenerateContentTransformer.aggregator.createState();
|
||||
for (const payload of sseParsed.events) {
|
||||
geminiGenerateContentTransformer.aggregator.apply(sseState, payload);
|
||||
}
|
||||
|
||||
const jsonState = geminiGenerateContentTransformer.aggregator.createState();
|
||||
for (const payload of geminiGenerateContentTransformer.stream.parseJsonArrayPayload(chunks)) {
|
||||
geminiGenerateContentTransformer.aggregator.apply(jsonState, payload);
|
||||
}
|
||||
|
||||
expect(serializeGeminiAggregateResponse(sseState)).toEqual(
|
||||
serializeGeminiAggregateResponse(jsonState),
|
||||
);
|
||||
expect(extractResponseMetadata(sseState)).toEqual(
|
||||
extractResponseMetadata(jsonState),
|
||||
);
|
||||
expect(extractGeminiUsage(sseState)).toEqual(
|
||||
extractGeminiUsage(jsonState),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
function normalizeBaseUrl(baseUrl: string): string {
|
||||
return (baseUrl || '').replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function baseIncludesVersion(baseUrl: string): boolean {
|
||||
return /\/v\d+(?:beta)?(?:\/|$)/i.test(baseUrl);
|
||||
}
|
||||
|
||||
export function resolveGeminiNativeBaseUrl(baseUrl: string, apiVersion: string): string {
|
||||
const normalized = normalizeBaseUrl(baseUrl);
|
||||
if (baseIncludesVersion(normalized)) return normalized;
|
||||
return `${normalized}/${apiVersion}`;
|
||||
}
|
||||
|
||||
export function resolveGeminiModelsUrl(
|
||||
baseUrl: string,
|
||||
apiVersion: string,
|
||||
apiKey: string,
|
||||
): string {
|
||||
const base = resolveGeminiNativeBaseUrl(baseUrl, apiVersion);
|
||||
const separator = base.includes('?') ? '&' : '?';
|
||||
return `${base}/models${separator}key=${encodeURIComponent(apiKey)}`;
|
||||
}
|
||||
|
||||
export function resolveGeminiGenerateContentUrl(
|
||||
baseUrl: string,
|
||||
apiVersion: string,
|
||||
modelActionPath: string,
|
||||
apiKey: string,
|
||||
search: string,
|
||||
): string {
|
||||
const base = resolveGeminiNativeBaseUrl(baseUrl, apiVersion);
|
||||
const normalizedAction = modelActionPath.replace(/^\/+/, '');
|
||||
const params = new URLSearchParams(search.startsWith('?') ? search.slice(1) : search);
|
||||
params.set('key', apiKey);
|
||||
const query = params.toString();
|
||||
return `${base}/${normalizedAction}${query ? `?${query}` : ''}`;
|
||||
}
|
||||
|
||||
import { geminiGenerateContentInbound } from './inbound.js';
|
||||
import { geminiGenerateContentOutbound } from './outbound.js';
|
||||
import { geminiGenerateContentStream } from './stream.js';
|
||||
import { createGeminiGenerateContentAggregateState, applyGeminiGenerateContentAggregate } from './aggregator.js';
|
||||
import { geminiGenerateContentUsage } from './usage.js';
|
||||
import { reasoningEffortToGeminiThinkingConfig, geminiThinkingConfigToReasoning } from './convert.js';
|
||||
|
||||
export const geminiGenerateContentTransformer = {
|
||||
protocol: 'gemini/generate-content' as const,
|
||||
inbound: geminiGenerateContentInbound,
|
||||
outbound: geminiGenerateContentOutbound,
|
||||
stream: geminiGenerateContentStream,
|
||||
aggregator: {
|
||||
createState: createGeminiGenerateContentAggregateState,
|
||||
apply: applyGeminiGenerateContentAggregate,
|
||||
},
|
||||
usage: geminiGenerateContentUsage,
|
||||
convert: {
|
||||
reasoningEffortToGeminiThinkingConfig,
|
||||
geminiThinkingConfigToReasoning,
|
||||
},
|
||||
resolveBaseUrl: resolveGeminiNativeBaseUrl,
|
||||
resolveModelsUrl: resolveGeminiModelsUrl,
|
||||
resolveActionUrl: resolveGeminiGenerateContentUrl,
|
||||
};
|
||||
|
||||
export {
|
||||
geminiGenerateContentInbound,
|
||||
geminiGenerateContentOutbound,
|
||||
geminiGenerateContentStream,
|
||||
createGeminiGenerateContentAggregateState,
|
||||
applyGeminiGenerateContentAggregate,
|
||||
geminiGenerateContentUsage,
|
||||
reasoningEffortToGeminiThinkingConfig,
|
||||
geminiThinkingConfigToReasoning,
|
||||
};
|
||||
@@ -0,0 +1,190 @@
|
||||
import {
|
||||
applyGeminiGenerateContentAggregate,
|
||||
createGeminiGenerateContentAggregateState,
|
||||
type GeminiGenerateContentAggregateState,
|
||||
} from './aggregator.js';
|
||||
|
||||
function normalizeBaseUrl(baseUrl: string): string {
|
||||
return (baseUrl || '').replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function baseIncludesVersion(baseUrl: string): boolean {
|
||||
return /\/v\d+(?:beta)?(?:\/|$)/i.test(baseUrl);
|
||||
}
|
||||
|
||||
function resolveBaseUrl(baseUrl: string, apiVersion: string): string {
|
||||
const normalized = normalizeBaseUrl(baseUrl);
|
||||
if (baseIncludesVersion(normalized)) return normalized;
|
||||
return `${normalized}/${apiVersion}`;
|
||||
}
|
||||
|
||||
function resolveModelsUrl(
|
||||
baseUrl: string,
|
||||
apiVersion: string,
|
||||
apiKey: string,
|
||||
): string {
|
||||
const resolvedBaseUrl = resolveBaseUrl(baseUrl, apiVersion);
|
||||
const separator = resolvedBaseUrl.includes('?') ? '&' : '?';
|
||||
return `${resolvedBaseUrl}/models${separator}key=${encodeURIComponent(apiKey)}`;
|
||||
}
|
||||
|
||||
function resolveActionUrl(
|
||||
baseUrl: string,
|
||||
apiVersion: string,
|
||||
modelActionPath: string,
|
||||
apiKey: string,
|
||||
search: string,
|
||||
): string {
|
||||
const resolvedBaseUrl = resolveBaseUrl(baseUrl, apiVersion);
|
||||
const normalizedAction = modelActionPath.replace(/^\/+/, '');
|
||||
const params = new URLSearchParams(search.startsWith('?') ? search.slice(1) : search);
|
||||
params.set('key', apiKey);
|
||||
const query = params.toString();
|
||||
return `${resolvedBaseUrl}/${normalizedAction}${query ? `?${query}` : ''}`;
|
||||
}
|
||||
|
||||
type GeminiRecord = Record<string, unknown>;
|
||||
|
||||
function isRecord(value: unknown): value is GeminiRecord {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isAggregateState(value: unknown): value is GeminiGenerateContentAggregateState {
|
||||
return isRecord(value) && Array.isArray(value.parts) && Array.isArray(value.groundingMetadata) && Array.isArray(value.citations);
|
||||
}
|
||||
|
||||
function ensureAggregateState(payload: unknown): GeminiGenerateContentAggregateState {
|
||||
if (isAggregateState(payload)) return payload;
|
||||
|
||||
const state = createGeminiGenerateContentAggregateState();
|
||||
const chunks = Array.isArray(payload) ? payload : [payload];
|
||||
for (const chunk of chunks) {
|
||||
applyGeminiGenerateContentAggregate(state, chunk);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
function buildUsageMetadata(state: GeminiGenerateContentAggregateState): GeminiRecord | undefined {
|
||||
const usage = state.usage;
|
||||
const next: GeminiRecord = {};
|
||||
if (typeof usage.promptTokenCount === 'number') next.promptTokenCount = usage.promptTokenCount;
|
||||
if (typeof usage.candidatesTokenCount === 'number') next.candidatesTokenCount = usage.candidatesTokenCount;
|
||||
if (typeof usage.totalTokenCount === 'number') next.totalTokenCount = usage.totalTokenCount;
|
||||
if (typeof usage.cachedContentTokenCount === 'number') next.cachedContentTokenCount = usage.cachedContentTokenCount;
|
||||
if (typeof usage.thoughtsTokenCount === 'number') next.thoughtsTokenCount = usage.thoughtsTokenCount;
|
||||
return Object.keys(next).length > 0 ? next : undefined;
|
||||
}
|
||||
|
||||
function buildCandidates(state: GeminiGenerateContentAggregateState): GeminiRecord[] {
|
||||
if (state.candidates.length > 0) {
|
||||
return state.candidates
|
||||
.slice()
|
||||
.sort((left, right) => left.index - right.index)
|
||||
.map((candidate) => {
|
||||
const next: GeminiRecord = {
|
||||
index: candidate.index,
|
||||
finishReason: candidate.finishReason || 'STOP',
|
||||
content: {
|
||||
role: 'model',
|
||||
parts: candidate.parts,
|
||||
},
|
||||
};
|
||||
if (candidate.groundingMetadata) next.groundingMetadata = candidate.groundingMetadata;
|
||||
if (candidate.citationMetadata) next.citationMetadata = candidate.citationMetadata;
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
const fallback: GeminiRecord = {
|
||||
index: 0,
|
||||
finishReason: state.finishReason || 'STOP',
|
||||
content: {
|
||||
role: 'model',
|
||||
parts: state.parts,
|
||||
},
|
||||
};
|
||||
if (state.groundingMetadata.length > 0) {
|
||||
fallback.groundingMetadata = state.groundingMetadata[0];
|
||||
}
|
||||
if (state.citations.length > 0) {
|
||||
fallback.citationMetadata = state.citations[0];
|
||||
}
|
||||
return [fallback];
|
||||
}
|
||||
|
||||
function extractRequestSemantics(requestPayload: unknown): GeminiRecord {
|
||||
if (!isRecord(requestPayload)) return {};
|
||||
|
||||
const metadata: GeminiRecord = {};
|
||||
if (requestPayload.systemInstruction !== undefined) metadata.systemInstruction = requestPayload.systemInstruction;
|
||||
if (requestPayload.cachedContent !== undefined) metadata.cachedContent = requestPayload.cachedContent;
|
||||
|
||||
const generationConfig = isRecord(requestPayload.generationConfig) ? requestPayload.generationConfig : null;
|
||||
if (generationConfig) {
|
||||
if (generationConfig.responseModalities !== undefined) metadata.responseModalities = generationConfig.responseModalities;
|
||||
if (generationConfig.responseSchema !== undefined) metadata.responseSchema = generationConfig.responseSchema;
|
||||
if (generationConfig.responseMimeType !== undefined) metadata.responseMimeType = generationConfig.responseMimeType;
|
||||
}
|
||||
|
||||
if (Array.isArray(requestPayload.tools)) {
|
||||
const requestTools = requestPayload.tools
|
||||
.filter((item) => isRecord(item))
|
||||
.map((item) => {
|
||||
const next: GeminiRecord = {};
|
||||
if (item.googleSearch !== undefined) next.googleSearch = item.googleSearch;
|
||||
if (item.urlContext !== undefined) next.urlContext = item.urlContext;
|
||||
if (item.codeExecution !== undefined) next.codeExecution = item.codeExecution;
|
||||
if (item.functionDeclarations !== undefined) next.functionDeclarations = item.functionDeclarations;
|
||||
return next;
|
||||
})
|
||||
.filter((item) => Object.keys(item).length > 0);
|
||||
|
||||
if (requestTools.length > 0) {
|
||||
metadata.tools = requestTools;
|
||||
}
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
export function serializeGeminiAggregateResponse(
|
||||
payload: GeminiGenerateContentAggregateState | unknown,
|
||||
): GeminiRecord {
|
||||
const state = ensureAggregateState(payload);
|
||||
const response: GeminiRecord = {
|
||||
responseId: state.responseId || '',
|
||||
modelVersion: state.modelVersion || '',
|
||||
candidates: buildCandidates(state),
|
||||
};
|
||||
|
||||
const usageMetadata = buildUsageMetadata(state);
|
||||
if (usageMetadata) {
|
||||
response.usageMetadata = usageMetadata;
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
export function extractResponseMetadata(payload: unknown, requestPayload?: unknown): GeminiRecord {
|
||||
const state = ensureAggregateState(payload);
|
||||
const metadata: GeminiRecord = extractRequestSemantics(requestPayload);
|
||||
|
||||
if (state.citations.length > 0) metadata.citations = state.citations;
|
||||
if (state.groundingMetadata.length > 0) metadata.groundingMetadata = state.groundingMetadata;
|
||||
if (state.thoughtSignatures.length > 0) {
|
||||
metadata.thoughtSignature = state.thoughtSignatures[0];
|
||||
metadata.thoughtSignatures = state.thoughtSignatures;
|
||||
}
|
||||
const usageMetadata = buildUsageMetadata(state);
|
||||
if (usageMetadata) metadata.usageMetadata = usageMetadata;
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
export const geminiGenerateContentOutbound = {
|
||||
resolveBaseUrl,
|
||||
resolveModelsUrl,
|
||||
resolveActionUrl,
|
||||
extractResponseMetadata,
|
||||
serializeAggregateResponse: serializeGeminiAggregateResponse,
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
applyGeminiGenerateContentAggregate,
|
||||
createGeminiGenerateContentAggregateState,
|
||||
type GeminiGenerateContentAggregateState,
|
||||
} from './aggregator.js';
|
||||
|
||||
type ParsedSsePayloads = {
|
||||
events: unknown[];
|
||||
rest: string;
|
||||
};
|
||||
|
||||
function serializeSsePayload(payload: unknown): string {
|
||||
return `data: ${JSON.stringify(payload)}\n\n`;
|
||||
}
|
||||
|
||||
function parseSsePayloads(buffer: string): ParsedSsePayloads {
|
||||
const normalized = buffer.replace(/\r\n/g, '\n');
|
||||
const events: unknown[] = [];
|
||||
let rest = normalized;
|
||||
|
||||
while (true) {
|
||||
const boundary = rest.indexOf('\n\n');
|
||||
if (boundary < 0) break;
|
||||
|
||||
const block = rest.slice(0, boundary);
|
||||
rest = rest.slice(boundary + 2);
|
||||
if (!block.trim()) continue;
|
||||
|
||||
const data = block
|
||||
.split('\n')
|
||||
.filter((line) => line.startsWith('data:'))
|
||||
.map((line) => line.slice(5).trimStart())
|
||||
.join('\n')
|
||||
.trim();
|
||||
|
||||
if (!data || data === '[DONE]') continue;
|
||||
|
||||
try {
|
||||
events.push(JSON.parse(data));
|
||||
} catch {
|
||||
// Ignore malformed event payloads so aggregation remains tolerant.
|
||||
}
|
||||
}
|
||||
|
||||
return { events, rest };
|
||||
}
|
||||
|
||||
export const geminiGenerateContentStream = {
|
||||
isSseContentType(contentType: string | null | undefined): boolean {
|
||||
return (contentType || '').toLowerCase().includes('text/event-stream');
|
||||
},
|
||||
|
||||
parseJsonArrayPayload(payload: unknown): unknown[] {
|
||||
return Array.isArray(payload) ? payload : [];
|
||||
},
|
||||
|
||||
parseSsePayloads,
|
||||
serializeSsePayload,
|
||||
|
||||
createAggregateState(): GeminiGenerateContentAggregateState {
|
||||
return createGeminiGenerateContentAggregateState();
|
||||
},
|
||||
|
||||
applyAggregate(state: GeminiGenerateContentAggregateState, payload: unknown): GeminiGenerateContentAggregateState {
|
||||
return applyGeminiGenerateContentAggregate(state, payload);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
import { createEmptyNormalizedUsage, mergeNormalizedUsage, type NormalizedUsage } from '../../shared/normalized.js';
|
||||
import { type GeminiGenerateContentAggregateState } from './aggregator.js';
|
||||
|
||||
type GeminiRecord = Record<string, unknown>;
|
||||
|
||||
function isRecord(value: unknown): value is GeminiRecord {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isAggregateState(value: unknown): value is GeminiGenerateContentAggregateState {
|
||||
return isRecord(value) && Array.isArray(value.parts) && isRecord(value.usage);
|
||||
}
|
||||
|
||||
export function extractGeminiUsage(payload: unknown): NormalizedUsage {
|
||||
const usageMetadata = isAggregateState(payload)
|
||||
? payload.usage
|
||||
: (isRecord(payload) && isRecord(payload.usageMetadata) ? payload.usageMetadata : null);
|
||||
|
||||
if (!usageMetadata) {
|
||||
return createEmptyNormalizedUsage();
|
||||
}
|
||||
|
||||
const promptTokens = typeof usageMetadata.promptTokenCount === 'number'
|
||||
? usageMetadata.promptTokenCount
|
||||
: 0;
|
||||
const candidatesTokenCount = typeof usageMetadata.candidatesTokenCount === 'number'
|
||||
? usageMetadata.candidatesTokenCount
|
||||
: 0;
|
||||
const thoughtsTokenCount = typeof usageMetadata.thoughtsTokenCount === 'number'
|
||||
? usageMetadata.thoughtsTokenCount
|
||||
: 0;
|
||||
const completionTokens = candidatesTokenCount + thoughtsTokenCount;
|
||||
const totalTokens = typeof usageMetadata.totalTokenCount === 'number'
|
||||
? usageMetadata.totalTokenCount
|
||||
: promptTokens + completionTokens;
|
||||
const cachedTokens = typeof usageMetadata.cachedContentTokenCount === 'number'
|
||||
? usageMetadata.cachedContentTokenCount
|
||||
: 0;
|
||||
const reasoningTokens = thoughtsTokenCount;
|
||||
|
||||
return {
|
||||
promptTokens,
|
||||
completionTokens,
|
||||
totalTokens,
|
||||
cachedTokens,
|
||||
reasoningTokens,
|
||||
};
|
||||
}
|
||||
|
||||
export const geminiGenerateContentUsage = {
|
||||
empty: createEmptyNormalizedUsage,
|
||||
fromPayload: extractGeminiUsage,
|
||||
merge: mergeNormalizedUsage,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -0,0 +1,336 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
createOpenAiResponsesAggregateState,
|
||||
serializeConvertedResponsesEvents,
|
||||
type OpenAiResponsesAggregateState,
|
||||
completeResponsesStream,
|
||||
} from './aggregator.js';
|
||||
import { openAiResponsesStream } from './stream.js';
|
||||
|
||||
function collectSerializedEvents(input: string[]): Array<{ event: string; data: any }> {
|
||||
const events: Array<{ event: string; data: any }> = [];
|
||||
for (const chunk of input) {
|
||||
const blocks = chunk.split('\n\n').filter((item) => item.trim().length > 0);
|
||||
for (const block of blocks) {
|
||||
const lines = block.split('\n');
|
||||
const eventLine = lines.find((line) => line.startsWith('event: '));
|
||||
const dataLine = lines.find((line) => line.startsWith('data: '));
|
||||
if (!dataLine) continue;
|
||||
const rawData = dataLine.slice('data: '.length);
|
||||
if (rawData === '[DONE]') {
|
||||
events.push({ event: eventLine?.slice('event: '.length) || 'done', data: '[DONE]' });
|
||||
continue;
|
||||
}
|
||||
events.push({
|
||||
event: eventLine?.slice('event: '.length) || '',
|
||||
data: JSON.parse(rawData),
|
||||
});
|
||||
}
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
function feedResponsesEvent(
|
||||
state: OpenAiResponsesAggregateState,
|
||||
payload: Record<string, unknown>,
|
||||
): Array<{ event: string; data: any }> {
|
||||
const context = openAiResponsesStream.createContext('gpt-5');
|
||||
const normalizedEvent = openAiResponsesStream.normalizeEvent(payload, context, 'gpt-5');
|
||||
return collectSerializedEvents(
|
||||
serializeConvertedResponsesEvents({
|
||||
state,
|
||||
streamContext: context,
|
||||
event: normalizedEvent,
|
||||
usage: {
|
||||
promptTokens: 0,
|
||||
completionTokens: 0,
|
||||
totalTokens: 0,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
describe('openAiResponsesStream / aggregator', () => {
|
||||
it('keeps reasoning separate from assistant text for normalized events and merges usage details', () => {
|
||||
const state = createOpenAiResponsesAggregateState('gpt-5');
|
||||
const context = openAiResponsesStream.createContext('gpt-5');
|
||||
|
||||
const contentEvent = {
|
||||
role: 'assistant',
|
||||
contentDelta: 'hello',
|
||||
};
|
||||
const reasoningEvent = {
|
||||
reasoningDelta: 'plan first',
|
||||
responsesPayload: {
|
||||
usage: {
|
||||
input_tokens: 5,
|
||||
output_tokens: 3,
|
||||
total_tokens: 8,
|
||||
output_tokens_details: { reasoning_tokens: 2 },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
collectSerializedEvents(
|
||||
serializeConvertedResponsesEvents({
|
||||
state,
|
||||
streamContext: context,
|
||||
event: contentEvent as any,
|
||||
usage: { promptTokens: 5, completionTokens: 3, totalTokens: 8 },
|
||||
}),
|
||||
);
|
||||
|
||||
const reasoningEmitted = collectSerializedEvents(
|
||||
serializeConvertedResponsesEvents({
|
||||
state,
|
||||
streamContext: context,
|
||||
event: reasoningEvent as any,
|
||||
usage: { promptTokens: 5, completionTokens: 3, totalTokens: 8 },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(reasoningEmitted.some((item) => item.event === 'response.reasoning_summary_part.added')).toBe(true);
|
||||
expect(reasoningEmitted.some((item) => item.event === 'response.reasoning_summary_text.delta')).toBe(true);
|
||||
|
||||
const completed = collectSerializedEvents(
|
||||
completeResponsesStream(state, context, { promptTokens: 5, completionTokens: 3, totalTokens: 8 }),
|
||||
).find((item) => item.event === 'response.completed');
|
||||
|
||||
expect(completed?.data.response.output).toEqual([
|
||||
{
|
||||
id: expect.any(String),
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
status: 'completed',
|
||||
content: [
|
||||
{
|
||||
type: 'output_text',
|
||||
text: 'hello',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: expect.any(String),
|
||||
type: 'reasoning',
|
||||
status: 'completed',
|
||||
summary: [
|
||||
{
|
||||
type: 'summary_text',
|
||||
text: 'plan first',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
expect(completed?.data.response.usage).toMatchObject({
|
||||
input_tokens: 5,
|
||||
output_tokens: 3,
|
||||
total_tokens: 8,
|
||||
output_tokens_details: { reasoning_tokens: 2 },
|
||||
});
|
||||
});
|
||||
|
||||
it('tracks reasoning summary events into completed response output', () => {
|
||||
const state = createOpenAiResponsesAggregateState('gpt-5');
|
||||
|
||||
const emitted = [
|
||||
...feedResponsesEvent(state, {
|
||||
type: 'response.output_item.added',
|
||||
output_index: 0,
|
||||
item: {
|
||||
id: 'rs_1',
|
||||
type: 'reasoning',
|
||||
status: 'in_progress',
|
||||
summary: [],
|
||||
},
|
||||
}),
|
||||
...feedResponsesEvent(state, {
|
||||
type: 'response.reasoning_summary_part.added',
|
||||
item_id: 'rs_1',
|
||||
output_index: 0,
|
||||
summary_index: 0,
|
||||
part: {
|
||||
type: 'summary_text',
|
||||
text: '',
|
||||
},
|
||||
}),
|
||||
...feedResponsesEvent(state, {
|
||||
type: 'response.reasoning_summary_text.delta',
|
||||
item_id: 'rs_1',
|
||||
output_index: 0,
|
||||
summary_index: 0,
|
||||
delta: 'Plan',
|
||||
}),
|
||||
...feedResponsesEvent(state, {
|
||||
type: 'response.reasoning_summary_text.done',
|
||||
item_id: 'rs_1',
|
||||
output_index: 0,
|
||||
summary_index: 0,
|
||||
text: 'Plan it out',
|
||||
}),
|
||||
...feedResponsesEvent(state, {
|
||||
type: 'response.reasoning_summary_part.done',
|
||||
item_id: 'rs_1',
|
||||
output_index: 0,
|
||||
summary_index: 0,
|
||||
part: {
|
||||
type: 'summary_text',
|
||||
text: 'Plan it out',
|
||||
},
|
||||
}),
|
||||
...feedResponsesEvent(state, {
|
||||
type: 'response.output_item.done',
|
||||
output_index: 0,
|
||||
item: {
|
||||
id: 'rs_1',
|
||||
type: 'reasoning',
|
||||
status: 'completed',
|
||||
summary: [],
|
||||
},
|
||||
}),
|
||||
...feedResponsesEvent(state, {
|
||||
type: 'response.completed',
|
||||
response: {
|
||||
id: 'resp_reasoning',
|
||||
object: 'response',
|
||||
created_at: 1700000000,
|
||||
status: 'completed',
|
||||
model: 'gpt-5',
|
||||
output: [],
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
expect(emitted.some((item) => item.event === 'response.reasoning_summary_part.added')).toBe(true);
|
||||
expect(emitted.some((item) => item.event === 'response.reasoning_summary_text.done')).toBe(true);
|
||||
|
||||
const completed = emitted.find((item) => item.event === 'response.completed');
|
||||
expect(completed?.data.response.output).toEqual([
|
||||
{
|
||||
id: 'rs_1',
|
||||
type: 'reasoning',
|
||||
status: 'completed',
|
||||
summary: [
|
||||
{
|
||||
type: 'summary_text',
|
||||
text: 'Plan it out',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('tracks custom tool input and image generation results', () => {
|
||||
const state = createOpenAiResponsesAggregateState('gpt-5');
|
||||
|
||||
const emitted = [
|
||||
...feedResponsesEvent(state, {
|
||||
type: 'response.output_item.added',
|
||||
output_index: 0,
|
||||
item: {
|
||||
id: 'ct_1',
|
||||
type: 'custom_tool_call',
|
||||
status: 'in_progress',
|
||||
call_id: 'ct_1',
|
||||
name: 'browser',
|
||||
input: '',
|
||||
},
|
||||
}),
|
||||
...feedResponsesEvent(state, {
|
||||
type: 'response.custom_tool_call_input.delta',
|
||||
item_id: 'ct_1',
|
||||
output_index: 0,
|
||||
delta: 'open ',
|
||||
}),
|
||||
...feedResponsesEvent(state, {
|
||||
type: 'response.custom_tool_call_input.done',
|
||||
item_id: 'ct_1',
|
||||
output_index: 0,
|
||||
input: 'open example.com',
|
||||
}),
|
||||
...feedResponsesEvent(state, {
|
||||
type: 'response.output_item.done',
|
||||
output_index: 0,
|
||||
item: {
|
||||
id: 'ct_1',
|
||||
type: 'custom_tool_call',
|
||||
status: 'completed',
|
||||
call_id: 'ct_1',
|
||||
name: 'browser',
|
||||
input: 'open example.com',
|
||||
},
|
||||
}),
|
||||
...feedResponsesEvent(state, {
|
||||
type: 'response.output_item.added',
|
||||
output_index: 1,
|
||||
item: {
|
||||
id: 'img_1',
|
||||
type: 'image_generation_call',
|
||||
status: 'in_progress',
|
||||
result: null,
|
||||
},
|
||||
}),
|
||||
...feedResponsesEvent(state, {
|
||||
type: 'response.image_generation_call.partial_image',
|
||||
item_id: 'img_1',
|
||||
output_index: 1,
|
||||
partial_image_index: 0,
|
||||
partial_image_b64: 'abc123',
|
||||
}),
|
||||
...feedResponsesEvent(state, {
|
||||
type: 'response.image_generation_call.completed',
|
||||
item_id: 'img_1',
|
||||
output_index: 1,
|
||||
result: 'data:image/png;base64,final',
|
||||
}),
|
||||
...feedResponsesEvent(state, {
|
||||
type: 'response.output_item.done',
|
||||
output_index: 1,
|
||||
item: {
|
||||
id: 'img_1',
|
||||
type: 'image_generation_call',
|
||||
status: 'completed',
|
||||
result: 'data:image/png;base64,final',
|
||||
},
|
||||
}),
|
||||
...feedResponsesEvent(state, {
|
||||
type: 'response.completed',
|
||||
response: {
|
||||
id: 'resp_tools',
|
||||
object: 'response',
|
||||
created_at: 1700000001,
|
||||
status: 'completed',
|
||||
model: 'gpt-5',
|
||||
output: [],
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
expect(emitted.some((item) => item.event === 'response.custom_tool_call_input.done')).toBe(true);
|
||||
expect(emitted.some((item) => item.event === 'response.image_generation_call.completed')).toBe(true);
|
||||
|
||||
const completed = emitted.find((item) => item.event === 'response.completed');
|
||||
expect(completed?.data.response.output).toEqual([
|
||||
{
|
||||
id: 'ct_1',
|
||||
type: 'custom_tool_call',
|
||||
status: 'completed',
|
||||
call_id: 'ct_1',
|
||||
name: 'browser',
|
||||
input: 'open example.com',
|
||||
},
|
||||
{
|
||||
id: 'img_1',
|
||||
type: 'image_generation_call',
|
||||
status: 'completed',
|
||||
result: 'data:image/png;base64,final',
|
||||
partial_images: [
|
||||
{
|
||||
partial_image_index: 0,
|
||||
partial_image_b64: 'abc123',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,762 @@
|
||||
import { type StreamTransformContext } from '../../shared/normalized.js';
|
||||
import type { OpenAiResponsesStreamEvent } from './stream.js';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function asTrimmedString(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function cloneRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (!isRecord(value)) return null;
|
||||
return { ...value };
|
||||
}
|
||||
|
||||
function cloneJson<T>(value: T): T {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => cloneJson(item)) as T;
|
||||
}
|
||||
if (isRecord(value)) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, item]) => [key, cloneJson(item)]),
|
||||
) as T;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function ensureResponseId(rawId: string): string {
|
||||
const trimmed = rawId.trim() || `resp_${Date.now()}`;
|
||||
return trimmed.startsWith('resp_') ? trimmed : `resp_${trimmed}`;
|
||||
}
|
||||
|
||||
function ensureOutputItemId(rawId: string, prefix: string, index: number): string {
|
||||
const trimmed = rawId.trim();
|
||||
if (trimmed) return trimmed;
|
||||
return `${prefix}_${index}`;
|
||||
}
|
||||
|
||||
function serializeSse(event: string, payload: unknown): string {
|
||||
return `event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`;
|
||||
}
|
||||
|
||||
function serializeDone(): string {
|
||||
return 'data: [DONE]\n\n';
|
||||
}
|
||||
|
||||
type ResponsesUsageSummary = {
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
cacheReadTokens?: number;
|
||||
cacheCreationTokens?: number;
|
||||
promptTokensIncludeCache?: boolean | null;
|
||||
};
|
||||
|
||||
type AggregateOutputItem = Record<string, unknown>;
|
||||
|
||||
export type OpenAiResponsesAggregateState = {
|
||||
modelName: string;
|
||||
responseId: string | null;
|
||||
createdAt: number | null;
|
||||
outputItems: Array<AggregateOutputItem | undefined>;
|
||||
outputIndexById: Record<string, number>;
|
||||
messageIndex: number | null;
|
||||
reasoningIndexById: Record<string, number>;
|
||||
functionIndexById: Record<string, number>;
|
||||
customToolIndexById: Record<string, number>;
|
||||
imageGenerationIndexById: Record<string, number>;
|
||||
usageExtras: Record<string, unknown>;
|
||||
completed: boolean;
|
||||
failed: boolean;
|
||||
};
|
||||
|
||||
export function createOpenAiResponsesAggregateState(modelName: string): OpenAiResponsesAggregateState {
|
||||
return {
|
||||
modelName,
|
||||
responseId: null,
|
||||
createdAt: null,
|
||||
outputItems: [],
|
||||
outputIndexById: {},
|
||||
messageIndex: null,
|
||||
reasoningIndexById: {},
|
||||
functionIndexById: {},
|
||||
customToolIndexById: {},
|
||||
imageGenerationIndexById: {},
|
||||
usageExtras: {},
|
||||
completed: false,
|
||||
failed: false,
|
||||
};
|
||||
}
|
||||
|
||||
function mergeUsageExtras(
|
||||
state: OpenAiResponsesAggregateState,
|
||||
usagePayload: unknown,
|
||||
): void {
|
||||
if (!isRecord(usagePayload)) return;
|
||||
for (const [key, value] of Object.entries(usagePayload)) {
|
||||
if (key === 'input_tokens' || key === 'output_tokens' || key === 'total_tokens') continue;
|
||||
state.usageExtras[key] = cloneJson(value);
|
||||
}
|
||||
}
|
||||
|
||||
function rememberOutputId(state: OpenAiResponsesAggregateState, index: number, item: AggregateOutputItem) {
|
||||
const itemId = asTrimmedString(item.id);
|
||||
if (itemId) {
|
||||
state.outputIndexById[itemId] = index;
|
||||
}
|
||||
const itemType = asTrimmedString(item.type).toLowerCase();
|
||||
const callId = asTrimmedString(item.call_id);
|
||||
if (itemType === 'function_call') {
|
||||
if (callId) state.functionIndexById[callId] = index;
|
||||
if (itemId) state.functionIndexById[itemId] = index;
|
||||
}
|
||||
if (itemType === 'custom_tool_call') {
|
||||
if (callId) state.customToolIndexById[callId] = index;
|
||||
if (itemId) state.customToolIndexById[itemId] = index;
|
||||
}
|
||||
if (itemType === 'image_generation_call' && itemId) {
|
||||
state.imageGenerationIndexById[itemId] = index;
|
||||
}
|
||||
if (itemType === 'reasoning' && itemId) {
|
||||
state.reasoningIndexById[itemId] = index;
|
||||
}
|
||||
if (itemType === 'message' && state.messageIndex === null) {
|
||||
state.messageIndex = index;
|
||||
}
|
||||
}
|
||||
|
||||
function setOutputItem(
|
||||
state: OpenAiResponsesAggregateState,
|
||||
index: number,
|
||||
item: AggregateOutputItem,
|
||||
): AggregateOutputItem {
|
||||
const current = cloneRecord(state.outputItems[index]) || {};
|
||||
const incoming = cloneJson(item);
|
||||
const next = {
|
||||
...current,
|
||||
...incoming,
|
||||
};
|
||||
if (Array.isArray(current.summary) && (!Array.isArray(incoming.summary) || incoming.summary.length <= 0)) {
|
||||
next.summary = current.summary;
|
||||
}
|
||||
if (Array.isArray(current.content) && (!Array.isArray(incoming.content) || incoming.content.length <= 0)) {
|
||||
next.content = current.content;
|
||||
}
|
||||
if (
|
||||
Array.isArray(current.partial_images)
|
||||
&& (!Array.isArray(incoming.partial_images) || incoming.partial_images.length <= 0)
|
||||
) {
|
||||
next.partial_images = current.partial_images;
|
||||
}
|
||||
state.outputItems[index] = next;
|
||||
rememberOutputId(state, index, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
function ensureOutputItem(
|
||||
state: OpenAiResponsesAggregateState,
|
||||
index: number,
|
||||
factory: () => AggregateOutputItem,
|
||||
): AggregateOutputItem {
|
||||
const existing = state.outputItems[index];
|
||||
if (existing) return existing;
|
||||
return setOutputItem(state, index, factory());
|
||||
}
|
||||
|
||||
function resolveOutputIndex(
|
||||
state: OpenAiResponsesAggregateState,
|
||||
rawIndex: unknown,
|
||||
...candidateIds: Array<unknown>
|
||||
): number {
|
||||
if (typeof rawIndex === 'number' && Number.isFinite(rawIndex)) {
|
||||
return Math.max(0, Math.trunc(rawIndex));
|
||||
}
|
||||
for (const rawId of candidateIds) {
|
||||
const itemId = asTrimmedString(rawId);
|
||||
if (itemId && state.outputIndexById[itemId] !== undefined) {
|
||||
return state.outputIndexById[itemId];
|
||||
}
|
||||
}
|
||||
return state.outputItems.length;
|
||||
}
|
||||
|
||||
function ensureMessageItem(state: OpenAiResponsesAggregateState, indexHint?: number): { index: number; item: AggregateOutputItem } {
|
||||
const index = state.messageIndex ?? indexHint ?? 0;
|
||||
const item = ensureOutputItem(state, index, () => ({
|
||||
id: ensureOutputItemId('', 'msg', index),
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
status: 'in_progress',
|
||||
content: [],
|
||||
}));
|
||||
if (!Array.isArray(item.content)) item.content = [];
|
||||
if (state.messageIndex === null) state.messageIndex = index;
|
||||
return { index, item };
|
||||
}
|
||||
|
||||
function ensureMessageOutputTextPart(
|
||||
state: OpenAiResponsesAggregateState,
|
||||
indexHint?: number,
|
||||
): { index: number; item: AggregateOutputItem; part: AggregateOutputItem; created: boolean } {
|
||||
const { index, item } = ensureMessageItem(state, indexHint);
|
||||
const content = Array.isArray(item.content) ? item.content as AggregateOutputItem[] : [];
|
||||
if (!Array.isArray(item.content)) item.content = content;
|
||||
let part = content[0];
|
||||
const created = !isRecord(part) || asTrimmedString(part.type).toLowerCase() !== 'output_text';
|
||||
if (created) {
|
||||
part = { type: 'output_text', text: '' };
|
||||
content[0] = part;
|
||||
}
|
||||
return { index, item, part, created };
|
||||
}
|
||||
|
||||
function ensureReasoningItem(
|
||||
state: OpenAiResponsesAggregateState,
|
||||
itemIdRaw: unknown,
|
||||
indexHint?: unknown,
|
||||
): { index: number; item: AggregateOutputItem; created: boolean } {
|
||||
const itemId = asTrimmedString(itemIdRaw);
|
||||
const existingIndex = itemId ? state.reasoningIndexById[itemId] : undefined;
|
||||
const index = existingIndex ?? resolveOutputIndex(state, indexHint, itemId);
|
||||
const created = !state.outputItems[index];
|
||||
const item = ensureOutputItem(state, index, () => ({
|
||||
id: ensureOutputItemId(itemId, 'rs', index),
|
||||
type: 'reasoning',
|
||||
status: 'in_progress',
|
||||
summary: [],
|
||||
}));
|
||||
if (!Array.isArray(item.summary)) item.summary = [];
|
||||
return { index, item, created };
|
||||
}
|
||||
|
||||
function ensureReasoningSummaryPart(
|
||||
state: OpenAiResponsesAggregateState,
|
||||
itemIdRaw: unknown,
|
||||
summaryIndexRaw: unknown,
|
||||
indexHint?: unknown,
|
||||
): { item: AggregateOutputItem; summary: AggregateOutputItem; index: number; summaryIndex: number; created: boolean } {
|
||||
const reasoningState = ensureReasoningItem(state, itemIdRaw, indexHint);
|
||||
const summaryIndex = typeof summaryIndexRaw === 'number' && Number.isFinite(summaryIndexRaw)
|
||||
? Math.max(0, Math.trunc(summaryIndexRaw))
|
||||
: 0;
|
||||
const summary = Array.isArray(reasoningState.item.summary) ? reasoningState.item.summary as AggregateOutputItem[] : [];
|
||||
if (!Array.isArray(reasoningState.item.summary)) reasoningState.item.summary = summary;
|
||||
let part = summary[summaryIndex];
|
||||
const created = !isRecord(part);
|
||||
if (!isRecord(part)) {
|
||||
part = { type: 'summary_text', text: '' };
|
||||
summary[summaryIndex] = part;
|
||||
}
|
||||
return { item: reasoningState.item, summary: part, index: reasoningState.index, summaryIndex, created: reasoningState.created || created };
|
||||
}
|
||||
|
||||
function ensureFunctionCallItem(
|
||||
state: OpenAiResponsesAggregateState,
|
||||
callIdRaw: unknown,
|
||||
nameRaw: unknown,
|
||||
indexHint?: number,
|
||||
): { index: number; item: AggregateOutputItem; created: boolean } {
|
||||
const callId = asTrimmedString(callIdRaw);
|
||||
const name = asTrimmedString(nameRaw);
|
||||
const existingIndex = callId ? state.functionIndexById[callId] : undefined;
|
||||
const index = existingIndex ?? resolveOutputIndex(state, indexHint, callId);
|
||||
const created = !state.outputItems[index];
|
||||
const item = ensureOutputItem(state, index, () => ({
|
||||
id: ensureOutputItemId(callId, 'fc', index),
|
||||
type: 'function_call',
|
||||
status: 'in_progress',
|
||||
call_id: ensureOutputItemId(callId, 'call', index),
|
||||
name,
|
||||
arguments: '',
|
||||
}));
|
||||
if (callId) item.call_id = ensureOutputItemId(callId, 'call', index);
|
||||
if (name) item.name = name;
|
||||
if (typeof item.arguments !== 'string') item.arguments = '';
|
||||
return { index, item, created };
|
||||
}
|
||||
|
||||
function ensureCustomToolItem(
|
||||
state: OpenAiResponsesAggregateState,
|
||||
itemIdRaw: unknown,
|
||||
callIdRaw: unknown,
|
||||
nameRaw: unknown,
|
||||
indexHint?: number,
|
||||
): { index: number; item: AggregateOutputItem; created: boolean } {
|
||||
const itemId = asTrimmedString(itemIdRaw);
|
||||
const callId = asTrimmedString(callIdRaw);
|
||||
const name = asTrimmedString(nameRaw);
|
||||
const existingIndex = (callId && state.customToolIndexById[callId] !== undefined)
|
||||
? state.customToolIndexById[callId]
|
||||
: (itemId && state.customToolIndexById[itemId] !== undefined ? state.customToolIndexById[itemId] : undefined);
|
||||
const index = existingIndex ?? resolveOutputIndex(state, indexHint, itemId, callId);
|
||||
const created = !state.outputItems[index];
|
||||
const item = ensureOutputItem(state, index, () => ({
|
||||
id: ensureOutputItemId(itemId, 'ct', index),
|
||||
type: 'custom_tool_call',
|
||||
status: 'in_progress',
|
||||
call_id: ensureOutputItemId(callId || itemId, 'call', index),
|
||||
name,
|
||||
input: '',
|
||||
}));
|
||||
if (callId || itemId) item.call_id = ensureOutputItemId(callId || itemId, 'call', index);
|
||||
if (name) item.name = name;
|
||||
if (typeof item.input !== 'string') item.input = '';
|
||||
return { index, item, created };
|
||||
}
|
||||
|
||||
function ensureImageGenerationItem(
|
||||
state: OpenAiResponsesAggregateState,
|
||||
itemIdRaw: unknown,
|
||||
indexHint?: number,
|
||||
): { index: number; item: AggregateOutputItem; created: boolean } {
|
||||
const itemId = asTrimmedString(itemIdRaw);
|
||||
const existingIndex = itemId ? state.imageGenerationIndexById[itemId] : undefined;
|
||||
const index = existingIndex ?? resolveOutputIndex(state, indexHint, itemId);
|
||||
const created = !state.outputItems[index];
|
||||
const item = ensureOutputItem(state, index, () => ({
|
||||
id: ensureOutputItemId(itemId, 'img', index),
|
||||
type: 'image_generation_call',
|
||||
status: 'in_progress',
|
||||
result: null,
|
||||
partial_images: [],
|
||||
}));
|
||||
if (!Array.isArray(item.partial_images)) item.partial_images = [];
|
||||
return { index, item, created };
|
||||
}
|
||||
|
||||
function buildUsagePayload(usage: ResponsesUsageSummary): Record<string, unknown> {
|
||||
const payload: Record<string, unknown> = {
|
||||
input_tokens: usage.promptTokens,
|
||||
output_tokens: usage.completionTokens,
|
||||
total_tokens: usage.totalTokens,
|
||||
};
|
||||
const inputDetails: Record<string, unknown> = {};
|
||||
if ((usage.cacheReadTokens || 0) > 0) inputDetails.cached_tokens = usage.cacheReadTokens;
|
||||
if ((usage.cacheCreationTokens || 0) > 0) inputDetails.cache_creation_tokens = usage.cacheCreationTokens;
|
||||
if (Object.keys(inputDetails).length > 0) payload.input_tokens_details = inputDetails;
|
||||
return {
|
||||
...payload,
|
||||
};
|
||||
}
|
||||
|
||||
function collectOutputText(state: OpenAiResponsesAggregateState): string {
|
||||
const parts: string[] = [];
|
||||
for (const item of state.outputItems) {
|
||||
if (!isRecord(item)) continue;
|
||||
if (asTrimmedString(item.type).toLowerCase() !== 'message') continue;
|
||||
const content = Array.isArray(item.content) ? item.content : [];
|
||||
for (const part of content) {
|
||||
if (!isRecord(part)) continue;
|
||||
const type = asTrimmedString(part.type).toLowerCase();
|
||||
if ((type === 'output_text' || type === 'text') && typeof part.text === 'string' && part.text) {
|
||||
parts.push(part.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
return parts.join('');
|
||||
}
|
||||
|
||||
function materializeResponse(
|
||||
state: OpenAiResponsesAggregateState,
|
||||
streamContext: StreamTransformContext,
|
||||
usage: ResponsesUsageSummary,
|
||||
responseTemplate?: Record<string, unknown> | null,
|
||||
statusOverride?: 'completed' | 'failed',
|
||||
): Record<string, unknown> {
|
||||
const base = cloneRecord(responseTemplate) || {};
|
||||
const responseId = ensureResponseId(
|
||||
asTrimmedString(base.id)
|
||||
|| state.responseId
|
||||
|| streamContext.id
|
||||
|| state.modelName,
|
||||
);
|
||||
const createdAt = (
|
||||
typeof base.created_at === 'number' && Number.isFinite(base.created_at)
|
||||
? base.created_at
|
||||
: (typeof base.created === 'number' && Number.isFinite(base.created) ? base.created : null)
|
||||
) ?? state.createdAt ?? Math.floor(Date.now() / 1000);
|
||||
const output = state.outputItems
|
||||
.filter((item): item is AggregateOutputItem => isRecord(item))
|
||||
.map((item) => {
|
||||
const status = asTrimmedString(item.status).toLowerCase();
|
||||
return {
|
||||
...item,
|
||||
status: status && status !== 'in_progress'
|
||||
? status
|
||||
: (state.failed ? 'failed' : 'completed'),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
...base,
|
||||
id: responseId,
|
||||
object: 'response',
|
||||
created_at: createdAt,
|
||||
status: statusOverride ?? (state.failed ? 'failed' : 'completed'),
|
||||
model: asTrimmedString(base.model) || streamContext.model || state.modelName,
|
||||
output,
|
||||
output_text: collectOutputText(state),
|
||||
usage: buildUsagePayload(usage),
|
||||
...Object.keys(state.usageExtras).length > 0 ? { usage: { ...buildUsagePayload(usage), ...state.usageExtras } } : {},
|
||||
};
|
||||
}
|
||||
|
||||
function serializeOriginalResponsesEvent(eventType: string, payload: Record<string, unknown>): string[] {
|
||||
return [serializeSse(eventType, payload)];
|
||||
}
|
||||
|
||||
function computeNovelDelta(existingText: string, incomingDelta: string): string {
|
||||
if (!incomingDelta) return '';
|
||||
if (!existingText) return incomingDelta;
|
||||
if (existingText.endsWith(incomingDelta)) return '';
|
||||
if (incomingDelta.startsWith(existingText)) {
|
||||
return incomingDelta.slice(existingText.length);
|
||||
}
|
||||
if (existingText.includes(incomingDelta)) return '';
|
||||
|
||||
const maxOverlap = Math.min(existingText.length, incomingDelta.length);
|
||||
for (let overlap = maxOverlap; overlap > 0; overlap -= 1) {
|
||||
if (existingText.slice(-overlap) === incomingDelta.slice(0, overlap)) {
|
||||
return incomingDelta.slice(overlap);
|
||||
}
|
||||
}
|
||||
return incomingDelta;
|
||||
}
|
||||
|
||||
function applyOriginalResponsesPayload(
|
||||
state: OpenAiResponsesAggregateState,
|
||||
eventType: string,
|
||||
payload: Record<string, unknown>,
|
||||
streamContext: StreamTransformContext,
|
||||
usage: ResponsesUsageSummary,
|
||||
): string[] {
|
||||
switch (eventType) {
|
||||
case 'response.output_item.added':
|
||||
case 'response.output_item.done': {
|
||||
const outputIndex = resolveOutputIndex(state, payload.output_index, (payload.item as Record<string, unknown> | undefined)?.id);
|
||||
const item = cloneRecord(payload.item) || {};
|
||||
if (Object.keys(item).length > 0) {
|
||||
const existing = isRecord(state.outputItems[outputIndex]) ? cloneRecord(state.outputItems[outputIndex]) || {} : {};
|
||||
const next = {
|
||||
...existing,
|
||||
...item,
|
||||
};
|
||||
if ((!Array.isArray(item.content) || item.content.length === 0) && Array.isArray(existing.content)) {
|
||||
next.content = existing.content;
|
||||
}
|
||||
if ((!Array.isArray(item.summary) || item.summary.length === 0) && Array.isArray(existing.summary)) {
|
||||
next.summary = existing.summary;
|
||||
}
|
||||
if ((!Array.isArray(item.partial_images) || item.partial_images.length === 0) && Array.isArray(existing.partial_images)) {
|
||||
next.partial_images = existing.partial_images;
|
||||
}
|
||||
setOutputItem(state, outputIndex, next);
|
||||
}
|
||||
return serializeOriginalResponsesEvent(eventType, {
|
||||
...payload,
|
||||
item: state.outputItems[outputIndex] ?? payload.item,
|
||||
});
|
||||
}
|
||||
case 'response.content_part.added':
|
||||
case 'response.content_part.done': {
|
||||
const outputIndex = resolveOutputIndex(state, payload.output_index, payload.item_id);
|
||||
const contentIndex = typeof payload.content_index === 'number' && Number.isFinite(payload.content_index)
|
||||
? Math.max(0, Math.trunc(payload.content_index))
|
||||
: 0;
|
||||
const message = ensureMessageItem(state, outputIndex).item;
|
||||
const content = Array.isArray(message.content) ? message.content as AggregateOutputItem[] : [];
|
||||
if (!Array.isArray(message.content)) message.content = content;
|
||||
const part = cloneRecord(payload.part);
|
||||
if (part) content[contentIndex] = part;
|
||||
return serializeOriginalResponsesEvent(eventType, payload);
|
||||
}
|
||||
case 'response.output_text.delta':
|
||||
case 'response.output_text.done': {
|
||||
const outputIndex = resolveOutputIndex(state, payload.output_index, payload.item_id);
|
||||
const textPart = ensureMessageOutputTextPart(state, outputIndex);
|
||||
if (eventType === 'response.output_text.done') {
|
||||
textPart.part.text = typeof payload.text === 'string' ? payload.text : String(payload.text ?? '');
|
||||
} else {
|
||||
textPart.part.text = `${typeof textPart.part.text === 'string' ? textPart.part.text : ''}${typeof payload.delta === 'string' ? payload.delta : ''}`;
|
||||
}
|
||||
return serializeOriginalResponsesEvent(eventType, payload);
|
||||
}
|
||||
case 'response.function_call_arguments.delta':
|
||||
case 'response.function_call_arguments.done': {
|
||||
const entry = ensureFunctionCallItem(
|
||||
state,
|
||||
payload.call_id ?? payload.item_id,
|
||||
payload.name,
|
||||
resolveOutputIndex(state, payload.output_index, payload.item_id, payload.call_id),
|
||||
);
|
||||
if (eventType === 'response.function_call_arguments.done') {
|
||||
entry.item.arguments = typeof payload.arguments === 'string' ? payload.arguments : String(payload.arguments ?? '');
|
||||
} else {
|
||||
entry.item.arguments = `${typeof entry.item.arguments === 'string' ? entry.item.arguments : ''}${typeof payload.delta === 'string' ? payload.delta : ''}`;
|
||||
}
|
||||
return serializeOriginalResponsesEvent(eventType, payload);
|
||||
}
|
||||
case 'response.custom_tool_call_input.delta':
|
||||
case 'response.custom_tool_call_input.done': {
|
||||
const entry = ensureCustomToolItem(
|
||||
state,
|
||||
payload.item_id,
|
||||
payload.call_id,
|
||||
payload.name,
|
||||
resolveOutputIndex(state, payload.output_index, payload.item_id, payload.call_id),
|
||||
);
|
||||
if (eventType === 'response.custom_tool_call_input.done') {
|
||||
entry.item.input = typeof payload.input === 'string' ? payload.input : String(payload.input ?? '');
|
||||
} else {
|
||||
entry.item.input = `${typeof entry.item.input === 'string' ? entry.item.input : ''}${typeof payload.delta === 'string' ? payload.delta : ''}`;
|
||||
}
|
||||
return serializeOriginalResponsesEvent(eventType, payload);
|
||||
}
|
||||
case 'response.reasoning_summary_part.added':
|
||||
case 'response.reasoning_summary_part.done': {
|
||||
const summaryState = ensureReasoningSummaryPart(state, payload.item_id, payload.summary_index, payload.output_index);
|
||||
const part = cloneRecord(payload.part);
|
||||
if (part) {
|
||||
const summary = Array.isArray(summaryState.item.summary) ? summaryState.item.summary as AggregateOutputItem[] : [];
|
||||
summary[summaryState.summaryIndex] = {
|
||||
...summaryState.summary,
|
||||
...part,
|
||||
};
|
||||
summaryState.item.summary = summary;
|
||||
}
|
||||
return serializeOriginalResponsesEvent(eventType, payload);
|
||||
}
|
||||
case 'response.reasoning_summary_text.delta':
|
||||
case 'response.reasoning_summary_text.done': {
|
||||
const summaryState = ensureReasoningSummaryPart(state, payload.item_id, payload.summary_index, payload.output_index);
|
||||
if (eventType === 'response.reasoning_summary_text.done') {
|
||||
summaryState.summary.text = typeof payload.text === 'string' ? payload.text : String(payload.text ?? '');
|
||||
} else {
|
||||
summaryState.summary.text = `${typeof summaryState.summary.text === 'string' ? summaryState.summary.text : ''}${typeof payload.delta === 'string' ? payload.delta : ''}`;
|
||||
}
|
||||
return serializeOriginalResponsesEvent(eventType, payload);
|
||||
}
|
||||
case 'response.image_generation_call.generating':
|
||||
case 'response.image_generation_call.in_progress':
|
||||
case 'response.image_generation_call.partial_image':
|
||||
case 'response.image_generation_call.completed': {
|
||||
const entry = ensureImageGenerationItem(
|
||||
state,
|
||||
payload.item_id,
|
||||
resolveOutputIndex(state, payload.output_index, payload.item_id),
|
||||
);
|
||||
if (eventType === 'response.image_generation_call.partial_image') {
|
||||
const partialImages = Array.isArray(entry.item.partial_images) ? entry.item.partial_images as AggregateOutputItem[] : [];
|
||||
partialImages.push({
|
||||
partial_image_index: payload.partial_image_index,
|
||||
partial_image_b64: payload.partial_image_b64,
|
||||
});
|
||||
entry.item.partial_images = partialImages;
|
||||
}
|
||||
if (payload.result !== undefined) {
|
||||
entry.item.result = payload.result;
|
||||
}
|
||||
if (eventType === 'response.image_generation_call.completed') {
|
||||
entry.item.status = 'completed';
|
||||
}
|
||||
return serializeOriginalResponsesEvent(eventType, payload);
|
||||
}
|
||||
case 'response.completed': {
|
||||
mergeUsageExtras(state, payload.response && isRecord(payload.response) ? payload.response.usage : payload.usage);
|
||||
state.completed = true;
|
||||
const responsePayload = cloneRecord(payload.response);
|
||||
const materialized = materializeResponse(state, streamContext, usage, responsePayload, 'completed');
|
||||
return [serializeSse('response.completed', { ...payload, response: materialized })];
|
||||
}
|
||||
case 'response.failed': {
|
||||
mergeUsageExtras(state, payload.response && isRecord(payload.response) ? payload.response.usage : payload.usage);
|
||||
state.failed = true;
|
||||
const responsePayload = cloneRecord(payload.response);
|
||||
const materialized = materializeResponse(state, streamContext, usage, responsePayload, 'failed');
|
||||
return [serializeSse('response.failed', { ...payload, response: materialized })];
|
||||
}
|
||||
default:
|
||||
mergeUsageExtras(state, payload.usage);
|
||||
return serializeOriginalResponsesEvent(eventType, payload);
|
||||
}
|
||||
}
|
||||
|
||||
function buildSyntheticMessageEvents(
|
||||
state: OpenAiResponsesAggregateState,
|
||||
delta: string,
|
||||
): string[] {
|
||||
const { index, item } = ensureMessageItem(state);
|
||||
const textPartState = ensureMessageOutputTextPart(state, index);
|
||||
const lines: string[] = [];
|
||||
const currentText = typeof textPartState.part.text === 'string' ? textPartState.part.text : '';
|
||||
const novelDelta = computeNovelDelta(currentText, delta);
|
||||
if (textPartState.created) {
|
||||
lines.push(serializeSse('response.output_item.added', {
|
||||
type: 'response.output_item.added',
|
||||
output_index: index,
|
||||
item,
|
||||
}));
|
||||
lines.push(serializeSse('response.content_part.added', {
|
||||
type: 'response.content_part.added',
|
||||
output_index: index,
|
||||
item_id: item.id,
|
||||
content_index: 0,
|
||||
part: { type: 'output_text', text: '' },
|
||||
}));
|
||||
}
|
||||
if (novelDelta) {
|
||||
textPartState.part.text = `${currentText}${novelDelta}`;
|
||||
lines.push(serializeSse('response.output_text.delta', {
|
||||
type: 'response.output_text.delta',
|
||||
output_index: index,
|
||||
item_id: item.id,
|
||||
delta: novelDelta,
|
||||
}));
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
function buildSyntheticReasoningEvents(
|
||||
state: OpenAiResponsesAggregateState,
|
||||
delta: string,
|
||||
): string[] {
|
||||
const summaryState = ensureReasoningSummaryPart(state, '', 0, state.outputItems.length);
|
||||
const itemId = asTrimmedString(summaryState.item.id);
|
||||
const lines: string[] = [];
|
||||
const currentText = typeof summaryState.summary.text === 'string' ? summaryState.summary.text : '';
|
||||
const novelDelta = computeNovelDelta(currentText, delta);
|
||||
if (summaryState.created) {
|
||||
lines.push(serializeSse('response.output_item.added', {
|
||||
type: 'response.output_item.added',
|
||||
output_index: summaryState.index,
|
||||
item: summaryState.item,
|
||||
}));
|
||||
lines.push(serializeSse('response.reasoning_summary_part.added', {
|
||||
type: 'response.reasoning_summary_part.added',
|
||||
item_id: itemId,
|
||||
output_index: summaryState.index,
|
||||
summary_index: 0,
|
||||
part: { type: 'summary_text', text: '' },
|
||||
}));
|
||||
}
|
||||
if (novelDelta) {
|
||||
summaryState.summary.text = `${currentText}${novelDelta}`;
|
||||
lines.push(serializeSse('response.reasoning_summary_text.delta', {
|
||||
type: 'response.reasoning_summary_text.delta',
|
||||
item_id: itemId,
|
||||
output_index: summaryState.index,
|
||||
summary_index: 0,
|
||||
delta: novelDelta,
|
||||
}));
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
function buildSyntheticToolEvents(
|
||||
state: OpenAiResponsesAggregateState,
|
||||
event: OpenAiResponsesStreamEvent,
|
||||
): string[] {
|
||||
const lines: string[] = [];
|
||||
if (!Array.isArray(event.toolCallDeltas)) return lines;
|
||||
for (const toolDelta of event.toolCallDeltas) {
|
||||
const entry = ensureFunctionCallItem(state, toolDelta.id, toolDelta.name, toolDelta.index);
|
||||
if (entry.created) {
|
||||
lines.push(serializeSse('response.output_item.added', {
|
||||
type: 'response.output_item.added',
|
||||
output_index: entry.index,
|
||||
item: entry.item,
|
||||
}));
|
||||
}
|
||||
if (toolDelta.argumentsDelta !== undefined && toolDelta.argumentsDelta.length > 0) {
|
||||
entry.item.arguments = `${typeof entry.item.arguments === 'string' ? entry.item.arguments : ''}${toolDelta.argumentsDelta}`;
|
||||
lines.push(serializeSse('response.function_call_arguments.delta', {
|
||||
type: 'response.function_call_arguments.delta',
|
||||
item_id: entry.item.id,
|
||||
call_id: entry.item.call_id,
|
||||
output_index: entry.index,
|
||||
name: entry.item.name,
|
||||
delta: toolDelta.argumentsDelta,
|
||||
}));
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
export function serializeConvertedResponsesEvents(input: {
|
||||
state: OpenAiResponsesAggregateState;
|
||||
streamContext: StreamTransformContext;
|
||||
event: OpenAiResponsesStreamEvent;
|
||||
usage: ResponsesUsageSummary;
|
||||
}): string[] {
|
||||
const { state, streamContext, event, usage } = input;
|
||||
mergeUsageExtras(state, event.responsesPayload && isRecord(event.responsesPayload) ? event.responsesPayload.usage : undefined);
|
||||
|
||||
if (event.responsesEventType && event.responsesPayload) {
|
||||
return applyOriginalResponsesPayload(
|
||||
state,
|
||||
event.responsesEventType,
|
||||
event.responsesPayload,
|
||||
streamContext,
|
||||
usage,
|
||||
);
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
if (event.contentDelta) {
|
||||
lines.push(...buildSyntheticMessageEvents(state, event.contentDelta));
|
||||
}
|
||||
if (event.reasoningDelta) {
|
||||
lines.push(...buildSyntheticReasoningEvents(state, event.reasoningDelta));
|
||||
}
|
||||
lines.push(...buildSyntheticToolEvents(state, event));
|
||||
return lines;
|
||||
}
|
||||
|
||||
export function completeResponsesStream(
|
||||
state: OpenAiResponsesAggregateState,
|
||||
streamContext: StreamTransformContext,
|
||||
usage: ResponsesUsageSummary,
|
||||
): string[] {
|
||||
if (state.failed || state.completed) {
|
||||
return [serializeDone()];
|
||||
}
|
||||
state.completed = true;
|
||||
return [
|
||||
serializeSse('response.completed', {
|
||||
type: 'response.completed',
|
||||
response: materializeResponse(state, streamContext, usage, null, 'completed'),
|
||||
}),
|
||||
serializeDone(),
|
||||
];
|
||||
}
|
||||
|
||||
export function failResponsesStream(
|
||||
state: OpenAiResponsesAggregateState,
|
||||
streamContext: StreamTransformContext,
|
||||
usage: ResponsesUsageSummary,
|
||||
payload: unknown,
|
||||
): string[] {
|
||||
if (state.failed) {
|
||||
return [serializeDone()];
|
||||
}
|
||||
state.failed = true;
|
||||
const errorPayload = cloneRecord(payload);
|
||||
const message = (
|
||||
isRecord(errorPayload?.error) && typeof errorPayload.error.message === 'string'
|
||||
? errorPayload.error.message
|
||||
: (typeof errorPayload?.message === 'string' ? errorPayload.message : 'upstream stream failed')
|
||||
);
|
||||
return [
|
||||
serializeSse('response.failed', {
|
||||
type: 'response.failed',
|
||||
response: materializeResponse(state, streamContext, usage, cloneRecord(errorPayload?.response), 'failed'),
|
||||
error: {
|
||||
message,
|
||||
type: 'upstream_error',
|
||||
},
|
||||
}),
|
||||
serializeDone(),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object';
|
||||
}
|
||||
|
||||
function asTrimmedString(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function toTextBlockType(role: string): 'input_text' | 'output_text' {
|
||||
return role === 'assistant' ? 'output_text' : 'input_text';
|
||||
}
|
||||
|
||||
function normalizeImageUrlValue(value: unknown): string | Record<string, unknown> | null {
|
||||
if (typeof value === 'string' && value.trim().length > 0) return value;
|
||||
if (!isRecord(value)) return null;
|
||||
const url = asTrimmedString(value.url);
|
||||
if (url) return { ...value, url };
|
||||
const imageUrl = asTrimmedString(value.image_url);
|
||||
if (imageUrl) return imageUrl;
|
||||
return Object.keys(value).length > 0 ? value : null;
|
||||
}
|
||||
|
||||
function normalizeAudioInputValue(value: unknown): Record<string, unknown> | null {
|
||||
if (!isRecord(value)) return null;
|
||||
const data = asTrimmedString(value.data);
|
||||
const format = asTrimmedString(value.format);
|
||||
if (!data && !format) return Object.keys(value).length > 0 ? value : null;
|
||||
return {
|
||||
...value,
|
||||
...(data ? { data } : {}),
|
||||
...(format ? { format } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeResponsesContentItem(
|
||||
item: unknown,
|
||||
role: string,
|
||||
): Record<string, unknown> | null {
|
||||
if (typeof item === 'string') {
|
||||
const text = item.trim();
|
||||
return text ? { type: toTextBlockType(role), text } : null;
|
||||
}
|
||||
|
||||
if (!isRecord(item)) return null;
|
||||
|
||||
const type = asTrimmedString(item.type).toLowerCase();
|
||||
if (!type) {
|
||||
const text = asTrimmedString(item.text ?? item.content ?? item.output_text);
|
||||
return text ? { type: toTextBlockType(role), text } : null;
|
||||
}
|
||||
|
||||
if (type === 'input_text' || type === 'output_text' || type === 'text') {
|
||||
const text = asTrimmedString(item.text ?? item.content ?? item.output_text);
|
||||
if (!text) return null;
|
||||
return {
|
||||
...item,
|
||||
type: type === 'text' ? toTextBlockType(role) : type,
|
||||
text,
|
||||
};
|
||||
}
|
||||
|
||||
if (type === 'input_image' || type === 'image_url') {
|
||||
const imageUrl = normalizeImageUrlValue(item.image_url ?? item.url);
|
||||
if (!imageUrl) return null;
|
||||
return {
|
||||
...item,
|
||||
type: 'input_image',
|
||||
image_url: imageUrl,
|
||||
};
|
||||
}
|
||||
|
||||
if (type === 'input_audio') {
|
||||
const inputAudio = normalizeAudioInputValue(item.input_audio);
|
||||
if (!inputAudio) return null;
|
||||
return {
|
||||
...item,
|
||||
type: 'input_audio',
|
||||
input_audio: inputAudio,
|
||||
};
|
||||
}
|
||||
|
||||
if (type === 'function_call' || type === 'function_call_output') {
|
||||
return item;
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
export function normalizeResponsesMessageContent(content: unknown, role: string): unknown {
|
||||
if (Array.isArray(content)) {
|
||||
const normalized = content
|
||||
.map((item) => normalizeResponsesContentItem(item, role))
|
||||
.filter((item): item is Record<string, unknown> => !!item);
|
||||
return normalized.length > 0 ? normalized : content;
|
||||
}
|
||||
|
||||
const single = normalizeResponsesContentItem(content, role);
|
||||
if (single) return [single];
|
||||
return content;
|
||||
}
|
||||
|
||||
function toResponsesInputMessageFromText(text: string): Record<string, unknown> {
|
||||
return {
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [{ type: 'input_text', text }],
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeResponsesMessageItem(item: Record<string, unknown>): Record<string, unknown> {
|
||||
const type = asTrimmedString(item.type).toLowerCase();
|
||||
if (type === 'function_call' || type === 'function_call_output') {
|
||||
return item;
|
||||
}
|
||||
|
||||
const role = asTrimmedString(item.role).toLowerCase() || 'user';
|
||||
const normalizedContent = normalizeResponsesMessageContent(item.content ?? item.text, role);
|
||||
|
||||
if (type === 'message') {
|
||||
return {
|
||||
...item,
|
||||
role,
|
||||
content: normalizedContent,
|
||||
};
|
||||
}
|
||||
|
||||
if (asTrimmedString(item.role)) {
|
||||
return {
|
||||
type: 'message',
|
||||
role,
|
||||
content: normalizedContent,
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof item.content === 'string') {
|
||||
const text = item.content.trim();
|
||||
return text ? toResponsesInputMessageFromText(text) : item;
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
export function normalizeResponsesInputForCompatibility(input: unknown): unknown {
|
||||
if (typeof input === 'string') {
|
||||
const normalized = input.trim();
|
||||
if (!normalized) return input;
|
||||
return [toResponsesInputMessageFromText(normalized)];
|
||||
}
|
||||
|
||||
if (Array.isArray(input)) {
|
||||
return input.map((item) => {
|
||||
if (typeof item === 'string') {
|
||||
const normalized = item.trim();
|
||||
return normalized ? toResponsesInputMessageFromText(normalized) : item;
|
||||
}
|
||||
if (!isRecord(item)) return item;
|
||||
return normalizeResponsesMessageItem(item);
|
||||
});
|
||||
}
|
||||
|
||||
if (isRecord(input)) {
|
||||
return [normalizeResponsesMessageItem(input)];
|
||||
}
|
||||
|
||||
return input;
|
||||
}
|
||||
|
||||
function extractTextFromResponsesContent(content: unknown): string {
|
||||
if (typeof content === 'string') return content.trim();
|
||||
if (!Array.isArray(content)) return '';
|
||||
return content
|
||||
.map((item) => {
|
||||
if (!isRecord(item)) return '';
|
||||
return asTrimmedString(item.text ?? item.content ?? item.output_text);
|
||||
})
|
||||
.filter((item) => item.length > 0)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function safeJsonStringify(value: unknown): string {
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeOpenAiToolArguments(raw: unknown): string {
|
||||
if (typeof raw === 'string') return raw;
|
||||
if (raw === undefined || raw === null) return '';
|
||||
if (typeof raw === 'number' || typeof raw === 'boolean') return String(raw);
|
||||
if (Array.isArray(raw) || isRecord(raw)) {
|
||||
return safeJsonStringify(raw);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function normalizeToolMessageContent(raw: unknown): string {
|
||||
if (raw === undefined || raw === null) return '';
|
||||
if (typeof raw === 'string') return raw;
|
||||
if (typeof raw === 'number' || typeof raw === 'boolean') return String(raw);
|
||||
if (Array.isArray(raw)) {
|
||||
const normalized = normalizeResponsesMessageContent(raw, 'user');
|
||||
const text = extractTextFromResponsesContent(normalized);
|
||||
return text || safeJsonStringify(raw);
|
||||
}
|
||||
if (isRecord(raw)) {
|
||||
const normalized = normalizeResponsesMessageContent(raw, 'user');
|
||||
const text = extractTextFromResponsesContent(normalized);
|
||||
return text || safeJsonStringify(raw);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function toFiniteNumber(value: unknown): number | null {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
const ALLOWED_RESPONSES_FIELDS = new Set([
|
||||
'model',
|
||||
'input',
|
||||
'instructions',
|
||||
'max_output_tokens',
|
||||
'max_completion_tokens',
|
||||
'temperature',
|
||||
'top_p',
|
||||
'truncation',
|
||||
'tools',
|
||||
'tool_choice',
|
||||
'parallel_tool_calls',
|
||||
'metadata',
|
||||
'reasoning',
|
||||
'store',
|
||||
'stream',
|
||||
'user',
|
||||
'previous_response_id',
|
||||
'text',
|
||||
'audio',
|
||||
'include',
|
||||
'response_format',
|
||||
'service_tier',
|
||||
'stop',
|
||||
'n',
|
||||
]);
|
||||
|
||||
export function convertOpenAiBodyToResponsesBody(
|
||||
openaiBody: Record<string, unknown>,
|
||||
modelName: string,
|
||||
stream: boolean,
|
||||
): Record<string, unknown> {
|
||||
const rawMessages = Array.isArray(openaiBody.messages) ? openaiBody.messages : [];
|
||||
const systemContents: string[] = [];
|
||||
const inputItems: Array<Record<string, unknown>> = [];
|
||||
|
||||
for (const item of rawMessages) {
|
||||
if (!isRecord(item)) continue;
|
||||
const role = asTrimmedString(item.role).toLowerCase() || 'user';
|
||||
|
||||
if (role === 'system' || role === 'developer') {
|
||||
const normalized = normalizeResponsesMessageContent(item.content, 'user');
|
||||
const content = extractTextFromResponsesContent(normalized).trim();
|
||||
if (content) systemContents.push(content);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (role === 'assistant') {
|
||||
const normalizedAssistantContent = normalizeResponsesMessageContent(item.content, 'assistant');
|
||||
if (Array.isArray(normalizedAssistantContent) && normalizedAssistantContent.length > 0) {
|
||||
inputItems.push({
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content: normalizedAssistantContent,
|
||||
});
|
||||
}
|
||||
|
||||
const rawToolCalls = Array.isArray(item.tool_calls) ? item.tool_calls : [];
|
||||
for (let index = 0; index < rawToolCalls.length; index += 1) {
|
||||
const toolCall = rawToolCalls[index];
|
||||
if (!isRecord(toolCall)) continue;
|
||||
const functionPart = isRecord(toolCall.function) ? toolCall.function : {};
|
||||
const callId = asTrimmedString(toolCall.id) || `call_${Date.now()}_${index}`;
|
||||
const name = (
|
||||
asTrimmedString(functionPart.name)
|
||||
|| asTrimmedString(toolCall.name)
|
||||
|| `tool_${index}`
|
||||
);
|
||||
const argumentsValue = normalizeOpenAiToolArguments(
|
||||
functionPart.arguments ?? toolCall.arguments,
|
||||
);
|
||||
|
||||
inputItems.push({
|
||||
type: 'function_call',
|
||||
call_id: callId,
|
||||
name,
|
||||
arguments: argumentsValue,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (role === 'tool') {
|
||||
const callId = asTrimmedString(item.tool_call_id) || asTrimmedString(item.id);
|
||||
if (!callId) continue;
|
||||
|
||||
inputItems.push({
|
||||
type: 'function_call_output',
|
||||
call_id: callId,
|
||||
output: normalizeToolMessageContent(item.content),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalizedUserContent = normalizeResponsesMessageContent(item.content, 'user');
|
||||
if (Array.isArray(normalizedUserContent) && normalizedUserContent.length > 0) {
|
||||
inputItems.push({
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: normalizedUserContent,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const maxOutputTokens = (
|
||||
toFiniteNumber(openaiBody.max_output_tokens)
|
||||
?? toFiniteNumber(openaiBody.max_completion_tokens)
|
||||
?? toFiniteNumber(openaiBody.max_tokens)
|
||||
?? 4096
|
||||
);
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
model: modelName,
|
||||
stream,
|
||||
max_output_tokens: maxOutputTokens,
|
||||
input: inputItems,
|
||||
};
|
||||
|
||||
if (systemContents.length > 0) {
|
||||
body.instructions = systemContents.join('\n\n');
|
||||
}
|
||||
|
||||
const temperature = toFiniteNumber(openaiBody.temperature);
|
||||
if (temperature !== null) body.temperature = temperature;
|
||||
|
||||
const topP = toFiniteNumber(openaiBody.top_p);
|
||||
if (topP !== null) body.top_p = topP;
|
||||
|
||||
if (openaiBody.metadata !== undefined) body.metadata = openaiBody.metadata;
|
||||
if (openaiBody.reasoning !== undefined) body.reasoning = openaiBody.reasoning;
|
||||
if (openaiBody.parallel_tool_calls !== undefined) body.parallel_tool_calls = openaiBody.parallel_tool_calls;
|
||||
if (openaiBody.tools !== undefined) body.tools = openaiBody.tools;
|
||||
if (openaiBody.tool_choice !== undefined) body.tool_choice = openaiBody.tool_choice;
|
||||
|
||||
return {
|
||||
...body,
|
||||
input: normalizeResponsesInputForCompatibility(body.input),
|
||||
};
|
||||
}
|
||||
|
||||
export function sanitizeResponsesBodyForProxy(
|
||||
body: Record<string, unknown>,
|
||||
modelName: string,
|
||||
stream: boolean,
|
||||
): Record<string, unknown> {
|
||||
let normalized: Record<string, unknown> = {
|
||||
...body,
|
||||
model: modelName,
|
||||
stream,
|
||||
};
|
||||
|
||||
if (normalized.input === undefined) {
|
||||
if (Array.isArray((normalized as Record<string, unknown>).messages)) {
|
||||
normalized = convertOpenAiBodyToResponsesBody(normalized, modelName, stream);
|
||||
} else {
|
||||
const prompt = asTrimmedString((normalized as Record<string, unknown>).prompt);
|
||||
if (prompt) {
|
||||
normalized = {
|
||||
...normalized,
|
||||
input: [toResponsesInputMessageFromText(prompt)],
|
||||
};
|
||||
}
|
||||
}
|
||||
} else {
|
||||
normalized = {
|
||||
...normalized,
|
||||
input: normalizeResponsesInputForCompatibility(normalized.input),
|
||||
};
|
||||
}
|
||||
|
||||
const sanitized: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(normalized)) {
|
||||
if (!ALLOWED_RESPONSES_FIELDS.has(key)) continue;
|
||||
if (key === 'max_completion_tokens') continue;
|
||||
sanitized[key] = value;
|
||||
}
|
||||
|
||||
const maxOutputTokens = toFiniteNumber(normalized.max_output_tokens);
|
||||
if (maxOutputTokens !== null && maxOutputTokens > 0) {
|
||||
sanitized.max_output_tokens = Math.trunc(maxOutputTokens);
|
||||
} else {
|
||||
const maxCompletionTokens = toFiniteNumber(normalized.max_completion_tokens);
|
||||
if (maxCompletionTokens !== null && maxCompletionTokens > 0) {
|
||||
sanitized.max_output_tokens = Math.trunc(maxCompletionTokens);
|
||||
}
|
||||
}
|
||||
|
||||
sanitized.model = modelName;
|
||||
sanitized.stream = stream;
|
||||
return sanitized;
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
convertOpenAiBodyToResponsesBody,
|
||||
convertResponsesBodyToOpenAiBody,
|
||||
sanitizeResponsesBodyForProxy,
|
||||
} from './conversion.js';
|
||||
|
||||
describe('sanitizeResponsesBodyForProxy', () => {
|
||||
it('preserves newer Responses request fields needed by the proxy', () => {
|
||||
const result = sanitizeResponsesBodyForProxy(
|
||||
{
|
||||
model: 'gpt-5',
|
||||
input: 'hello',
|
||||
safety_identifier: 'safe-user-1',
|
||||
max_tool_calls: 3,
|
||||
prompt_cache_key: 'cache-key',
|
||||
prompt_cache_retention: { scope: 'session' },
|
||||
stream_options: { include_obfuscation: true },
|
||||
background: true,
|
||||
text: { format: { type: 'text' }, verbosity: 'high' },
|
||||
},
|
||||
'gpt-5',
|
||||
true,
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
model: 'gpt-5',
|
||||
stream: true,
|
||||
safety_identifier: 'safe-user-1',
|
||||
max_tool_calls: 3,
|
||||
prompt_cache_key: 'cache-key',
|
||||
prompt_cache_retention: { scope: 'session' },
|
||||
stream_options: { include_obfuscation: true },
|
||||
background: true,
|
||||
text: { format: { type: 'text' }, verbosity: 'high' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertOpenAiBodyToResponsesBody', () => {
|
||||
it('maps extra request fields and preserves custom/image_generation tools', () => {
|
||||
const result = convertOpenAiBodyToResponsesBody(
|
||||
{
|
||||
model: 'gpt-5',
|
||||
messages: [{ role: 'user', content: 'draw a cat' }],
|
||||
safety_identifier: 'safe-user-2',
|
||||
max_tool_calls: 2,
|
||||
prompt_cache_key: 'prompt-key',
|
||||
prompt_cache_retention: { scope: 'workspace' },
|
||||
stream_options: { include_obfuscation: true },
|
||||
background: false,
|
||||
verbosity: 'low',
|
||||
tools: [
|
||||
{
|
||||
type: 'custom',
|
||||
name: 'browser',
|
||||
description: 'browse the web',
|
||||
format: { type: 'text' },
|
||||
},
|
||||
{
|
||||
type: 'image_generation',
|
||||
background: 'transparent',
|
||||
size: '1024x1024',
|
||||
},
|
||||
],
|
||||
},
|
||||
'gpt-5',
|
||||
false,
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
model: 'gpt-5',
|
||||
stream: false,
|
||||
safety_identifier: 'safe-user-2',
|
||||
max_tool_calls: 2,
|
||||
prompt_cache_key: 'prompt-key',
|
||||
prompt_cache_retention: { scope: 'workspace' },
|
||||
stream_options: { include_obfuscation: true },
|
||||
background: false,
|
||||
text: { verbosity: 'low' },
|
||||
tools: [
|
||||
{
|
||||
type: 'custom',
|
||||
name: 'browser',
|
||||
description: 'browse the web',
|
||||
format: { type: 'text' },
|
||||
},
|
||||
{
|
||||
type: 'image_generation',
|
||||
background: 'transparent',
|
||||
size: '1024x1024',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertResponsesBodyToOpenAiBody', () => {
|
||||
it('preserves richer Responses request fields back onto the OpenAI-compatible body', () => {
|
||||
const result = convertResponsesBodyToOpenAiBody(
|
||||
{
|
||||
model: 'gpt-5',
|
||||
input: [{ type: 'message', role: 'user', content: [{ type: 'input_text', text: 'hello' }] }],
|
||||
safety_identifier: 'safe-user-3',
|
||||
max_tool_calls: 4,
|
||||
prompt_cache_key: 'prompt-key-2',
|
||||
prompt_cache_retention: { scope: 'project' },
|
||||
stream_options: { include_obfuscation: true },
|
||||
background: true,
|
||||
text: { format: { type: 'json_object' }, verbosity: 'high' },
|
||||
tools: [
|
||||
{
|
||||
type: 'custom',
|
||||
name: 'browser',
|
||||
format: { type: 'grammar', syntax: 'lark' },
|
||||
},
|
||||
{
|
||||
type: 'image_generation',
|
||||
background: 'transparent',
|
||||
partial_images: 2,
|
||||
output_format: 'png',
|
||||
},
|
||||
],
|
||||
},
|
||||
'gpt-5',
|
||||
true,
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
model: 'gpt-5',
|
||||
stream: true,
|
||||
safety_identifier: 'safe-user-3',
|
||||
max_tool_calls: 4,
|
||||
prompt_cache_key: 'prompt-key-2',
|
||||
prompt_cache_retention: { scope: 'project' },
|
||||
stream_options: { include_obfuscation: true },
|
||||
background: true,
|
||||
verbosity: 'high',
|
||||
tools: [
|
||||
{
|
||||
type: 'custom',
|
||||
name: 'browser',
|
||||
format: { type: 'grammar', syntax: 'lark' },
|
||||
},
|
||||
{
|
||||
type: 'image_generation',
|
||||
background: 'transparent',
|
||||
partial_images: 2,
|
||||
output_format: 'png',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('converts custom tool calls and outputs into OpenAI-compatible tool messages', () => {
|
||||
const result = convertResponsesBodyToOpenAiBody(
|
||||
{
|
||||
model: 'gpt-5',
|
||||
input: [
|
||||
{
|
||||
type: 'custom_tool_call',
|
||||
id: 'ct_1',
|
||||
call_id: 'ct_1',
|
||||
name: 'browser',
|
||||
input: 'open example.com',
|
||||
},
|
||||
{
|
||||
type: 'custom_tool_call_output',
|
||||
call_id: 'ct_1',
|
||||
output: 'done',
|
||||
},
|
||||
],
|
||||
},
|
||||
'gpt-5',
|
||||
false,
|
||||
);
|
||||
|
||||
expect(result.messages).toEqual([
|
||||
{
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'ct_1',
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'browser',
|
||||
arguments: 'open example.com',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'tool',
|
||||
tool_call_id: 'ct_1',
|
||||
content: 'done',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('converts reasoning items back into assistant content instead of dropping them', () => {
|
||||
const result = convertResponsesBodyToOpenAiBody(
|
||||
{
|
||||
model: 'gpt-5',
|
||||
input: [
|
||||
{
|
||||
type: 'reasoning',
|
||||
id: 'rs_1',
|
||||
status: 'completed',
|
||||
summary: [
|
||||
{ type: 'summary_text', text: 'Think step by step' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
'gpt-5',
|
||||
false,
|
||||
);
|
||||
|
||||
expect(result.messages).toEqual([
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Think step by step',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves remaining request fields needed for OpenAI-compatible downstream fallback', () => {
|
||||
const result = convertResponsesBodyToOpenAiBody(
|
||||
{
|
||||
model: 'gpt-5',
|
||||
input: 'hello',
|
||||
user: 'user-123',
|
||||
include: ['reasoning.encrypted_content'],
|
||||
previous_response_id: 'resp_prev',
|
||||
truncation: 'auto',
|
||||
service_tier: 'priority',
|
||||
top_logprobs: 4,
|
||||
},
|
||||
'gpt-5',
|
||||
true,
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
model: 'gpt-5',
|
||||
stream: true,
|
||||
user: 'user-123',
|
||||
include: ['reasoning.encrypted_content'],
|
||||
previous_response_id: 'resp_prev',
|
||||
truncation: 'auto',
|
||||
service_tier: 'priority',
|
||||
top_logprobs: 4,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,698 @@
|
||||
import {
|
||||
normalizeResponsesInputForCompatibility as normalizeResponsesInputForCompatibilityViaCompatibility,
|
||||
normalizeResponsesMessageItem,
|
||||
} from './compatibility.js';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object';
|
||||
}
|
||||
|
||||
function asTrimmedString(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function toFiniteNumber(value: unknown): number | null {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
function safeJsonStringify(value: unknown): string {
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function cloneRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (!isRecord(value)) return null;
|
||||
return { ...value };
|
||||
}
|
||||
|
||||
function cloneJsonValue<T>(value: T): T {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => cloneJsonValue(item)) as T;
|
||||
}
|
||||
if (isRecord(value)) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, item]) => [key, cloneJsonValue(item)]),
|
||||
) as T;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseJsonString(raw: string): unknown {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return {};
|
||||
try {
|
||||
return JSON.parse(trimmed);
|
||||
} catch {
|
||||
return { value: raw };
|
||||
}
|
||||
}
|
||||
|
||||
export function extractTextContent(value: unknown): string {
|
||||
if (typeof value === 'string') return value;
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map((item) => extractTextContent(item))
|
||||
.filter((item) => item.length > 0)
|
||||
.join('\n');
|
||||
}
|
||||
if (!isRecord(value)) return '';
|
||||
|
||||
const direct = asTrimmedString(
|
||||
value.text
|
||||
?? value.content
|
||||
?? value.input_text
|
||||
?? value.output_text
|
||||
?? value.reasoning
|
||||
?? value.reasoning_content
|
||||
?? value.thinking,
|
||||
);
|
||||
if (direct) return direct;
|
||||
|
||||
if (Array.isArray(value.parts)) return extractTextContent(value.parts);
|
||||
if (Array.isArray(value.content)) return extractTextContent(value.content);
|
||||
if (Array.isArray(value.output)) return extractTextContent(value.output);
|
||||
return '';
|
||||
}
|
||||
|
||||
function normalizeOpenAiToolArguments(raw: unknown): string {
|
||||
if (typeof raw === 'string') return raw;
|
||||
if (raw === undefined || raw === null) return '';
|
||||
if (typeof raw === 'number' || typeof raw === 'boolean') return String(raw);
|
||||
if (Array.isArray(raw) || isRecord(raw)) return safeJsonStringify(raw);
|
||||
return '';
|
||||
}
|
||||
|
||||
function normalizeToolOutput(raw: unknown): string {
|
||||
const text = extractTextContent(raw).trim();
|
||||
if (text) return text;
|
||||
if (raw === undefined || raw === null) return '';
|
||||
if (typeof raw === 'string') return raw;
|
||||
if (typeof raw === 'number' || typeof raw === 'boolean') return String(raw);
|
||||
if (Array.isArray(raw) || isRecord(raw)) return safeJsonStringify(raw);
|
||||
return '';
|
||||
}
|
||||
|
||||
export function normalizeResponsesInputForCompatibility(input: unknown): unknown {
|
||||
return normalizeResponsesInputForCompatibilityViaCompatibility(input);
|
||||
}
|
||||
|
||||
function toResponsesInputMessageFromText(text: string): Record<string, unknown> {
|
||||
return {
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [{ type: 'input_text', text }],
|
||||
};
|
||||
}
|
||||
|
||||
function convertOpenAiToolsToResponses(rawTools: unknown): unknown {
|
||||
if (!Array.isArray(rawTools)) return rawTools;
|
||||
|
||||
const converted = rawTools
|
||||
.map((item) => {
|
||||
if (!isRecord(item)) return null;
|
||||
|
||||
const type = asTrimmedString(item.type).toLowerCase();
|
||||
if (type === 'function' && isRecord(item.function)) {
|
||||
const fn = item.function;
|
||||
const name = asTrimmedString(fn.name);
|
||||
if (!name) return null;
|
||||
|
||||
const mapped: Record<string, unknown> = {
|
||||
type: 'function',
|
||||
name,
|
||||
};
|
||||
const description = asTrimmedString(fn.description);
|
||||
if (description) mapped.description = description;
|
||||
if (fn.parameters !== undefined) mapped.parameters = fn.parameters;
|
||||
if (fn.strict !== undefined) mapped.strict = fn.strict;
|
||||
return mapped;
|
||||
}
|
||||
|
||||
if (type === 'function' && asTrimmedString(item.name)) {
|
||||
return item;
|
||||
}
|
||||
|
||||
if (type === 'image_generation') {
|
||||
return item;
|
||||
}
|
||||
|
||||
if (type === 'custom' && asTrimmedString(item.name)) {
|
||||
return item;
|
||||
}
|
||||
|
||||
return null;
|
||||
})
|
||||
.filter((item): item is Record<string, unknown> => !!item);
|
||||
|
||||
return converted.length > 0 ? converted : rawTools;
|
||||
}
|
||||
|
||||
function convertOpenAiToolChoiceToResponses(rawToolChoice: unknown): unknown {
|
||||
if (rawToolChoice === undefined) return undefined;
|
||||
if (typeof rawToolChoice === 'string') return rawToolChoice;
|
||||
if (!isRecord(rawToolChoice)) return rawToolChoice;
|
||||
|
||||
const type = asTrimmedString(rawToolChoice.type).toLowerCase();
|
||||
if (type === 'function' && isRecord(rawToolChoice.function)) {
|
||||
const name = asTrimmedString(rawToolChoice.function.name);
|
||||
if (!name) return 'required';
|
||||
return { type: 'function', name };
|
||||
}
|
||||
|
||||
return rawToolChoice;
|
||||
}
|
||||
|
||||
function normalizeResponsesBodyForCompatibility(
|
||||
body: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const nextInput = normalizeResponsesInputForCompatibility(body.input);
|
||||
if (nextInput === body.input) return body;
|
||||
return {
|
||||
...body,
|
||||
input: nextInput,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeResponsesMessageContent(role: string, content: unknown): Array<Record<string, unknown>> {
|
||||
const normalized = normalizeResponsesMessageItem({
|
||||
type: 'message',
|
||||
role,
|
||||
content,
|
||||
});
|
||||
|
||||
if (isRecord(normalized) && Array.isArray(normalized.content)) {
|
||||
return normalized.content.filter((item): item is Record<string, unknown> => isRecord(item));
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
const ALLOWED_RESPONSES_FIELDS = new Set([
|
||||
'model',
|
||||
'input',
|
||||
'instructions',
|
||||
'max_output_tokens',
|
||||
'max_completion_tokens',
|
||||
'temperature',
|
||||
'top_p',
|
||||
'truncation',
|
||||
'tools',
|
||||
'tool_choice',
|
||||
'parallel_tool_calls',
|
||||
'metadata',
|
||||
'reasoning',
|
||||
'store',
|
||||
'safety_identifier',
|
||||
'stream',
|
||||
'stream_options',
|
||||
'user',
|
||||
'max_tool_calls',
|
||||
'prompt_cache_key',
|
||||
'prompt_cache_retention',
|
||||
'background',
|
||||
'previous_response_id',
|
||||
'text',
|
||||
'audio',
|
||||
'include',
|
||||
'response_format',
|
||||
'service_tier',
|
||||
'stop',
|
||||
'n',
|
||||
]);
|
||||
|
||||
export function sanitizeResponsesBodyForProxy(
|
||||
body: Record<string, unknown>,
|
||||
modelName: string,
|
||||
stream: boolean,
|
||||
): Record<string, unknown> {
|
||||
let normalized = normalizeResponsesBodyForCompatibility({
|
||||
...body,
|
||||
model: modelName,
|
||||
stream,
|
||||
});
|
||||
|
||||
if (normalized.input === undefined) {
|
||||
if (Array.isArray((normalized as Record<string, unknown>).messages)) {
|
||||
normalized = normalizeResponsesBodyForCompatibility(
|
||||
convertOpenAiBodyToResponsesBody(normalized, modelName, stream),
|
||||
);
|
||||
} else {
|
||||
const prompt = asTrimmedString((normalized as Record<string, unknown>).prompt);
|
||||
if (prompt) {
|
||||
normalized = {
|
||||
...normalized,
|
||||
input: [toResponsesInputMessageFromText(prompt)],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sanitized: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(normalized)) {
|
||||
if (!ALLOWED_RESPONSES_FIELDS.has(key)) continue;
|
||||
if (key === 'max_completion_tokens') continue;
|
||||
sanitized[key] = value;
|
||||
}
|
||||
|
||||
const maxOutputTokens = toFiniteNumber(normalized.max_output_tokens);
|
||||
if (maxOutputTokens !== null && maxOutputTokens > 0) {
|
||||
sanitized.max_output_tokens = Math.trunc(maxOutputTokens);
|
||||
} else {
|
||||
const maxCompletionTokens = toFiniteNumber(normalized.max_completion_tokens);
|
||||
if (maxCompletionTokens !== null && maxCompletionTokens > 0) {
|
||||
sanitized.max_output_tokens = Math.trunc(maxCompletionTokens);
|
||||
}
|
||||
}
|
||||
|
||||
sanitized.model = modelName;
|
||||
sanitized.stream = stream;
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
export function convertOpenAiBodyToResponsesBody(
|
||||
openaiBody: Record<string, unknown>,
|
||||
modelName: string,
|
||||
stream: boolean,
|
||||
): Record<string, unknown> {
|
||||
const rawMessages = Array.isArray(openaiBody.messages) ? openaiBody.messages : [];
|
||||
const systemContents: string[] = [];
|
||||
const inputItems: Array<Record<string, unknown>> = [];
|
||||
|
||||
for (const item of rawMessages) {
|
||||
if (!isRecord(item)) continue;
|
||||
const role = asTrimmedString(item.role).toLowerCase() || 'user';
|
||||
|
||||
if (role === 'system' || role === 'developer') {
|
||||
const content = extractTextContent(item.content).trim();
|
||||
if (content) systemContents.push(content);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (role === 'assistant') {
|
||||
const normalizedContent = normalizeResponsesMessageContent('assistant', item.content);
|
||||
if (normalizedContent.length > 0) {
|
||||
inputItems.push({
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content: normalizedContent,
|
||||
});
|
||||
}
|
||||
|
||||
const rawToolCalls = Array.isArray(item.tool_calls) ? item.tool_calls : [];
|
||||
for (let index = 0; index < rawToolCalls.length; index += 1) {
|
||||
const toolCall = rawToolCalls[index];
|
||||
if (!isRecord(toolCall)) continue;
|
||||
const functionPart = isRecord(toolCall.function) ? toolCall.function : {};
|
||||
const callId = asTrimmedString(toolCall.id) || `call_${Date.now()}_${index}`;
|
||||
const name = (
|
||||
asTrimmedString(functionPart.name)
|
||||
|| asTrimmedString(toolCall.name)
|
||||
|| `tool_${index}`
|
||||
);
|
||||
const argumentsValue = normalizeOpenAiToolArguments(
|
||||
functionPart.arguments ?? toolCall.arguments,
|
||||
);
|
||||
|
||||
inputItems.push({
|
||||
type: 'function_call',
|
||||
call_id: callId,
|
||||
name,
|
||||
arguments: argumentsValue,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (role === 'tool') {
|
||||
const callId = asTrimmedString(item.tool_call_id) || asTrimmedString(item.id);
|
||||
if (!callId) continue;
|
||||
inputItems.push({
|
||||
type: 'function_call_output',
|
||||
call_id: callId,
|
||||
output: normalizeToolOutput(item.content),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalizedContent = normalizeResponsesMessageContent('user', item.content);
|
||||
if (normalizedContent.length <= 0) continue;
|
||||
inputItems.push({
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: normalizedContent,
|
||||
});
|
||||
}
|
||||
|
||||
const maxOutputTokens = (
|
||||
toFiniteNumber(openaiBody.max_output_tokens)
|
||||
?? toFiniteNumber(openaiBody.max_completion_tokens)
|
||||
?? toFiniteNumber(openaiBody.max_tokens)
|
||||
?? 4096
|
||||
);
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
model: modelName,
|
||||
stream,
|
||||
max_output_tokens: maxOutputTokens,
|
||||
input: inputItems,
|
||||
};
|
||||
|
||||
if (systemContents.length > 0) {
|
||||
body.instructions = systemContents.join('\n\n');
|
||||
}
|
||||
|
||||
const temperature = toFiniteNumber(openaiBody.temperature);
|
||||
if (temperature !== null) body.temperature = temperature;
|
||||
|
||||
const topP = toFiniteNumber(openaiBody.top_p);
|
||||
if (topP !== null) body.top_p = topP;
|
||||
|
||||
if (openaiBody.metadata !== undefined) body.metadata = openaiBody.metadata;
|
||||
if (openaiBody.reasoning !== undefined) body.reasoning = openaiBody.reasoning;
|
||||
if (openaiBody.parallel_tool_calls !== undefined) body.parallel_tool_calls = openaiBody.parallel_tool_calls;
|
||||
if (openaiBody.tools !== undefined) body.tools = convertOpenAiToolsToResponses(openaiBody.tools);
|
||||
if (openaiBody.safety_identifier !== undefined) body.safety_identifier = openaiBody.safety_identifier;
|
||||
if (openaiBody.max_tool_calls !== undefined) body.max_tool_calls = openaiBody.max_tool_calls;
|
||||
if (openaiBody.prompt_cache_key !== undefined) body.prompt_cache_key = openaiBody.prompt_cache_key;
|
||||
if (openaiBody.prompt_cache_retention !== undefined) {
|
||||
body.prompt_cache_retention = openaiBody.prompt_cache_retention;
|
||||
}
|
||||
if (openaiBody.background !== undefined) body.background = openaiBody.background;
|
||||
if (openaiBody.user !== undefined) body.user = openaiBody.user;
|
||||
if (openaiBody.include !== undefined) body.include = cloneJsonValue(openaiBody.include);
|
||||
if (openaiBody.previous_response_id !== undefined) body.previous_response_id = openaiBody.previous_response_id;
|
||||
if (openaiBody.truncation !== undefined) body.truncation = openaiBody.truncation;
|
||||
if (openaiBody.service_tier !== undefined) body.service_tier = openaiBody.service_tier;
|
||||
if (openaiBody.top_logprobs !== undefined) body.top_logprobs = openaiBody.top_logprobs;
|
||||
if (openaiBody.stream_options !== undefined) body.stream_options = openaiBody.stream_options;
|
||||
|
||||
const textConfig = cloneRecord(openaiBody.text) || {};
|
||||
const verbosity = (
|
||||
asTrimmedString(openaiBody.verbosity)
|
||||
|| asTrimmedString(isRecord(openaiBody.text) ? openaiBody.text.verbosity : undefined)
|
||||
);
|
||||
if (verbosity) {
|
||||
textConfig.verbosity = verbosity;
|
||||
}
|
||||
if (Object.keys(textConfig).length > 0) {
|
||||
body.text = textConfig;
|
||||
}
|
||||
|
||||
const responsesToolChoice = convertOpenAiToolChoiceToResponses(openaiBody.tool_choice);
|
||||
if (responsesToolChoice !== undefined) body.tool_choice = responsesToolChoice;
|
||||
|
||||
return normalizeResponsesBodyForCompatibility(body);
|
||||
}
|
||||
|
||||
type OpenAiToolCall = {
|
||||
id: string;
|
||||
type: 'function';
|
||||
function: {
|
||||
name: string;
|
||||
arguments: string;
|
||||
};
|
||||
};
|
||||
|
||||
function toOpenAiToolCall(item: Record<string, unknown>, fallbackIndex: number): OpenAiToolCall | null {
|
||||
const callId = (
|
||||
asTrimmedString(item.call_id)
|
||||
|| asTrimmedString(item.id)
|
||||
|| `call_${Date.now()}_${fallbackIndex}`
|
||||
);
|
||||
const name = asTrimmedString(item.name);
|
||||
if (!name) return null;
|
||||
|
||||
return {
|
||||
id: callId,
|
||||
type: 'function',
|
||||
function: {
|
||||
name,
|
||||
arguments: normalizeOpenAiToolArguments(item.arguments ?? item.input),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeOpenAiContentBlock(item: Record<string, unknown>): string | Record<string, unknown> | null {
|
||||
const type = asTrimmedString(item.type).toLowerCase();
|
||||
if (!type) {
|
||||
const text = extractTextContent(item).trim();
|
||||
return text ? { type: 'text', text } : null;
|
||||
}
|
||||
|
||||
if (
|
||||
type === 'input_text'
|
||||
|| type === 'output_text'
|
||||
|| type === 'text'
|
||||
|| type === 'summary_text'
|
||||
|| type === 'reasoning_text'
|
||||
) {
|
||||
const text = extractTextContent(item).trim();
|
||||
return text ? { type: 'text', text } : null;
|
||||
}
|
||||
|
||||
if (type === 'input_image') {
|
||||
const imageUrl = item.image_url ?? item.url;
|
||||
if (imageUrl === undefined) return null;
|
||||
return {
|
||||
type: 'image_url',
|
||||
image_url: imageUrl,
|
||||
};
|
||||
}
|
||||
|
||||
if (type === 'input_audio' && item.input_audio !== undefined) {
|
||||
return {
|
||||
type: 'input_audio',
|
||||
input_audio: item.input_audio,
|
||||
};
|
||||
}
|
||||
|
||||
if (type === 'reasoning' || type === 'thinking' || type === 'redacted_reasoning') {
|
||||
const text = extractTextContent(item).trim();
|
||||
return text ? { type: 'text', text } : null;
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
function toOpenAiMessageContent(content: unknown): string | Array<string | Record<string, unknown>> {
|
||||
if (typeof content === 'string') return content;
|
||||
if (!Array.isArray(content)) {
|
||||
if (isRecord(content)) {
|
||||
const single = normalizeOpenAiContentBlock(content);
|
||||
if (!single) return '';
|
||||
return typeof single === 'string' ? single : [single];
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
const blocks = content
|
||||
.map((item) => {
|
||||
if (typeof item === 'string') return item.trim() ? item : null;
|
||||
if (!isRecord(item)) return null;
|
||||
return normalizeOpenAiContentBlock(item);
|
||||
})
|
||||
.filter((item): item is string | Record<string, unknown> => !!item);
|
||||
|
||||
if (blocks.length === 1 && typeof blocks[0] === 'string') {
|
||||
return blocks[0];
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
function convertResponsesToolsToOpenAi(rawTools: unknown): unknown {
|
||||
if (!Array.isArray(rawTools)) return rawTools;
|
||||
|
||||
return rawTools.map((item) => {
|
||||
if (!isRecord(item)) return item;
|
||||
const type = asTrimmedString(item.type).toLowerCase();
|
||||
|
||||
if (type === 'custom' || type === 'image_generation') return item;
|
||||
if (type !== 'function') return item;
|
||||
if (isRecord(item.function) && asTrimmedString(item.function.name)) return item;
|
||||
|
||||
const name = asTrimmedString(item.name);
|
||||
if (!name) return item;
|
||||
|
||||
const fn: Record<string, unknown> = { name };
|
||||
const description = asTrimmedString(item.description);
|
||||
if (description) fn.description = description;
|
||||
if (item.parameters !== undefined) fn.parameters = item.parameters;
|
||||
if (item.strict !== undefined) fn.strict = item.strict;
|
||||
|
||||
return {
|
||||
type: 'function',
|
||||
function: fn,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function convertResponsesToolChoiceToOpenAi(rawToolChoice: unknown): unknown {
|
||||
if (rawToolChoice === undefined) return undefined;
|
||||
if (typeof rawToolChoice === 'string') return rawToolChoice;
|
||||
if (!isRecord(rawToolChoice)) return rawToolChoice;
|
||||
|
||||
const type = asTrimmedString(rawToolChoice.type).toLowerCase();
|
||||
if (type === 'function') {
|
||||
if (isRecord(rawToolChoice.function) && asTrimmedString(rawToolChoice.function.name)) {
|
||||
return rawToolChoice;
|
||||
}
|
||||
|
||||
const name = asTrimmedString(rawToolChoice.name);
|
||||
if (!name) return 'required';
|
||||
return {
|
||||
type: 'function',
|
||||
function: { name },
|
||||
};
|
||||
}
|
||||
|
||||
if (type === 'auto' || type === 'none' || type === 'required') {
|
||||
return type;
|
||||
}
|
||||
|
||||
return rawToolChoice;
|
||||
}
|
||||
|
||||
export function convertResponsesBodyToOpenAiBody(
|
||||
body: Record<string, unknown>,
|
||||
modelName: string,
|
||||
stream: boolean,
|
||||
): Record<string, unknown> {
|
||||
const messages: Array<Record<string, unknown>> = [];
|
||||
const input = body.input;
|
||||
let functionCallIndex = 0;
|
||||
let pendingToolCalls: OpenAiToolCall[] = [];
|
||||
|
||||
const flushPendingToolCalls = () => {
|
||||
if (pendingToolCalls.length <= 0) return;
|
||||
messages.push({
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
tool_calls: pendingToolCalls,
|
||||
});
|
||||
pendingToolCalls = [];
|
||||
};
|
||||
|
||||
const pushToolOutputMessage = (callIdRaw: unknown, outputRaw: unknown) => {
|
||||
const toolCallId = asTrimmedString(callIdRaw);
|
||||
if (!toolCallId) return;
|
||||
messages.push({
|
||||
role: 'tool',
|
||||
tool_call_id: toolCallId,
|
||||
content: normalizeToolOutput(outputRaw),
|
||||
});
|
||||
};
|
||||
|
||||
const processInputItem = (item: unknown) => {
|
||||
if (typeof item === 'string') {
|
||||
flushPendingToolCalls();
|
||||
const text = item.trim();
|
||||
if (text) messages.push({ role: 'user', content: text });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isRecord(item)) return;
|
||||
|
||||
const itemType = asTrimmedString(item.type).toLowerCase();
|
||||
if (itemType === 'function_call' || itemType === 'custom_tool_call') {
|
||||
const toolCall = toOpenAiToolCall(item, functionCallIndex);
|
||||
functionCallIndex += 1;
|
||||
if (toolCall) pendingToolCalls.push(toolCall);
|
||||
return;
|
||||
}
|
||||
|
||||
if (itemType === 'function_call_output' || itemType === 'custom_tool_call_output') {
|
||||
flushPendingToolCalls();
|
||||
pushToolOutputMessage(item.call_id ?? item.id, item.output ?? item.content);
|
||||
return;
|
||||
}
|
||||
|
||||
if (itemType === 'reasoning') {
|
||||
flushPendingToolCalls();
|
||||
const reasoningContent = toOpenAiMessageContent(item.summary ?? item.content ?? item);
|
||||
const hasReasoningContent = typeof reasoningContent === 'string'
|
||||
? reasoningContent.trim().length > 0
|
||||
: Array.isArray(reasoningContent) && reasoningContent.length > 0;
|
||||
if (!hasReasoningContent) return;
|
||||
|
||||
messages.push({
|
||||
role: 'assistant',
|
||||
content: reasoningContent,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
flushPendingToolCalls();
|
||||
const role = asTrimmedString(item.role).toLowerCase() || 'user';
|
||||
const normalizedRole = role === 'developer' ? 'system' : role;
|
||||
const content = toOpenAiMessageContent(item.content ?? item.input ?? item);
|
||||
|
||||
if (normalizedRole === 'tool') {
|
||||
pushToolOutputMessage(item.tool_call_id ?? item.call_id ?? item.id, item.content);
|
||||
return;
|
||||
}
|
||||
|
||||
const hasContent = typeof content === 'string'
|
||||
? content.trim().length > 0
|
||||
: Array.isArray(content) && content.length > 0;
|
||||
if (!hasContent) return;
|
||||
|
||||
messages.push({
|
||||
role: normalizedRole,
|
||||
content,
|
||||
});
|
||||
};
|
||||
|
||||
if (typeof input === 'string') {
|
||||
const text = input.trim();
|
||||
if (text) messages.push({ role: 'user', content: text });
|
||||
} else if (Array.isArray(input)) {
|
||||
for (const item of input) processInputItem(item);
|
||||
} else if (isRecord(input)) {
|
||||
processInputItem(input);
|
||||
}
|
||||
flushPendingToolCalls();
|
||||
|
||||
const instructions = asTrimmedString(body.instructions);
|
||||
if (instructions) {
|
||||
messages.unshift({ role: 'system', content: instructions });
|
||||
}
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
model: modelName,
|
||||
stream,
|
||||
messages,
|
||||
};
|
||||
|
||||
if (typeof body.temperature === 'number' && Number.isFinite(body.temperature)) {
|
||||
payload.temperature = body.temperature;
|
||||
}
|
||||
if (typeof body.top_p === 'number' && Number.isFinite(body.top_p)) {
|
||||
payload.top_p = body.top_p;
|
||||
}
|
||||
if (typeof body.max_output_tokens === 'number' && Number.isFinite(body.max_output_tokens)) {
|
||||
payload.max_tokens = body.max_output_tokens;
|
||||
}
|
||||
if (body.parallel_tool_calls !== undefined) payload.parallel_tool_calls = body.parallel_tool_calls;
|
||||
if (body.tools !== undefined) payload.tools = convertResponsesToolsToOpenAi(body.tools);
|
||||
if (body.tool_choice !== undefined) payload.tool_choice = convertResponsesToolChoiceToOpenAi(body.tool_choice);
|
||||
if (body.safety_identifier !== undefined) payload.safety_identifier = body.safety_identifier;
|
||||
if (body.max_tool_calls !== undefined) payload.max_tool_calls = body.max_tool_calls;
|
||||
if (body.prompt_cache_key !== undefined) payload.prompt_cache_key = body.prompt_cache_key;
|
||||
if (body.prompt_cache_retention !== undefined) payload.prompt_cache_retention = body.prompt_cache_retention;
|
||||
if (body.background !== undefined) payload.background = body.background;
|
||||
if (body.user !== undefined) payload.user = body.user;
|
||||
if (body.include !== undefined) payload.include = cloneJsonValue(body.include);
|
||||
if (body.previous_response_id !== undefined) payload.previous_response_id = body.previous_response_id;
|
||||
if (body.truncation !== undefined) payload.truncation = body.truncation;
|
||||
if (body.service_tier !== undefined) payload.service_tier = body.service_tier;
|
||||
if (body.top_logprobs !== undefined) payload.top_logprobs = body.top_logprobs;
|
||||
if (body.stream_options !== undefined) payload.stream_options = body.stream_options;
|
||||
if (isRecord(body.text) && asTrimmedString(body.text.verbosity)) {
|
||||
payload.verbosity = asTrimmedString(body.text.verbosity);
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { normalizeResponsesInputForCompatibility, normalizeResponsesMessageItem } from './compatibility.js';
|
||||
|
||||
export const openAiResponsesInbound = {
|
||||
normalizeInput: normalizeResponsesInputForCompatibility,
|
||||
normalizeMessage: normalizeResponsesMessageItem,
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
import { type StreamTransformContext } from '../../shared/normalized.js';
|
||||
import {
|
||||
convertOpenAiBodyToResponsesBody,
|
||||
convertResponsesBodyToOpenAiBody,
|
||||
normalizeResponsesInputForCompatibility,
|
||||
normalizeResponsesMessageContent,
|
||||
sanitizeResponsesBodyForProxy,
|
||||
} from './conversion.js';
|
||||
import { normalizeResponsesMessageItem } from './compatibility.js';
|
||||
import {
|
||||
type OpenAiResponsesAggregateState,
|
||||
completeResponsesStream,
|
||||
createOpenAiResponsesAggregateState,
|
||||
failResponsesStream,
|
||||
serializeConvertedResponsesEvents,
|
||||
} from './aggregator.js';
|
||||
import { openAiResponsesOutbound } from './outbound.js';
|
||||
import { openAiResponsesStream } from './stream.js';
|
||||
import { openAiResponsesUsage } from './usage.js';
|
||||
|
||||
export const openAiResponsesTransformer = {
|
||||
protocol: 'openai/responses' as const,
|
||||
inbound: {
|
||||
normalizeInput: normalizeResponsesInputForCompatibility,
|
||||
normalizeMessage: normalizeResponsesMessageItem,
|
||||
normalizeContent: normalizeResponsesMessageContent,
|
||||
sanitizeProxyBody: sanitizeResponsesBodyForProxy,
|
||||
fromOpenAiBody: convertOpenAiBodyToResponsesBody,
|
||||
toOpenAiBody: convertResponsesBodyToOpenAiBody,
|
||||
},
|
||||
outbound: openAiResponsesOutbound,
|
||||
stream: openAiResponsesStream,
|
||||
usage: openAiResponsesUsage,
|
||||
aggregator: {
|
||||
createState: createOpenAiResponsesAggregateState,
|
||||
serialize: serializeConvertedResponsesEvents,
|
||||
complete: completeResponsesStream,
|
||||
fail: failResponsesStream,
|
||||
},
|
||||
transformRequest(body: unknown) {
|
||||
return body;
|
||||
},
|
||||
createStreamContext(modelName: string): StreamTransformContext {
|
||||
return openAiResponsesStream.createContext(modelName);
|
||||
},
|
||||
transformFinalResponse(payload: unknown, modelName: string, fallbackText = '') {
|
||||
return openAiResponsesOutbound.normalizeFinal(payload, modelName, fallbackText);
|
||||
},
|
||||
transformStreamEvent(payload: unknown, context: StreamTransformContext, modelName: string) {
|
||||
return openAiResponsesStream.normalizeEvent(payload, context, modelName);
|
||||
},
|
||||
pullSseEvents(buffer: string) {
|
||||
return openAiResponsesStream.pullSseEvents(buffer);
|
||||
},
|
||||
};
|
||||
|
||||
export type OpenAiResponsesTransformer = typeof openAiResponsesTransformer;
|
||||
export type OpenAiResponsesAggregate = OpenAiResponsesAggregateState;
|
||||
export {
|
||||
convertOpenAiBodyToResponsesBody,
|
||||
convertResponsesBodyToOpenAiBody,
|
||||
normalizeResponsesInputForCompatibility,
|
||||
normalizeResponsesMessageContent,
|
||||
normalizeResponsesMessageItem,
|
||||
sanitizeResponsesBodyForProxy,
|
||||
};
|
||||
@@ -0,0 +1,182 @@
|
||||
import {
|
||||
normalizeUpstreamFinalResponse,
|
||||
type NormalizedFinalResponse,
|
||||
} from '../../shared/normalized.js';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object';
|
||||
}
|
||||
|
||||
function asTrimmedString(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function ensureResponseId(rawId: string): string {
|
||||
const trimmed = rawId.trim() || `resp_${Date.now()}`;
|
||||
return trimmed.startsWith('resp_') ? trimmed : `resp_${trimmed}`;
|
||||
}
|
||||
|
||||
function ensureMessageId(rawId: string): string {
|
||||
const trimmed = rawId.trim() || `msg_${Date.now()}`;
|
||||
return trimmed.startsWith('msg_') ? trimmed : `msg_${trimmed}`;
|
||||
}
|
||||
|
||||
function ensureFunctionCallId(rawId: string): string {
|
||||
const trimmed = rawId.trim();
|
||||
if (!trimmed) return `call_${Date.now()}`;
|
||||
return trimmed.startsWith('call_') ? trimmed : `call_${trimmed}`;
|
||||
}
|
||||
|
||||
function stringifyToolInput(input: unknown): string {
|
||||
if (typeof input === 'string') return input;
|
||||
if (!input || typeof input !== 'object') return '';
|
||||
try {
|
||||
return JSON.stringify(input);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export type ResponsesUsageSummary = {
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
};
|
||||
|
||||
export type ResponsesToolCall = {
|
||||
id: string;
|
||||
name: string;
|
||||
arguments: string;
|
||||
};
|
||||
|
||||
function extractToolCallsFromUpstream(payload: unknown): ResponsesToolCall[] {
|
||||
if (!isRecord(payload)) return [];
|
||||
|
||||
if (Array.isArray(payload.choices)) {
|
||||
const choice = payload.choices[0];
|
||||
const message = isRecord((choice as any)?.message) ? (choice as any).message : {};
|
||||
const toolCalls = Array.isArray((message as any).tool_calls) ? (message as any).tool_calls : [];
|
||||
return toolCalls
|
||||
.map((item: unknown, index: number) => {
|
||||
if (!isRecord(item)) return null;
|
||||
const fn = isRecord(item.function) ? item.function : {};
|
||||
const id = typeof item.id === 'string' && item.id.trim().length > 0
|
||||
? item.id
|
||||
: `call_${index}`;
|
||||
const name = typeof fn.name === 'string' ? fn.name : '';
|
||||
const args = typeof fn.arguments === 'string' ? fn.arguments : '';
|
||||
return {
|
||||
id: ensureFunctionCallId(id),
|
||||
name,
|
||||
arguments: args,
|
||||
};
|
||||
})
|
||||
.filter((item): item is ResponsesToolCall => !!item);
|
||||
}
|
||||
|
||||
if (payload.type === 'message' && Array.isArray(payload.content)) {
|
||||
return payload.content
|
||||
.map((item: unknown, index: number) => {
|
||||
if (!isRecord(item) || item.type !== 'tool_use') return null;
|
||||
const id = typeof item.id === 'string' && item.id.trim().length > 0
|
||||
? item.id
|
||||
: `call_${index}`;
|
||||
const name = typeof item.name === 'string' ? item.name : '';
|
||||
const args = stringifyToolInput(item.input);
|
||||
return {
|
||||
id: ensureFunctionCallId(id),
|
||||
name,
|
||||
arguments: args,
|
||||
};
|
||||
})
|
||||
.filter((item): item is ResponsesToolCall => !!item);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
export function normalizeResponsesFinalPayload(
|
||||
payload: unknown,
|
||||
modelName: string,
|
||||
fallbackText = '',
|
||||
): NormalizedFinalResponse {
|
||||
return normalizeUpstreamFinalResponse(payload, modelName, fallbackText);
|
||||
}
|
||||
|
||||
export function serializeResponsesFinalPayload(input: {
|
||||
upstreamPayload: unknown;
|
||||
normalized: NormalizedFinalResponse;
|
||||
usage: ResponsesUsageSummary;
|
||||
}): Record<string, unknown> {
|
||||
const { upstreamPayload, normalized, usage } = input;
|
||||
if (isRecord(upstreamPayload) && upstreamPayload.object === 'response') {
|
||||
return upstreamPayload;
|
||||
}
|
||||
|
||||
const normalizedId = typeof normalized.id === 'string' && normalized.id.trim()
|
||||
? normalized.id.trim()
|
||||
: `resp_${Date.now()}`;
|
||||
const responseId = ensureResponseId(normalizedId);
|
||||
const messageId = ensureMessageId(normalizedId);
|
||||
const toolCalls = extractToolCallsFromUpstream(upstreamPayload);
|
||||
|
||||
const output: Array<Record<string, unknown>> = [];
|
||||
if (normalized.reasoningContent) {
|
||||
output.push({
|
||||
id: ensureMessageId(`${normalizedId}_reasoning`),
|
||||
type: 'reasoning',
|
||||
status: 'completed',
|
||||
summary: [{
|
||||
type: 'summary_text',
|
||||
text: normalized.reasoningContent,
|
||||
}],
|
||||
});
|
||||
}
|
||||
if (normalized.content || toolCalls.length === 0) {
|
||||
output.push({
|
||||
id: messageId,
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
status: 'completed',
|
||||
content: [{
|
||||
type: 'output_text',
|
||||
text: normalized.content || '',
|
||||
}],
|
||||
});
|
||||
}
|
||||
|
||||
for (const toolCall of toolCalls) {
|
||||
output.push({
|
||||
id: toolCall.id,
|
||||
type: 'function_call',
|
||||
status: 'completed',
|
||||
call_id: toolCall.id,
|
||||
name: toolCall.name,
|
||||
arguments: toolCall.arguments,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
id: responseId,
|
||||
object: 'response',
|
||||
created: normalized.created,
|
||||
status: 'completed',
|
||||
model: normalized.model,
|
||||
output,
|
||||
output_text: normalized.content || '',
|
||||
usage: {
|
||||
input_tokens: usage.promptTokens,
|
||||
output_tokens: usage.completionTokens,
|
||||
total_tokens: usage.totalTokens,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const openAiResponsesOutbound = {
|
||||
normalizeFinal: normalizeResponsesFinalPayload,
|
||||
serializeFinal: serializeResponsesFinalPayload,
|
||||
};
|
||||
|
||||
export {
|
||||
serializeResponsesFinalPayload as toResponsesPayload,
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
createStreamTransformContext,
|
||||
normalizeUpstreamStreamEvent,
|
||||
pullSseEventsWithDone,
|
||||
type NormalizedStreamEvent,
|
||||
type StreamTransformContext,
|
||||
} from '../../shared/normalized.js';
|
||||
import {
|
||||
completeResponsesStream,
|
||||
createOpenAiResponsesAggregateState,
|
||||
failResponsesStream,
|
||||
serializeConvertedResponsesEvents,
|
||||
type OpenAiResponsesAggregateState,
|
||||
} from './aggregator.js';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object';
|
||||
}
|
||||
|
||||
export type OpenAiResponsesStreamEvent = NormalizedStreamEvent & {
|
||||
responsesEventType?: string;
|
||||
responsesPayload?: Record<string, unknown>;
|
||||
usagePayload?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export const openAiResponsesStream = {
|
||||
eventNames: [
|
||||
'response.created',
|
||||
'response.in_progress',
|
||||
'response.output_item.added',
|
||||
'response.output_item.done',
|
||||
'response.content_part.added',
|
||||
'response.content_part.done',
|
||||
'response.output_text.delta',
|
||||
'response.output_text.done',
|
||||
'response.function_call_arguments.delta',
|
||||
'response.function_call_arguments.done',
|
||||
'response.custom_tool_call_input.delta',
|
||||
'response.custom_tool_call_input.done',
|
||||
'response.reasoning_summary_part.added',
|
||||
'response.reasoning_summary_part.done',
|
||||
'response.reasoning_summary_text.delta',
|
||||
'response.reasoning_summary_text.done',
|
||||
'response.image_generation_call.generating',
|
||||
'response.image_generation_call.in_progress',
|
||||
'response.image_generation_call.partial_image',
|
||||
'response.image_generation_call.completed',
|
||||
'response.completed',
|
||||
'response.failed',
|
||||
] as const,
|
||||
createContext(modelName: string): StreamTransformContext {
|
||||
return createStreamTransformContext(modelName);
|
||||
},
|
||||
normalizeEvent(payload: unknown, context: StreamTransformContext, modelName: string): OpenAiResponsesStreamEvent {
|
||||
const normalized = normalizeUpstreamStreamEvent(payload, context, modelName) as OpenAiResponsesStreamEvent;
|
||||
if (isRecord(payload) && typeof payload.type === 'string' && payload.type.startsWith('response.')) {
|
||||
normalized.responsesEventType = payload.type;
|
||||
normalized.responsesPayload = payload;
|
||||
}
|
||||
if (isRecord(payload) && isRecord(payload.usage)) {
|
||||
normalized.usagePayload = payload.usage;
|
||||
}
|
||||
return normalized;
|
||||
},
|
||||
pullSseEvents(buffer: string) {
|
||||
return pullSseEventsWithDone(buffer);
|
||||
},
|
||||
};
|
||||
|
||||
export type ResponsesStreamState = OpenAiResponsesAggregateState;
|
||||
|
||||
export function createResponsesStreamState(modelName: string): ResponsesStreamState {
|
||||
return createOpenAiResponsesAggregateState(modelName);
|
||||
}
|
||||
|
||||
export {
|
||||
completeResponsesStream,
|
||||
failResponsesStream,
|
||||
serializeConvertedResponsesEvents,
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import { createEmptyNormalizedUsage, mergeNormalizedUsage } from '../../shared/normalized.js';
|
||||
|
||||
export const openAiResponsesUsage = {
|
||||
empty: createEmptyNormalizedUsage,
|
||||
merge: mergeNormalizedUsage,
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,110 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
normalizeStopReason,
|
||||
normalizeUpstreamFinalResponse,
|
||||
pullSseEventsWithDone,
|
||||
serializeFinalResponse,
|
||||
type NormalizedFinalResponse,
|
||||
} from './normalized.js';
|
||||
|
||||
describe('shared normalized helpers', () => {
|
||||
it('does not depend on route-level chatFormats helpers', () => {
|
||||
const source = readFileSync(new URL('./normalized.ts', import.meta.url), 'utf8');
|
||||
expect(source).not.toContain('routes/proxy/chatFormats');
|
||||
expect(source).not.toContain('chatFormats.js');
|
||||
});
|
||||
|
||||
it('parses SSE events and keeps the trailing partial block', () => {
|
||||
const pulled = pullSseEventsWithDone([
|
||||
'event: message',
|
||||
'data: {"id":"1"}',
|
||||
'',
|
||||
'data: [DONE]',
|
||||
'',
|
||||
'data: {"partial":true}',
|
||||
].join('\n'));
|
||||
|
||||
expect(pulled.events).toEqual([
|
||||
{ event: 'message', data: '{"id":"1"}' },
|
||||
{ event: '', data: '[DONE]' },
|
||||
]);
|
||||
expect(pulled.rest).toBe('data: {"partial":true}');
|
||||
});
|
||||
|
||||
it('normalizes responses payloads with tool calls', () => {
|
||||
expect(normalizeUpstreamFinalResponse({
|
||||
object: 'response',
|
||||
id: 'resp_1',
|
||||
model: 'gpt-test',
|
||||
created: 123,
|
||||
output: [
|
||||
{
|
||||
type: 'message',
|
||||
content: [{ type: 'output_text', text: 'hello' }],
|
||||
},
|
||||
{
|
||||
type: 'function_call',
|
||||
call_id: 'call_1',
|
||||
name: 'lookup',
|
||||
arguments: '{"q":"x"}',
|
||||
},
|
||||
],
|
||||
status: 'completed',
|
||||
}, 'fallback-model')).toEqual({
|
||||
id: 'resp_1',
|
||||
model: 'gpt-test',
|
||||
created: 123,
|
||||
content: 'hello',
|
||||
reasoningContent: '',
|
||||
finishReason: 'tool_calls',
|
||||
toolCalls: [{
|
||||
id: 'call_1',
|
||||
name: 'lookup',
|
||||
arguments: '{"q":"x"}',
|
||||
}],
|
||||
});
|
||||
});
|
||||
|
||||
it('serializes normalized final responses for claude', () => {
|
||||
const normalized: NormalizedFinalResponse = {
|
||||
id: 'chatcmpl-1',
|
||||
model: 'claude-test',
|
||||
created: 456,
|
||||
content: 'done',
|
||||
reasoningContent: 'thinking',
|
||||
finishReason: 'tool_calls',
|
||||
toolCalls: [{ id: 'tool_1', name: 'lookup', arguments: '{"q":"x"}' }],
|
||||
};
|
||||
|
||||
expect(serializeFinalResponse('claude', normalized, {
|
||||
promptTokens: 10,
|
||||
completionTokens: 5,
|
||||
totalTokens: 15,
|
||||
})).toEqual({
|
||||
id: 'msg_chatcmpl-1',
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
model: 'claude-test',
|
||||
content: [
|
||||
{ type: 'thinking', thinking: 'thinking' },
|
||||
{ type: 'text', text: 'done' },
|
||||
{ type: 'tool_use', id: 'tool_1', name: 'lookup', input: { q: 'x' } },
|
||||
],
|
||||
stop_reason: 'tool_use',
|
||||
stop_sequence: null,
|
||||
usage: {
|
||||
input_tokens: 10,
|
||||
output_tokens: 5,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes known stop reasons', () => {
|
||||
expect(normalizeStopReason('max_output_tokens')).toBe('length');
|
||||
expect(normalizeStopReason('tool_use')).toBe('tool_calls');
|
||||
expect(normalizeStopReason('completed')).toBe('stop');
|
||||
expect(normalizeStopReason('mystery')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
export {
|
||||
buildSyntheticOpenAiChunks,
|
||||
createClaudeDownstreamContext,
|
||||
createStreamTransformContext,
|
||||
normalizeStopReason,
|
||||
normalizeUpstreamFinalResponse,
|
||||
normalizeUpstreamStreamEvent,
|
||||
parseDownstreamChatRequest,
|
||||
pullSseEventsWithDone,
|
||||
serializeFinalResponse,
|
||||
serializeNormalizedStreamEvent,
|
||||
serializeStreamDone,
|
||||
toClaudeStopReason,
|
||||
type ClaudeDownstreamContext,
|
||||
type DownstreamFormat,
|
||||
type NormalizedFinalResponse,
|
||||
type NormalizedStreamEvent,
|
||||
type ParsedDownstreamChatRequest,
|
||||
type ParsedSseEvent,
|
||||
type StreamTransformContext,
|
||||
} from './chatFormatsCore.js';
|
||||
|
||||
export type NormalizedContentBlockType =
|
||||
| 'text'
|
||||
| 'image_url'
|
||||
| 'image_inline'
|
||||
| 'input_audio'
|
||||
| 'output_audio'
|
||||
| 'tool_call'
|
||||
| 'tool_result'
|
||||
| 'function_response'
|
||||
| 'reasoning'
|
||||
| 'redacted_reasoning';
|
||||
|
||||
export type NormalizedContentBlock = {
|
||||
type: NormalizedContentBlockType;
|
||||
role?: string | null;
|
||||
text?: string | null;
|
||||
mimeType?: string | null;
|
||||
url?: string | null;
|
||||
data?: string | null;
|
||||
toolName?: string | null;
|
||||
toolCallId?: string | null;
|
||||
argumentsText?: string | null;
|
||||
result?: unknown;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export type NormalizedUsage = {
|
||||
promptTokens?: number | null;
|
||||
completionTokens?: number | null;
|
||||
totalTokens?: number | null;
|
||||
cachedTokens?: number | null;
|
||||
reasoningTokens?: number | null;
|
||||
audioInputTokens?: number | null;
|
||||
audioOutputTokens?: number | null;
|
||||
acceptedPredictionTokens?: number | null;
|
||||
rejectedPredictionTokens?: number | null;
|
||||
};
|
||||
|
||||
export type ParsedDownstreamChatRequestResult = {
|
||||
value?: import('./chatFormatsCore.js').ParsedDownstreamChatRequest;
|
||||
error?: { statusCode: number; payload: unknown };
|
||||
};
|
||||
|
||||
export type TransformerMetadata = {
|
||||
include?: unknown;
|
||||
maxToolCalls?: number | null;
|
||||
promptCacheRetention?: unknown;
|
||||
includeObfuscation?: boolean | null;
|
||||
citations?: unknown;
|
||||
annotations?: unknown;
|
||||
geminiSafetySettings?: unknown;
|
||||
geminiImageConfig?: unknown;
|
||||
thoughtSignature?: string | null;
|
||||
passthrough?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type NormalizedRequest = {
|
||||
protocol: import('./chatFormatsCore.js').DownstreamFormat | 'responses' | 'gemini';
|
||||
model: string;
|
||||
stream: boolean;
|
||||
rawBody: unknown;
|
||||
parsed: import('./chatFormatsCore.js').ParsedDownstreamChatRequest | null;
|
||||
contentBlocks?: NormalizedContentBlock[];
|
||||
metadata?: TransformerMetadata;
|
||||
};
|
||||
|
||||
export type NormalizedResponseEnvelope = {
|
||||
protocol: import('./chatFormatsCore.js').DownstreamFormat | 'responses' | 'gemini';
|
||||
model: string;
|
||||
final: import('./chatFormatsCore.js').NormalizedFinalResponse;
|
||||
usage?: unknown;
|
||||
contentBlocks?: NormalizedContentBlock[];
|
||||
metadata?: TransformerMetadata;
|
||||
};
|
||||
|
||||
export type NormalizedStreamEnvelope = {
|
||||
protocol: import('./chatFormatsCore.js').DownstreamFormat | 'responses' | 'gemini';
|
||||
model: string;
|
||||
event: import('./chatFormatsCore.js').NormalizedStreamEvent;
|
||||
metadata?: TransformerMetadata;
|
||||
};
|
||||
|
||||
export function createEmptyNormalizedUsage(): NormalizedUsage {
|
||||
return {
|
||||
promptTokens: 0,
|
||||
completionTokens: 0,
|
||||
totalTokens: 0,
|
||||
cachedTokens: 0,
|
||||
reasoningTokens: 0,
|
||||
audioInputTokens: 0,
|
||||
audioOutputTokens: 0,
|
||||
acceptedPredictionTokens: 0,
|
||||
rejectedPredictionTokens: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function mergeNormalizedUsage(
|
||||
base: NormalizedUsage | undefined,
|
||||
next: NormalizedUsage | undefined,
|
||||
): NormalizedUsage {
|
||||
const merged = { ...createEmptyNormalizedUsage(), ...(base || {}) };
|
||||
const incoming = next || {};
|
||||
return {
|
||||
promptTokens: (merged.promptTokens || 0) + (incoming.promptTokens || 0),
|
||||
completionTokens: (merged.completionTokens || 0) + (incoming.completionTokens || 0),
|
||||
totalTokens: (merged.totalTokens || 0) + (incoming.totalTokens || 0),
|
||||
cachedTokens: (merged.cachedTokens || 0) + (incoming.cachedTokens || 0),
|
||||
reasoningTokens: (merged.reasoningTokens || 0) + (incoming.reasoningTokens || 0),
|
||||
audioInputTokens: (merged.audioInputTokens || 0) + (incoming.audioInputTokens || 0),
|
||||
audioOutputTokens: (merged.audioOutputTokens || 0) + (incoming.audioOutputTokens || 0),
|
||||
acceptedPredictionTokens: (merged.acceptedPredictionTokens || 0) + (incoming.acceptedPredictionTokens || 0),
|
||||
rejectedPredictionTokens: (merged.rejectedPredictionTokens || 0) + (incoming.rejectedPredictionTokens || 0),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user