complete codex cliproxyapi parity
This commit is contained in:
@@ -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