fix modeltester attachment replay and add proxy file retention
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import * as modelTesterSessionModule from './modelTesterSession.js';
|
||||
import {
|
||||
DEBUG_TABS,
|
||||
DEFAULT_INPUTS,
|
||||
@@ -80,6 +81,17 @@ describe('modelTesterSession', () => {
|
||||
customRequestBody: '{"model":"gemini-2.5-pro","contents":[]}',
|
||||
showDebugPanel: true,
|
||||
activeDebugTab: DEBUG_TABS.REQUEST,
|
||||
conversationFiles: [
|
||||
{
|
||||
localId: 'draft-file-1',
|
||||
name: 'draft.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
dataUrl: 'data:application/pdf;base64,JVBERi0x',
|
||||
fileId: 'file-metapi-draft',
|
||||
status: 'uploaded',
|
||||
errorMessage: null,
|
||||
},
|
||||
],
|
||||
modeState: {
|
||||
...DEFAULT_MODE_STATE,
|
||||
searchQuery: 'hello',
|
||||
@@ -273,6 +285,37 @@ describe('modelTesterSession', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('hydrates local file ids into inline replay files for claude', async () => {
|
||||
const resolveConversationReplayFiles = (modelTesterSessionModule as Record<string, any>).resolveConversationReplayFiles;
|
||||
const loader = vi.fn(async () => ({
|
||||
filename: 'brief.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
data: 'data:application/pdf;base64,JVBERi0x',
|
||||
}));
|
||||
|
||||
const files = await resolveConversationReplayFiles?.(
|
||||
[
|
||||
{
|
||||
fileId: 'file-metapi-123',
|
||||
filename: 'brief.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
},
|
||||
],
|
||||
'claude',
|
||||
loader,
|
||||
);
|
||||
|
||||
expect(loader).toHaveBeenCalledWith('file-metapi-123');
|
||||
expect(files).toEqual([
|
||||
{
|
||||
fileId: 'file-metapi-123',
|
||||
filename: 'brief.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
data: 'data:application/pdf;base64,JVBERi0x',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves file reference parts when building responses conversation payloads', () => {
|
||||
const payload = buildApiPayload(
|
||||
[{
|
||||
@@ -486,6 +529,136 @@ describe('modelTesterSession', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('prefers native file ids over inline backups for OpenAI and Responses payloads', () => {
|
||||
const message = {
|
||||
id: 'u1',
|
||||
role: 'user' as const,
|
||||
content: 'reuse native file id',
|
||||
createAt: 1,
|
||||
parts: [
|
||||
{
|
||||
type: 'input_file' as const,
|
||||
fileId: 'file-metapi-123',
|
||||
filename: 'brief.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
data: 'data:application/pdf;base64,JVBERi0x',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
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: 'reuse native file id' },
|
||||
{
|
||||
type: 'file',
|
||||
file: {
|
||||
file_id: 'file-metapi-123',
|
||||
filename: 'brief.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: 'reuse native file id' },
|
||||
{
|
||||
type: 'input_file',
|
||||
file_id: 'file-metapi-123',
|
||||
filename: 'brief.pdf',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
stream: false,
|
||||
temperature: 0.7,
|
||||
});
|
||||
});
|
||||
|
||||
it('strips data URL wrappers when replaying inline files into OpenAI chat payloads', () => {
|
||||
const payload = buildApiPayload(
|
||||
[{
|
||||
id: 'u1',
|
||||
role: 'user',
|
||||
content: 'summarize this inline file',
|
||||
createAt: 1,
|
||||
parts: [
|
||||
{
|
||||
type: 'input_file',
|
||||
filename: 'brief.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
data: 'data:application/pdf;base64,JVBERi0x',
|
||||
},
|
||||
],
|
||||
} as ChatMessage],
|
||||
{
|
||||
...DEFAULT_INPUTS,
|
||||
model: 'gpt-4.1',
|
||||
protocol: 'openai',
|
||||
},
|
||||
DEFAULT_PARAMETER_ENABLED,
|
||||
);
|
||||
|
||||
expect(payload.jsonBody).toEqual({
|
||||
model: 'gpt-4.1',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'summarize this inline file' },
|
||||
{
|
||||
type: 'file',
|
||||
file: {
|
||||
file_data: 'JVBERi0x',
|
||||
filename: 'brief.pdf',
|
||||
mime_type: 'application/pdf',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
stream: false,
|
||||
temperature: 0.7,
|
||||
frequency_penalty: 0,
|
||||
presence_penalty: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('builds Claude conversation payloads with inline document blocks for conversation files', () => {
|
||||
const payload = buildApiPayload(
|
||||
[{
|
||||
|
||||
@@ -76,6 +76,16 @@ export type ConversationUploadedFile = {
|
||||
data?: string | null;
|
||||
};
|
||||
|
||||
export type ConversationDraftFile = {
|
||||
localId: string;
|
||||
name: string;
|
||||
mimeType: string;
|
||||
dataUrl: string;
|
||||
fileId?: string | null;
|
||||
status: 'pending' | 'uploading' | 'uploaded' | 'error';
|
||||
errorMessage?: string | null;
|
||||
};
|
||||
|
||||
export type TesterProxyEnvelope = {
|
||||
method: ProxyRequestMethod;
|
||||
path: string;
|
||||
@@ -133,6 +143,7 @@ export type ModelTesterSessionState = {
|
||||
inputs: ModelTesterInputs;
|
||||
parameterEnabled: ParameterEnabled;
|
||||
messages: ChatMessage[];
|
||||
conversationFiles: ConversationDraftFile[];
|
||||
pendingPayload: TesterProxyEnvelope | null;
|
||||
pendingJobId?: string | null;
|
||||
customRequestMode: boolean;
|
||||
@@ -200,8 +211,10 @@ const VALID_MODES: ReadonlySet<string> = new Set([
|
||||
'videos.inspect',
|
||||
]);
|
||||
const VALID_PROTOCOLS: ReadonlySet<string> = new Set(['openai', 'responses', 'claude', 'gemini']);
|
||||
const VALID_CONVERSATION_DRAFT_STATUSES: ReadonlySet<string> = new Set(['pending', 'uploading', 'uploaded', 'error']);
|
||||
const VALID_PROXY_METHODS: ReadonlySet<string> = new Set(['POST', 'GET', 'DELETE']);
|
||||
const VALID_REQUEST_KINDS: ReadonlySet<string> = new Set(['json', 'multipart', 'empty']);
|
||||
const LOCAL_PROXY_FILE_ID_PREFIX = 'file-metapi-';
|
||||
|
||||
let messageCounter = 0;
|
||||
|
||||
@@ -296,10 +309,20 @@ const toOpenAiContentPart = (part: ConversationContentPart): Record<string, unkn
|
||||
|
||||
if (part.type === 'input_file') {
|
||||
const filePayload: Record<string, unknown> = {};
|
||||
if (typeof part.data === 'string' && part.data.trim()) filePayload.file_data = part.data.trim();
|
||||
else if (typeof part.fileId === 'string' && part.fileId.trim()) filePayload.file_id = part.fileId.trim();
|
||||
const fileId = typeof part.fileId === 'string' && part.fileId.trim()
|
||||
? part.fileId.trim()
|
||||
: '';
|
||||
const rawData = typeof part.data === 'string' && part.data.trim()
|
||||
? part.data.trim()
|
||||
: '';
|
||||
const parsed = rawData
|
||||
? parseDataUrl(rawData)
|
||||
: null;
|
||||
if (fileId) filePayload.file_id = fileId;
|
||||
else if (rawData) filePayload.file_data = parsed?.data || rawData;
|
||||
if (typeof part.filename === 'string' && part.filename.trim()) filePayload.filename = part.filename.trim();
|
||||
if (typeof part.mimeType === 'string' && part.mimeType.trim()) filePayload.mime_type = part.mimeType.trim();
|
||||
else if (parsed?.mimeType) filePayload.mime_type = parsed.mimeType;
|
||||
if (Object.keys(filePayload).length === 0) return null;
|
||||
return {
|
||||
type: 'file',
|
||||
@@ -341,8 +364,8 @@ const toResponsesContentPart = (part: ConversationContentPart): Record<string, u
|
||||
const fileBlock: Record<string, unknown> = {
|
||||
type: 'input_file',
|
||||
};
|
||||
if (typeof part.data === 'string' && part.data.trim()) fileBlock.file_data = part.data.trim();
|
||||
else if (typeof part.fileId === 'string' && part.fileId.trim()) fileBlock.file_id = part.fileId.trim();
|
||||
if (typeof part.fileId === 'string' && part.fileId.trim()) fileBlock.file_id = part.fileId.trim();
|
||||
else if (typeof part.data === 'string' && part.data.trim()) fileBlock.file_data = part.data.trim();
|
||||
if (typeof part.filename === 'string' && part.filename.trim()) fileBlock.filename = part.filename.trim();
|
||||
if (Object.keys(fileBlock).length === 1) return null;
|
||||
return fileBlock;
|
||||
@@ -702,6 +725,42 @@ const parseParameterEnabled = (value: unknown): ParameterEnabled => {
|
||||
};
|
||||
};
|
||||
|
||||
const parseConversationDraftFiles = (value: unknown): ConversationDraftFile[] => {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.flatMap((item, index) => {
|
||||
if (!isRecord(item)) return [];
|
||||
const localId = typeof item.localId === 'string' && item.localId.trim()
|
||||
? item.localId.trim()
|
||||
: `draft-file-restored-${index}`;
|
||||
const name = typeof item.name === 'string' && item.name.trim()
|
||||
? item.name.trim()
|
||||
: 'upload.bin';
|
||||
const mimeType = typeof item.mimeType === 'string' && item.mimeType.trim()
|
||||
? item.mimeType.trim()
|
||||
: 'application/octet-stream';
|
||||
const dataUrl = typeof item.dataUrl === 'string' && item.dataUrl.trim()
|
||||
? item.dataUrl.trim()
|
||||
: '';
|
||||
if (!dataUrl) return [];
|
||||
|
||||
return [{
|
||||
localId,
|
||||
name,
|
||||
mimeType,
|
||||
dataUrl,
|
||||
...(typeof item.fileId === 'string' && item.fileId.trim()
|
||||
? { fileId: item.fileId.trim() }
|
||||
: {}),
|
||||
status: typeof item.status === 'string' && VALID_CONVERSATION_DRAFT_STATUSES.has(item.status)
|
||||
? item.status as ConversationDraftFile['status']
|
||||
: 'pending',
|
||||
errorMessage: typeof item.errorMessage === 'string'
|
||||
? item.errorMessage
|
||||
: null,
|
||||
}];
|
||||
});
|
||||
};
|
||||
|
||||
const parseModeState = (value: unknown): ModelTesterModeState => {
|
||||
if (!isRecord(value)) return { ...DEFAULT_MODE_STATE };
|
||||
return {
|
||||
@@ -943,6 +1002,48 @@ export const extractConversationUploadedFilesFromMessage = (
|
||||
});
|
||||
};
|
||||
|
||||
export async function resolveConversationReplayFiles(
|
||||
files: ConversationUploadedFile[],
|
||||
protocol: PlaygroundProtocol,
|
||||
loadLocalFile: (fileId: string) => Promise<{ filename?: string | null; mimeType?: string | null; data: string }>,
|
||||
): Promise<ConversationUploadedFile[]> {
|
||||
if (protocol !== 'claude' && protocol !== 'gemini') {
|
||||
return files.map((file) => ({ ...file }));
|
||||
}
|
||||
|
||||
return Promise.all(files.map(async (file) => {
|
||||
const fileId = typeof file.fileId === 'string' && file.fileId.trim()
|
||||
? file.fileId.trim()
|
||||
: '';
|
||||
const data = typeof file.data === 'string' && file.data.trim()
|
||||
? file.data.trim()
|
||||
: '';
|
||||
if (!fileId || data) {
|
||||
return { ...file };
|
||||
}
|
||||
if (!fileId.startsWith(LOCAL_PROXY_FILE_ID_PREFIX)) {
|
||||
throw new Error(`会话附件 ${fileId} 只有 file_id,当前协议需要可重放的内联数据。`);
|
||||
}
|
||||
|
||||
const resolved = await loadLocalFile(fileId);
|
||||
const resolvedData = typeof resolved?.data === 'string' ? resolved.data.trim() : '';
|
||||
if (!resolvedData) {
|
||||
throw new Error(`会话附件 ${fileId} 缺少可重放的数据。`);
|
||||
}
|
||||
|
||||
return {
|
||||
...file,
|
||||
...(typeof resolved?.filename === 'string' && resolved.filename.trim()
|
||||
? { filename: resolved.filename.trim() }
|
||||
: {}),
|
||||
...(typeof resolved?.mimeType === 'string' && resolved.mimeType.trim()
|
||||
? { mimeType: resolved.mimeType.trim() }
|
||||
: {}),
|
||||
data: resolvedData,
|
||||
};
|
||||
}));
|
||||
}
|
||||
|
||||
export const createLoadingAssistantMessage = (): ChatMessage =>
|
||||
createMessage('assistant', '', {
|
||||
status: MESSAGE_STATUS.LOADING,
|
||||
@@ -1047,6 +1148,7 @@ export const parseModelTesterSession = (raw: string | null): ModelTesterSessionS
|
||||
inputs,
|
||||
parameterEnabled,
|
||||
messages: sanitizeMessages(parsed.messages),
|
||||
conversationFiles: parseConversationDraftFiles(parsed.conversationFiles),
|
||||
pendingPayload: parsePendingPayload(parsed.pendingPayload, inputs, parameterEnabled),
|
||||
customRequestMode: toBoolean(parsed.customRequestMode, false),
|
||||
customRequestBody: typeof parsed.customRequestBody === 'string' ? parsed.customRequestBody : '',
|
||||
|
||||
Reference in New Issue
Block a user