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,
|
||||
};
|
||||
Reference in New Issue
Block a user