complete codex cliproxyapi parity

This commit is contained in:
cita
2026-03-20 11:23:29 +08:00
parent 5403a946c6
commit adc4fb1be7
28 changed files with 2969 additions and 204 deletions
+23 -6
View File
@@ -44,6 +44,7 @@ import {
type TestTargetFormat,
type TestChatPayload,
} from './helpers/modelTesterSession.js';
import { resolveConversationFileCapability } from './helpers/conversationFileCapabilities.js';
import ModernSelect from '../components/ModernSelect.js';
import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js';
import { tr } from '../i18n.js';
@@ -970,6 +971,14 @@ export default function ModelTester() {
return uploaded;
}, [conversationFiles, pushDebug]);
const inlineConversationFiles = useCallback((): ConversationUploadedFile[] =>
conversationFiles.map((item) => ({
fileId: item.fileId,
filename: item.name,
mimeType: item.mimeType,
data: item.dataUrl,
})), [conversationFiles]);
const buildConversationMessagesWithSystem = useCallback((baseMessages: ChatMessage[]) => {
if (!inputs.systemPrompt.trim()) return baseMessages;
return [
@@ -1339,7 +1348,11 @@ export default function ModelTester() {
() => filteredModels.map((item) => ({ value: item, label: item })),
[filteredModels],
);
const conversationFileSupported = inputs.protocol === 'openai' || inputs.protocol === 'responses';
const conversationFileCapability = useMemo(
() => resolveConversationFileCapability(inputs.protocol),
[inputs.protocol],
);
const conversationFileSupported = conversationFileCapability.supported;
const canSend = useMemo(() => {
if (sending || pendingJobId || !inputs.model) return false;
if (inputs.mode !== 'conversation') {
@@ -1808,7 +1821,7 @@ export default function ModelTester() {
}
if (conversationFiles.length > 0 && !conversationFileSupported) {
const message = '当前协议的会话附件仅支持 OpenAI / Responses;请切换协议或移除附件。';
const message = conversationFileCapability.reason || '当前协议暂不支持会话附件。';
setError(message);
pushDebug('warn', message);
return;
@@ -1817,7 +1830,9 @@ export default function ModelTester() {
if (!customRequestMode && conversationFileSupported && conversationFiles.length > 0) {
setSending(true);
try {
const uploadedFiles = await uploadConversationFiles();
const uploadedFiles = conversationFileCapability.documentMode === 'inline_only'
? inlineConversationFiles()
: await uploadConversationFiles();
setInput('');
setConversationFiles([]);
await sendWithPrompt(trimmed, messages, uploadedFiles);
@@ -1855,7 +1870,7 @@ export default function ModelTester() {
{ stream: inputs.stream, jobMode: !inputs.stream },
),
);
}, [canSend, conversationFileSupported, conversationFiles.length, customRequestBody, customRequestMode, dispatchPayload, input, inputs.mode, messages, pushDebug, sendModeRequest, sendWithPrompt, uploadConversationFiles]);
}, [canSend, conversationFileCapability, conversationFileSupported, conversationFiles.length, customRequestBody, customRequestMode, dispatchPayload, inlineConversationFiles, input, inputs.mode, messages, pushDebug, sendModeRequest, sendWithPrompt, uploadConversationFiles]);
const retryPending = useCallback(async () => {
if (sending || pendingJobId || !pendingPayload) return;
@@ -2812,8 +2827,10 @@ export default function ModelTester() {
{customRequestMode
? '自定义请求模式不会自动上传这些附件;关闭自定义模式后可走标准 /v1/files 链路。'
: !conversationFileSupported
? '当前协议暂不支持会话附件注入;请切换到 OpenAI 或 Responses。'
: '支持 PDF / TXT / Markdown / JSON / 图片 / 音频;发送前会先上传到 /v1/files。'}
? (conversationFileCapability.reason || '当前协议暂不支持会话附件注入。')
: conversationFileCapability.documentMode === 'inline_only'
? '支持 PDF / TXT / Markdown / JSON / 图片 / 音频;发送时会以内联文档方式注入。'
: '支持 PDF / TXT / Markdown / JSON / 图片 / 音频;发送前会先上传到 /v1/files。'}
</span>
</div>
{conversationFiles.length > 0 && (
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest';
import { resolveConversationFileCapability } from './conversationFileCapabilities.js';
describe('resolveConversationFileCapability', () => {
it('keeps OpenAI and Responses on native file references', () => {
expect(resolveConversationFileCapability('openai')).toEqual({
supported: true,
documentMode: 'native',
reason: '',
});
expect(resolveConversationFileCapability('responses')).toEqual({
supported: true,
documentMode: 'native',
reason: '',
});
});
it('marks Claude and Gemini as inline-only document transports', () => {
expect(resolveConversationFileCapability('claude')).toEqual({
supported: true,
documentMode: 'inline_only',
reason: '当前协议会以内联文档方式发送会话附件。',
});
expect(resolveConversationFileCapability('gemini')).toEqual({
supported: true,
documentMode: 'inline_only',
reason: '当前协议会以内联文档方式发送会话附件。',
});
});
});
@@ -0,0 +1,33 @@
import type { PlaygroundProtocol } from './modelTesterSession.js';
export type ConversationFileCapability = {
supported: boolean;
documentMode: 'native' | 'inline_only' | 'unsupported';
reason: string;
};
export function resolveConversationFileCapability(
protocol: PlaygroundProtocol,
): ConversationFileCapability {
if (protocol === 'openai' || protocol === 'responses') {
return {
supported: true,
documentMode: 'native',
reason: '',
};
}
if (protocol === 'claude' || protocol === 'gemini') {
return {
supported: true,
documentMode: 'inline_only',
reason: '当前协议会以内联文档方式发送会话附件。',
};
}
return {
supported: false,
documentMode: 'unsupported',
reason: '当前协议暂不支持会话附件。',
};
}
@@ -435,6 +435,98 @@ describe('modelTesterSession', () => {
});
});
it('builds Claude conversation payloads with inline document blocks for conversation files', () => {
const payload = buildApiPayload(
[{
id: 'u1',
role: 'user',
content: 'summarize this',
createAt: 1,
parts: [
{
type: 'input_file',
filename: 'brief.pdf',
mimeType: 'application/pdf',
data: 'data:application/pdf;base64,JVBERi0xLjc=',
},
],
} as ChatMessage],
{
...DEFAULT_INPUTS,
model: 'claude-opus-4-6',
protocol: 'claude',
},
DEFAULT_PARAMETER_ENABLED,
);
expect(payload.jsonBody).toEqual({
model: 'claude-opus-4-6',
stream: false,
max_tokens: 4096,
messages: [
{
role: 'user',
content: [
{ type: 'text', text: 'summarize this' },
{
type: 'document',
source: {
type: 'base64',
media_type: 'application/pdf',
data: 'JVBERi0xLjc=',
},
title: 'brief.pdf',
},
],
},
],
temperature: 0.7,
});
});
it('builds Gemini conversation payloads with inlineData document parts for conversation files', () => {
const payload = buildApiPayload(
[{
id: 'u1',
role: 'user',
content: 'summarize this',
createAt: 1,
parts: [
{
type: 'input_file',
filename: 'brief.pdf',
mimeType: 'application/pdf',
data: 'data:application/pdf;base64,JVBERi0xLjc=',
},
],
} as ChatMessage],
{
...DEFAULT_INPUTS,
model: 'gemini-2.5-pro',
protocol: 'gemini',
},
DEFAULT_PARAMETER_ENABLED,
);
expect(payload.jsonBody).toEqual({
contents: [
{
role: 'user',
parts: [
{ text: 'summarize this' },
{
inlineData: {
mimeType: 'application/pdf',
data: 'JVBERi0xLjc=',
},
},
],
},
],
generationConfig: { temperature: 0.7 },
});
});
it('builds embeddings and search envelopes', () => {
expect(buildEmbeddingsRequestEnvelope('hello', { ...DEFAULT_INPUTS, model: 'text-embedding-3-large' })).toEqual({
method: 'POST',
+119 -14
View File
@@ -70,9 +70,10 @@ export type PlaygroundMultipartFile = {
};
export type ConversationUploadedFile = {
fileId: string;
fileId?: string | null;
filename?: string | null;
mimeType?: string | null;
data?: string | null;
};
export type TesterProxyEnvelope = {
@@ -295,9 +296,9 @@ const toOpenAiContentPart = (part: ConversationContentPart): Record<string, unkn
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();
else 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.mimeType === 'string' && part.mimeType.trim()) filePayload.mime_type = part.mimeType.trim();
if (Object.keys(filePayload).length === 0) return null;
return {
@@ -340,9 +341,9 @@ const toResponsesContentPart = (part: ConversationContentPart): Record<string, u
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();
else 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 (Object.keys(fileBlock).length === 1) return null;
return fileBlock;
}
@@ -350,11 +351,95 @@ const toResponsesContentPart = (part: ConversationContentPart): Record<string, u
return null;
};
const toClaudeContentPart = (part: ConversationContentPart): Record<string, unknown> | null => {
if (part.type !== 'input_file' || typeof part.data !== 'string' || !part.data.trim()) return null;
const parsed = parseDataUrl(part.data);
if (!parsed) return null;
return {
type: 'document',
source: {
type: 'base64',
media_type: part.mimeType || parsed.mimeType || 'application/octet-stream',
data: parsed.data,
},
...(typeof part.filename === 'string' && part.filename.trim()
? { title: part.filename.trim() }
: {}),
};
};
const toGeminiContentPart = (part: ConversationContentPart): Record<string, unknown> | null => {
if (part.type === 'image_url') {
return {
fileData: {
fileUri: part.url,
},
};
}
if (part.type === 'image_inline') {
const parsed = parseDataUrl(part.dataUrl);
if (!parsed) return null;
return {
inlineData: {
mimeType: part.mimeType || parsed.mimeType,
data: parsed.data,
},
};
}
if (part.type === 'input_audio') {
const parsed = parseDataUrl(part.dataUrl);
if (!parsed) return null;
return {
inlineData: {
mimeType: part.mimeType || parsed.mimeType,
data: parsed.data,
},
};
}
if (part.type === 'input_file') {
if (typeof part.data === 'string' && part.data.trim()) {
const parsed = parseDataUrl(part.data);
if (!parsed) return null;
return {
inlineData: {
mimeType: part.mimeType || parsed.mimeType || 'application/octet-stream',
data: parsed.data,
},
};
}
const fileUri = typeof part.fileId === 'string' && part.fileId.trim()
? part.fileId.trim()
: '';
if (!fileUri) return null;
return {
fileData: {
fileUri,
...(typeof part.mimeType === 'string' && part.mimeType.trim()
? { mimeType: part.mimeType.trim() }
: {}),
},
};
}
return null;
};
const toGeminiContents = (messages: ApiChatMessage[]) =>
messages.map((message) => ({
role: message.role === 'assistant' ? 'model' : 'user',
parts: [{ text: message.content }],
}));
parts: [
...(message.content.trim() ? [{ text: message.content }] : []),
...((Array.isArray(message.parts)
? message.parts
.map((part) => toGeminiContentPart(part))
.filter((item): item is Record<string, unknown> => !!item)
: [])),
],
})).filter((message) => Array.isArray(message.parts) && message.parts.length > 0);
const toOpenAiMessageContent = (message: ApiChatMessage): unknown => {
const parts = Array.isArray(message.parts)
@@ -409,10 +494,21 @@ const toResponsesInput = (messages: ApiChatMessage[]) => {
const toClaudeMessages = (messages: ApiChatMessage[]) =>
messages
.filter((message) => message.role !== 'system' && message.role !== 'developer')
.map((message) => ({
role: message.role === 'assistant' ? 'assistant' : 'user',
content: message.content,
}));
.map((message) => {
const parts = [
...(message.content.trim() ? [{ type: 'text', text: message.content }] : []),
...((Array.isArray(message.parts)
? message.parts
.map((part) => toClaudeContentPart(part))
.filter((item): item is Record<string, unknown> => !!item)
: [])),
];
return {
role: message.role === 'assistant' ? 'assistant' : 'user',
content: parts.length > 0 ? parts : message.content,
};
});
const buildConversationJsonBody = (
messages: ChatMessage[],
@@ -779,9 +875,18 @@ export const createConversationInputFilePart = (
file: ConversationUploadedFile,
): ConversationContentPart => ({
type: 'input_file',
fileId: file.fileId,
filename: file.filename ?? null,
mimeType: file.mimeType ?? null,
...(typeof file.fileId === 'string' && file.fileId.trim()
? { fileId: file.fileId.trim() }
: {}),
...(typeof file.filename === 'string' && file.filename.trim()
? { filename: file.filename.trim() }
: {}),
...(typeof file.mimeType === 'string' && file.mimeType.trim()
? { mimeType: file.mimeType.trim() }
: {}),
...(typeof file.data === 'string' && file.data.trim()
? { data: file.data.trim() }
: {}),
});
export const createConversationUserMessage = (