feat: 支持 openai 图片兼容

This commit is contained in:
claude-code-best
2026-04-06 10:48:27 +08:00
parent dee2ffd638
commit eca1acc662
5 changed files with 238 additions and 2 deletions

View File

@@ -199,4 +199,69 @@ describe('anthropicMessagesToGemini', () => {
},
])
})
test('converts base64 image to inlineData', () => {
const result = anthropicMessagesToGemini(
[makeUserMsg([
{ type: 'text', text: 'describe this' },
{
type: 'image',
source: {
type: 'base64',
media_type: 'image/png',
data: 'iVBORw0KGgo=',
},
},
])],
[] as any,
)
expect(result.contents).toEqual([
{
role: 'user',
parts: [
{ text: 'describe this' },
{ inlineData: { mimeType: 'image/png', data: 'iVBORw0KGgo=' } },
],
},
])
})
test('converts url image to text fallback', () => {
const result = anthropicMessagesToGemini(
[makeUserMsg([
{
type: 'image',
source: {
type: 'url',
url: 'https://example.com/img.png',
},
},
])],
[] as any,
)
expect(result.contents).toEqual([
{
role: 'user',
parts: [{ text: '[image: https://example.com/img.png]' }],
},
])
})
test('defaults to image/png when media_type is missing', () => {
const result = anthropicMessagesToGemini(
[makeUserMsg([
{
type: 'image',
source: {
type: 'base64',
data: 'ABC123',
},
},
])],
[] as any,
)
expect(result.contents[0].parts[0]).toEqual({
inlineData: { mimeType: 'image/png', data: 'ABC123' },
})
})
})

View File

@@ -113,6 +113,26 @@ function convertUserContentBlockToGeminiParts(
]
}
// 将 Anthropic image 块转换为 Gemini inlineData
if (block.type === 'image') {
const source = block.source as Record<string, unknown> | undefined
if (source?.type === 'base64' && typeof source.data === 'string') {
const mediaType = (source.media_type as string) || 'image/png'
return [
{
inlineData: {
mimeType: mediaType,
data: source.data,
},
},
]
}
// url 类型的图片Gemini 不直接支持,转为文本描述
if (source?.type === 'url' && typeof source.url === 'string') {
return createTextGeminiParts(`[image: ${source.url}]`)
}
}
return []
}

View File

@@ -10,12 +10,18 @@ export type GeminiFunctionResponse = {
response?: Record<string, unknown>
}
export type GeminiInlineData = {
mimeType: string
data: string
}
export type GeminiPart = {
text?: string
thought?: boolean
thoughtSignature?: string
functionCall?: GeminiFunctionCall
functionResponse?: GeminiFunctionResponse
inlineData?: GeminiInlineData
}
export type GeminiContent = {

View File

@@ -154,4 +154,98 @@ describe('anthropicMessagesToOpenAI', () => {
expect((result[2] as any).tool_calls).toBeDefined()
expect(result[3].role).toBe('tool')
})
test('converts base64 image to image_url', () => {
const result = anthropicMessagesToOpenAI(
[makeUserMsg([
{ type: 'text', text: 'what is this?' },
{
type: 'image' as const,
source: {
type: 'base64',
media_type: 'image/png',
data: 'iVBORw0KGgo=',
},
},
])],
[] as any,
)
expect(result).toEqual([{
role: 'user',
content: [
{ type: 'text', text: 'what is this?' },
{
type: 'image_url',
image_url: { url: 'data:image/png;base64,iVBORw0KGgo=' },
},
],
}])
})
test('converts url image to image_url', () => {
const result = anthropicMessagesToOpenAI(
[makeUserMsg([
{
type: 'image' as const,
source: {
type: 'url',
url: 'https://example.com/img.png',
},
},
])],
[] as any,
)
expect(result).toEqual([{
role: 'user',
content: [
{
type: 'image_url',
image_url: { url: 'https://example.com/img.png' },
},
],
}])
})
test('converts image-only message without text', () => {
const result = anthropicMessagesToOpenAI(
[makeUserMsg([
{
type: 'image' as const,
source: {
type: 'base64',
media_type: 'image/jpeg',
data: '/9j/4AAQ',
},
},
])],
[] as any,
)
expect(result).toEqual([{
role: 'user',
content: [
{
type: 'image_url',
image_url: { url: 'data:image/jpeg;base64,/9j/4AAQ' },
},
],
}])
})
test('defaults to image/png when media_type is missing', () => {
const result = anthropicMessagesToOpenAI(
[makeUserMsg([
{
type: 'image' as const,
source: {
type: 'base64',
data: 'ABC123',
},
},
])],
[] as any,
)
expect((result[0].content as any[])[0].image_url.url).toBe(
'data:image/png;base64,ABC123',
)
})
})

View File

@@ -75,6 +75,7 @@ function convertInternalUserMessage(
} else if (Array.isArray(content)) {
const textParts: string[] = []
const toolResults: BetaToolResultBlockParam[] = []
const imageParts: Array<{ type: 'image_url'; image_url: { url: string } }> = []
for (const block of content) {
if (typeof block === 'string') {
@@ -83,11 +84,26 @@ function convertInternalUserMessage(
textParts.push(block.text)
} else if (block.type === 'tool_result') {
toolResults.push(block as BetaToolResultBlockParam)
} else if (block.type === 'image') {
const imagePart = convertImageBlockToOpenAI(block as Record<string, unknown>)
if (imagePart) {
imageParts.push(imagePart)
}
}
// Skip image, document, thinking, cache_edits, etc.
}
if (textParts.length > 0) {
// 如果有图片,构建多模态 content 数组
if (imageParts.length > 0) {
const multiContent: Array<{ type: 'text'; text: string } | { type: 'image_url'; image_url: { url: string } }> = []
if (textParts.length > 0) {
multiContent.push({ type: 'text', text: textParts.join('\n') })
}
multiContent.push(...imageParts)
result.push({
role: 'user',
content: multiContent,
} satisfies ChatCompletionUserMessageParam)
} else if (textParts.length > 0) {
result.push({
role: 'user',
content: textParts.join('\n'),
@@ -182,3 +198,38 @@ function convertInternalAssistantMessage(
return [result]
}
/**
* 将 Anthropic image 块转换为 OpenAI image_url 格式。
*
* Anthropic 格式: { type: "image", source: { type: "base64", media_type: "image/png", data: "..." } }
* OpenAI 格式: { type: "image_url", image_url: { url: "data:image/png;base64,..." } }
*/
function convertImageBlockToOpenAI(
block: Record<string, unknown>,
): { type: 'image_url'; image_url: { url: string } } | null {
const source = block.source as Record<string, unknown> | undefined
if (!source) return null
if (source.type === 'base64' && typeof source.data === 'string') {
const mediaType = (source.media_type as string) || 'image/png'
return {
type: 'image_url',
image_url: {
url: `data:${mediaType};base64,${source.data}`,
},
}
}
// url 类型的图片直接传递
if (source.type === 'url' && typeof source.url === 'string') {
return {
type: 'image_url',
image_url: {
url: source.url,
},
}
}
return null
}