feat: implement proxy input file resolution and storage
- Added `inputFiles.ts` for handling input file resolution, including validation and normalization of file types. - Introduced `proxyFileStore.ts` for managing file storage, retrieval, and deletion with owner-based isolation. - Created tests for proxy file storage functionality in `proxyFileStore.test.ts`. - Developed `proxyInputFileResolver.ts` to resolve input files in OpenAI and response formats. - Added utility functions for handling reasoning signatures in `reasoningTransport.ts` and corresponding tests. - Enhanced shared input file transformers in `inputFile.ts` for better file handling and type inference.
This commit is contained in:
@@ -151,6 +151,50 @@ describe('sanitizeAnthropicMessagesBody', () => {
|
||||
});
|
||||
|
||||
describe('convertOpenAiBodyToAnthropicMessagesBody', () => {
|
||||
it('maps OpenAI file blocks into Anthropic document blocks', () => {
|
||||
const base64Pdf = Buffer.from('%PDF-hello').toString('base64');
|
||||
const body = convertOpenAiBodyToAnthropicMessagesBody(
|
||||
{
|
||||
model: 'gpt-5',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'summarize this file' },
|
||||
{
|
||||
type: 'file',
|
||||
file: {
|
||||
filename: 'paper.pdf',
|
||||
file_data: base64Pdf,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
'claude-opus-4-6',
|
||||
false,
|
||||
);
|
||||
|
||||
expect(body.messages).toEqual([
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'summarize this file' },
|
||||
{
|
||||
type: 'document',
|
||||
source: {
|
||||
type: 'base64',
|
||||
media_type: 'application/pdf',
|
||||
data: base64Pdf,
|
||||
},
|
||||
title: 'paper.pdf',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves image blocks, tool_use blocks, and tool_result chains', () => {
|
||||
const body = convertOpenAiBodyToAnthropicMessagesBody(
|
||||
{
|
||||
@@ -238,6 +282,82 @@ describe('convertOpenAiBodyToAnthropicMessagesBody', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('drops thinking blocks whose signatures belong to another provider', () => {
|
||||
const body = convertOpenAiBodyToAnthropicMessagesBody(
|
||||
{
|
||||
model: 'gpt-5',
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'thinking',
|
||||
thinking: 'internal only',
|
||||
signature: 'metapi:openai-encrypted-reasoning:enc-foreign',
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: 'final answer',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
'claude-opus-4-6',
|
||||
false,
|
||||
);
|
||||
|
||||
expect(body.messages).toEqual([
|
||||
{
|
||||
role: 'assistant',
|
||||
content: 'final answer',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('decodes anthropic-tagged thinking signatures before sending upstream', () => {
|
||||
const body = convertOpenAiBodyToAnthropicMessagesBody(
|
||||
{
|
||||
model: 'gpt-5',
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'thinking',
|
||||
thinking: 'internal only',
|
||||
signature: 'metapi:anthropic-signature:sig-anthropic-1',
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: 'final answer',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
'claude-opus-4-6',
|
||||
false,
|
||||
);
|
||||
|
||||
expect(body.messages).toEqual([
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'thinking',
|
||||
thinking: 'internal only',
|
||||
signature: 'sig-anthropic-1',
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: 'final answer',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('strips cache_control from thinking blocks and empty text blocks', () => {
|
||||
const body = convertOpenAiBodyToAnthropicMessagesBody(
|
||||
{
|
||||
@@ -410,7 +530,7 @@ describe('convertOpenAiBodyToAnthropicMessagesBody', () => {
|
||||
expect(body.output_config).toEqual({ effort: 'high' });
|
||||
});
|
||||
|
||||
it('rebuilds cache_control anchors at the anthropic inbound boundary', () => {
|
||||
it('keeps inbound claude bodies thin instead of rebuilding cache anchors', () => {
|
||||
const result = anthropicMessagesInbound.parse(
|
||||
{
|
||||
model: 'gpt-5',
|
||||
@@ -431,36 +551,22 @@ describe('convertOpenAiBodyToAnthropicMessagesBody', () => {
|
||||
);
|
||||
|
||||
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' },
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
expect(result.value?.claudeOriginalBody).toEqual({
|
||||
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' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import { normalizeInputFileBlock, toAnthropicDocumentBlock } from '../../shared/inputFile.js';
|
||||
import {
|
||||
decodeAnthropicReasoningSignature,
|
||||
} from '../../shared/reasoningTransport.js';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object';
|
||||
}
|
||||
@@ -256,6 +261,19 @@ function toAnthropicTextBlock(text: string): Record<string, unknown> | null {
|
||||
return normalized ? { type: 'text', text: normalized } : null;
|
||||
}
|
||||
|
||||
function resolveAnthropicThinkingSignature(item: Record<string, unknown>): string | null | undefined {
|
||||
const rawSignature = asTrimmedString(item.signature ?? item.reasoning_signature);
|
||||
if (!rawSignature) return undefined;
|
||||
const decodedTaggedSignature = decodeAnthropicReasoningSignature(rawSignature);
|
||||
if (decodedTaggedSignature !== null) {
|
||||
return decodedTaggedSignature;
|
||||
}
|
||||
if (item.reasoning_signature !== undefined || rawSignature.startsWith('metapi:')) {
|
||||
return null;
|
||||
}
|
||||
return rawSignature;
|
||||
}
|
||||
|
||||
function sanitizeAnthropicContentBlock(item: Record<string, unknown>): Record<string, unknown> | null {
|
||||
const type = asTrimmedString(item.type).toLowerCase();
|
||||
if (!type) {
|
||||
@@ -276,9 +294,16 @@ function sanitizeAnthropicContentBlock(item: Record<string, unknown>): Record<st
|
||||
return toAnthropicImageBlock(item);
|
||||
}
|
||||
|
||||
if (type === 'file' || type === 'input_file') {
|
||||
const fileBlock = normalizeInputFileBlock(item);
|
||||
return fileBlock ? toAnthropicDocumentBlock(fileBlock) : null;
|
||||
}
|
||||
|
||||
if (type === 'thinking' || type === 'redacted_thinking' || type === 'reasoning') {
|
||||
const text = asTrimmedString(item.thinking ?? item.text ?? item.content ?? item.data);
|
||||
if (!text) return null;
|
||||
const signature = type === 'redacted_thinking' ? undefined : resolveAnthropicThinkingSignature(item);
|
||||
if (signature === null) return null;
|
||||
const next: Record<string, unknown> = { ...item };
|
||||
if (type === 'redacted_thinking') {
|
||||
next.type = 'redacted_thinking';
|
||||
@@ -287,10 +312,16 @@ function sanitizeAnthropicContentBlock(item: Record<string, unknown>): Record<st
|
||||
} else {
|
||||
next.type = type === 'reasoning' ? 'thinking' : type;
|
||||
next.thinking = text;
|
||||
if (signature) {
|
||||
next.signature = signature;
|
||||
} else {
|
||||
delete next.signature;
|
||||
}
|
||||
}
|
||||
delete next.cache_control;
|
||||
delete next.text;
|
||||
delete next.content;
|
||||
delete next.reasoning_signature;
|
||||
return next;
|
||||
}
|
||||
|
||||
@@ -893,6 +924,7 @@ export function convertOpenAiBodyToAnthropicMessagesBody(
|
||||
|
||||
export function validateAnthropicMessagesBody(
|
||||
body: Record<string, unknown>,
|
||||
options: { autoOptimizeCacheControls?: boolean } = {},
|
||||
): { sanitizedBody?: Record<string, unknown>; error?: { statusCode: number; payload: unknown } } {
|
||||
const thinkingResult = sanitizeAnthropicThinkingConfig(body.thinking);
|
||||
if (thinkingResult.error) {
|
||||
@@ -928,5 +960,5 @@ export function validateAnthropicMessagesBody(
|
||||
};
|
||||
}
|
||||
|
||||
return { sanitizedBody: sanitizeAnthropicMessagesBody(body) };
|
||||
return { sanitizedBody: sanitizeAnthropicMessagesBody(body, options) };
|
||||
}
|
||||
|
||||
@@ -60,4 +60,97 @@ describe('anthropicMessagesInbound', () => {
|
||||
tool_choice: { type: 'tool', name: 'lookup' },
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves already-native anthropic block shapes and cache markers', () => {
|
||||
const result = anthropicMessagesInbound.parse({
|
||||
model: 'claude-opus-4-6',
|
||||
max_tokens: 512,
|
||||
system: [
|
||||
{ type: 'text', text: 'system prompt', cache_control: { type: 'ephemeral' } },
|
||||
],
|
||||
tools: [
|
||||
{
|
||||
name: 'lookup',
|
||||
input_schema: { type: 'object' },
|
||||
cache_control: { type: 'ephemeral' },
|
||||
},
|
||||
],
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'hello', cache_control: { type: 'ephemeral' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
thinking: { type: 'adaptive' },
|
||||
output_config: { effort: 'high' },
|
||||
tool_choice: { type: 'tool', name: 'lookup' },
|
||||
});
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.value?.claudeOriginalBody).toEqual({
|
||||
model: 'claude-opus-4-6',
|
||||
max_tokens: 512,
|
||||
system: [
|
||||
{ type: 'text', text: 'system prompt', cache_control: { type: 'ephemeral' } },
|
||||
],
|
||||
tools: [
|
||||
{
|
||||
name: 'lookup',
|
||||
input_schema: { type: 'object' },
|
||||
cache_control: { type: 'ephemeral' },
|
||||
},
|
||||
],
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'hello', cache_control: { type: 'ephemeral' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
thinking: { type: 'adaptive' },
|
||||
output_config: { effort: 'high' },
|
||||
tool_choice: { type: 'tool', name: 'lookup' },
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps native cache_control placement for already-native anthropic bodies', () => {
|
||||
const nativeBody = {
|
||||
model: 'claude-opus-4-6',
|
||||
max_tokens: 512,
|
||||
tools: [
|
||||
{
|
||||
name: 'lookup',
|
||||
input_schema: { type: 'object' },
|
||||
cache_control: { type: 'ephemeral' },
|
||||
},
|
||||
],
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_use',
|
||||
id: 'tool_1',
|
||||
name: 'lookup',
|
||||
input: { city: 'paris' },
|
||||
cache_control: { type: 'ephemeral' },
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: 'done',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
tool_choice: { type: 'tool', name: 'lookup' },
|
||||
};
|
||||
|
||||
const result = anthropicMessagesInbound.parse(nativeBody);
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.value?.claudeOriginalBody).toEqual(nativeBody);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -123,7 +123,9 @@ function sanitizeAnthropicInboundBody(
|
||||
const toolChoiceError = validateToolChoice(body);
|
||||
if (toolChoiceError) return { error: toolChoiceError };
|
||||
|
||||
const validation = validateAnthropicMessagesBody(body);
|
||||
const validation = validateAnthropicMessagesBody(body, {
|
||||
autoOptimizeCacheControls: false,
|
||||
});
|
||||
if (validation.error) {
|
||||
return { error: validation.error };
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ describe('extractAnthropicUsage', () => {
|
||||
completionTokens: 30,
|
||||
totalTokens: 130,
|
||||
cachedTokens: 12,
|
||||
cacheReadTokens: 12,
|
||||
cacheCreationTokens: 0,
|
||||
reasoningTokens: 0,
|
||||
audioInputTokens: 0,
|
||||
audioOutputTokens: 0,
|
||||
|
||||
@@ -256,6 +256,8 @@ export function extractAnthropicUsage(payload: unknown): NormalizedUsage {
|
||||
completionTokens: summary.completionTokens,
|
||||
totalTokens: summary.totalTokens,
|
||||
cachedTokens: summary.cacheReadInputTokens,
|
||||
cacheReadTokens: summary.cacheReadInputTokens,
|
||||
cacheCreationTokens: summary.cacheCreationInputTokens,
|
||||
reasoningTokens: 0,
|
||||
audioInputTokens: 0,
|
||||
audioOutputTokens: 0,
|
||||
|
||||
Reference in New Issue
Block a user