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:
cita
2026-03-08 12:08:57 +08:00
parent ed05b37626
commit 23922a5965
115 changed files with 18597 additions and 4411 deletions
@@ -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,
};