1
0
forked from boos/BoosAPI

Merge pull request #218 from cita-777/codex/modeltester-attachment-review-fixes

[codex] Fix model tester attachment replay and capability gating
This commit is contained in:
Cita
2026-03-20 23:12:43 +08:00
committed by GitHub
9 changed files with 561 additions and 105 deletions
@@ -34,7 +34,7 @@ describe('anthropicMessagesTransformer protocol contract', () => {
});
});
it('parses native document blocks into canonical file parts', () => {
it('preserves native image and document blocks when parsing into canonical envelopes', () => {
const result = anthropicMessagesTransformer.parseRequest({
model: 'claude-sonnet-4-5',
max_tokens: 256,
@@ -42,15 +42,23 @@ describe('anthropicMessagesTransformer protocol contract', () => {
{
role: 'user',
content: [
{ type: 'text', text: 'summarize this pdf' },
{ type: 'text', text: 'inspect both attachments' },
{
type: 'image',
source: {
type: 'base64',
media_type: 'image/png',
data: 'QUFBQQ==',
},
},
{
type: 'document',
title: 'brief.pdf',
source: {
type: 'base64',
media_type: 'application/pdf',
data: 'JVBERi0xLjQK',
data: 'JVBERi0x',
},
title: 'brief.pdf',
},
],
},
@@ -58,20 +66,23 @@ describe('anthropicMessagesTransformer protocol contract', () => {
});
expect(result.error).toBeUndefined();
expect(result.value?.messages).toEqual([
{
role: 'user',
parts: [
{ type: 'text', text: 'summarize this pdf' },
{
type: 'file',
filename: 'brief.pdf',
mimeType: 'application/pdf',
fileData: 'JVBERi0xLjQK',
},
],
},
]);
expect(result.value).toMatchObject({
messages: [
{
role: 'user',
parts: [
{ type: 'text', text: 'inspect both attachments' },
{ type: 'image', url: 'data:image/png;base64,QUFBQQ==' },
{
type: 'file',
fileData: 'JVBERi0x',
filename: 'brief.pdf',
mimeType: 'application/pdf',
},
],
},
],
});
});
it('builds native messages requests from canonical envelopes', () => {
@@ -1,3 +1,4 @@
import { toOpenAiChatFileBlock } from '../../shared/inputFile.js';
import type { NormalizedFinalResponse } from '../../shared/normalized.js';
import { extractReasoningMetadataFromGeminiRequest } from './convert.js';
@@ -34,24 +35,58 @@ function buildDataUrl(part: GeminiRecord): string | null {
return `data:${mimeType};base64,${data}`;
}
function buildOpenAiFileBlock(input: {
fileData?: string;
fileUrl?: string;
mimeType?: string;
}): Record<string, unknown> | null {
const fileData = asTrimmedString(input.fileData);
const fileUrl = asTrimmedString(input.fileUrl);
const mimeType = asTrimmedString(input.mimeType);
if (!fileData && !fileUrl) return null;
function buildInlineData(
part: GeminiRecord,
): { mimeType: string; data: string } | null {
const inlineData = isRecord(part.inlineData) ? part.inlineData : null;
if (!inlineData) return null;
const mimeType = asTrimmedString(inlineData.mime_type ?? inlineData.mimeType) || 'application/octet-stream';
const data = asTrimmedString(inlineData.data);
if (!data) return null;
return { mimeType, data };
}
const file: Record<string, unknown> = {};
if (fileData) file.file_data = fileData;
if (fileUrl && !fileData) file.file_url = fileUrl;
if (mimeType) file.mime_type = mimeType;
return {
type: 'file',
file,
};
function buildFileDataSource(
part: GeminiRecord,
): { fileUri: string; mimeType: string | null } | null {
const fileData = isRecord(part.fileData) ? part.fileData : null;
if (!fileData) return null;
const fileUri = asTrimmedString(fileData.fileUri ?? fileData.file_uri);
if (!fileUri) return null;
const mimeType = asTrimmedString(fileData.mimeType ?? fileData.mime_type) || null;
return { fileUri, mimeType };
}
function toOpenAiBlockFromGeminiPart(part: GeminiRecord): Record<string, unknown> | null {
const inlineData = buildInlineData(part);
if (inlineData) {
const normalizedMimeType = inlineData.mimeType.toLowerCase();
if (normalizedMimeType.startsWith('image/')) {
return {
type: 'image_url',
image_url: { url: buildDataUrl(part)! },
};
}
return toOpenAiChatFileBlock({
fileData: inlineData.data,
mimeType: inlineData.mimeType,
});
}
const fileData = buildFileDataSource(part);
if (!fileData) return null;
if (fileData.mimeType?.toLowerCase().startsWith('image/')) {
return {
type: 'image_url',
image_url: { url: fileData.fileUri },
};
}
return toOpenAiChatFileBlock({
fileUrl: fileData.fileUri,
mimeType: fileData.mimeType,
});
}
function toOpenAiContent(contentParts: GeminiRecord[]): string | Array<Record<string, unknown>> {
@@ -65,41 +100,9 @@ function toOpenAiContent(contentParts: GeminiRecord[]): string | Array<Record<st
continue;
}
const dataUrl = buildDataUrl(part);
if (dataUrl) {
const inlineData = isRecord(part.inlineData) ? part.inlineData : null;
const mimeType = asTrimmedString(inlineData?.mime_type ?? inlineData?.mimeType) || 'application/octet-stream';
if (isImageMimeType(mimeType)) {
blocks.push({
type: 'image_url',
image_url: { url: dataUrl },
});
} else {
const fileBlock = buildOpenAiFileBlock({
fileData: asTrimmedString(inlineData?.data),
mimeType,
});
if (fileBlock) blocks.push(fileBlock);
}
continue;
}
const fileData = isRecord(part.fileData) ? part.fileData : null;
const fileUri = asTrimmedString(fileData?.fileUri ?? fileData?.file_uri);
if (fileUri) {
const mimeType = asTrimmedString(fileData?.mimeType ?? fileData?.mime_type);
if (mimeType && isImageMimeType(mimeType)) {
blocks.push({
type: 'image_url',
image_url: { url: fileUri },
});
} else {
const fileBlock = buildOpenAiFileBlock({
fileUrl: fileUri,
mimeType: mimeType || undefined,
});
if (fileBlock) blocks.push(fileBlock);
}
const block = toOpenAiBlockFromGeminiPart(part);
if (block) {
blocks.push(block);
}
}
@@ -130,6 +130,49 @@ describe('geminiGenerateContentTransformer.inbound', () => {
});
});
it('compatibility preserves inline document parts as OpenAI file blocks', () => {
const body = geminiGenerateContentTransformer.compatibility.buildOpenAiBodyFromGeminiRequest({
modelName: 'gpt-5.4',
stream: false,
body: {
contents: [
{
role: 'user',
parts: [
{ text: 'summarize this file' },
{
inlineData: {
mimeType: 'application/pdf',
data: 'JVBERi0x',
},
},
],
},
],
},
});
expect(body).toEqual({
model: 'gpt-5.4',
stream: false,
messages: [
{
role: 'user',
content: [
{ type: 'text', text: 'summarize this file' },
{
type: 'file',
file: {
file_data: 'JVBERi0x',
mime_type: 'application/pdf',
},
},
],
},
],
});
});
it('preserves native Gemini request fields through normalization', () => {
const body = geminiGenerateContentTransformer.inbound.normalizeRequest({
contents: [
@@ -1,6 +1,7 @@
import {
decodeAnthropicReasoningSignature,
} from './reasoningTransport.js';
import { toOpenAiChatFileBlock } from './inputFile.js';
import {
consumeThinkTaggedText,
createThinkTagParserState,
@@ -86,6 +87,10 @@ function isNonEmptyString(value: unknown): value is string {
return typeof value === 'string' && value.trim().length > 0;
}
function asTrimmedString(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
}
function pickFiniteNumber(value: unknown): number | undefined {
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
}
@@ -548,6 +553,111 @@ function collectToolCallsFromResponsesPayload(payload: Record<string, unknown>):
return toolCalls;
}
function formatAnthropicBase64DataUrl(mimeType: string, data: string): string {
return `data:${mimeType};base64,${data}`;
}
function parseAnthropicBase64Source(
source: unknown,
): { mimeType: string; data: string } | null {
if (!isRecord(source)) return null;
const sourceType = asTrimmedString(source.type).toLowerCase();
if (sourceType !== 'base64') return null;
const mimeType = (
asTrimmedString(source.media_type)
|| asTrimmedString(source.mime_type)
|| asTrimmedString(source.mediaType)
|| asTrimmedString(source.mimeType)
|| 'application/octet-stream'
);
const data = asTrimmedString(source.data);
if (!data) return null;
return { mimeType, data };
}
function parseAnthropicUrlSource(
source: unknown,
): { url: string; mimeType: string | null } | null {
if (!isRecord(source)) return null;
const sourceType = asTrimmedString(source.type).toLowerCase();
if (sourceType !== 'url') return null;
const url = asTrimmedString(source.url);
if (!url) return null;
const mimeType = (
asTrimmedString(source.media_type)
|| asTrimmedString(source.mime_type)
|| asTrimmedString(source.mediaType)
|| asTrimmedString(source.mimeType)
|| null
);
return { url, mimeType };
}
function toOpenAiContentBlockFromClaudeBlock(
block: Record<string, unknown>,
): Record<string, unknown> | null {
const blockType = asTrimmedString(block.type).toLowerCase();
if (blockType === 'text') {
const text = asTrimmedString(block.text);
return text ? { type: 'text', text } : null;
}
if (blockType !== 'image' && blockType !== 'document') {
return null;
}
const title = asTrimmedString(block.title);
const base64Source = parseAnthropicBase64Source(block.source);
const urlSource = parseAnthropicUrlSource(block.source);
const mimeType = (base64Source?.mimeType || urlSource?.mimeType || '').toLowerCase();
const treatAsImage = blockType === 'image' || mimeType.startsWith('image/');
if (treatAsImage) {
const imageUrl = base64Source
? formatAnthropicBase64DataUrl(base64Source.mimeType, base64Source.data)
: (urlSource?.url || '');
return imageUrl
? {
type: 'image_url',
image_url: { url: imageUrl },
}
: null;
}
if (base64Source) {
return toOpenAiChatFileBlock({
fileData: base64Source.data,
filename: title || undefined,
mimeType: base64Source.mimeType,
});
}
if (urlSource) {
return toOpenAiChatFileBlock({
fileUrl: urlSource.url,
filename: title || undefined,
mimeType: urlSource.mimeType,
});
}
return null;
}
function collapseOpenAiContentBlocks(
blocks: Array<Record<string, unknown>>,
): string | Array<Record<string, unknown>> | null {
if (blocks.length <= 0) return null;
const textOnly = blocks.every((block) => block.type === 'text' && typeof block.text === 'string');
if (!textOnly) return blocks;
const text = blocks
.map((block) => (typeof block.text === 'string' ? block.text : ''))
.join('\n\n')
.trim();
return text || null;
}
function convertClaudeRequestToOpenAiBody(body: Record<string, unknown>): {
model: string;
stream: boolean;
@@ -609,10 +719,10 @@ function convertClaudeRequestToOpenAiBody(body: Record<string, unknown>): {
const toolCalls: Array<Record<string, unknown>> = [];
const flushContentAsMessage = () => {
const contentValue = buildOpenAiMessageContent(contentBlocks);
const contentPayload = collapseOpenAiContentBlocks(contentBlocks);
contentBlocks.length = 0;
if (contentValue === undefined || contentValue === '') return;
messages.push({ role: mappedRole, content: contentValue });
if (contentPayload === null) return;
messages.push({ role: mappedRole, content: contentPayload });
};
for (const block of content) {
@@ -648,22 +758,33 @@ function convertClaudeRequestToOpenAiBody(body: Record<string, unknown>): {
continue;
}
const contentBlock = convertClaudeContentBlockToOpenAi(block);
if (contentBlock) contentBlocks.push(contentBlock);
const contentBlock = toOpenAiContentBlockFromClaudeBlock(block);
if (contentBlock) {
contentBlocks.push(contentBlock);
continue;
}
const text = parseClaudeMessageContent(block);
if (text) {
contentBlocks.push({
type: 'text',
text,
});
}
}
const contentValue = buildOpenAiMessageContent(contentBlocks);
const merged = collapseOpenAiContentBlocks(contentBlocks);
if (toolCalls.length > 0) {
const assistantMessage: Record<string, unknown> = {
role: 'assistant',
tool_calls: toolCalls,
};
assistantMessage.content = contentValue ?? '';
assistantMessage.content = merged || '';
messages.push(assistantMessage);
} else if (contentValue !== undefined && contentValue !== '') {
} else if (merged !== null) {
messages.push({
role: mappedRole,
content: contentValue,
content: merged,
});
}
}