Skip to content

Commit c0c99a7

Browse files
arvinxxclaude
andauthored
šŸ› fix(model-runtime): filter unsupported image types (SVG) before sending to vision models (lobehub#11698)
Vision models like Claude and Gemini don't support SVG images (image/svg+xml). Previously, SVG images were passed through unchanged, causing runtime errors. Changes: - Add supported image types check in Anthropic context builder - Add supported image types check in Google context builder - Filter out unsupported formats (like SVG) by returning undefined - Add 4 test cases for SVG filtering (base64 and URL scenarios) Supported formats: image/jpeg, image/jpg, image/png, image/gif, image/webp Closes: LOBE-4125 šŸ¤– Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 6df7731 commit c0c99a7

File tree

4 files changed

+117
-1
lines changed

4 files changed

+117
-1
lines changed

ā€Žpackages/model-runtime/src/core/contextBuilders/anthropic.test.tsā€Ž

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,44 @@ describe('anthropicHelpers', () => {
125125

126126
await expect(buildAnthropicBlock(content)).rejects.toThrow('Invalid image URL: invalid-url');
127127
});
128+
129+
it('should return undefined for unsupported SVG image (base64)', async () => {
130+
vi.mocked(parseDataUri).mockReturnValueOnce({
131+
mimeType: 'image/svg+xml',
132+
base64: 'PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjwvc3ZnPg==',
133+
type: 'base64',
134+
});
135+
136+
const content = {
137+
type: 'image_url',
138+
image_url: {
139+
url: 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjwvc3ZnPg==',
140+
},
141+
} as const;
142+
143+
const result = await buildAnthropicBlock(content);
144+
expect(result).toBeUndefined();
145+
});
146+
147+
it('should return undefined for unsupported SVG image (URL)', async () => {
148+
vi.mocked(parseDataUri).mockReturnValueOnce({
149+
mimeType: null,
150+
base64: null,
151+
type: 'url',
152+
});
153+
vi.mocked(imageUrlToBase64).mockResolvedValueOnce({
154+
base64: 'PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjwvc3ZnPg==',
155+
mimeType: 'image/svg+xml',
156+
});
157+
158+
const content = {
159+
type: 'image_url',
160+
image_url: { url: 'https://example.com/image.svg' },
161+
} as const;
162+
163+
const result = await buildAnthropicBlock(content);
164+
expect(result).toBeUndefined();
165+
});
128166
});
129167

130168
describe('buildAnthropicMessage', () => {

ā€Žpackages/model-runtime/src/core/contextBuilders/anthropic.tsā€Ž

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,19 @@ import OpenAI from 'openai';
55
import { OpenAIChatMessage, UserMessageContentPart } from '../../types';
66
import { parseDataUri } from '../../utils/uriParser';
77

8+
const ANTHROPIC_SUPPORTED_IMAGE_TYPES = new Set([
9+
'image/jpeg',
10+
'image/jpg',
11+
'image/png',
12+
'image/gif',
13+
'image/webp',
14+
]);
15+
16+
const isImageTypeSupported = (mimeType: string | null): boolean => {
17+
if (!mimeType) return true;
18+
return ANTHROPIC_SUPPORTED_IMAGE_TYPES.has(mimeType.toLowerCase());
19+
};
20+
821
export const buildAnthropicBlock = async (
922
content: UserMessageContentPart,
1023
): Promise<Anthropic.ContentBlock | Anthropic.ImageBlockParam | undefined> => {
@@ -23,7 +36,9 @@ export const buildAnthropicBlock = async (
2336
case 'image_url': {
2437
const { mimeType, base64, type } = parseDataUri(content.image_url.url);
2538

26-
if (type === 'base64')
39+
if (type === 'base64') {
40+
if (!isImageTypeSupported(mimeType)) return undefined;
41+
2742
return {
2843
source: {
2944
data: base64 as string,
@@ -32,9 +47,13 @@ export const buildAnthropicBlock = async (
3247
},
3348
type: 'image',
3449
};
50+
}
3551

3652
if (type === 'url') {
3753
const { base64, mimeType } = await imageUrlToBase64(content.image_url.url);
54+
55+
if (!isImageTypeSupported(mimeType)) return undefined;
56+
3857
return {
3958
source: {
4059
data: base64 as string,

ā€Žpackages/model-runtime/src/core/contextBuilders/google.test.tsā€Ž

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,48 @@ describe('google contextBuilders', () => {
149149
thoughtSignature: GEMINI_MAGIC_THOUGHT_SIGNATURE,
150150
});
151151
});
152+
153+
it('should return undefined for unsupported SVG image (base64)', async () => {
154+
const svgBase64 =
155+
'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjwvc3ZnPg==';
156+
157+
vi.mocked(parseDataUri).mockReturnValueOnce({
158+
base64: 'PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjwvc3ZnPg==',
159+
mimeType: 'image/svg+xml',
160+
type: 'base64',
161+
});
162+
163+
const content: UserMessageContentPart = {
164+
image_url: { url: svgBase64 },
165+
type: 'image_url',
166+
};
167+
168+
const result = await buildGooglePart(content);
169+
expect(result).toBeUndefined();
170+
});
171+
172+
it('should return undefined for unsupported SVG image (URL)', async () => {
173+
const svgUrl = 'https://example.com/image.svg';
174+
175+
vi.mocked(parseDataUri).mockReturnValueOnce({
176+
base64: null,
177+
mimeType: null,
178+
type: 'url',
179+
});
180+
181+
vi.spyOn(imageToBase64Module, 'imageUrlToBase64').mockResolvedValueOnce({
182+
base64: 'PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjwvc3ZnPg==',
183+
mimeType: 'image/svg+xml',
184+
});
185+
186+
const content: UserMessageContentPart = {
187+
image_url: { url: svgUrl },
188+
type: 'image_url',
189+
};
190+
191+
const result = await buildGooglePart(content);
192+
expect(result).toBeUndefined();
193+
});
152194
});
153195

154196
describe('buildGoogleMessage', () => {

ā€Žpackages/model-runtime/src/core/contextBuilders/google.tsā€Ž

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,19 @@ import { ChatCompletionTool, OpenAIChatMessage, UserMessageContentPart } from '.
1111
import { safeParseJSON } from '../../utils/safeParseJSON';
1212
import { parseDataUri } from '../../utils/uriParser';
1313

14+
const GOOGLE_SUPPORTED_IMAGE_TYPES = new Set([
15+
'image/jpeg',
16+
'image/jpg',
17+
'image/png',
18+
'image/gif',
19+
'image/webp',
20+
]);
21+
22+
const isImageTypeSupported = (mimeType: string | null): boolean => {
23+
if (!mimeType) return true;
24+
return GOOGLE_SUPPORTED_IMAGE_TYPES.has(mimeType.toLowerCase());
25+
};
26+
1427
/**
1528
* Magic thoughtSignature
1629
* @see https://ai.google.dev/gemini-api/docs/thought-signatures#model-behavior:~:text=context_engineering_is_the_way_to_go
@@ -43,6 +56,8 @@ export const buildGooglePart = async (
4356
throw new TypeError("Image URL doesn't contain base64 data");
4457
}
4558

59+
if (!isImageTypeSupported(mimeType)) return undefined;
60+
4661
return {
4762
inlineData: { data: base64, mimeType: mimeType || 'image/png' },
4863
thoughtSignature: GEMINI_MAGIC_THOUGHT_SIGNATURE,
@@ -52,6 +67,8 @@ export const buildGooglePart = async (
5267
if (type === 'url') {
5368
const { base64, mimeType } = await imageUrlToBase64(content.image_url.url);
5469

70+
if (!isImageTypeSupported(mimeType)) return undefined;
71+
5572
return {
5673
inlineData: { data: base64, mimeType },
5774
thoughtSignature: GEMINI_MAGIC_THOUGHT_SIGNATURE,

0 commit comments

Comments
Ā (0)