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