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
@@ -8,16 +8,19 @@ import {
MESSAGE_STATUS,
buildApiPayload,
buildEmbeddingsRequestEnvelope,
buildFileUploadRequestEnvelope,
buildRawProxyRequestEnvelope,
buildSearchRequestEnvelope,
collectModelTesterModelNames,
countConversationTurns,
createConversationUserMessage,
filterModelTesterModelNames,
parseCustomRequestBody,
parseModelTesterSession,
serializeModelTesterSession,
syncMessagesToCustomRequestBody,
toApiMessages,
type ChatMessage,
type ModelTesterSessionState,
} from './modelTesterSession.js';
@@ -171,6 +174,93 @@ describe('modelTesterSession', () => {
});
});
it('builds multipart upload envelopes for /v1/files', () => {
expect(buildFileUploadRequestEnvelope({
name: 'paper.pdf',
mimeType: 'application/pdf',
dataUrl: 'data:application/pdf;base64,JVBERi0xLjc=',
})).toEqual({
method: 'POST',
path: '/v1/files',
requestKind: 'multipart',
stream: false,
jobMode: false,
rawMode: false,
multipartFields: {
purpose: 'assistants',
},
multipartFiles: [
{
field: 'file',
name: 'paper.pdf',
mimeType: 'application/pdf',
dataUrl: 'data:application/pdf;base64,JVBERi0xLjc=',
},
],
});
});
it('creates user conversation messages that preserve uploaded file references', () => {
const message = createConversationUserMessage('请总结附件', [
{
fileId: 'file-metapi-123',
filename: 'paper.pdf',
mimeType: 'application/pdf',
},
]);
expect(message.role).toBe('user');
expect(message.content).toBe('请总结附件');
expect(message.parts).toEqual([
{
type: 'input_file',
fileId: 'file-metapi-123',
filename: 'paper.pdf',
mimeType: 'application/pdf',
},
]);
});
it('preserves file reference parts when building responses conversation payloads', () => {
const payload = buildApiPayload(
[{
id: 'u1',
role: 'user',
content: '请总结上传文件',
createAt: 1,
parts: [
{
type: 'input_file',
fileId: 'file_123',
filename: 'notes.txt',
mimeType: 'text/plain',
},
],
} as ChatMessage],
{
...DEFAULT_INPUTS,
model: 'gpt-5',
protocol: 'responses',
},
DEFAULT_PARAMETER_ENABLED,
);
expect(payload.jsonBody).toEqual({
model: 'gpt-5',
input: [
{
role: 'user',
content: [
{ type: 'input_text', text: '请总结上传文件' },
{ type: 'input_file', file_id: 'file_123', filename: 'notes.txt', mime_type: 'text/plain' },
],
},
],
stream: false,
temperature: 0.7,
});
});
it('builds gemini conversation envelope with generationConfig', () => {
const payload = buildApiPayload(
[{ id: 'u1', role: 'user', content: 'hello', createAt: 1 }],
@@ -196,6 +286,87 @@ describe('modelTesterSession', () => {
});
});
it('serializes conversation file parts into openai and responses payloads', () => {
const message = {
id: 'u1',
role: 'user' as const,
content: 'summarize this',
createAt: 1,
parts: [
{
type: 'input_file' as const,
fileId: 'file_metapi_123',
filename: 'paper.pdf',
mimeType: 'application/pdf',
},
],
};
const openaiPayload = buildApiPayload(
[message],
{
...DEFAULT_INPUTS,
model: 'gpt-4.1',
protocol: 'openai',
},
DEFAULT_PARAMETER_ENABLED,
);
expect(openaiPayload.jsonBody).toEqual({
model: 'gpt-4.1',
messages: [
{
role: 'user',
content: [
{ type: 'text', text: 'summarize this' },
{
type: 'file',
file: {
file_id: 'file_metapi_123',
filename: 'paper.pdf',
mime_type: 'application/pdf',
},
},
],
},
],
stream: false,
temperature: 0.7,
frequency_penalty: 0,
presence_penalty: 0,
});
const responsesPayload = buildApiPayload(
[message],
{
...DEFAULT_INPUTS,
model: 'gpt-4.1',
protocol: 'responses',
},
DEFAULT_PARAMETER_ENABLED,
);
expect(responsesPayload.jsonBody).toEqual({
model: 'gpt-4.1',
input: [
{
role: 'user',
content: [
{ type: 'input_text', text: 'summarize this' },
{
type: 'input_file',
file_id: 'file_metapi_123',
filename: 'paper.pdf',
mime_type: 'application/pdf',
},
],
},
],
stream: false,
temperature: 0.7,
});
});
it('builds embeddings and search envelopes', () => {
expect(buildEmbeddingsRequestEnvelope('hello', { ...DEFAULT_INPUTS, model: 'text-embedding-3-large' })).toEqual({
method: 'POST',
+184 -3
View File
@@ -35,6 +35,7 @@ export type ConversationContentPart =
| { type: 'image_url'; url: string }
| { type: 'image_inline'; dataUrl: string; mimeType?: string | null }
| { type: 'input_audio'; dataUrl: string; mimeType?: string | null }
| { type: 'input_file'; fileId?: string | null; filename?: string | null; mimeType?: string | null; data?: string | null }
| { type: 'output_audio'; dataUrl: string; mimeType?: string | null }
| { type: 'tool_call'; name?: string; argumentsText?: string }
| { type: 'tool_result'; name?: string; outputText?: string }
@@ -58,6 +59,7 @@ export type ChatMessage = {
type ApiChatMessage = {
role: ChatRole;
content: string;
parts?: ConversationContentPart[] | null;
};
export type PlaygroundMultipartFile = {
@@ -67,6 +69,12 @@ export type PlaygroundMultipartFile = {
dataUrl: string;
};
export type ConversationUploadedFile = {
fileId: string;
filename?: string | null;
mimeType?: string | null;
};
export type TesterProxyEnvelope = {
method: ProxyRequestMethod;
path: string;
@@ -249,21 +257,151 @@ const appendOptionalNumber = (target: Record<string, unknown>, key: string, enab
}
};
const parseDataUrl = (dataUrl: string): { mimeType: string; data: string } | null => {
const match = /^data:([^;,]+)?;base64,(.+)$/i.exec((dataUrl || '').trim());
if (!match) return null;
return {
mimeType: match[1] || 'application/octet-stream',
data: match[2],
};
};
const toOpenAiContentPart = (part: ConversationContentPart): Record<string, unknown> | null => {
if (part.type === 'image_url') {
return {
type: 'image_url',
image_url: part.url,
};
}
if (part.type === 'image_inline') {
return {
type: 'image_url',
image_url: part.dataUrl,
};
}
if (part.type === 'input_audio') {
const parsed = parseDataUrl(part.dataUrl);
if (!parsed) return null;
return {
type: 'input_audio',
input_audio: {
data: parsed.data,
format: (part.mimeType || parsed.mimeType || 'audio/wav').split('/').pop() || 'wav',
},
};
}
if (part.type === 'input_file') {
const filePayload: Record<string, unknown> = {};
if (typeof part.fileId === 'string' && part.fileId.trim()) filePayload.file_id = part.fileId.trim();
if (typeof part.filename === 'string' && part.filename.trim()) filePayload.filename = part.filename.trim();
if (typeof part.data === 'string' && part.data.trim()) filePayload.file_data = part.data.trim();
if (typeof part.mimeType === 'string' && part.mimeType.trim()) filePayload.mime_type = part.mimeType.trim();
if (Object.keys(filePayload).length === 0) return null;
return {
type: 'file',
file: filePayload,
};
}
return null;
};
const toResponsesContentPart = (part: ConversationContentPart): Record<string, unknown> | null => {
if (part.type === 'image_url') {
return {
type: 'input_image',
image_url: part.url,
};
}
if (part.type === 'image_inline') {
return {
type: 'input_image',
image_url: part.dataUrl,
};
}
if (part.type === 'input_audio') {
const parsed = parseDataUrl(part.dataUrl);
if (!parsed) return null;
return {
type: 'input_audio',
input_audio: {
data: parsed.data,
format: (part.mimeType || parsed.mimeType || 'audio/wav').split('/').pop() || 'wav',
},
};
}
if (part.type === 'input_file') {
const fileBlock: Record<string, unknown> = {
type: 'input_file',
};
if (typeof part.fileId === 'string' && part.fileId.trim()) fileBlock.file_id = part.fileId.trim();
if (typeof part.filename === 'string' && part.filename.trim()) fileBlock.filename = part.filename.trim();
if (typeof part.data === 'string' && part.data.trim()) fileBlock.file_data = part.data.trim();
if (typeof part.mimeType === 'string' && part.mimeType.trim()) fileBlock.mime_type = part.mimeType.trim();
if (Object.keys(fileBlock).length === 1) return null;
return fileBlock;
}
return null;
};
const toGeminiContents = (messages: ApiChatMessage[]) =>
messages.map((message) => ({
role: message.role === 'assistant' ? 'model' : 'user',
parts: [{ text: message.content }],
}));
const toOpenAiMessageContent = (message: ApiChatMessage): unknown => message.content;
const toOpenAiMessageContent = (message: ApiChatMessage): unknown => {
const parts = Array.isArray(message.parts)
? message.parts
.map((part) => toOpenAiContentPart(part))
.filter((item): item is Record<string, unknown> => !!item)
: [];
if (parts.length <= 0) {
return message.content;
}
if (message.content.trim()) {
return [
{ type: 'text', text: message.content },
...parts,
];
}
return parts;
};
const toResponsesInput = (messages: ApiChatMessage[]) => {
if (messages.length === 1 && messages[0].role === 'user') {
const hasStructuredParts = messages.some((message) => Array.isArray(message.parts) && message.parts.length > 0);
if (!hasStructuredParts && messages.length === 1 && messages[0].role === 'user') {
return messages[0].content;
}
return messages.map((message) => ({
role: message.role === 'assistant' ? 'assistant' : 'user',
content: message.content,
content: [
...(message.content.trim() ? [{ type: 'input_text', text: message.content }] : []),
...((Array.isArray(message.parts)
? message.parts
.map((part) => toResponsesContentPart(part))
.filter((item): item is Record<string, unknown> => !!item)
: [])),
].length > 0
? [
...(message.content.trim() ? [{ type: 'input_text', text: message.content }] : []),
...((Array.isArray(message.parts)
? message.parts
.map((part) => toResponsesContentPart(part))
.filter((item): item is Record<string, unknown> => !!item)
: [])),
]
: message.content,
}));
};
@@ -636,6 +774,27 @@ export const createMessage = (role: ChatRole, content: string, extra: Partial<Ch
...extra,
});
export const createConversationInputFilePart = (
file: ConversationUploadedFile,
): ConversationContentPart => ({
type: 'input_file',
fileId: file.fileId,
filename: file.filename ?? null,
mimeType: file.mimeType ?? null,
});
export const createConversationUserMessage = (
content: string,
files: ConversationUploadedFile[] = [],
extra: Partial<ChatMessage> = {},
): ChatMessage => {
const parts = files.map((file) => createConversationInputFilePart(file));
return createMessage('user', content, {
...extra,
...(parts.length > 0 ? { parts } : {}),
});
};
export const createLoadingAssistantMessage = (): ChatMessage =>
createMessage('assistant', '', {
status: MESSAGE_STATUS.LOADING,
@@ -772,6 +931,7 @@ export const toApiMessages = (messages: ChatMessage[]): ApiChatMessage[] =>
.map((message) => ({
role: message.role,
content: message.content,
parts: message.parts,
}));
export const buildConversationRequestEnvelope = (
@@ -844,6 +1004,27 @@ export const buildImagesGenerationsRequestEnvelope = (
},
});
export const buildFileUploadRequestEnvelope = (
file: Omit<PlaygroundMultipartFile, 'field'> & { field?: string },
purpose = 'assistants',
): TesterProxyEnvelope => ({
method: 'POST',
path: '/v1/files',
requestKind: 'multipart',
stream: false,
jobMode: false,
rawMode: false,
multipartFields: {
purpose,
},
multipartFiles: [{
field: file.field || 'file',
name: file.name,
mimeType: file.mimeType,
dataUrl: file.dataUrl,
}],
});
export const buildImagesEditRequestEnvelope = (
inputs: ModelTesterInputs,
modeState: ModelTesterModeState,