1
0
forked from boos/BoosAPI

Merge pull request #219 from cita-777/codex/latest-main-protocol-review

[codex] fix modeltester attachment replay and add proxy file retention
This commit is contained in:
Cita
2026-03-21 01:10:36 +08:00
committed by GitHub
11 changed files with 703 additions and 48 deletions
+2
View File
@@ -110,6 +110,8 @@ export function buildConfig(env: NodeJS.ProcessEnv) {
tokenRouterCacheTtlMs: Math.max(100, Math.trunc(parseNumber(env.TOKEN_ROUTER_CACHE_TTL_MS, 1_500))),
proxyLogRetentionDays: Math.max(0, Math.trunc(parseNumber(env.PROXY_LOG_RETENTION_DAYS, 30))),
proxyLogRetentionPruneIntervalMinutes: Math.max(1, Math.trunc(parseNumber(env.PROXY_LOG_RETENTION_PRUNE_INTERVAL_MINUTES, 30))),
proxyFileRetentionDays: Math.max(0, Math.trunc(parseNumber(env.PROXY_FILE_RETENTION_DAYS, 30))),
proxyFileRetentionPruneIntervalMinutes: Math.max(1, Math.trunc(parseNumber(env.PROXY_FILE_RETENTION_PRUNE_INTERVAL_MINUTES, 60))),
proxyErrorKeywords: parseCsvList(env.PROXY_ERROR_KEYWORDS),
proxyEmptyContentFailEnabled: parseBoolean(env.PROXY_EMPTY_CONTENT_FAIL, false),
codexHeaderDefaults: {
+3
View File
@@ -23,6 +23,7 @@ import { siteAnnouncementsRoutes } from './routes/api/siteAnnouncements.js';
import { proxyRoutes } from './routes/proxy/router.js';
import { startScheduler } from './services/checkinScheduler.js';
import { rebuildTokenRoutesFromAvailability } from './services/modelService.js';
import { startProxyFileRetentionService, stopProxyFileRetentionService } from './services/proxyFileRetentionService.js';
import { setLegacyProxyLogRetentionFallbackEnabled, stopProxyLogRetentionService } from './services/proxyLogRetentionService.js';
import { buildStartupSummaryLines } from './services/startupInfo.js';
import { repairStoredCreatedAtValues } from './services/storedTimestampRepairService.js';
@@ -406,7 +407,9 @@ try {
console.warn(`Failed to start OAuth callback listeners: ${(error as Error)?.message || 'unknown error'}`);
}
setLegacyProxyLogRetentionFallbackEnabled(!config.logCleanupConfigured);
startProxyFileRetentionService();
app.addHook('onClose', async () => {
stopProxyFileRetentionService();
stopProxyLogRetentionService();
await stopOAuthLoopbackCallbackServers();
});
@@ -0,0 +1,112 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
type DbModule = typeof import('../db/index.js');
type StoreModule = typeof import('./proxyFileStore.js');
type RetentionModule = typeof import('./proxyFileRetentionService.js');
type ConfigModule = typeof import('../config.js');
describe('proxyFileRetentionService', () => {
let db: DbModule['db'];
let schema: DbModule['schema'];
let store: StoreModule;
let retention: RetentionModule;
let config: ConfigModule['config'];
let dataDir = '';
let originalRetentionDays = 0;
let originalPruneIntervalMinutes = 0;
beforeAll(async () => {
dataDir = mkdtempSync(join(tmpdir(), 'metapi-proxy-file-retention-'));
process.env.DATA_DIR = dataDir;
await import('../db/migrate.js');
const dbModule = await import('../db/index.js');
const storeModule = await import('./proxyFileStore.js');
const retentionModule = await import('./proxyFileRetentionService.js');
const configModule = await import('../config.js');
db = dbModule.db;
schema = dbModule.schema;
store = storeModule;
retention = retentionModule;
config = configModule.config;
originalRetentionDays = config.proxyFileRetentionDays;
originalPruneIntervalMinutes = config.proxyFileRetentionPruneIntervalMinutes;
});
beforeEach(async () => {
config.proxyFileRetentionDays = originalRetentionDays;
config.proxyFileRetentionPruneIntervalMinutes = originalPruneIntervalMinutes;
await db.delete(schema.proxyFiles).run();
});
afterAll(async () => {
config.proxyFileRetentionDays = originalRetentionDays;
config.proxyFileRetentionPruneIntervalMinutes = originalPruneIntervalMinutes;
const dbModule = await import('../db/index.js');
await dbModule.closeDbConnections();
delete process.env.DATA_DIR;
});
it('returns disabled when proxy file retention is turned off', async () => {
config.proxyFileRetentionDays = 0;
const result = await retention.cleanupExpiredProxyFiles(Date.parse('2026-03-20T00:00:00Z'));
expect(result).toEqual({
enabled: false,
retentionDays: 0,
cutoffUtc: null,
deleted: 0,
});
});
it('purges files older than the configured retention window', async () => {
config.proxyFileRetentionDays = 7;
await db.insert(schema.proxyFiles).values([
{
publicId: 'file-metapi-old',
ownerType: 'global_proxy_token',
ownerId: 'global',
filename: 'old.txt',
mimeType: 'text/plain',
purpose: 'assistants',
byteSize: 3,
sha256: 'old-hash',
contentBase64: Buffer.from('old').toString('base64'),
createdAt: '2026-03-10 00:00:00',
updatedAt: '2026-03-10 00:00:00',
deletedAt: null,
},
{
publicId: 'file-metapi-new',
ownerType: 'global_proxy_token',
ownerId: 'global',
filename: 'new.txt',
mimeType: 'text/plain',
purpose: 'assistants',
byteSize: 3,
sha256: 'new-hash',
contentBase64: Buffer.from('new').toString('base64'),
createdAt: '2026-03-18 00:00:00',
updatedAt: '2026-03-18 00:00:00',
deletedAt: null,
},
]).run();
const result = await retention.cleanupExpiredProxyFiles(Date.parse('2026-03-20T00:00:00Z'));
expect(result).toEqual({
enabled: true,
retentionDays: 7,
cutoffUtc: '2026-03-13 00:00:00',
deleted: 1,
});
const remaining = await store.listProxyFilesByOwner({ ownerType: 'global_proxy_token', ownerId: 'global' });
expect(remaining.map((item) => item.publicId)).toEqual(['file-metapi-new']);
});
});
@@ -0,0 +1,63 @@
import { config } from '../config.js';
import { getLogCleanupCutoffUtc } from './logCleanupService.js';
import { purgeExpiredProxyFiles } from './proxyFileStore.js';
let retentionTimer: ReturnType<typeof setInterval> | null = null;
export function getProxyFileRetentionCutoffUtc(nowMs = Date.now()): string | null {
const days = Math.max(0, Math.trunc(config.proxyFileRetentionDays));
if (days <= 0) return null;
return getLogCleanupCutoffUtc(days, nowMs);
}
export async function cleanupExpiredProxyFiles(nowMs = Date.now()): Promise<{
enabled: boolean;
retentionDays: number;
cutoffUtc: string | null;
deleted: number;
}> {
const retentionDays = Math.max(0, Math.trunc(config.proxyFileRetentionDays));
const cutoffUtc = getProxyFileRetentionCutoffUtc(nowMs);
if (!cutoffUtc) {
return {
enabled: false,
retentionDays,
cutoffUtc: null,
deleted: 0,
};
}
const deleted = await purgeExpiredProxyFiles(cutoffUtc);
return {
enabled: true,
retentionDays,
cutoffUtc,
deleted,
};
}
export function startProxyFileRetentionService(): void {
if (retentionTimer) return;
const intervalMinutes = Math.max(1, Math.trunc(config.proxyFileRetentionPruneIntervalMinutes));
const intervalMs = intervalMinutes * 60 * 1000;
const runCleanup = async () => {
try {
const result = await cleanupExpiredProxyFiles();
if (!result.enabled || result.deleted <= 0) return;
console.info(`[proxy-file-retention] deleted ${result.deleted} files before ${result.cutoffUtc}`);
} catch (error) {
console.warn('[proxy-file-retention] cleanup failed', error);
}
};
void runCleanup();
retentionTimer = setInterval(() => { void runCleanup(); }, intervalMs);
retentionTimer.unref?.();
}
export function stopProxyFileRetentionService(): void {
if (!retentionTimer) return;
clearInterval(retentionTimer);
retentionTimer = null;
}
@@ -112,4 +112,45 @@ describe('proxyFileStore', () => {
});
expect(loaded).toBeNull();
});
it('purges expired proxy files before a cutoff to control database growth', async () => {
await db.insert(schema.proxyFiles).values([
{
publicId: 'file-metapi-old',
ownerType: 'global_proxy_token',
ownerId: 'global',
filename: 'old.txt',
mimeType: 'text/plain',
purpose: 'assistants',
byteSize: 3,
sha256: 'old-hash',
contentBase64: Buffer.from('old').toString('base64'),
createdAt: '2026-03-01 00:00:00',
updatedAt: '2026-03-01 00:00:00',
deletedAt: null,
},
{
publicId: 'file-metapi-new',
ownerType: 'global_proxy_token',
ownerId: 'global',
filename: 'new.txt',
mimeType: 'text/plain',
purpose: 'assistants',
byteSize: 3,
sha256: 'new-hash',
contentBase64: Buffer.from('new').toString('base64'),
createdAt: '2026-03-19 00:00:00',
updatedAt: '2026-03-19 00:00:00',
deletedAt: null,
},
]).run();
const purgeExpiredProxyFiles = (store as Record<string, any>).purgeExpiredProxyFiles;
const deleted = await purgeExpiredProxyFiles?.('2026-03-10 00:00:00');
expect(deleted).toBe(1);
const remainingRows = await db.select().from(schema.proxyFiles).all();
expect(remainingRows.map((row) => row.publicId)).toEqual(['file-metapi-new']);
});
});
+15 -1
View File
@@ -1,4 +1,4 @@
import { and, desc, eq, isNull, or } from 'drizzle-orm';
import { and, desc, eq, isNull, lt, or } from 'drizzle-orm';
import { createHash } from 'node:crypto';
import { db, schema } from '../db/index.js';
import type { ProxyResourceOwner } from '../middleware/auth.js';
@@ -204,3 +204,17 @@ export async function softDeleteProxyFile(publicId: string): Promise<boolean> {
.run();
return Number(result?.changes || 0) > 0;
}
export async function purgeExpiredProxyFiles(cutoffUtc: string): Promise<number> {
const normalizedCutoff = cutoffUtc.trim();
if (!normalizedCutoff) return 0;
const result = await db.delete(schema.proxyFiles)
.where(or(
lt(schema.proxyFiles.createdAt, normalizedCutoff),
and(isNull(schema.proxyFiles.createdAt), lt(schema.proxyFiles.updatedAt, normalizedCutoff)),
))
.run();
return Number(result?.changes || 0);
}
+52
View File
@@ -97,4 +97,56 @@ describe('api proxy test timeout handling', () => {
await vi.advanceTimersByTimeAsync(30_000);
await expect(promise).resolves.toMatchObject({ message: '请求超时(30s)' });
});
it('times out replay hydration file-content fetches after 30 seconds', async () => {
installPendingFetch();
const getProxyFileContentDataUrl = (api as Record<string, any>).getProxyFileContentDataUrl;
let settled = false;
const handled = getProxyFileContentDataUrl?.('file-metapi-123')
.then(() => ({ ok: true as const }))
.catch((error: Error) => ({ ok: false as const, error }))
.finally(() => {
settled = true;
});
await vi.advanceTimersByTimeAsync(30_000);
expect(settled).toBe(true);
const result = await handled;
expect(result.ok).toBe(false);
if (result.ok) {
throw new Error('Expected replay hydration file-content fetch to time out');
}
expect(result.error.message).toBe('请求超时(30s)');
});
it('loads proxy file content as a data URL for replay hydration', async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(
new Blob([Buffer.from('PDF')], { type: 'application/pdf' }),
{
status: 200,
headers: {
'content-type': 'application/pdf',
'content-disposition': 'inline; filename="brief.pdf"',
},
},
));
vi.stubGlobal('fetch', fetchMock);
const getProxyFileContentDataUrl = (api as Record<string, any>).getProxyFileContentDataUrl;
const result = await getProxyFileContentDataUrl?.('file-metapi-123');
expect(fetchMock).toHaveBeenCalledWith('/v1/files/file-metapi-123/content', expect.objectContaining({
method: 'GET',
headers: expect.objectContaining({
Authorization: 'Bearer token-1',
}),
}));
expect(result).toEqual({
filename: 'brief.pdf',
mimeType: 'application/pdf',
data: 'data:application/pdf;base64,UERG',
});
});
});
+104 -33
View File
@@ -4,7 +4,74 @@ type RequestOptions = RequestInit & {
timeoutMs?: number;
};
async function request(url: string, options: RequestOptions = {}) {
function requireAuthToken(): string {
const token = getAuthToken(localStorage);
if (!token) {
const hadToken = !!localStorage.getItem('auth_token');
clearAuthSession(localStorage);
if (hadToken && typeof window !== 'undefined' && typeof window.location?.reload === 'function') {
window.location.reload();
}
throw new Error('Session expired');
}
return token;
}
async function extractResponseErrorMessage(res: Response): Promise<string> {
let message = `HTTP ${res.status}`;
try {
const text = await res.text();
if (text) {
try {
const json = JSON.parse(text);
if (json?.message && typeof json.message === 'string') {
message = json.message;
} else if (json?.error && typeof json.error === 'string') {
message = json.error;
} else if (json?.error?.message && typeof json.error.message === 'string') {
message = json.error.message;
} else {
message = `${message}: ${text.slice(0, 120)}`;
}
} catch {
message = `${message}: ${text.slice(0, 120)}`;
}
}
} catch { }
return message;
}
function parseContentDispositionFilename(headerValue: string | null): string | null {
if (!headerValue) return null;
const utf8Match = /filename\*=UTF-8''([^;]+)/i.exec(headerValue);
if (utf8Match?.[1]) {
try {
return decodeURIComponent(utf8Match[1]);
} catch {
return utf8Match[1];
}
}
const quotedMatch = /filename="([^"]+)"/i.exec(headerValue);
if (quotedMatch?.[1]) return quotedMatch[1];
const bareMatch = /filename=([^;]+)/i.exec(headerValue);
return bareMatch?.[1]?.trim() || null;
}
function arrayBufferToBase64(buffer: ArrayBuffer): string {
if (typeof Buffer !== 'undefined') {
return Buffer.from(buffer).toString('base64');
}
let binary = '';
const bytes = new Uint8Array(buffer);
const chunkSize = 0x8000;
for (let index = 0; index < bytes.length; index += chunkSize) {
binary += String.fromCharCode(...bytes.subarray(index, index + chunkSize));
}
return btoa(binary);
}
async function fetchAuthenticatedResponse(url: string, options: RequestOptions = {}): Promise<Response> {
const { timeoutMs = 30_000, signal: externalSignal, ...fetchOptions } = options;
const controller = new AbortController();
let timeoutHandle: ReturnType<typeof setTimeout> | null = setTimeout(() => {
@@ -22,15 +89,7 @@ async function request(url: string, options: RequestOptions = {}) {
}
}
const token = getAuthToken(localStorage);
if (!token) {
const hadToken = !!localStorage.getItem('auth_token');
clearAuthSession(localStorage);
if (hadToken && typeof window !== 'undefined' && typeof window.location?.reload === 'function') {
window.location.reload();
}
throw new Error('Session expired');
}
const token = requireAuthToken();
const headers: Record<string, string> = {
'Authorization': `Bearer ${token}`,
};
@@ -48,31 +107,12 @@ async function request(url: string, options: RequestOptions = {}) {
if (res.status === 401 || res.status === 403) {
const hadToken = !!getAuthToken(localStorage);
clearAuthSession(localStorage);
if (hadToken) window.location.reload();
if (hadToken && typeof window !== 'undefined' && typeof window.location?.reload === 'function') {
window.location.reload();
}
throw new Error('Session expired');
}
if (!res.ok) {
let message = `HTTP ${res.status}`;
try {
const text = await res.text();
if (text) {
try {
const json = JSON.parse(text);
if (json?.message && typeof json.message === 'string') {
message = json.message;
} else if (json?.error && typeof json.error === 'string') {
message = json.error;
} else {
message = `${message}: ${text.slice(0, 120)}`;
}
} catch {
message = `${message}: ${text.slice(0, 120)}`;
}
}
} catch { }
throw new Error(message);
}
return res.json();
return res;
} catch (error: any) {
if (error?.name === 'AbortError') {
if (externalSignal?.aborted) throw error;
@@ -88,6 +128,14 @@ async function request(url: string, options: RequestOptions = {}) {
}
}
async function request(url: string, options: RequestOptions = {}) {
const res = await fetchAuthenticatedResponse(url, options);
if (!res.ok) {
throw new Error(await extractResponseErrorMessage(res));
}
return res.json();
}
function buildQueryString(params?: Record<string, string | number | boolean | null | undefined>) {
if (!params) return '';
const searchParams = new URLSearchParams();
@@ -623,6 +671,29 @@ export const api = {
}),
getProxyTestJob: (jobId: string) => request(`/api/test/proxy/jobs/${encodeURIComponent(jobId)}`),
deleteProxyTestJob: (jobId: string) => request(`/api/test/proxy/jobs/${encodeURIComponent(jobId)}`, { method: 'DELETE' }),
getProxyFileContentDataUrl: async (
fileId: string,
options: Pick<RequestOptions, 'signal' | 'timeoutMs'> = {},
) => {
const response = await fetchAuthenticatedResponse(`/v1/files/${encodeURIComponent(fileId)}/content`, {
method: 'GET',
...options,
});
if (!response.ok) {
throw new Error(await extractResponseErrorMessage(response));
}
const mimeType = (response.headers.get('content-type') || 'application/octet-stream')
.split(';')[0]
.trim() || 'application/octet-stream';
const filename = parseContentDispositionFilename(response.headers.get('content-disposition'));
const base64 = arrayBufferToBase64(await response.arrayBuffer());
return {
filename,
mimeType,
data: `data:${mimeType};base64,${base64}`,
};
},
testProxy: (data: ProxyTestRequestEnvelope) =>
request('/api/test/proxy', {
method: 'POST',
+31 -9
View File
@@ -30,10 +30,12 @@ import {
parseCustomRequestBody,
parseModelTesterSession,
processThinkTags,
resolveConversationReplayFiles,
serializeModelTesterSession,
syncCustomRequestBodyToMessages,
syncMessagesToCustomRequestBody,
type ChatMessage,
type ConversationDraftFile,
type ConversationContentPart,
type ConversationUploadedFile,
type DebugTab,
@@ -74,12 +76,7 @@ type UploadState = {
dataUrl: string;
};
type ConversationFileState = UploadState & {
localId: string;
fileId?: string | null;
status: 'pending' | 'uploading' | 'uploaded' | 'error';
errorMessage?: string | null;
};
type ConversationFileState = ConversationDraftFile;
const POLL_INTERVAL_MS = 1200;
const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
@@ -767,6 +764,7 @@ export default function ModelTester() {
setAssetPrompt(restored.modeState.imagesPrompt || restored.modeState.videosPrompt);
setVideoInspectId(restored.modeState.videosInspectId);
setVideoInspectAction(restored.inputs.videoInspectAction === 'delete' ? 'DELETE' : 'GET');
setConversationFiles(restored.conversationFiles);
if (restored.pendingJobId) {
setSending(true);
@@ -827,6 +825,7 @@ export default function ModelTester() {
inputs,
parameterEnabled,
messages,
conversationFiles,
modeState: {
embeddingsInput: embeddingInputText,
searchQuery: searchQueryValue,
@@ -852,6 +851,7 @@ export default function ModelTester() {
input,
inputs,
messages,
conversationFiles,
assetPrompt,
customRequestBody,
embeddingInputText,
@@ -1014,6 +1014,19 @@ export default function ModelTester() {
return false;
}, [conversationFileCapability, conversationFileHint, pushDebug]);
const loadLocalConversationFile = useCallback(async (fileId: string) => {
const resolved = await api.getProxyFileContentDataUrl(fileId) as {
filename?: string | null;
mimeType?: string | null;
data: string;
};
return {
filename: resolved.filename || null,
mimeType: resolved.mimeType || null,
data: resolved.data,
};
}, []);
const buildConversationMessagesWithSystem = useCallback((baseMessages: ChatMessage[]) => {
if (!inputs.systemPrompt.trim()) return baseMessages;
return [
@@ -1805,10 +1818,19 @@ export default function ModelTester() {
baseMessages: ChatMessage[],
files: ConversationUploadedFile[] = [],
) => {
if (!ensureSupportedConversationFiles(files)) {
let resolvedFiles = files;
try {
resolvedFiles = await resolveConversationReplayFiles(files, inputs.protocol, loadLocalConversationFile);
} catch (resolveError: any) {
const message = resolveError?.message || '读取会话附件失败';
setError(message);
pushDebug('error', message);
return;
}
const userMessage = createConversationUserMessage(prompt, files);
if (!ensureSupportedConversationFiles(resolvedFiles)) {
return;
}
const userMessage = createConversationUserMessage(prompt, resolvedFiles);
const loadingAssistant = createLoadingAssistantMessage();
const nextMessages = [...baseMessages, userMessage, loadingAssistant];
const useProxyTransport = inputs.protocol === 'gemini' || customRequestMode;
@@ -1826,7 +1848,7 @@ export default function ModelTester() {
}
await dispatchPayload(nextMessages, payload, { syncedCustomBody });
}, [buildConversationProxyEnvelope, buildPayloadWithMessages, createConversationUserMessage, customRequestMode, dispatchPayload, dispatchProxyEnvelope, ensureSupportedConversationFiles, inputs.protocol, pushDebug]);
}, [buildConversationProxyEnvelope, buildPayloadWithMessages, createConversationUserMessage, customRequestMode, dispatchPayload, dispatchProxyEnvelope, ensureSupportedConversationFiles, inputs.protocol, loadLocalConversationFile, pushDebug]);
const sendModeRequest = useCallback(async () => {
const envelope = buildModeProxyEnvelope();
@@ -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(
[{
+106 -4
View File
@@ -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 : '',