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