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:
cita
2026-03-08 22:10:31 +08:00
parent ede8998244
commit 7bd3114ea0
54 changed files with 4504 additions and 164 deletions
@@ -1,3 +1,7 @@
import {
decodeAnthropicReasoningSignature,
} from './reasoningTransport.js';
export type DownstreamFormat = 'openai' | 'claude';
export type ParsedSseEvent = {
@@ -32,6 +36,8 @@ export type NormalizedStreamEvent = {
role?: 'assistant';
contentDelta?: string;
reasoningDelta?: string;
reasoningSignature?: string;
redactedReasoningContent?: string;
toolCallDeltas?: Array<{
index: number;
id?: string;
@@ -48,6 +54,8 @@ export type NormalizedFinalResponse = {
created: number;
content: string;
reasoningContent: string;
reasoningSignature?: string;
redactedReasoningContent?: string;
finishReason: string;
toolCalls: Array<{
id: string;
@@ -746,6 +754,9 @@ export function normalizeUpstreamStreamEvent(
|| (typeof (delta as any).reasoning === 'string' ? (delta as any).reasoning : '')
|| deltaParsed.reasoning
|| '';
const reasoningSignature = isNonEmptyString((delta as any).reasoning_signature)
? (delta as any).reasoning_signature
: undefined;
// Some upstream providers (e.g. certain OpenAI-compatible aggregators) emit thinking
// tokens with the same text duplicated in both delta.content and delta.reasoning_content.
@@ -791,6 +802,7 @@ export function normalizeUpstreamStreamEvent(
role: (delta as any).role === 'assistant' ? 'assistant' : undefined,
contentDelta: contentDelta || undefined,
reasoningDelta: reasoningDelta || undefined,
reasoningSignature,
toolCallDeltas: toolCallDeltas.length > 0 ? toolCallDeltas : undefined,
finishReason: normalizeStopReason(choice?.finish_reason),
};
@@ -830,6 +842,11 @@ export function normalizeUpstreamStreamEvent(
if (type === 'response.output_item.added' && isRecord((payload as any).item)) {
const item = (payload as any).item as Record<string, unknown>;
if (item.type === 'reasoning' && isNonEmptyString(item.encrypted_content)) {
return {
reasoningSignature: item.encrypted_content,
};
}
if (item.type === 'function_call') {
const outputIndex = (
typeof (payload as any).output_index === 'number' && Number.isFinite((payload as any).output_index)
@@ -1371,13 +1388,29 @@ export function serializeFinalResponse(
usage: { promptTokens: number; completionTokens: number; totalTokens: number },
): Record<string, unknown> {
const toolCalls = Array.isArray(normalized.toolCalls) ? normalized.toolCalls : [];
const rawReasoningSignature = typeof normalized.reasoningSignature === 'string'
? normalized.reasoningSignature.trim()
: '';
const taggedReasoningSignature = rawReasoningSignature.startsWith('metapi:');
const claudeReasoningSignature = decodeAnthropicReasoningSignature(rawReasoningSignature)
?? (rawReasoningSignature && !taggedReasoningSignature ? rawReasoningSignature : null);
if (downstreamFormat === 'claude') {
const contentBlocks: Array<Record<string, unknown>> = [];
if (normalized.reasoningContent) {
contentBlocks.push({
const thinkingBlock: Record<string, unknown> = {
type: 'thinking',
thinking: normalized.reasoningContent,
};
if (claudeReasoningSignature) {
thinkingBlock.signature = claudeReasoningSignature;
}
contentBlocks.push(thinkingBlock);
}
if (normalized.redactedReasoningContent) {
contentBlocks.push({
type: 'redacted_thinking',
data: normalized.redactedReasoningContent,
});
}
if (normalized.content) {
@@ -1422,6 +1455,9 @@ export function serializeFinalResponse(
if (normalized.reasoningContent) {
message.reasoning_content = normalized.reasoningContent;
}
if (rawReasoningSignature) {
message.reasoning_signature = rawReasoningSignature;
}
if (toolCalls.length > 0) {
message.tool_calls = toOpenAiToolCalls(toolCalls);
if (!normalized.content) message.content = '';
@@ -1470,6 +1506,9 @@ export function buildSyntheticOpenAiChunks(normalized: NormalizedFinalResponse):
if (normalized.reasoningContent) {
startDelta.reasoning_content = normalized.reasoningContent;
}
if (typeof normalized.reasoningSignature === 'string' && normalized.reasoningSignature.trim()) {
startDelta.reasoning_signature = normalized.reasoningSignature.trim();
}
if (toolCalls.length > 0) {
startDelta.tool_calls = toOpenAiToolCalls(toolCalls);
}
+132
View File
@@ -0,0 +1,132 @@
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object';
}
function asTrimmedString(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
}
function splitBase64DataUrl(value: string): { mimeType: string; data: string } | null {
const match = /^data:([^;,]+);base64,(.+)$/i.exec(value.trim());
if (!match) return null;
return {
mimeType: match[1].trim().toLowerCase(),
data: match[2].trim(),
};
}
export type NormalizedInputFile = {
sourceType?: 'file' | 'input_file';
fileId?: string;
fileData?: string;
filename?: string;
mimeType?: string | null;
hadDataUrl?: boolean;
};
export function inferInputFileMimeType(input: Pick<NormalizedInputFile, 'filename' | 'mimeType'>): string | null {
const explicit = asTrimmedString(input.mimeType);
if (explicit) return explicit.toLowerCase();
const filename = asTrimmedString(input.filename).toLowerCase();
if (!filename) return null;
if (filename.endsWith('.pdf')) return 'application/pdf';
if (filename.endsWith('.txt')) return 'text/plain';
if (filename.endsWith('.md') || filename.endsWith('.markdown')) return 'text/markdown';
if (filename.endsWith('.json')) return 'application/json';
if (filename.endsWith('.png')) return 'image/png';
if (filename.endsWith('.jpg') || filename.endsWith('.jpeg')) return 'image/jpeg';
if (filename.endsWith('.gif')) return 'image/gif';
if (filename.endsWith('.webp')) return 'image/webp';
if (filename.endsWith('.wav')) return 'audio/wav';
if (filename.endsWith('.mp3')) return 'audio/mpeg';
return null;
}
export function normalizeInputFileBlock(item: Record<string, unknown>): NormalizedInputFile | null {
const type = asTrimmedString(item.type).toLowerCase();
if (type === 'input_file') {
const fileId = asTrimmedString(item.file_id);
const fileData = asTrimmedString(item.file_data);
const filename = asTrimmedString(item.filename);
let mimeType = asTrimmedString(item.mime_type ?? item.mimeType) || null;
if (!fileId && !fileData) return null;
const parsedDataUrl = fileData ? splitBase64DataUrl(fileData) : null;
if (parsedDataUrl) {
mimeType = mimeType || parsedDataUrl.mimeType;
}
return {
sourceType: 'input_file',
fileId: fileId || undefined,
fileData: fileData || undefined,
filename: filename || undefined,
mimeType,
hadDataUrl: /^data:[^;,]+;base64,/i.test(fileData),
};
}
if (type === 'file') {
const file = isRecord(item.file) ? item.file : item;
const fileId = asTrimmedString(file.file_id ?? item.file_id);
const fileData = asTrimmedString(file.file_data ?? item.file_data);
const filename = asTrimmedString(file.filename ?? item.filename);
let mimeType = asTrimmedString(file.mime_type ?? file.mimeType ?? item.mime_type ?? item.mimeType) || null;
if (!fileId && !fileData) return null;
const parsedDataUrl = fileData ? splitBase64DataUrl(fileData) : null;
if (parsedDataUrl) {
mimeType = mimeType || parsedDataUrl.mimeType;
}
return {
sourceType: 'file',
fileId: fileId || undefined,
fileData: fileData || undefined,
filename: filename || undefined,
mimeType,
hadDataUrl: /^data:[^;,]+;base64,/i.test(fileData),
};
}
return null;
}
export function toResponsesInputFileBlock(file: NormalizedInputFile): Record<string, unknown> {
const parsedDataUrl = file.fileData ? splitBase64DataUrl(file.fileData) : null;
const block: Record<string, unknown> = { type: 'input_file' };
if (file.fileId) block.file_id = file.fileId;
if (file.fileData) block.file_data = parsedDataUrl?.data || file.fileData;
if (file.filename) block.filename = file.filename;
if (file.mimeType) block.mime_type = file.mimeType;
else if (parsedDataUrl?.mimeType) block.mime_type = parsedDataUrl.mimeType;
return block;
}
export function toOpenAiChatFileBlock(file: NormalizedInputFile): Record<string, unknown> {
const parsedDataUrl = file.fileData ? splitBase64DataUrl(file.fileData) : null;
const payload: Record<string, unknown> = {};
if (file.fileId) payload.file_id = file.fileId;
if (file.fileData) payload.file_data = parsedDataUrl?.data || file.fileData;
if (file.filename) payload.filename = file.filename;
if (file.mimeType) payload.mime_type = file.mimeType;
else if (parsedDataUrl?.mimeType) payload.mime_type = parsedDataUrl.mimeType;
const block: Record<string, unknown> = { type: 'file' };
block.file = payload;
return block;
}
export function toAnthropicDocumentBlock(file: NormalizedInputFile): Record<string, unknown> | null {
if (!file.fileData) return null;
const parsedDataUrl = splitBase64DataUrl(file.fileData);
const mimeType = parsedDataUrl?.mimeType || inferInputFileMimeType(file);
if (!mimeType) return null;
return {
type: 'document',
...(file.hadDataUrl ? { cache_control: { type: 'ephemeral' } } : {}),
source: {
type: 'base64',
media_type: mimeType,
data: parsedDataUrl?.data || file.fileData,
},
...(file.filename ? { title: file.filename } : {}),
};
}
@@ -16,6 +16,14 @@ describe('shared normalized helpers', () => {
expect(source).not.toContain('chatFormats.js');
});
it('exposes shared transformer metadata extensions', () => {
const source = readFileSync(new URL('./normalized.ts', import.meta.url), 'utf8');
expect(source).toContain('thoughtSignatures');
expect(source).toContain('promptCacheKey');
expect(source).toContain('truncation');
expect(source).toContain('serviceTier');
});
it('parses SSE events and keeps the trailing partial block', () => {
const pulled = pullSseEventsWithDone([
'event: message',
@@ -68,14 +76,19 @@ describe('shared normalized helpers', () => {
});
it('serializes normalized final responses for claude', () => {
const normalized: NormalizedFinalResponse = {
const normalized = {
id: 'chatcmpl-1',
model: 'claude-test',
created: 456,
content: 'done',
reasoningContent: 'thinking',
reasoningSignature: 'metapi:anthropic-signature:sig-1',
redactedReasoningContent: 'ciphertext',
finishReason: 'tool_calls',
toolCalls: [{ id: 'tool_1', name: 'lookup', arguments: '{"q":"x"}' }],
} as NormalizedFinalResponse & {
reasoningSignature: string;
redactedReasoningContent: string;
};
expect(serializeFinalResponse('claude', normalized, {
@@ -88,7 +101,8 @@ describe('shared normalized helpers', () => {
role: 'assistant',
model: 'claude-test',
content: [
{ type: 'thinking', thinking: 'thinking' },
{ type: 'thinking', thinking: 'thinking', signature: 'sig-1' },
{ type: 'redacted_thinking', data: 'ciphertext' },
{ type: 'text', text: 'done' },
{ type: 'tool_use', id: 'tool_1', name: 'lookup', input: { q: 'x' } },
],
@@ -101,6 +115,34 @@ describe('shared normalized helpers', () => {
});
});
it('serializes provider-tagged reasoning signatures for openai-compatible downstreams', () => {
const normalized = {
id: 'chatcmpl-2',
model: 'gpt-test',
created: 789,
content: 'final',
reasoningContent: 'deliberation',
reasoningSignature: 'metapi:openai-encrypted-reasoning:enc-1',
finishReason: 'stop',
toolCalls: [],
} as NormalizedFinalResponse & { reasoningSignature: string };
expect(serializeFinalResponse('openai', normalized, {
promptTokens: 3,
completionTokens: 5,
totalTokens: 8,
})).toMatchObject({
choices: [{
message: {
role: 'assistant',
content: 'final',
reasoning_content: 'deliberation',
reasoning_signature: 'metapi:openai-encrypted-reasoning:enc-1',
},
}],
});
});
it('normalizes known stop reasons', () => {
expect(normalizeStopReason('max_output_tokens')).toBe('length');
expect(normalizeStopReason('tool_use')).toBe('tool_calls');
@@ -24,6 +24,7 @@ export type NormalizedContentBlockType =
| 'text'
| 'image_url'
| 'image_inline'
| 'input_file'
| 'input_audio'
| 'output_audio'
| 'tool_call'
@@ -39,6 +40,8 @@ export type NormalizedContentBlock = {
mimeType?: string | null;
url?: string | null;
data?: string | null;
fileId?: string | null;
filename?: string | null;
toolName?: string | null;
toolCallId?: string | null;
argumentsText?: string | null;
@@ -51,6 +54,8 @@ export type NormalizedUsage = {
completionTokens?: number | null;
totalTokens?: number | null;
cachedTokens?: number | null;
cacheReadTokens?: number | null;
cacheCreationTokens?: number | null;
reasoningTokens?: number | null;
audioInputTokens?: number | null;
audioOutputTokens?: number | null;
@@ -66,13 +71,17 @@ export type ParsedDownstreamChatRequestResult = {
export type TransformerMetadata = {
include?: unknown;
maxToolCalls?: number | null;
promptCacheKey?: unknown;
promptCacheRetention?: unknown;
truncation?: unknown;
serviceTier?: unknown;
includeObfuscation?: boolean | null;
citations?: unknown;
annotations?: unknown;
geminiSafetySettings?: unknown;
geminiImageConfig?: unknown;
thoughtSignature?: string | null;
thoughtSignatures?: string[];
passthrough?: Record<string, unknown>;
};
@@ -108,6 +117,8 @@ export function createEmptyNormalizedUsage(): NormalizedUsage {
completionTokens: 0,
totalTokens: 0,
cachedTokens: 0,
cacheReadTokens: 0,
cacheCreationTokens: 0,
reasoningTokens: 0,
audioInputTokens: 0,
audioOutputTokens: 0,
@@ -127,6 +138,8 @@ export function mergeNormalizedUsage(
completionTokens: (merged.completionTokens || 0) + (incoming.completionTokens || 0),
totalTokens: (merged.totalTokens || 0) + (incoming.totalTokens || 0),
cachedTokens: (merged.cachedTokens || 0) + (incoming.cachedTokens || 0),
cacheReadTokens: (merged.cacheReadTokens || 0) + (incoming.cacheReadTokens || 0),
cacheCreationTokens: (merged.cacheCreationTokens || 0) + (incoming.cacheCreationTokens || 0),
reasoningTokens: (merged.reasoningTokens || 0) + (incoming.reasoningTokens || 0),
audioInputTokens: (merged.audioInputTokens || 0) + (incoming.audioInputTokens || 0),
audioOutputTokens: (merged.audioOutputTokens || 0) + (incoming.audioOutputTokens || 0),
@@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest';
import {
decodeAnthropicReasoningSignature,
decodeGeminiThoughtSignature,
decodeOpenAiEncryptedReasoning,
encodeAnthropicReasoningSignature,
encodeGeminiThoughtSignature,
encodeOpenAiEncryptedReasoning,
isAnthropicReasoningSignature,
isGeminiThoughtSignature,
isOpenAiEncryptedReasoning,
isSafeAnthropicRedactedReasoningCarrier,
} from './reasoningTransport.js';
describe('reasoning transport codecs', () => {
it('round-trips provider-tagged reasoning signatures', () => {
const anthropic = encodeAnthropicReasoningSignature('anthropic-sig');
const gemini = encodeGeminiThoughtSignature('gemini-sig');
const openai = encodeOpenAiEncryptedReasoning('openai-enc');
expect(isAnthropicReasoningSignature(anthropic)).toBe(true);
expect(isGeminiThoughtSignature(gemini)).toBe(true);
expect(isOpenAiEncryptedReasoning(openai)).toBe(true);
expect(decodeAnthropicReasoningSignature(anthropic)).toBe('anthropic-sig');
expect(decodeGeminiThoughtSignature(gemini)).toBe('gemini-sig');
expect(decodeOpenAiEncryptedReasoning(openai)).toBe('openai-enc');
});
it('prevents prefixed signatures from being treated as anthropic redacted reasoning carriers', () => {
expect(isSafeAnthropicRedactedReasoningCarrier('ciphertext')).toBe(true);
expect(isSafeAnthropicRedactedReasoningCarrier(encodeAnthropicReasoningSignature('sig'))).toBe(false);
expect(isSafeAnthropicRedactedReasoningCarrier(encodeGeminiThoughtSignature('sig'))).toBe(false);
expect(isSafeAnthropicRedactedReasoningCarrier(encodeOpenAiEncryptedReasoning('enc'))).toBe(false);
});
});
@@ -0,0 +1,61 @@
const PROVIDER_PREFIX = 'metapi';
export const ANTHROPIC_REASONING_SIGNATURE_PREFIX = `${PROVIDER_PREFIX}:anthropic-signature:`;
export const GEMINI_THOUGHT_SIGNATURE_PREFIX = `${PROVIDER_PREFIX}:gemini-thought-signature:`;
export const OPENAI_ENCRYPTED_REASONING_PREFIX = `${PROVIDER_PREFIX}:openai-encrypted-reasoning:`;
function stripPrefix(value: string, prefix: string): string | null {
return value.startsWith(prefix) ? value.slice(prefix.length) : null;
}
export function isAnthropicReasoningSignature(value: string | null | undefined): value is string {
return typeof value === 'string' && value.startsWith(ANTHROPIC_REASONING_SIGNATURE_PREFIX);
}
export function encodeAnthropicReasoningSignature(signature: string | null | undefined): string | null {
if (!signature) return null;
return isAnthropicReasoningSignature(signature) ? signature : `${ANTHROPIC_REASONING_SIGNATURE_PREFIX}${signature}`;
}
export function decodeAnthropicReasoningSignature(signature: string | null | undefined): string | null {
if (!signature) return null;
return stripPrefix(signature, ANTHROPIC_REASONING_SIGNATURE_PREFIX);
}
export function isGeminiThoughtSignature(value: string | null | undefined): value is string {
return typeof value === 'string' && value.startsWith(GEMINI_THOUGHT_SIGNATURE_PREFIX);
}
export function encodeGeminiThoughtSignature(signature: string | null | undefined): string | null {
if (!signature) return null;
return isGeminiThoughtSignature(signature) ? signature : `${GEMINI_THOUGHT_SIGNATURE_PREFIX}${signature}`;
}
export function decodeGeminiThoughtSignature(signature: string | null | undefined): string | null {
if (!signature) return null;
return stripPrefix(signature, GEMINI_THOUGHT_SIGNATURE_PREFIX);
}
export function isOpenAiEncryptedReasoning(value: string | null | undefined): value is string {
return typeof value === 'string' && value.startsWith(OPENAI_ENCRYPTED_REASONING_PREFIX);
}
export function encodeOpenAiEncryptedReasoning(encryptedContent: string | null | undefined): string | null {
if (!encryptedContent) return null;
return isOpenAiEncryptedReasoning(encryptedContent)
? encryptedContent
: `${OPENAI_ENCRYPTED_REASONING_PREFIX}${encryptedContent}`;
}
export function decodeOpenAiEncryptedReasoning(signature: string | null | undefined): string | null {
if (!signature) return null;
return stripPrefix(signature, OPENAI_ENCRYPTED_REASONING_PREFIX);
}
export function isSafeAnthropicRedactedReasoningCarrier(value: string | null | undefined): value is string {
return typeof value === 'string'
&& value.length > 0
&& !isAnthropicReasoningSignature(value)
&& !isGeminiThoughtSignature(value)
&& !isOpenAiEncryptedReasoning(value);
}