forked from boos/BoosAPI
fix cross-protocol conversation file compatibility
This commit is contained in:
@@ -34,6 +34,46 @@ describe('anthropicMessagesTransformer protocol contract', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('parses native document blocks into canonical file parts', () => {
|
||||
const result = anthropicMessagesTransformer.parseRequest({
|
||||
model: 'claude-sonnet-4-5',
|
||||
max_tokens: 256,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'summarize this pdf' },
|
||||
{
|
||||
type: 'document',
|
||||
title: 'brief.pdf',
|
||||
source: {
|
||||
type: 'base64',
|
||||
media_type: 'application/pdf',
|
||||
data: 'JVBERi0xLjQK',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
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',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('builds native messages requests from canonical envelopes', () => {
|
||||
const body = anthropicMessagesTransformer.buildProtocolRequest({
|
||||
operation: 'count_tokens',
|
||||
|
||||
@@ -11,6 +11,10 @@ function asTrimmedString(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function isImageMimeType(mimeType: string): boolean {
|
||||
return mimeType.toLowerCase().startsWith('image/');
|
||||
}
|
||||
|
||||
function parseJsonIfPossible(value: string): unknown {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return {};
|
||||
@@ -30,6 +34,26 @@ 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;
|
||||
|
||||
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 toOpenAiContent(contentParts: GeminiRecord[]): string | Array<Record<string, unknown>> {
|
||||
const blocks: Array<Record<string, unknown>> = [];
|
||||
let textContent = '';
|
||||
@@ -43,10 +67,39 @@ function toOpenAiContent(contentParts: GeminiRecord[]): string | Array<Record<st
|
||||
|
||||
const dataUrl = buildDataUrl(part);
|
||||
if (dataUrl) {
|
||||
blocks.push({
|
||||
type: 'image_url',
|
||||
image_url: { url: 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -50,6 +50,41 @@ describe('geminiGenerateContentTransformer.inbound', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('parses non-image inlineData parts into canonical file parts', () => {
|
||||
const result = geminiGenerateContentTransformer.parseRequest({
|
||||
model: 'gemini-2.5-pro',
|
||||
contents: [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [
|
||||
{ text: 'summarize this pdf' },
|
||||
{
|
||||
inlineData: {
|
||||
mimeType: 'application/pdf',
|
||||
data: 'JVBERi0xLjQK',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.value?.messages).toEqual([
|
||||
{
|
||||
role: 'user',
|
||||
parts: [
|
||||
{ type: 'text', text: 'summarize this pdf' },
|
||||
{
|
||||
type: 'file',
|
||||
fileData: 'JVBERi0xLjQK',
|
||||
mimeType: 'application/pdf',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('builds native Gemini requests from canonical envelopes', () => {
|
||||
const body = geminiGenerateContentTransformer.buildProtocolRequest({
|
||||
operation: 'generate',
|
||||
|
||||
@@ -333,6 +333,90 @@ function parseClaudeMessageContent(content: unknown): string {
|
||||
return extractTextAndReasoning(content).content;
|
||||
}
|
||||
|
||||
function buildOpenAiImageUrlBlock(url: string): Record<string, unknown> {
|
||||
return {
|
||||
type: 'image_url',
|
||||
image_url: { url },
|
||||
};
|
||||
}
|
||||
|
||||
function buildOpenAiFileBlock(input: {
|
||||
fileData?: string;
|
||||
fileUrl?: string;
|
||||
filename?: string;
|
||||
mimeType?: string;
|
||||
}): Record<string, unknown> | null {
|
||||
const fileData = typeof input.fileData === 'string' ? input.fileData.trim() : '';
|
||||
const fileUrl = typeof input.fileUrl === 'string' ? input.fileUrl.trim() : '';
|
||||
const filename = typeof input.filename === 'string' ? input.filename.trim() : '';
|
||||
const mimeType = typeof input.mimeType === 'string' ? input.mimeType.trim() : '';
|
||||
if (!fileData && !fileUrl) return null;
|
||||
|
||||
const file: Record<string, unknown> = {};
|
||||
if (fileData) file.file_data = fileData;
|
||||
if (fileUrl && !fileData) file.file_url = fileUrl;
|
||||
if (filename) file.filename = filename;
|
||||
if (mimeType) file.mime_type = mimeType;
|
||||
return {
|
||||
type: 'file',
|
||||
file,
|
||||
};
|
||||
}
|
||||
|
||||
function convertClaudeContentBlockToOpenAi(block: Record<string, unknown>): Record<string, unknown> | null {
|
||||
const blockType = typeof block.type === 'string' ? block.type : '';
|
||||
|
||||
if (blockType === 'text') {
|
||||
const text = parseClaudeMessageContent(block);
|
||||
return text ? { type: 'text', text } : null;
|
||||
}
|
||||
|
||||
if (blockType === 'image') {
|
||||
const source = isRecord(block.source) ? block.source : null;
|
||||
const sourceType = typeof source?.type === 'string' ? source.type : '';
|
||||
if (sourceType === 'url' && typeof source?.url === 'string' && source.url.trim()) {
|
||||
return buildOpenAiImageUrlBlock(source.url.trim());
|
||||
}
|
||||
if (
|
||||
sourceType === 'base64'
|
||||
&& typeof source?.media_type === 'string'
|
||||
&& source.media_type.trim()
|
||||
&& typeof source?.data === 'string'
|
||||
&& source.data.trim()
|
||||
) {
|
||||
return buildOpenAiImageUrlBlock(`data:${source.media_type.trim()};base64,${source.data.trim()}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (blockType === 'document') {
|
||||
const source = isRecord(block.source) ? block.source : null;
|
||||
const sourceType = typeof source?.type === 'string' ? source.type : '';
|
||||
return buildOpenAiFileBlock({
|
||||
fileData: sourceType === 'base64' && typeof source?.data === 'string' ? source.data : undefined,
|
||||
fileUrl: sourceType === 'url' && typeof source?.url === 'string' ? source.url : undefined,
|
||||
filename: typeof block.title === 'string' ? block.title : undefined,
|
||||
mimeType: typeof source?.media_type === 'string' ? source.media_type : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
const text = parseClaudeMessageContent(block);
|
||||
return text ? { type: 'text', text } : null;
|
||||
}
|
||||
|
||||
function buildOpenAiMessageContent(
|
||||
contentBlocks: Array<Record<string, unknown>>,
|
||||
): string | Array<Record<string, unknown>> | undefined {
|
||||
if (contentBlocks.length <= 0) return undefined;
|
||||
if (contentBlocks.every((block) => block.type === 'text' && typeof block.text === 'string')) {
|
||||
return contentBlocks
|
||||
.map((block) => String(block.text).trim())
|
||||
.filter(Boolean)
|
||||
.join('\n\n');
|
||||
}
|
||||
return contentBlocks;
|
||||
}
|
||||
|
||||
function parseResponsesOutputText(payload: Record<string, unknown>): string {
|
||||
const direct = typeof payload.output_text === 'string' ? payload.output_text : '';
|
||||
if (direct) return direct;
|
||||
@@ -521,14 +605,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 contentValue = buildOpenAiMessageContent(contentBlocks);
|
||||
contentBlocks.length = 0;
|
||||
if (contentValue === undefined || contentValue === '') return;
|
||||
messages.push({ role: mappedRole, content: contentValue });
|
||||
};
|
||||
|
||||
for (const block of content) {
|
||||
@@ -536,7 +620,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,23 +648,22 @@ function convertClaudeRequestToOpenAiBody(body: Record<string, unknown>): {
|
||||
continue;
|
||||
}
|
||||
|
||||
const text = parseClaudeMessageContent(block);
|
||||
if (text) textParts.push(text);
|
||||
const contentBlock = convertClaudeContentBlockToOpenAi(block);
|
||||
if (contentBlock) contentBlocks.push(contentBlock);
|
||||
}
|
||||
|
||||
const merged = textParts.map((item) => item.trim()).filter(Boolean).join('\n\n');
|
||||
const contentValue = buildOpenAiMessageContent(contentBlocks);
|
||||
if (toolCalls.length > 0) {
|
||||
const assistantMessage: Record<string, unknown> = {
|
||||
role: 'assistant',
|
||||
tool_calls: toolCalls,
|
||||
};
|
||||
// Keep textual assistant preface when present.
|
||||
assistantMessage.content = merged || '';
|
||||
assistantMessage.content = contentValue ?? '';
|
||||
messages.push(assistantMessage);
|
||||
} else if (merged) {
|
||||
} else if (contentValue !== undefined && contentValue !== '') {
|
||||
messages.push({
|
||||
role: mappedRole,
|
||||
content: merged,
|
||||
content: contentValue,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +76,21 @@ describe('shared input file helpers', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('creates anthropic document blocks from remote file urls', () => {
|
||||
expect(toAnthropicDocumentBlock({
|
||||
fileUrl: 'https://example.com/remote.pdf',
|
||||
filename: 'remote.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
})).toEqual({
|
||||
type: 'document',
|
||||
source: {
|
||||
type: 'url',
|
||||
url: 'https://example.com/remote.pdf',
|
||||
},
|
||||
title: 'remote.pdf',
|
||||
});
|
||||
});
|
||||
|
||||
it('infers common mime types from filenames', () => {
|
||||
expect(inferInputFileMimeType({ filename: 'notes.md', mimeType: null })).toBe('text/markdown');
|
||||
expect(inferInputFileMimeType({ filename: 'photo.jpeg', mimeType: null })).toBe('image/jpeg');
|
||||
|
||||
@@ -159,6 +159,16 @@ export function toOpenAiChatFileBlock(file: NormalizedInputFile): Record<string,
|
||||
}
|
||||
|
||||
export function toAnthropicDocumentBlock(file: NormalizedInputFile): Record<string, unknown> | null {
|
||||
if (file.fileUrl) {
|
||||
return {
|
||||
type: 'document',
|
||||
source: {
|
||||
type: 'url',
|
||||
url: file.fileUrl,
|
||||
},
|
||||
...(file.filename ? { title: file.filename } : {}),
|
||||
};
|
||||
}
|
||||
if (!file.fileData) return null;
|
||||
const parsedDataUrl = splitBase64DataUrl(file.fileData);
|
||||
const mimeType = parsedDataUrl?.mimeType || inferInputFileMimeType(file);
|
||||
|
||||
Reference in New Issue
Block a user