complete codex cliproxyapi parity
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
Reference in New Issue
Block a user