Add comprehensive tests for Anthropics and Gemini transformers

- Implement tests for anthropicMessagesTransformer including serialization and event handling in stream.test.ts.
- Add usage extraction tests for anthropicMessagesUsage in usage.test.ts.
- Create extensive tests for Gemini generate content aggregation and outbound usage in aggregator-outbound-usage.test.ts.
- Introduce tests for Gemini stream handling and payload parsing in stream.test.ts.
- Enhance OpenAI response serialization tests to ensure proper mapping of usage details and preservation of annotations in outbound.test.ts.
This commit is contained in:
cita
2026-03-08 16:42:41 +08:00
parent 7081be14c7
commit ede8998244
48 changed files with 6357 additions and 1110 deletions
@@ -0,0 +1,157 @@
import { describe, expect, it } from 'vitest';
import { applyAnthropicMessagesAggregateEvent, createAnthropicMessagesAggregateState } from './aggregator.js';
describe('applyAnthropicMessagesAggregateEvent', () => {
it('buffers signature deltas until the thinking block stops', () => {
const state = createAnthropicMessagesAggregateState();
applyAnthropicMessagesAggregateEvent(state, {
anthropic: {
startBlock: {
kind: 'thinking',
index: 0,
},
},
} as any);
applyAnthropicMessagesAggregateEvent(state, { reasoningDelta: 'step-1' });
applyAnthropicMessagesAggregateEvent(state, {
anthropic: {
signatureDelta: 'sig-buffered',
},
} as any);
applyAnthropicMessagesAggregateEvent(state, { reasoningDelta: 'step-2' });
expect(state.pendingSignature).toBe('sig-buffered');
expect((state as any).contentBlocks).toEqual([
{
type: 'thinking',
thinking: 'step-1step-2',
signature: '',
},
]);
applyAnthropicMessagesAggregateEvent(state, {
anthropic: {
stopBlockIndex: 0,
},
} as any);
expect(state.pendingSignature).toBeNull();
expect((state as any).contentBlocks).toEqual([
{
type: 'thinking',
thinking: 'step-1step-2',
signature: 'sig-buffered',
},
]);
});
it('keeps thinking signatures and redacted_thinking blocks in content order', () => {
const state = createAnthropicMessagesAggregateState();
applyAnthropicMessagesAggregateEvent(state, {
anthropic: {
startBlock: {
kind: 'thinking',
index: 0,
},
},
} as any);
applyAnthropicMessagesAggregateEvent(state, { reasoningDelta: 'internal-step' });
applyAnthropicMessagesAggregateEvent(state, {
anthropic: {
signatureDelta: 'sig-1',
},
} as any);
applyAnthropicMessagesAggregateEvent(state, {
anthropic: {
stopBlockIndex: 0,
},
} as any);
applyAnthropicMessagesAggregateEvent(state, {
anthropic: {
startBlock: {
kind: 'redacted_thinking',
index: 1,
},
redactedThinkingData: 'ciphertext',
},
} as any);
applyAnthropicMessagesAggregateEvent(state, {
anthropic: {
stopBlockIndex: 1,
},
} as any);
expect((state as any).contentBlocks).toEqual([
{
type: 'thinking',
thinking: 'internal-step',
signature: 'sig-1',
},
{
type: 'redacted_thinking',
data: 'ciphertext',
},
]);
expect(state.blockLifecycle).toEqual([
{ kind: 'thinking', phase: 'start', index: 0 },
{ kind: 'thinking', phase: 'stop', index: 0 },
{ kind: 'redacted_thinking', phase: 'start', index: 1 },
{ kind: 'redacted_thinking', phase: 'stop', index: 1 },
]);
});
it('accumulates tool_use JSON on one block and preserves block order after thinking', () => {
const state = createAnthropicMessagesAggregateState();
applyAnthropicMessagesAggregateEvent(state, {
anthropic: {
startBlock: {
kind: 'thinking',
index: 0,
},
},
} as any);
applyAnthropicMessagesAggregateEvent(state, { reasoningDelta: 'plan' });
applyAnthropicMessagesAggregateEvent(state, {
anthropic: {
stopBlockIndex: 0,
},
} as any);
applyAnthropicMessagesAggregateEvent(state, {
toolCallDeltas: [{
index: 0,
id: 'call_1',
name: 'lookup_city',
argumentsDelta: '{"city":"par',
}],
});
applyAnthropicMessagesAggregateEvent(state, {
toolCallDeltas: [{
index: 0,
argumentsDelta: 'is"}',
}],
});
expect((state as any).contentBlocks).toEqual([
{
type: 'thinking',
thinking: 'plan',
signature: '',
},
{
type: 'tool_use',
id: 'call_1',
name: 'lookup_city',
inputJson: '{"city":"paris"}',
},
]);
expect(state.toolCalls[0]).toEqual({
id: 'call_1',
name: 'lookup_city',
arguments: '{"city":"paris"}',
});
});
});
@@ -1,5 +1,26 @@
import { type NormalizedStreamEvent } from '../../shared/normalized.js';
export type AnthropicAggregatedContentBlock =
| {
type: 'text';
text: string;
}
| {
type: 'thinking';
thinking: string;
signature: string;
}
| {
type: 'redacted_thinking';
data: string;
}
| {
type: 'tool_use';
id: string;
name: string;
inputJson: string;
};
export type AnthropicStreamExtension = {
signatureDelta?: string;
redactedThinkingData?: string;
@@ -31,6 +52,11 @@ export type AnthropicMessagesAggregateState = {
thinkingBlockIndex: number | null;
redactedBlockIndexes: number[];
blockLifecycle: AnthropicBlockLifecycleEntry[];
contentBlocks: AnthropicAggregatedContentBlock[];
textBlockOrder: number | null;
thinkingBlockOrder: number | null;
redactedBlockOrders: Record<number, number>;
toolBlockOrders: Record<number, number>;
};
export function createAnthropicMessagesAggregateState(): AnthropicMessagesAggregateState {
@@ -45,9 +71,75 @@ export function createAnthropicMessagesAggregateState(): AnthropicMessagesAggreg
thinkingBlockIndex: null,
redactedBlockIndexes: [],
blockLifecycle: [],
contentBlocks: [],
textBlockOrder: null,
thinkingBlockOrder: null,
redactedBlockOrders: {},
toolBlockOrders: {},
};
}
function appendContentBlock(
state: AnthropicMessagesAggregateState,
block: AnthropicAggregatedContentBlock,
): number {
state.contentBlocks.push(block);
return state.contentBlocks.length - 1;
}
function ensureThinkingContentBlock(
state: AnthropicMessagesAggregateState,
): Extract<AnthropicAggregatedContentBlock, { type: 'thinking' }> {
const order = state.thinkingBlockOrder;
if (order !== null && state.contentBlocks[order]?.type === 'thinking') {
return state.contentBlocks[order] as Extract<AnthropicAggregatedContentBlock, { type: 'thinking' }>;
}
const created: Extract<AnthropicAggregatedContentBlock, { type: 'thinking' }> = {
type: 'thinking',
thinking: '',
signature: '',
};
state.thinkingBlockOrder = appendContentBlock(state, created);
return created;
}
function ensureToolContentBlock(
state: AnthropicMessagesAggregateState,
index: number,
): Extract<AnthropicAggregatedContentBlock, { type: 'tool_use' }> {
const existingOrder = state.toolBlockOrders[index];
if (existingOrder !== undefined && state.contentBlocks[existingOrder]?.type === 'tool_use') {
return state.contentBlocks[existingOrder] as Extract<AnthropicAggregatedContentBlock, { type: 'tool_use' }>;
}
const created: Extract<AnthropicAggregatedContentBlock, { type: 'tool_use' }> = {
type: 'tool_use',
id: `toolu_${index}`,
name: '',
inputJson: '',
};
state.toolBlockOrders[index] = appendContentBlock(state, created);
return created;
}
function ensureRedactedContentBlock(
state: AnthropicMessagesAggregateState,
index: number,
): Extract<AnthropicAggregatedContentBlock, { type: 'redacted_thinking' }> {
const existingOrder = state.redactedBlockOrders[index];
if (existingOrder !== undefined && state.contentBlocks[existingOrder]?.type === 'redacted_thinking') {
return state.contentBlocks[existingOrder] as Extract<AnthropicAggregatedContentBlock, { type: 'redacted_thinking' }>;
}
const created: Extract<AnthropicAggregatedContentBlock, { type: 'redacted_thinking' }> = {
type: 'redacted_thinking',
data: '',
};
state.redactedBlockOrders[index] = appendContentBlock(state, created);
return created;
}
function ensureToolState(
state: AnthropicMessagesAggregateState,
index: number,
@@ -62,8 +154,24 @@ function ensureToolState(
return state.toolCalls[index];
}
function applySignatureToThinkingBlock(
state: AnthropicMessagesAggregateState,
signature: string,
): void {
const thinkingBlock = ensureThinkingContentBlock(state);
thinkingBlock.signature += signature;
}
function bufferPendingSignature(
state: AnthropicMessagesAggregateState,
signature: string,
): void {
state.pendingSignature = `${state.pendingSignature || ''}${signature}`;
}
function flushPendingSignature(state: AnthropicMessagesAggregateState): void {
if (!state.pendingSignature) return;
applySignatureToThinkingBlock(state, state.pendingSignature);
state.signatures.push(state.pendingSignature);
state.pendingSignature = null;
}
@@ -76,18 +184,21 @@ function recordBlockStart(
state.blockLifecycle.push({ kind, phase: 'start', index });
if (kind === 'thinking') {
state.thinkingBlockIndex = typeof index === 'number' ? index : state.thinkingBlockIndex;
ensureThinkingContentBlock(state);
return;
}
if (kind === 'redacted_thinking' && typeof index === 'number') {
state.redactedBlockIndexes.push(index);
ensureRedactedContentBlock(state, 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);
state.thinkingBlockIndex = null;
state.thinkingBlockOrder = null;
return;
}
@@ -95,6 +206,7 @@ function recordBlockStop(state: AnthropicMessagesAggregateState, index: number):
if (redactedIndex >= 0) {
state.blockLifecycle.push({ kind: 'redacted_thinking', phase: 'stop', index });
state.redactedBlockIndexes.splice(redactedIndex, 1);
delete state.redactedBlockOrders[index];
}
}
@@ -105,7 +217,14 @@ function finalizeOpenBlocks(state: AnthropicMessagesAggregateState): void {
}
if (state.pendingSignature) {
if (state.thinkingBlockIndex === null && state.thinkingBlockOrder === null) {
state.blockLifecycle.push({ kind: 'thinking', phase: 'start' });
}
flushPendingSignature(state);
if (state.thinkingBlockIndex === null && state.thinkingBlockOrder !== null) {
state.blockLifecycle.push({ kind: 'thinking', phase: 'stop' });
state.thinkingBlockOrder = null;
}
}
const openRedactedIndexes = [...state.redactedBlockIndexes];
@@ -130,33 +249,50 @@ export function applyAnthropicMessagesAggregateEvent(
if (event.reasoningDelta) {
state.reasoning.push(event.reasoningDelta);
ensureThinkingContentBlock(state).thinking += event.reasoningDelta;
}
if (event.contentDelta) {
state.text.push(event.contentDelta);
if (state.textBlockOrder === null || state.contentBlocks[state.textBlockOrder]?.type !== 'text') {
state.textBlockOrder = appendContentBlock(state, {
type: 'text',
text: '',
});
}
const textBlock = state.contentBlocks[state.textBlockOrder] as Extract<AnthropicAggregatedContentBlock, { type: 'text' }>;
textBlock.text += event.contentDelta;
}
if (Array.isArray(event.toolCallDeltas)) {
for (const toolDelta of event.toolCallDeltas) {
const toolState = ensureToolState(state, toolDelta.index);
const toolBlock = ensureToolContentBlock(state, toolDelta.index);
if (toolDelta.id) toolState.id = toolDelta.id;
if (toolDelta.name) toolState.name = toolDelta.name;
if (toolDelta.id) toolBlock.id = toolDelta.id;
if (toolDelta.name) toolBlock.name = toolDelta.name;
if (toolDelta.argumentsDelta) {
toolState.arguments += toolDelta.argumentsDelta;
toolBlock.inputJson += toolDelta.argumentsDelta;
}
}
}
if (event.anthropic?.signatureDelta) {
if (state.thinkingBlockIndex !== null) {
state.signatures.push(event.anthropic.signatureDelta);
} else {
state.pendingSignature = event.anthropic.signatureDelta;
}
bufferPendingSignature(state, event.anthropic.signatureDelta);
}
if (event.anthropic?.redactedThinkingData) {
state.redactedReasoning.push(event.anthropic.redactedThinkingData);
const redactedIndex = (
typeof event.anthropic.startBlock?.index === 'number'
? event.anthropic.startBlock.index
: state.redactedBlockIndexes[state.redactedBlockIndexes.length - 1]
);
if (typeof redactedIndex === 'number') {
ensureRedactedContentBlock(state, redactedIndex).data = event.anthropic.redactedThinkingData;
}
}
if (
@@ -0,0 +1,17 @@
import { type DownstreamFormat } from '../../shared/normalized.js';
export function shouldRetryNormalizedMessagesBody(input: {
downstreamFormat: DownstreamFormat;
endpointPath: string;
status: number;
upstreamErrorText: string;
}): boolean {
if (input.downstreamFormat !== 'claude') return false;
if (!input.endpointPath.includes('/v1/messages')) return false;
if (input.status < 400 || input.status >= 500) return false;
return /messages\s+is\s+required/i.test(input.upstreamErrorText);
}
export function isMessagesRequiredError(upstreamErrorText: string): boolean {
return /messages\s+is\s+required/i.test(upstreamErrorText);
}
@@ -29,6 +29,71 @@ describe('sanitizeAnthropicMessagesBody', () => {
});
});
it('normalizes string system and message content before rebuilding cache anchors', () => {
const result = sanitizeAnthropicMessagesBody({
model: 'claude-opus-4-6',
system: 'system prompt',
messages: [
{
role: 'user',
content: 'hello from user',
},
],
});
expect(result.system).toEqual([
{
type: 'text',
text: 'system prompt',
cache_control: { type: 'ephemeral' },
},
]);
expect(result.messages).toEqual([
{
role: 'user',
content: [
{
type: 'text',
text: 'hello from user',
cache_control: { type: 'ephemeral' },
},
],
},
]);
});
it('drops output_config.effort unless thinking stays adaptive', () => {
const enabled = sanitizeAnthropicMessagesBody({
model: 'claude-opus-4-6',
thinking: {
type: 'enabled',
budget_tokens: 256,
},
output_config: {
effort: 'high',
format: 'json',
},
});
const adaptive = sanitizeAnthropicMessagesBody({
model: 'claude-opus-4-6',
thinking: {
type: 'adaptive',
},
output_config: {
effort: 'high',
format: 'json',
},
});
expect(enabled.output_config).toEqual({
format: 'json',
});
expect(adaptive.output_config).toEqual({
effort: 'high',
format: 'json',
});
});
it('drops non-ephemeral cache_control markers while preserving valid tool block markers', () => {
const result = sanitizeAnthropicMessagesBody({
model: 'claude-opus-4-6',
@@ -344,6 +409,59 @@ describe('convertOpenAiBodyToAnthropicMessagesBody', () => {
expect(body.thinking).toEqual({ type: 'adaptive' });
expect(body.output_config).toEqual({ effort: 'high' });
});
it('rebuilds cache_control anchors at the anthropic inbound boundary', () => {
const result = anthropicMessagesInbound.parse(
{
model: 'gpt-5',
max_tokens: 512,
tools: [
{
name: 'lookup',
input_schema: { type: 'object' },
},
{
name: 'search',
input_schema: { type: 'object' },
},
],
system: 'system prompt',
messages: [{ role: 'user', content: 'hello' }],
},
);
expect(result.error).toBeUndefined();
expect(result.value?.claudeOriginalBody?.system).toEqual([
{
type: 'text',
text: 'system prompt',
cache_control: { type: 'ephemeral' },
},
]);
expect(result.value?.claudeOriginalBody?.tools).toEqual([
{
name: 'lookup',
input_schema: { type: 'object' },
},
{
name: 'search',
input_schema: { type: 'object' },
cache_control: { type: 'ephemeral' },
},
]);
expect(result.value?.claudeOriginalBody?.messages).toEqual([
{
role: 'user',
content: [
{
type: 'text',
text: 'hello',
cache_control: { type: 'ephemeral' },
},
],
},
]);
});
});
describe('convertOpenAiToolChoiceToAnthropic', () => {
@@ -410,6 +528,17 @@ describe('anthropicMessagesInbound', () => {
expect(result.error?.statusCode).toBe(400);
});
it('rejects string tool_choice tool without a tool name', () => {
const result = anthropicMessagesInbound.parse({
model: 'claude-opus-4-6',
max_tokens: 512,
messages: [{ role: 'user', content: 'hello' }],
tool_choice: 'tool',
});
expect(result.error?.statusCode).toBe(400);
});
});
describe('anthropicMessagesTransformer.stream', () => {
@@ -470,13 +599,20 @@ describe('anthropicMessagesTransformer.stream', () => {
const streamContext = anthropicMessagesTransformer.createStreamContext('claude-opus-4-6');
const downstreamContext = anthropicMessagesTransformer.createDownstreamContext();
const redacted = anthropicMessagesTransformer.transformStreamEvent({
const redactedStart = anthropicMessagesTransformer.transformStreamEvent({
type: 'content_block_start',
index: 0,
content_block: { type: 'redacted_thinking', data: 'ciphertext' },
}, streamContext, 'claude-opus-4-6');
const redactedStop = anthropicMessagesTransformer.transformStreamEvent({
type: 'content_block_stop',
index: 0,
}, streamContext, 'claude-opus-4-6');
const serialized = anthropicMessagesTransformer.serializeStreamEvent(redacted, streamContext, downstreamContext).join('');
const serialized = [
...anthropicMessagesTransformer.serializeStreamEvent(redactedStart, streamContext, downstreamContext),
...anthropicMessagesTransformer.serializeStreamEvent(redactedStop, streamContext, downstreamContext),
].join('');
expect(serialized).toContain('"type":"redacted_thinking"');
expect(serialized).toContain('content_block_start');
expect(serialized).toContain('content_block_stop');
@@ -492,11 +628,14 @@ describe('extractAnthropicUsage', () => {
cache_read_input_tokens: 12,
cache_creation_input_tokens: 8,
},
})).toEqual({
})).toMatchObject({
promptTokens: 120,
completionTokens: 30,
totalTokens: 150,
cachedTokens: 12,
cacheReadInputTokens: 12,
cacheCreationInputTokens: 8,
promptTokensIncludingCache: 120,
reasoningTokens: 0,
audioInputTokens: 0,
audioOutputTokens: 0,
@@ -517,7 +656,7 @@ describe('extractAnthropicUsage', () => {
ephemeral_1h_input_tokens: 3,
},
},
})).toEqual({
})).toMatchObject({
cacheReadInputTokens: 12,
cacheCreationInputTokens: 8,
ephemeral5mInputTokens: 5,
@@ -537,7 +676,7 @@ describe('extractAnthropicUsage', () => {
ephemeral_1h_input_tokens: 3,
},
},
})).toEqual({
})).toMatchObject({
cacheReadInputTokens: 12,
cacheCreationInputTokens: 8,
ephemeral5mInputTokens: 5,
@@ -663,4 +802,32 @@ describe('applyAnthropicMessagesAggregateEvent', () => {
]);
expect(state.finishReason).toBe('stop');
});
it('creates a synthetic thinking lifecycle when a buffered signature is flushed on finish', () => {
const state = createAnthropicMessagesAggregateState();
applyAnthropicMessagesAggregateEvent(state, {
anthropic: {
signatureDelta: 'sig-only',
},
} as any);
applyAnthropicMessagesAggregateEvent(state, {
finishReason: 'stop',
done: true,
});
expect(state.pendingSignature).toBeNull();
expect(state.signatures).toEqual(['sig-only']);
expect(state.contentBlocks).toEqual([
{
type: 'thinking',
thinking: '',
signature: 'sig-only',
},
]);
expect(state.blockLifecycle).toEqual([
{ kind: 'thinking', phase: 'start' },
{ kind: 'thinking', phase: 'stop' },
]);
});
});
@@ -5,6 +5,8 @@ function isRecord(value: unknown): value is Record<string, unknown> {
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']);
const MAX_ANTHROPIC_CACHE_CONTROL_BREAKPOINTS = 4;
const ADAPTIVE_ANTHROPIC_CACHE_CONTROL_BLOCK_WINDOW = 20;
function asTrimmedString(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
@@ -47,17 +49,23 @@ function sanitizeCacheControl(value: unknown): Record<string, unknown> | undefin
function sanitizeAnthropicOutputConfig(
value: unknown,
options: { allowEffort?: boolean } = {},
): { value?: Record<string, unknown>; error?: string } {
if (!isRecord(value)) return {};
const allowEffort = options.allowEffort === true;
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)) {
if (!effort) {
// Ignore blank effort and continue preserving other output_config fields.
} else if (!allowEffort) {
// Ignore effort outside adaptive thinking; upstream only applies it there.
} else if (!VALID_ANTHROPIC_EFFORTS.has(effort)) {
return { error: 'output_config.effort must be one of: low, medium, high, max' };
} else {
next.effort = effort;
}
next.effort = effort;
}
for (const [key, entry] of Object.entries(value)) {
@@ -77,6 +85,9 @@ function sanitizeAnthropicToolChoiceValue(
const normalized = asTrimmedString(value).toLowerCase();
if (!normalized) return {};
if (normalized === 'required') return { value: { type: 'any' } };
if (normalized === 'tool') {
return { error: 'tool_choice.name is required when type is tool' };
}
if (!VALID_ANTHROPIC_TOOL_CHOICE_TYPES.has(normalized)) {
return { error: 'tool_choice.type must be one of: auto, none, any, tool' };
}
@@ -359,6 +370,34 @@ function clearAnthropicCacheControls(body: Record<string, unknown>) {
});
}
function normalizeAnthropicMessageContents(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;
if (typeof message.content !== 'string' || message.content.length === 0) return message;
return {
...message,
content: [
{
type: 'text',
text: message.content,
},
],
};
});
}
function normalizeAnthropicSystemPrompts(body: Record<string, unknown>) {
if (Array.isArray(body.system)) return;
if (typeof body.system !== 'string' || body.system.length === 0) return;
body.system = [
{
type: 'text',
text: body.system,
},
];
}
function ensureStructuralAnthropicCacheControls(body: Record<string, unknown>): number {
let count = 0;
@@ -454,10 +493,12 @@ function sanitizeUnsupportedAnthropicCacheControls(body: Record<string, unknown>
}
function optimizeAnthropicCacheControls(body: Record<string, unknown>) {
normalizeAnthropicMessageContents(body);
normalizeAnthropicSystemPrompts(body);
clearAnthropicCacheControls(body);
const structuralAnchors = ensureStructuralAnthropicCacheControls(body);
const remaining = Math.max(0, 4 - structuralAnchors);
const remaining = Math.max(0, MAX_ANTHROPIC_CACHE_CONTROL_BREAKPOINTS - structuralAnchors);
if (remaining <= 0) {
sanitizeUnsupportedAnthropicCacheControls(body);
return;
@@ -469,7 +510,7 @@ function optimizeAnthropicCacheControls(body: Record<string, unknown>) {
return;
}
const desiredMessageAnchors = refs.length >= 20 ? 2 : 1;
const desiredMessageAnchors = refs.length >= ADAPTIVE_ANTHROPIC_CACHE_CONTROL_BLOCK_WINDOW ? 2 : 1;
const targetAnchors = Math.min(desiredMessageAnchors, remaining);
const usedIndexes = new Set<number>();
@@ -482,7 +523,7 @@ function optimizeAnthropicCacheControls(body: Record<string, unknown>) {
applyAnchor(refs.length - 1);
if (targetAnchors > 1) {
const targetIndex = Math.max(refs.length - 1 - 20, 0);
const targetIndex = Math.max(refs.length - 1 - ADAPTIVE_ANTHROPIC_CACHE_CONTROL_BLOCK_WINDOW, 0);
let chosen = -1;
for (let index = targetIndex; index >= 0; index -= 1) {
if (!usedIndexes.has(index)) {
@@ -546,7 +587,12 @@ export function sanitizeAnthropicMessagesBody(
delete sanitized.thinking;
}
const outputConfigResult = sanitizeAnthropicOutputConfig(sanitized.output_config ?? sanitized.outputConfig);
const allowOutputConfigEffort = isRecord(sanitized.thinking)
&& asTrimmedString(sanitized.thinking.type).toLowerCase() === 'adaptive';
const outputConfigResult = sanitizeAnthropicOutputConfig(
sanitized.output_config ?? sanitized.outputConfig,
{ allowEffort: allowOutputConfigEffort },
);
if (outputConfigResult.value) {
sanitized.output_config = outputConfigResult.value;
} else {
@@ -653,28 +699,29 @@ export function convertOpenAiToolsToAnthropic(rawTools: unknown): unknown {
export function convertOpenAiToolChoiceToAnthropic(rawToolChoice: unknown): unknown {
if (rawToolChoice === undefined) return undefined;
let mappedValue: unknown = rawToolChoice;
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 (normalized === 'required') mappedValue = { type: 'any' };
else if (normalized === 'none') mappedValue = { type: 'none' };
else if (normalized === 'auto') mappedValue = { type: 'auto' };
else if (normalized === 'any') mappedValue = { type: 'any' };
else mappedValue = 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 (isRecord(rawToolChoice)) {
const type = asTrimmedString(rawToolChoice.type).toLowerCase();
if (type === 'function' && isRecord(rawToolChoice.function)) {
const name = asTrimmedString(rawToolChoice.function.name);
mappedValue = name ? { type: 'tool', name } : undefined;
} else {
mappedValue = rawToolChoice;
}
}
if (type === 'tool' || type === 'auto' || type === 'any' || type === 'none') {
return rawToolChoice;
}
return rawToolChoice;
if (mappedValue === undefined) return undefined;
const toolChoiceResult = sanitizeAnthropicToolChoiceValue(mappedValue);
return toolChoiceResult.value;
}
export function convertOpenAiBodyToAnthropicMessagesBody(
@@ -857,7 +904,11 @@ export function validateAnthropicMessagesBody(
};
}
const outputConfigResult = sanitizeAnthropicOutputConfig(body.output_config ?? body.outputConfig);
const allowOutputConfigEffort = !!thinkingResult.value && thinkingResult.value.type === 'adaptive';
const outputConfigResult = sanitizeAnthropicOutputConfig(
body.output_config ?? body.outputConfig,
{ allowEffort: allowOutputConfigEffort },
);
if (outputConfigResult.error) {
return {
error: {
@@ -0,0 +1,63 @@
import { describe, expect, it } from 'vitest';
import { anthropicMessagesInbound } from './inbound.js';
describe('anthropicMessagesInbound', () => {
it('rejects requests without a positive max_tokens value', () => {
const result = anthropicMessagesInbound.parse({
model: 'claude-opus-4-6',
max_tokens: 0,
messages: [{ role: 'user', content: 'hello' }],
});
expect(result.error).toEqual({
statusCode: 400,
payload: {
error: {
message: 'max_tokens is required and must be positive',
type: 'invalid_request_error',
},
},
});
});
it('rejects system prompt blocks that are not text entries', () => {
const result = anthropicMessagesInbound.parse({
model: 'claude-opus-4-6',
max_tokens: 256,
system: [
{ type: 'text', text: 'allowed' },
{ type: 'image', source: { type: 'url', url: 'https://example.com/system.png' } },
],
messages: [{ role: 'user', content: 'hello' }],
});
expect(result.error).toEqual({
statusCode: 400,
payload: {
error: {
message: 'system prompt must be text',
type: 'invalid_request_error',
},
},
});
});
it('normalizes adaptive effort and tool choice at the inbound boundary', () => {
const result = anthropicMessagesInbound.parse({
model: 'claude-opus-4-6',
max_tokens: 512,
thinking: { type: 'adaptive' },
output_config: { effort: 'high', preserve: true },
tool_choice: { type: 'tool', tool: { name: 'lookup' } },
messages: [{ role: 'user', content: 'hello' }],
});
expect(result.error).toBeUndefined();
expect(result.value?.claudeOriginalBody).toMatchObject({
thinking: { type: 'adaptive' },
output_config: { effort: 'high', preserve: true },
tool_choice: { type: 'tool', name: 'lookup' },
});
});
});
@@ -1,20 +1,152 @@
import { parseDownstreamChatRequest, type ParsedDownstreamChatRequest } from '../../shared/normalized.js';
import { validateAnthropicMessagesBody } from './conversion.js';
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
function asTrimmedString(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
}
function toPositiveInteger(value: unknown): number {
const numberValue = typeof value === 'number' ? value : Number(value);
if (!Number.isFinite(numberValue) || numberValue <= 0) return 0;
return Math.trunc(numberValue);
}
function invalidRequest(message: string): { statusCode: number; payload: unknown } {
return {
statusCode: 400,
payload: {
error: {
message,
type: 'invalid_request_error',
},
},
};
}
function validateMaxTokens(body: Record<string, unknown>): { statusCode: number; payload: unknown } | undefined {
const maxTokens = toPositiveInteger(body.max_tokens ?? body.maxTokens);
if (maxTokens > 0) return undefined;
return invalidRequest('max_tokens is required and must be positive');
}
function validateSystemPrompts(body: Record<string, unknown>): { statusCode: number; payload: unknown } | undefined {
const system = body.system;
if (system === undefined || system === null || typeof system === 'string') return undefined;
if (!Array.isArray(system)) return undefined;
for (const entry of system) {
if (typeof entry === 'string') continue;
if (!isRecord(entry)) {
return invalidRequest('system prompt must be text');
}
const type = asTrimmedString(entry.type).toLowerCase();
if (type && type !== 'text') {
return invalidRequest('system prompt must be text');
}
}
return undefined;
}
function validateAdaptiveEffort(body: Record<string, unknown>): { statusCode: number; payload: unknown } | undefined {
const thinking = isRecord(body.thinking) ? body.thinking : null;
const thinkingType = asTrimmedString(thinking?.type).toLowerCase();
const outputConfig = isRecord(body.output_config)
? body.output_config
: (isRecord(body.outputConfig) ? body.outputConfig : null);
if (!outputConfig || !('effort' in outputConfig)) return undefined;
const effort = asTrimmedString(outputConfig.effort).toLowerCase();
if (!effort || thinkingType !== 'adaptive') return undefined;
if (!['low', 'medium', 'high', 'max'].includes(effort)) {
return invalidRequest('output_config.effort must be one of: low, medium, high, max');
}
return undefined;
}
function validateToolChoice(body: Record<string, unknown>): { statusCode: number; payload: unknown } | undefined {
const rawToolChoice = body.tool_choice ?? body.toolChoice;
if (rawToolChoice === undefined) return undefined;
if (typeof rawToolChoice === 'string') {
const type = asTrimmedString(rawToolChoice).toLowerCase();
if (!type) return undefined;
if (type === 'required' || type === 'auto' || type === 'none' || type === 'any') return undefined;
if (type === 'tool') {
return invalidRequest('tool_choice.name is required when type is tool');
}
return invalidRequest('tool_choice.type must be one of: auto, none, any, tool');
}
if (!isRecord(rawToolChoice)) {
return invalidRequest('tool_choice must be an object or string');
}
const type = asTrimmedString(rawToolChoice.type).toLowerCase();
if (!['auto', 'none', 'any', 'tool'].includes(type)) {
return invalidRequest('tool_choice.type must be one of: auto, none, any, tool');
}
if (type !== 'tool') return undefined;
const name = asTrimmedString(
rawToolChoice.name
?? (isRecord(rawToolChoice.tool) ? rawToolChoice.tool.name : undefined),
);
if (!name) {
return invalidRequest('tool_choice.name is required when type is tool');
}
return undefined;
}
function sanitizeAnthropicInboundBody(
body: Record<string, unknown>,
): { sanitizedBody?: Record<string, unknown>; error?: { statusCode: number; payload: unknown } } {
const maxTokensError = validateMaxTokens(body);
if (maxTokensError) return { error: maxTokensError };
const systemError = validateSystemPrompts(body);
if (systemError) return { error: systemError };
const adaptiveEffortError = validateAdaptiveEffort(body);
if (adaptiveEffortError) return { error: adaptiveEffortError };
const toolChoiceError = validateToolChoice(body);
if (toolChoiceError) return { error: toolChoiceError };
const validation = validateAnthropicMessagesBody(body);
if (validation.error) {
return { error: validation.error };
}
return {
sanitizedBody: validation.sanitizedBody ?? body,
};
}
export const anthropicMessagesInbound = {
parse(body: unknown): { value?: ParsedDownstreamChatRequest; error?: { statusCode: number; payload: unknown } } {
const parsed = parseDownstreamChatRequest(body, 'claude');
const rawBody = isRecord(body) ? body : null;
const inboundValidation = rawBody ? sanitizeAnthropicInboundBody(rawBody) : null;
if (inboundValidation?.error) {
return { error: inboundValidation.error };
}
const effectiveBody = inboundValidation?.sanitizedBody ?? body;
const parsed = parseDownstreamChatRequest(effectiveBody, '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;
}
if (inboundValidation?.sanitizedBody) {
parsed.value.claudeOriginalBody = inboundValidation.sanitizedBody;
}
return parsed;
@@ -4,6 +4,7 @@ import { anthropicMessagesOutbound } from './outbound.js';
import { anthropicMessagesStream, consumeAnthropicSseEvent } from './stream.js';
import { anthropicMessagesUsage } from './usage.js';
import { createAnthropicMessagesAggregateState } from './aggregator.js';
import { isMessagesRequiredError, shouldRetryNormalizedMessagesBody } from './compatibility.js';
export {
ANTHROPIC_RAW_SSE_EVENT_NAMES,
consumeAnthropicSseEvent,
@@ -20,6 +21,10 @@ export const anthropicMessagesTransformer = {
outbound: anthropicMessagesOutbound,
stream: anthropicMessagesStream,
usage: anthropicMessagesUsage,
compatibility: {
shouldRetryNormalizedBody: shouldRetryNormalizedMessagesBody,
isMessagesRequiredError,
},
aggregator: {
createState: createAnthropicMessagesAggregateState,
},
@@ -0,0 +1,152 @@
import { describe, expect, it } from 'vitest';
import type { NormalizedFinalResponse } from '../../shared/normalized.js';
import { anthropicMessagesOutbound } from './outbound.js';
import { anthropicMessagesUsage } from './usage.js';
const normalized: NormalizedFinalResponse = {
id: 'chatcmpl-1',
model: 'claude-test',
created: 456,
content: 'done',
reasoningContent: '',
finishReason: 'stop',
toolCalls: [],
};
describe('anthropicMessagesOutbound.serializeFinal', () => {
it('round-trips native anthropic thinking/tool/text block order and signature', () => {
const payload = {
id: 'msg_native-1',
type: 'message',
role: 'assistant',
model: 'claude-test',
content: [
{
type: 'thinking',
thinking: 'plan first',
signature: 'sig-native',
},
{
type: 'tool_use',
id: 'toolu_lookup',
name: 'lookup_weather',
input: { city: 'Paris' },
},
{
type: 'text',
text: 'done',
},
],
stop_reason: 'tool_use',
stop_sequence: null,
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,
},
},
};
const normalized = anthropicMessagesOutbound.normalizeFinal(payload, 'claude-test');
expect(anthropicMessagesOutbound.serializeFinal(
normalized,
anthropicMessagesUsage.fromPayload(payload),
)).toEqual({
id: 'msg_native-1',
type: 'message',
role: 'assistant',
model: 'claude-test',
content: [
{
type: 'thinking',
thinking: 'plan first',
signature: 'sig-native',
},
{
type: 'tool_use',
id: 'toolu_lookup',
name: 'lookup_weather',
input: { city: 'Paris' },
},
{
type: 'text',
text: 'done',
},
],
stop_reason: 'tool_use',
stop_sequence: null,
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,
},
},
});
});
it('preserves anthropic cache read and cache creation semantics from parsed usage', () => {
expect(anthropicMessagesOutbound.serializeFinal(normalized, {
promptTokens: 100,
completionTokens: 30,
totalTokens: 150,
cacheReadTokens: 12,
cacheCreationTokens: 8,
promptTokensIncludeCache: false,
})).toEqual({
id: 'msg_chatcmpl-1',
type: 'message',
role: 'assistant',
model: 'claude-test',
content: [{ type: 'text', text: 'done' }],
stop_reason: 'end_turn',
stop_sequence: null,
usage: {
input_tokens: 100,
output_tokens: 30,
cache_read_input_tokens: 12,
cache_creation_input_tokens: 8,
},
});
});
it('emits cache_creation breakdown when detailed anthropic usage fields are available', () => {
expect(anthropicMessagesOutbound.serializeFinal(normalized, {
promptTokens: 100,
completionTokens: 30,
totalTokens: 150,
cacheReadInputTokens: 12,
cacheCreationInputTokens: 8,
ephemeral5mInputTokens: 5,
ephemeral1hInputTokens: 3,
promptTokensIncludingCache: 120,
})).toEqual({
id: 'msg_chatcmpl-1',
type: 'message',
role: 'assistant',
model: 'claude-test',
content: [{ type: 'text', text: 'done' }],
stop_reason: 'end_turn',
stop_sequence: null,
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,
},
},
});
});
});
@@ -1,10 +1,147 @@
import { normalizeUpstreamFinalResponse, serializeFinalResponse, type NormalizedFinalResponse } from '../../shared/normalized.js';
import { normalizeUpstreamFinalResponse, toClaudeStopReason, type NormalizedFinalResponse } from '../../shared/normalized.js';
import { toAnthropicUsagePayload } from './usage.js';
type AnthropicRecord = Record<string, unknown>;
type AnthropicMessagesNormalizedFinalResponse = NormalizedFinalResponse & {
nativeContent?: AnthropicRecord[];
stopSequence?: string | null;
usagePayload?: AnthropicRecord;
};
function isRecord(value: unknown): value is AnthropicRecord {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
function cloneJsonValue<T>(value: T): T {
if (Array.isArray(value)) {
return value.map((item) => cloneJsonValue(item)) as T;
}
if (isRecord(value)) {
return Object.fromEntries(
Object.entries(value).map(([key, item]) => [key, cloneJsonValue(item)]),
) as T;
}
return value;
}
function parseJsonLike(raw: string): unknown {
const trimmed = raw.trim();
if (!trimmed) return {};
try {
return JSON.parse(trimmed);
} catch {
return { value: raw };
}
}
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 buildAnthropicContent(normalized: NormalizedFinalResponse): Array<Record<string, unknown>> {
const anthropicNormalized = normalized as AnthropicMessagesNormalizedFinalResponse;
if (Array.isArray(anthropicNormalized.nativeContent) && anthropicNormalized.nativeContent.length > 0) {
return anthropicNormalized.nativeContent.map((block) => cloneJsonValue(block));
}
const contentBlocks: Array<Record<string, unknown>> = [];
if (normalized.reasoningContent) {
contentBlocks.push({
type: 'thinking',
thinking: normalized.reasoningContent,
});
}
if (normalized.content) {
contentBlocks.push({
type: 'text',
text: normalized.content,
});
}
const toolCalls = Array.isArray(normalized.toolCalls) ? normalized.toolCalls : [];
for (let index = 0; index < toolCalls.length; index += 1) {
const toolCall = toolCalls[index];
contentBlocks.push({
type: 'tool_use',
id: toolCall.id || `toolu_${index}`,
name: toolCall.name || `tool_${index}`,
input: parseJsonLike(toolCall.arguments || ''),
});
}
return contentBlocks.length > 0
? contentBlocks
: [{ type: 'text', text: '' }];
}
function extractNativeAnthropicContent(payload: unknown): AnthropicRecord[] | undefined {
if (!isRecord(payload) || !Array.isArray(payload.content)) return undefined;
const content = payload.content
.filter((block): block is AnthropicRecord => isRecord(block))
.map((block) => cloneJsonValue(block));
return content.length > 0 ? content : undefined;
}
function extractStopSequence(payload: unknown): string | null {
if (!isRecord(payload) || typeof payload.stop_sequence !== 'string') return null;
return payload.stop_sequence;
}
function extractUsagePayload(payload: unknown): AnthropicRecord | undefined {
if (!isRecord(payload) || !isRecord(payload.usage)) return undefined;
return cloneJsonValue(payload.usage);
}
function mergeUsagePayload(
usage: unknown,
normalized: AnthropicMessagesNormalizedFinalResponse,
): AnthropicRecord {
const merged = toAnthropicUsagePayload(usage ?? normalized.usagePayload);
const usagePayload = normalized.usagePayload;
if (!usagePayload) return merged;
for (const [key, value] of Object.entries(usagePayload)) {
if (key === 'input_tokens' || key === 'output_tokens') continue;
if (key === 'cache_creation' && isRecord(value) && isRecord(merged.cache_creation)) {
merged.cache_creation = {
...cloneJsonValue(value),
...merged.cache_creation,
};
continue;
}
if (merged[key] === undefined) {
merged[key] = cloneJsonValue(value);
}
}
return merged;
}
export const anthropicMessagesOutbound = {
normalizeFinal(payload: unknown, modelName: string, fallbackText = ''): NormalizedFinalResponse {
return normalizeUpstreamFinalResponse(payload, modelName, fallbackText);
normalizeFinal(payload: unknown, modelName: string, fallbackText = ''): AnthropicMessagesNormalizedFinalResponse {
const nativeContent = extractNativeAnthropicContent(payload);
const stopSequence = extractStopSequence(payload);
const usagePayload = extractUsagePayload(payload);
return {
...normalizeUpstreamFinalResponse(payload, modelName, fallbackText),
...(nativeContent ? { nativeContent } : {}),
...(stopSequence !== null ? { stopSequence } : {}),
...(usagePayload ? { usagePayload } : {}),
};
},
serializeFinal(normalized: NormalizedFinalResponse, usage?: unknown) {
return serializeFinalResponse('claude', normalized, usage as any);
const anthropicNormalized = normalized as AnthropicMessagesNormalizedFinalResponse;
return {
id: buildClaudeMessageId(normalized.id),
type: 'message',
role: 'assistant',
model: normalized.model,
content: buildAnthropicContent(normalized),
stop_reason: toClaudeStopReason(normalized.finishReason),
stop_sequence: anthropicNormalized.stopSequence ?? null,
usage: mergeUsagePayload(usage, anthropicNormalized),
};
},
};
@@ -0,0 +1,306 @@
import { describe, expect, it } from 'vitest';
import { anthropicMessagesTransformer } from './index.js';
type SerializedAnthropicEvent = {
event: string;
payload: Record<string, any>;
};
function collectSerializedEvents(events: Array<Record<string, unknown>>): SerializedAnthropicEvent[] {
const streamContext = anthropicMessagesTransformer.createStreamContext('claude-opus-4-6');
const downstreamContext = anthropicMessagesTransformer.createDownstreamContext();
const serialized = events
.flatMap((event) => anthropicMessagesTransformer.serializeStreamEvent(event as any, streamContext, downstreamContext))
.join('');
return anthropicMessagesTransformer.pullSseEvents(serialized).events.map((item) => ({
event: item.event,
payload: JSON.parse(item.data),
}));
}
describe('anthropicMessagesStream.serializeEvent', () => {
it('buffers signature deltas until the active thinking block closes', () => {
const events = collectSerializedEvents([
{
anthropic: {
startBlock: {
kind: 'thinking',
index: 0,
},
},
},
{
reasoningDelta: 'step-1',
},
{
anthropic: {
signatureDelta: 'sig-buffered-until-stop',
},
},
{
reasoningDelta: 'step-2',
},
{
anthropic: {
stopBlockIndex: 0,
},
},
]);
expect(events.map((item) => ({
type: item.payload.type,
deltaType: item.payload.delta?.type,
thinking: item.payload.delta?.thinking,
signature: item.payload.delta?.signature,
}))).toEqual([
{
type: 'message_start',
deltaType: undefined,
thinking: undefined,
signature: undefined,
},
{
type: 'content_block_start',
deltaType: undefined,
thinking: undefined,
signature: undefined,
},
{
type: 'content_block_delta',
deltaType: 'thinking_delta',
thinking: 'step-1',
signature: undefined,
},
{
type: 'content_block_delta',
deltaType: 'thinking_delta',
thinking: 'step-2',
signature: undefined,
},
{
type: 'content_block_delta',
deltaType: 'signature_delta',
thinking: undefined,
signature: 'sig-buffered-until-stop',
},
{
type: 'content_block_stop',
deltaType: undefined,
thinking: undefined,
signature: undefined,
},
]);
});
it('flushes a buffered signature when an explicit thinking block closes before redacted thinking', () => {
const events = collectSerializedEvents([
{
anthropic: {
startBlock: {
kind: 'thinking',
index: 0,
},
},
},
{
anthropic: {
signatureDelta: 'sig-buffered',
},
},
{
anthropic: {
startBlock: {
kind: 'redacted_thinking',
index: 1,
},
redactedThinkingData: 'ciphertext',
},
},
]);
expect(events.map((item) => item.payload.type)).toEqual([
'message_start',
'content_block_start',
'content_block_delta',
'content_block_stop',
'content_block_start',
]);
expect(events[1]?.payload.content_block).toEqual({
type: 'thinking',
thinking: '',
});
expect(events[2]?.payload.delta).toEqual({
type: 'signature_delta',
signature: 'sig-buffered',
});
expect(events[3]?.payload.index).toBe(0);
expect(events[4]?.payload.content_block).toEqual({
type: 'redacted_thinking',
data: 'ciphertext',
});
});
it('closes the previous tool_use block before starting the next one and keeps JSON increments on the same block', () => {
const events = collectSerializedEvents([
{
toolCallDeltas: [{
index: 0,
id: 'call_1',
name: 'lookup_city',
argumentsDelta: '{"city":"par',
}],
},
{
toolCallDeltas: [{
index: 0,
argumentsDelta: 'is"}',
}],
},
{
toolCallDeltas: [{
index: 1,
id: 'call_2',
name: 'lookup_weather',
argumentsDelta: '{"days":2}',
}],
},
]);
expect(events.map((item) => ({
type: item.payload.type,
index: item.payload.index,
blockType: item.payload.content_block?.type,
deltaType: item.payload.delta?.type,
partialJson: item.payload.delta?.partial_json,
}))).toEqual([
{
type: 'message_start',
index: undefined,
blockType: undefined,
deltaType: undefined,
partialJson: undefined,
},
{
type: 'content_block_start',
index: 0,
blockType: 'tool_use',
deltaType: undefined,
partialJson: undefined,
},
{
type: 'content_block_delta',
index: 0,
blockType: undefined,
deltaType: 'input_json_delta',
partialJson: '{"city":"par',
},
{
type: 'content_block_delta',
index: 0,
blockType: undefined,
deltaType: 'input_json_delta',
partialJson: 'is"}',
},
{
type: 'content_block_stop',
index: 0,
blockType: undefined,
deltaType: undefined,
partialJson: undefined,
},
{
type: 'content_block_start',
index: 1,
blockType: 'tool_use',
deltaType: undefined,
partialJson: undefined,
},
{
type: 'content_block_delta',
index: 1,
blockType: undefined,
deltaType: 'input_json_delta',
partialJson: '{"days":2}',
},
]);
});
it('flushes a buffered signature on finish even when no thinking delta ever arrived', () => {
const events = collectSerializedEvents([
{
anthropic: {
signatureDelta: 'sig-finish-only',
},
},
{
finishReason: 'stop',
done: true,
},
]);
expect(events.map((item) => item.payload.type)).toEqual([
'message_start',
'content_block_start',
'content_block_delta',
'content_block_stop',
'message_delta',
'message_stop',
]);
expect(events[1]?.payload.content_block).toEqual({
type: 'thinking',
thinking: '',
});
expect(events[2]?.payload.delta).toEqual({
type: 'signature_delta',
signature: 'sig-finish-only',
});
expect(events[4]?.payload.delta).toEqual({
stop_reason: 'end_turn',
stop_sequence: null,
});
});
it('keeps redacted_thinking open until an explicit stop arrives', () => {
const streamContext = anthropicMessagesTransformer.createStreamContext('claude-opus-4-6');
const downstreamContext = anthropicMessagesTransformer.createDownstreamContext();
const startSerialized = anthropicMessagesTransformer.serializeStreamEvent({
anthropic: {
startBlock: {
kind: 'redacted_thinking',
index: 1,
},
redactedThinkingData: 'ciphertext',
},
} as any, streamContext, downstreamContext).join('');
const stopSerialized = anthropicMessagesTransformer.serializeStreamEvent({
anthropic: {
stopBlockIndex: 1,
},
} as any, streamContext, downstreamContext).join('');
const startEvents = anthropicMessagesTransformer.pullSseEvents(startSerialized).events.map((item) => ({
event: item.event,
payload: JSON.parse(item.data),
}));
const stopEvents = anthropicMessagesTransformer.pullSseEvents(stopSerialized).events.map((item) => ({
event: item.event,
payload: JSON.parse(item.data),
}));
expect(startEvents.map((item) => item.payload.type)).toEqual([
'message_start',
'content_block_start',
]);
expect(startEvents[1]?.payload.content_block).toEqual({
type: 'redacted_thinking',
data: 'ciphertext',
});
expect(stopEvents.map((item) => item.payload.type)).toEqual([
'content_block_stop',
]);
expect(stopEvents[0]?.payload.index).toBe(1);
});
});
@@ -13,9 +13,25 @@ import { type AnthropicExtendedStreamEvent } from './aggregator.js';
type AnthropicStreamPayload = Record<string, unknown>;
type AnthropicBlockKind = 'thinking' | 'text' | 'tool_use' | 'redacted_thinking';
type ExtendedToolBlockState = {
contentIndex: number;
id: string;
name: string;
open: boolean;
sourceIndex: number | null;
};
type ExtendedClaudeDownstreamContext = ClaudeDownstreamContext & {
toolBlocks: Record<number, ExtendedToolBlockState>;
thinkingBlockIndex?: number | null;
thinkingSourceIndex?: number | null;
redactedBlockIndex?: number | null;
redactedSourceIndex?: number | null;
textSourceIndex?: number | null;
pendingSignature?: string | null;
activeToolSlot?: number | null;
};
export const ANTHROPIC_RAW_SSE_EVENT_NAMES = new Set([
@@ -56,7 +72,12 @@ export function serializeAnthropicRawSseEvent(event: string, data: string): stri
function ensureContext(context: ClaudeDownstreamContext): ExtendedClaudeDownstreamContext {
const extended = context as ExtendedClaudeDownstreamContext;
if (extended.thinkingBlockIndex === undefined) extended.thinkingBlockIndex = null;
if (extended.thinkingSourceIndex === undefined) extended.thinkingSourceIndex = null;
if (extended.redactedBlockIndex === undefined) extended.redactedBlockIndex = null;
if (extended.redactedSourceIndex === undefined) extended.redactedSourceIndex = null;
if (extended.textSourceIndex === undefined) extended.textSourceIndex = null;
if (extended.pendingSignature === undefined) extended.pendingSignature = null;
if (extended.activeToolSlot === undefined) extended.activeToolSlot = null;
return extended;
}
@@ -132,6 +153,7 @@ function closeTextBlock(context: ExtendedClaudeDownstreamContext): string[] {
if (context.textBlockIndex === null || context.textBlockIndex === undefined) return [];
const index = context.textBlockIndex;
context.textBlockIndex = null;
context.textSourceIndex = null;
context.contentBlockStarted = false;
return [
serializeSse('content_block_stop', {
@@ -159,6 +181,13 @@ function emitPendingSignature(context: ExtendedClaudeDownstreamContext): string[
];
}
function bufferPendingSignature(
context: ExtendedClaudeDownstreamContext,
signature: string,
): void {
context.pendingSignature = `${context.pendingSignature || ''}${signature}`;
}
function closeThinkingBlock(context: ExtendedClaudeDownstreamContext): string[] {
if (context.thinkingBlockIndex === null || context.thinkingBlockIndex === undefined) return [];
const index = context.thinkingBlockIndex;
@@ -170,9 +199,23 @@ function closeThinkingBlock(context: ExtendedClaudeDownstreamContext): string[]
}),
];
context.thinkingBlockIndex = null;
context.thinkingSourceIndex = null;
return events;
}
function closeRedactedBlock(context: ExtendedClaudeDownstreamContext): string[] {
if (context.redactedBlockIndex === null || context.redactedBlockIndex === undefined) return [];
const index = context.redactedBlockIndex;
context.redactedBlockIndex = null;
context.redactedSourceIndex = null;
return [
serializeSse('content_block_stop', {
type: 'content_block_stop',
index,
}),
];
}
function closeToolBlocks(context: ExtendedClaudeDownstreamContext): string[] {
const openBlocks = Object.values(context.toolBlocks)
.filter((item) => item.open)
@@ -188,6 +231,21 @@ function closeToolBlocks(context: ExtendedClaudeDownstreamContext): string[] {
index: block.contentIndex,
}));
}
context.activeToolSlot = null;
return events;
}
function emitPendingSignatureAsThinkingBlock(
context: ExtendedClaudeDownstreamContext,
): string[] {
if (!context.pendingSignature) return [];
const signature = context.pendingSignature;
const events = [
...ensureThinkingBlockStart(context),
];
context.pendingSignature = signature;
events.push(...emitPendingSignature(context));
events.push(...closeThinkingBlock(context));
return events;
}
@@ -195,15 +253,41 @@ function closeAllBlocks(context: ExtendedClaudeDownstreamContext): string[] {
return [
...closeTextBlock(context),
...closeThinkingBlock(context),
...closeRedactedBlock(context),
...closeToolBlocks(context),
];
}
function ensureTextBlockStart(context: ExtendedClaudeDownstreamContext): string[] {
if (context.textBlockIndex !== null && context.textBlockIndex !== undefined) return [];
function normalizeBlockIndex(value: unknown): number | null {
if (typeof value !== 'number' || !Number.isFinite(value)) return null;
return Math.max(0, Math.trunc(value));
}
function allocateContentIndex(
context: ExtendedClaudeDownstreamContext,
preferredIndex?: number | null,
): number {
const normalizedPreferred = normalizeBlockIndex(preferredIndex);
if (normalizedPreferred !== null) {
if (context.nextContentBlockIndex <= normalizedPreferred) {
context.nextContentBlockIndex = normalizedPreferred + 1;
}
return normalizedPreferred;
}
const index = context.nextContentBlockIndex;
context.nextContentBlockIndex += 1;
return index;
}
function ensureTextBlockStart(
context: ExtendedClaudeDownstreamContext,
preferredIndex?: number | null,
): string[] {
if (context.textBlockIndex !== null && context.textBlockIndex !== undefined) return [];
const index = allocateContentIndex(context, preferredIndex);
context.textBlockIndex = index;
context.textSourceIndex = normalizeBlockIndex(preferredIndex) ?? index;
context.contentBlockStarted = true;
return [
serializeSse('content_block_start', {
@@ -217,11 +301,14 @@ function ensureTextBlockStart(context: ExtendedClaudeDownstreamContext): string[
];
}
function ensureThinkingBlockStart(context: ExtendedClaudeDownstreamContext): string[] {
function ensureThinkingBlockStart(
context: ExtendedClaudeDownstreamContext,
preferredIndex?: number | null,
): string[] {
if (context.thinkingBlockIndex !== null && context.thinkingBlockIndex !== undefined) return [];
const index = context.nextContentBlockIndex;
context.nextContentBlockIndex += 1;
const index = allocateContentIndex(context, preferredIndex);
context.thinkingBlockIndex = index;
context.thinkingSourceIndex = normalizeBlockIndex(preferredIndex) ?? index;
return [
serializeSse('content_block_start', {
type: 'content_block_start',
@@ -234,6 +321,27 @@ function ensureThinkingBlockStart(context: ExtendedClaudeDownstreamContext): str
];
}
function ensureRedactedBlockStart(
context: ExtendedClaudeDownstreamContext,
data: string,
preferredIndex?: number | null,
): string[] {
if (context.redactedBlockIndex !== null && context.redactedBlockIndex !== undefined) return [];
const index = allocateContentIndex(context, preferredIndex);
context.redactedBlockIndex = index;
context.redactedSourceIndex = normalizeBlockIndex(preferredIndex) ?? index;
return [
serializeSse('content_block_start', {
type: 'content_block_start',
index,
content_block: {
type: 'redacted_thinking',
data,
},
}),
];
}
function ensureToolBlockStart(
context: ExtendedClaudeDownstreamContext,
toolDelta: NonNullable<AnthropicExtendedStreamEvent['toolCallDeltas']>[number],
@@ -242,12 +350,12 @@ function ensureToolBlockStart(
let state = context.toolBlocks[toolSlot];
if (!state) {
state = {
contentIndex: context.nextContentBlockIndex,
contentIndex: allocateContentIndex(context),
id: toolDelta.id || `toolu_${toolSlot}`,
name: toolDelta.name || `tool_${toolSlot}`,
open: false,
sourceIndex: toolSlot,
};
context.nextContentBlockIndex += 1;
context.toolBlocks[toolSlot] = state;
} else {
if (toolDelta.id) state.id = toolDelta.id;
@@ -269,9 +377,81 @@ function ensureToolBlockStart(
}));
}
context.activeToolSlot = toolSlot;
return { events, contentIndex: state.contentIndex };
}
function ensureExplicitToolBlockStart(
context: ExtendedClaudeDownstreamContext,
sourceIndex?: number | null,
): string[] {
const toolSlot = normalizeBlockIndex(sourceIndex) ?? 0;
const state = context.toolBlocks[toolSlot];
const toolDelta: NonNullable<AnthropicExtendedStreamEvent['toolCallDeltas']>[number] = {
index: toolSlot,
id: state?.id,
name: state?.name,
};
return ensureToolBlockStart(context, toolDelta).events;
}
function closePreviousToolBlockIfNeeded(
context: ExtendedClaudeDownstreamContext,
toolSlot: number,
): string[] {
const activeToolSlot = context.activeToolSlot;
if (activeToolSlot === null || activeToolSlot === undefined || activeToolSlot === toolSlot) {
return [];
}
return closeToolBlocks(context);
}
function isMatchingBlockIndex(
targetIndex: number,
contentIndex: number | null | undefined,
sourceIndex: number | null | undefined,
): boolean {
if (sourceIndex !== null && sourceIndex !== undefined) return sourceIndex === targetIndex;
if (contentIndex !== null && contentIndex !== undefined) return contentIndex === targetIndex;
return false;
}
function handleExplicitBlockStart(
kind: AnthropicBlockKind,
sourceIndex: number | null,
context: ExtendedClaudeDownstreamContext,
): string[] {
if (kind === 'thinking') {
return [
...closeRedactedBlock(context),
...closeToolBlocks(context),
...closeTextBlock(context),
...ensureThinkingBlockStart(context, sourceIndex),
];
}
if (kind === 'text') {
return [
...closeRedactedBlock(context),
...closeToolBlocks(context),
...closeThinkingBlock(context),
...ensureTextBlockStart(context, sourceIndex),
];
}
if (kind === 'tool_use') {
return [
...closeRedactedBlock(context),
...closeTextBlock(context),
...closeThinkingBlock(context),
...closePreviousToolBlockIfNeeded(context, normalizeBlockIndex(sourceIndex) ?? 0),
...ensureExplicitToolBlockStart(context, sourceIndex),
];
}
return [];
}
function buildDoneEvents(
streamContext: StreamTransformContext,
context: ExtendedClaudeDownstreamContext,
@@ -282,6 +462,7 @@ function buildDoneEvents(
const events = [
...ensureClaudeStartEvents(streamContext, context),
...closeAllBlocks(context),
...emitPendingSignatureAsThinkingBlock(context),
serializeSse('message_delta', {
type: 'message_delta',
delta: {
@@ -384,6 +565,17 @@ function normalizeAnthropicRawEvent(
const blockType = asTrimmedString(contentBlock.type);
const index = typeof payload.index === 'number' ? payload.index : undefined;
if (blockType === 'text') {
return {
anthropic: {
startBlock: {
kind: 'text',
index,
},
},
};
}
if (blockType === 'thinking') {
return {
anthropic: {
@@ -409,6 +601,12 @@ function normalizeAnthropicRawEvent(
if (blockType === 'tool_use') {
return {
anthropic: {
startBlock: {
kind: 'tool_use',
index,
},
},
toolCallDeltas: [{
index: typeof index === 'number' ? index : 0,
id: asTrimmedString(contentBlock.id) || undefined,
@@ -548,6 +746,8 @@ export const anthropicMessagesStream = {
const context = ensureContext(downstreamContext);
const anthropicEvent = event as AnthropicExtendedStreamEvent;
const events: string[] = [];
const startBlock = anthropicEvent.anthropic?.startBlock;
const normalizedStartIndex = normalizeBlockIndex(startBlock?.index);
const needsStart = (
event.role === 'assistant'
@@ -562,43 +762,30 @@ export const anthropicMessagesStream = {
events.push(...ensureClaudeStartEvents(streamContext, context));
}
if (startBlock && startBlock.kind !== 'redacted_thinking') {
events.push(...handleExplicitBlockStart(startBlock.kind, normalizedStartIndex, 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;
}
bufferPendingSignature(context, 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,
}));
events.push(...closeToolBlocks(context));
events.push(...closeTextBlock(context));
events.push(...closeThinkingBlock(context));
events.push(...ensureRedactedBlockStart(
context,
anthropicEvent.anthropic.redactedThinkingData,
normalizedStartIndex,
));
}
if (event.reasoningDelta) {
events.push(...closeRedactedBlock(context));
events.push(...closeToolBlocks(context));
events.push(...closeTextBlock(context));
events.push(...ensureThinkingBlockStart(context));
events.push(...ensureThinkingBlockStart(context, normalizedStartIndex));
events.push(serializeSse('content_block_delta', {
type: 'content_block_delta',
index: context.thinkingBlockIndex ?? 0,
@@ -610,9 +797,14 @@ export const anthropicMessagesStream = {
}
if (Array.isArray(event.toolCallDeltas) && event.toolCallDeltas.length > 0) {
events.push(...closeRedactedBlock(context));
events.push(...closeTextBlock(context));
events.push(...closeThinkingBlock(context));
for (const toolDelta of event.toolCallDeltas) {
events.push(...closePreviousToolBlockIfNeeded(
context,
Number.isFinite(toolDelta.index) ? Math.max(0, Math.trunc(toolDelta.index)) : 0,
));
const toolBlock = ensureToolBlockStart(context, toolDelta);
events.push(...toolBlock.events);
if (toolDelta.argumentsDelta) {
@@ -629,9 +821,10 @@ export const anthropicMessagesStream = {
}
if (event.contentDelta) {
events.push(...closeRedactedBlock(context));
events.push(...closeToolBlocks(context));
events.push(...closeThinkingBlock(context));
events.push(...ensureTextBlockStart(context));
events.push(...ensureTextBlockStart(context, normalizedStartIndex));
events.push(serializeSse('content_block_delta', {
type: 'content_block_delta',
index: context.textBlockIndex ?? 0,
@@ -647,15 +840,27 @@ export const anthropicMessagesStream = {
&& anthropicEvent.anthropic.stopBlockIndex !== null
) {
const targetIndex = anthropicEvent.anthropic.stopBlockIndex;
if (context.thinkingBlockIndex === targetIndex) {
if (isMatchingBlockIndex(targetIndex, context.thinkingBlockIndex, context.thinkingSourceIndex)) {
events.push(...closeThinkingBlock(context));
}
if (context.textBlockIndex === targetIndex) {
if (isMatchingBlockIndex(targetIndex, context.textBlockIndex, context.textSourceIndex)) {
events.push(...closeTextBlock(context));
}
const matchingToolBlock = Object.values(context.toolBlocks).find((item) => item.open && item.contentIndex === targetIndex);
if (isMatchingBlockIndex(targetIndex, context.redactedBlockIndex, context.redactedSourceIndex)) {
events.push(...closeRedactedBlock(context));
}
const matchingToolBlock = Object.values(context.toolBlocks).find((item) => (
item.open
&& isMatchingBlockIndex(targetIndex, item.contentIndex, item.sourceIndex)
));
if (matchingToolBlock) {
matchingToolBlock.open = false;
if (typeof context.activeToolSlot === 'number') {
const activeState = context.toolBlocks[context.activeToolSlot];
if (activeState?.contentIndex === matchingToolBlock.contentIndex) {
context.activeToolSlot = null;
}
}
events.push(serializeSse('content_block_stop', {
type: 'content_block_stop',
index: matchingToolBlock.contentIndex,
@@ -0,0 +1,86 @@
import { describe, expect, it } from 'vitest';
import {
anthropicMessagesUsage,
extractAnthropicUsage,
extractAnthropicUsageMetadata,
} from './usage.js';
describe('extractAnthropicUsage', () => {
it('treats cached_tokens as already included in input_tokens for moonshot-style payloads', () => {
expect(extractAnthropicUsage({
usage: {
input_tokens: 100,
output_tokens: 30,
cached_tokens: 12,
},
})).toEqual({
promptTokens: 100,
completionTokens: 30,
totalTokens: 130,
cachedTokens: 12,
reasoningTokens: 0,
audioInputTokens: 0,
audioOutputTokens: 0,
acceptedPredictionTokens: 0,
rejectedPredictionTokens: 0,
cacheReadInputTokens: 12,
cacheCreationInputTokens: 0,
ephemeral5mInputTokens: 0,
ephemeral1hInputTokens: 0,
promptTokensIncludingCache: 100,
serviceTier: null,
promptTokensIncludeCache: true,
});
});
it('reports prompt tokens including cache without double-counting cached_tokens aliases', () => {
expect(extractAnthropicUsageMetadata({
usage: {
input_tokens: 100,
output_tokens: 30,
cached_tokens: 12,
},
})).toEqual({
cacheReadInputTokens: 12,
cacheCreationInputTokens: 0,
ephemeral5mInputTokens: 0,
ephemeral1hInputTokens: 0,
promptTokensIncludingCache: 100,
serviceTier: null,
cachedTokens: 12,
promptTokensIncludeCache: true,
});
});
});
describe('anthropicMessagesUsage.toPayload', () => {
it('reconstructs anthropic cache breakdown for downstream responses', () => {
const toPayload = (
anthropicMessagesUsage as typeof anthropicMessagesUsage & {
toPayload?: (usage: unknown) => unknown;
}
).toPayload;
expect(typeof toPayload).toBe('function');
expect(toPayload?.({
promptTokens: 100,
completionTokens: 30,
totalTokens: 150,
cacheReadInputTokens: 12,
cacheCreationInputTokens: 8,
ephemeral5mInputTokens: 5,
ephemeral1hInputTokens: 3,
promptTokensIncludingCache: 120,
})).toEqual({
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,
},
});
});
});
@@ -6,6 +6,10 @@ function isRecord(value: unknown): value is AnthropicRecord {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
function hasOwn(record: AnthropicRecord, key: string): boolean {
return Object.prototype.hasOwnProperty.call(record, key);
}
function toPositiveInt(value: unknown): number {
const numberValue = typeof value === 'number' ? value : Number(value);
if (!Number.isFinite(numberValue)) return 0;
@@ -18,90 +22,330 @@ function resolveAnthropicUsageRecord(payload: unknown): AnthropicRecord | null {
return null;
}
function readEphemeralBreakdown(record: AnthropicRecord): {
ephemeral5mInputTokens: number;
ephemeral1hInputTokens: number;
} {
const snake = isRecord(record.cache_creation) ? record.cache_creation : null;
const camel = isRecord(record.cacheCreation) ? record.cacheCreation : null;
return {
ephemeral5mInputTokens: Math.max(
toPositiveInt(snake?.ephemeral_5m_input_tokens),
toPositiveInt(camel?.ephemeral5mInputTokens),
toPositiveInt(record.ephemeral_5m_input_tokens),
toPositiveInt(record.ephemeral5mInputTokens),
),
ephemeral1hInputTokens: Math.max(
toPositiveInt(snake?.ephemeral_1h_input_tokens),
toPositiveInt(camel?.ephemeral1hInputTokens),
toPositiveInt(record.ephemeral_1h_input_tokens),
toPositiveInt(record.ephemeral1hInputTokens),
),
};
}
function readServiceTier(record: AnthropicRecord): string | null {
if (typeof record.service_tier === 'string' && record.service_tier.trim().length > 0) {
return record.service_tier.trim();
}
if (typeof record.serviceTier === 'string' && record.serviceTier.trim().length > 0) {
return record.serviceTier.trim();
}
return null;
}
function readInputTokens(record: AnthropicRecord): number {
return Math.max(
toPositiveInt(record.input_tokens),
toPositiveInt(record.inputTokens),
toPositiveInt(record.prompt_tokens),
toPositiveInt(record.promptTokens),
);
}
function readCompletionTokens(record: AnthropicRecord): number {
return Math.max(
toPositiveInt(record.output_tokens),
toPositiveInt(record.outputTokens),
toPositiveInt(record.completion_tokens),
toPositiveInt(record.completionTokens),
);
}
function readTotalTokens(record: AnthropicRecord): number {
return Math.max(
toPositiveInt(record.total_tokens),
toPositiveInt(record.totalTokens),
);
}
function readCacheReadTokens(record: AnthropicRecord): {
cacheReadInputTokens: number;
cachedTokens: number;
hasCachedTokensAlias: boolean;
hasExplicitCacheReadField: boolean;
} {
const cachedTokens = Math.max(
toPositiveInt(record.cached_tokens),
toPositiveInt(record.cachedTokens),
);
const cacheReadInputTokens = Math.max(
toPositiveInt(record.cache_read_input_tokens),
toPositiveInt(record.cacheReadInputTokens),
toPositiveInt(record.cacheReadTokens),
cachedTokens,
);
return {
cacheReadInputTokens,
cachedTokens: Math.max(cachedTokens, cacheReadInputTokens),
hasCachedTokensAlias: hasOwn(record, 'cached_tokens') || hasOwn(record, 'cachedTokens'),
hasExplicitCacheReadField: (
hasOwn(record, 'cache_read_input_tokens')
|| hasOwn(record, 'cacheReadInputTokens')
|| hasOwn(record, 'cacheReadTokens')
),
};
}
function readCacheCreationInputTokens(
record: AnthropicRecord,
breakdown: { ephemeral5mInputTokens: number; ephemeral1hInputTokens: number },
): number {
return Math.max(
toPositiveInt(record.cache_creation_input_tokens),
toPositiveInt(record.cacheCreationInputTokens),
toPositiveInt(record.cacheCreationTokens),
breakdown.ephemeral5mInputTokens + breakdown.ephemeral1hInputTokens,
);
}
function readPromptTokensIncludeCache(record: AnthropicRecord, cacheRead: {
hasCachedTokensAlias: boolean;
hasExplicitCacheReadField: boolean;
}): boolean | null {
if (typeof record.promptTokensIncludeCache === 'boolean') {
return record.promptTokensIncludeCache;
}
if (typeof record.prompt_tokens_include_cache === 'boolean') {
return record.prompt_tokens_include_cache;
}
if (cacheRead.hasCachedTokensAlias && !cacheRead.hasExplicitCacheReadField) {
return true;
}
if (
cacheRead.hasExplicitCacheReadField
|| hasOwn(record, 'cache_creation_input_tokens')
|| hasOwn(record, 'cacheCreationInputTokens')
|| hasOwn(record, 'cacheCreationTokens')
|| hasOwn(record, 'cache_creation')
|| hasOwn(record, 'cacheCreation')
) {
return false;
}
return null;
}
export type AnthropicUsageMetadata = {
cacheReadInputTokens: number;
cacheCreationInputTokens: number;
ephemeral5mInputTokens: number;
ephemeral1hInputTokens: number;
promptTokensIncludingCache: number;
promptTokensIncludeCache?: boolean | null;
serviceTier?: string | null;
cachedTokens?: number;
};
export function extractAnthropicUsage(payload: unknown): NormalizedUsage {
const usageRecord = resolveAnthropicUsageRecord(payload);
if (!usageRecord) {
return createEmptyNormalizedUsage();
}
export type AnthropicNormalizedUsage = NormalizedUsage & AnthropicUsageMetadata & {
promptTokensIncludeCache?: boolean | null;
};
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),
);
type AnthropicUsageSummary = {
inputTokens: number;
completionTokens: number;
totalTokens: number;
cacheReadInputTokens: number;
cacheCreationInputTokens: number;
ephemeral5mInputTokens: number;
ephemeral1hInputTokens: number;
promptTokensIncludingCache: number;
promptTokensIncludeCache: boolean | null;
serviceTier: string | null;
cachedTokens: number;
};
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) {
function summarizeAnthropicUsage(payload: unknown): AnthropicUsageSummary {
const record = resolveAnthropicUsageRecord(payload);
if (!record) {
return {
inputTokens: 0,
completionTokens: 0,
totalTokens: 0,
cacheReadInputTokens: 0,
cacheCreationInputTokens: 0,
ephemeral5mInputTokens: 0,
ephemeral1hInputTokens: 0,
promptTokensIncludingCache: 0,
promptTokensIncludeCache: null,
serviceTier: null,
cachedTokens: 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,
const breakdown = readEphemeralBreakdown(record);
const cacheRead = readCacheReadTokens(record);
const cacheCreationInputTokens = readCacheCreationInputTokens(record, breakdown);
const promptTokensIncludeCache = readPromptTokensIncludeCache(record, cacheRead);
let inputTokens = readInputTokens(record);
const completionTokens = readCompletionTokens(record);
const explicitPromptTokensIncludingCache = Math.max(
toPositiveInt(record.promptTokensIncludingCache),
toPositiveInt(record.prompt_tokens_including_cache),
);
let promptTokensIncludingCache = explicitPromptTokensIncludingCache;
if (promptTokensIncludingCache <= 0) {
if (promptTokensIncludeCache === true) {
promptTokensIncludingCache = inputTokens;
} else if (promptTokensIncludeCache === false) {
promptTokensIncludingCache = inputTokens + cacheRead.cacheReadInputTokens + cacheCreationInputTokens;
} else {
promptTokensIncludingCache = Math.max(
inputTokens,
inputTokens + cacheRead.cacheReadInputTokens + cacheCreationInputTokens,
);
}
}
if (inputTokens <= 0 && promptTokensIncludingCache > 0) {
inputTokens = Math.max(
promptTokensIncludingCache - cacheRead.cacheReadInputTokens - cacheCreationInputTokens,
0,
);
}
return {
cacheReadInputTokens,
inputTokens,
completionTokens,
totalTokens: Math.max(
readTotalTokens(record),
promptTokensIncludingCache + completionTokens,
),
cacheReadInputTokens: cacheRead.cacheReadInputTokens,
cacheCreationInputTokens,
ephemeral5mInputTokens,
ephemeral1hInputTokens,
promptTokensIncludingCache: inputTokens + cacheReadInputTokens + cacheCreationInputTokens,
ephemeral5mInputTokens: breakdown.ephemeral5mInputTokens,
ephemeral1hInputTokens: breakdown.ephemeral1hInputTokens,
promptTokensIncludingCache,
promptTokensIncludeCache,
serviceTier: readServiceTier(record),
cachedTokens: cacheRead.cachedTokens,
};
}
export function extractAnthropicUsage(payload: unknown): NormalizedUsage {
const summary = summarizeAnthropicUsage(payload);
return {
promptTokens: summary.promptTokensIncludingCache,
completionTokens: summary.completionTokens,
totalTokens: summary.totalTokens,
cachedTokens: summary.cacheReadInputTokens,
reasoningTokens: 0,
audioInputTokens: 0,
audioOutputTokens: 0,
acceptedPredictionTokens: 0,
rejectedPredictionTokens: 0,
cacheReadInputTokens: summary.cacheReadInputTokens,
cacheCreationInputTokens: summary.cacheCreationInputTokens,
ephemeral5mInputTokens: summary.ephemeral5mInputTokens,
ephemeral1hInputTokens: summary.ephemeral1hInputTokens,
promptTokensIncludingCache: summary.promptTokensIncludingCache,
promptTokensIncludeCache: summary.promptTokensIncludeCache,
serviceTier: summary.serviceTier,
} as AnthropicNormalizedUsage;
}
export function extractAnthropicUsageMetadata(payload: unknown): AnthropicUsageMetadata {
const summary = summarizeAnthropicUsage(payload);
return {
cacheReadInputTokens: summary.cacheReadInputTokens,
cacheCreationInputTokens: summary.cacheCreationInputTokens,
ephemeral5mInputTokens: summary.ephemeral5mInputTokens,
ephemeral1hInputTokens: summary.ephemeral1hInputTokens,
promptTokensIncludingCache: summary.promptTokensIncludingCache,
promptTokensIncludeCache: summary.promptTokensIncludeCache,
serviceTier: summary.serviceTier,
cachedTokens: summary.cachedTokens,
};
}
export function toAnthropicUsagePayload(usage: unknown): Record<string, unknown> {
const summary = summarizeAnthropicUsage(usage);
const shouldDeriveInputTokens = (
summary.cacheReadInputTokens > 0
|| summary.cacheCreationInputTokens > 0
|| summary.promptTokensIncludeCache === true
|| summary.promptTokensIncludingCache !== summary.inputTokens
);
const inputTokens = shouldDeriveInputTokens
? Math.max(
summary.promptTokensIncludingCache - summary.cacheReadInputTokens - summary.cacheCreationInputTokens,
0,
)
: summary.inputTokens;
const payload: Record<string, unknown> = {
input_tokens: inputTokens,
output_tokens: summary.completionTokens,
};
if (summary.cacheReadInputTokens > 0) {
payload.cache_read_input_tokens = summary.cacheReadInputTokens;
}
if (summary.cacheCreationInputTokens > 0) {
payload.cache_creation_input_tokens = summary.cacheCreationInputTokens;
}
if (summary.ephemeral5mInputTokens > 0 || summary.ephemeral1hInputTokens > 0) {
payload.cache_creation = {
ephemeral_5m_input_tokens: summary.ephemeral5mInputTokens,
ephemeral_1h_input_tokens: summary.ephemeral1hInputTokens,
};
}
if (summary.serviceTier) {
payload.service_tier = summary.serviceTier;
}
return payload;
}
function mergeAnthropicUsage(
base: NormalizedUsage | undefined,
next: NormalizedUsage | undefined,
): AnthropicNormalizedUsage {
const mergedBase = mergeNormalizedUsage(base, next) as AnthropicNormalizedUsage;
const baseUsage = (base ?? {}) as AnthropicNormalizedUsage;
const nextUsage = (next ?? {}) as AnthropicNormalizedUsage;
const basePromptTokensIncludingCache = toPositiveInt(baseUsage.promptTokensIncludingCache ?? baseUsage.promptTokens);
const nextPromptTokensIncludingCache = toPositiveInt(nextUsage.promptTokensIncludingCache ?? nextUsage.promptTokens);
return {
...mergedBase,
cacheReadInputTokens: toPositiveInt(baseUsage.cacheReadInputTokens) + toPositiveInt(nextUsage.cacheReadInputTokens),
cacheCreationInputTokens: toPositiveInt(baseUsage.cacheCreationInputTokens) + toPositiveInt(nextUsage.cacheCreationInputTokens),
ephemeral5mInputTokens: toPositiveInt(baseUsage.ephemeral5mInputTokens) + toPositiveInt(nextUsage.ephemeral5mInputTokens),
ephemeral1hInputTokens: toPositiveInt(baseUsage.ephemeral1hInputTokens) + toPositiveInt(nextUsage.ephemeral1hInputTokens),
promptTokensIncludingCache: basePromptTokensIncludingCache + nextPromptTokensIncludingCache,
promptTokensIncludeCache: nextUsage.promptTokensIncludeCache ?? baseUsage.promptTokensIncludeCache ?? null,
serviceTier: typeof nextUsage.serviceTier === 'string'
? nextUsage.serviceTier
: (typeof baseUsage.serviceTier === 'string' ? baseUsage.serviceTier : null),
cachedTokens: toPositiveInt(baseUsage.cachedTokens) + toPositiveInt(nextUsage.cachedTokens),
};
}
@@ -109,5 +353,6 @@ export const anthropicMessagesUsage = {
empty: createEmptyNormalizedUsage,
fromPayload: extractAnthropicUsage,
metadataFromPayload: extractAnthropicUsageMetadata,
merge: mergeNormalizedUsage,
toPayload: toAnthropicUsagePayload,
merge: mergeAnthropicUsage,
};