forked from boos/BoosAPI
fix model tester attachment replay and capability gating
This commit is contained in:
@@ -34,6 +34,57 @@ describe('anthropicMessagesTransformer protocol contract', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves native image and document blocks when parsing into canonical envelopes', () => {
|
||||
const result = anthropicMessagesTransformer.parseRequest({
|
||||
model: 'claude-sonnet-4-5',
|
||||
max_tokens: 256,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'inspect both attachments' },
|
||||
{
|
||||
type: 'image',
|
||||
source: {
|
||||
type: 'base64',
|
||||
media_type: 'image/png',
|
||||
data: 'QUFBQQ==',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'document',
|
||||
source: {
|
||||
type: 'base64',
|
||||
media_type: 'application/pdf',
|
||||
data: 'JVBERi0x',
|
||||
},
|
||||
title: 'brief.pdf',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
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', () => {
|
||||
const body = anthropicMessagesTransformer.buildProtocolRequest({
|
||||
operation: 'count_tokens',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { toOpenAiChatFileBlock } from '../../shared/inputFile.js';
|
||||
import type { NormalizedFinalResponse } from '../../shared/normalized.js';
|
||||
import { extractReasoningMetadataFromGeminiRequest } from './convert.js';
|
||||
|
||||
@@ -30,6 +31,60 @@ function buildDataUrl(part: GeminiRecord): string | null {
|
||||
return `data:${mimeType};base64,${data}`;
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
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>> {
|
||||
const blocks: Array<Record<string, unknown>> = [];
|
||||
let textContent = '';
|
||||
@@ -41,12 +96,9 @@ function toOpenAiContent(contentParts: GeminiRecord[]): string | Array<Record<st
|
||||
continue;
|
||||
}
|
||||
|
||||
const dataUrl = buildDataUrl(part);
|
||||
if (dataUrl) {
|
||||
blocks.push({
|
||||
type: 'image_url',
|
||||
image_url: { url: dataUrl },
|
||||
});
|
||||
const block = toOpenAiBlockFromGeminiPart(part);
|
||||
if (block) {
|
||||
blocks.push(block);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -95,6 +95,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;
|
||||
}
|
||||
@@ -464,6 +469,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;
|
||||
@@ -521,14 +631,14 @@ function convertClaudeRequestToOpenAiBody(body: Record<string, unknown>): {
|
||||
// Claude tool blocks need explicit OpenAI mapping:
|
||||
// - assistant.tool_use -> assistant.tool_calls
|
||||
// - user.tool_result -> tool messages with tool_call_id
|
||||
const textParts: string[] = [];
|
||||
const contentBlocks: Array<Record<string, unknown>> = [];
|
||||
const toolCalls: Array<Record<string, unknown>> = [];
|
||||
|
||||
const flushTextAsMessage = () => {
|
||||
const merged = textParts.map((item) => item.trim()).filter(Boolean).join('\n\n');
|
||||
textParts.length = 0;
|
||||
if (!merged) return;
|
||||
messages.push({ role: mappedRole, content: merged });
|
||||
const flushContentAsMessage = () => {
|
||||
const contentPayload = collapseOpenAiContentBlocks(contentBlocks);
|
||||
contentBlocks.length = 0;
|
||||
if (contentPayload === null) return;
|
||||
messages.push({ role: mappedRole, content: contentPayload });
|
||||
};
|
||||
|
||||
for (const block of content) {
|
||||
@@ -536,7 +646,7 @@ function convertClaudeRequestToOpenAiBody(body: Record<string, unknown>): {
|
||||
const blockType = typeof block.type === 'string' ? block.type : '';
|
||||
|
||||
if (blockType === 'tool_result') {
|
||||
flushTextAsMessage();
|
||||
flushContentAsMessage();
|
||||
appendToolResultMessage(block.tool_use_id, block.content);
|
||||
continue;
|
||||
}
|
||||
@@ -564,11 +674,22 @@ function convertClaudeRequestToOpenAiBody(body: Record<string, unknown>): {
|
||||
continue;
|
||||
}
|
||||
|
||||
const contentBlock = toOpenAiContentBlockFromClaudeBlock(block);
|
||||
if (contentBlock) {
|
||||
contentBlocks.push(contentBlock);
|
||||
continue;
|
||||
}
|
||||
|
||||
const text = parseClaudeMessageContent(block);
|
||||
if (text) textParts.push(text);
|
||||
if (text) {
|
||||
contentBlocks.push({
|
||||
type: 'text',
|
||||
text,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const merged = textParts.map((item) => item.trim()).filter(Boolean).join('\n\n');
|
||||
const merged = collapseOpenAiContentBlocks(contentBlocks);
|
||||
if (toolCalls.length > 0) {
|
||||
const assistantMessage: Record<string, unknown> = {
|
||||
role: 'assistant',
|
||||
@@ -577,7 +698,7 @@ function convertClaudeRequestToOpenAiBody(body: Record<string, unknown>): {
|
||||
// Keep textual assistant preface when present.
|
||||
assistantMessage.content = merged || '';
|
||||
messages.push(assistantMessage);
|
||||
} else if (merged) {
|
||||
} else if (merged !== null) {
|
||||
messages.push({
|
||||
role: mappedRole,
|
||||
content: merged,
|
||||
|
||||
Reference in New Issue
Block a user