🔨 chore(google): Support External URL file input with SSRF validation to optimize transmission (#12657)

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: yutengjing <ytj2713151713@gmail.com>
This commit is contained in:
sxjeru
2026-06-09 16:13:54 +08:00
committed by GitHub
parent 1ccc86e589
commit 77dbe4b7b3
29 changed files with 643 additions and 59 deletions
+1 -1
View File
@@ -846,7 +846,7 @@
"upload.preview.status.pending": "جارٍ التحضير للرفع...",
"upload.preview.status.processing": "جارٍ معالجة الملف...",
"upload.validation.unsupportedFileType": "نوع الملف غير مدعوم: {{files}}. الصور المدعومة: JPG، PNG، GIF، WebP. المستندات المدعومة تشمل PDF، Word، Excel، PowerPoint، Markdown، النص، CSV، JSON، وملفات التعليمات البرمجية.",
"upload.validation.videoSizeExceeded": "يجب ألا يتجاوز حجم ملف الفيديو 20 ميغابايت. الحجم الحالي هو {{actualSize}}.",
"upload.validation.videoSizeExceeded": "يجب ألا يتجاوز حجم ملف الفيديو {{maxSize}}. الحجم الحالي هو {{actualSize}}.",
"viewMode.fullWidth": "العرض الكامل",
"viewMode.normal": "قياسي",
"viewMode.wideScreen": "شاشة عريضة",
+1 -1
View File
@@ -846,7 +846,7 @@
"upload.preview.status.pending": "Подготовка за качване...",
"upload.preview.status.processing": "Обработка на файла...",
"upload.validation.unsupportedFileType": "Неподдържан тип файл: {{files}}. Поддържани изображения: JPG, PNG, GIF, WebP. Поддържани документи включват PDF, Word, Excel, PowerPoint, Markdown, текст, CSV, JSON и файлове с код.",
"upload.validation.videoSizeExceeded": "Размерът на видеофайла не трябва да надвишава 20MB. Текущият размер е {{actualSize}}.",
"upload.validation.videoSizeExceeded": "Размерът на видеофайла не трябва да надвишава {{maxSize}}. Текущият размер е {{actualSize}}.",
"viewMode.fullWidth": "Пълна ширина",
"viewMode.normal": "Стандартен",
"viewMode.wideScreen": "Широкоекранен",
+1 -1
View File
@@ -846,7 +846,7 @@
"upload.preview.status.pending": "Vorbereitung zum Hochladen...",
"upload.preview.status.processing": "Datei wird verarbeitet...",
"upload.validation.unsupportedFileType": "Nicht unterstützter Dateityp: {{files}}. Unterstützte Bilder: JPG, PNG, GIF, WebP. Unterstützte Dokumente umfassen PDF, Word, Excel, PowerPoint, Markdown, Text, CSV, JSON und Code-Dateien.",
"upload.validation.videoSizeExceeded": "Die Videodatei darf 20 MB nicht überschreiten. Aktuelle Größe: {{actualSize}}.",
"upload.validation.videoSizeExceeded": "Die Videodatei darf {{maxSize}} nicht überschreiten. Aktuelle Größe: {{actualSize}}.",
"viewMode.fullWidth": "Volle Breite",
"viewMode.normal": "Standard",
"viewMode.wideScreen": "Breitbild",
+1 -1
View File
@@ -865,7 +865,7 @@
"upload.preview.status.pending": "Preparing to upload...",
"upload.preview.status.processing": "Processing file...",
"upload.validation.unsupportedFileType": "Unsupported file type: {{files}}. Supported images: JPG, PNG, GIF, WebP. Supported documents include PDF, Word, Excel, PowerPoint, Markdown, text, CSV, JSON, and code files.",
"upload.validation.videoSizeExceeded": "Video file size must not exceed 20MB. Current file size is {{actualSize}}.",
"upload.validation.videoSizeExceeded": "Video file size must not exceed {{maxSize}}. Current file size is {{actualSize}}.",
"viewMode.fullWidth": "Full Width",
"viewMode.normal": "Standard",
"viewMode.wideScreen": "Widescreen",
+1 -1
View File
@@ -846,7 +846,7 @@
"upload.preview.status.pending": "Preparando para subir...",
"upload.preview.status.processing": "Procesando archivo...",
"upload.validation.unsupportedFileType": "Tipo de archivo no compatible: {{files}}. Imágenes compatibles: JPG, PNG, GIF, WebP. Documentos compatibles incluyen PDF, Word, Excel, PowerPoint, Markdown, texto, CSV, JSON y archivos de código.",
"upload.validation.videoSizeExceeded": "El tamaño del archivo de video no debe superar los 20MB. Tamaño actual: {{actualSize}}.",
"upload.validation.videoSizeExceeded": "El tamaño del archivo de video no debe superar los {{maxSize}}. Tamaño actual: {{actualSize}}.",
"viewMode.fullWidth": "Ancho completo",
"viewMode.normal": "Estándar",
"viewMode.wideScreen": "Pantalla ancha",
+1 -1
View File
@@ -846,7 +846,7 @@
"upload.preview.status.pending": "در حال آماده‌سازی برای بارگذاری...",
"upload.preview.status.processing": "در حال پردازش فایل...",
"upload.validation.unsupportedFileType": "نوع فایل پشتیبانی نشده: {{files}}. تصاویر پشتیبانی شده: JPG، PNG، GIF، WebP. اسناد پشتیبانی شده شامل PDF، Word، Excel، PowerPoint، Markdown، متن، CSV، JSON و فایل‌های کد هستند.",
"upload.validation.videoSizeExceeded": "حجم فایل ویدیو نباید بیش از ۲۰ مگابایت باشد. حجم فعلی: {{actualSize}}.",
"upload.validation.videoSizeExceeded": "حجم فایل ویدیو نباید بیش از {{maxSize}} باشد. حجم فعلی: {{actualSize}}.",
"viewMode.fullWidth": "تمام‌عرض",
"viewMode.normal": "استاندارد",
"viewMode.wideScreen": "نمای عریض",
+1 -1
View File
@@ -846,7 +846,7 @@
"upload.preview.status.pending": "Préparation au téléversement...",
"upload.preview.status.processing": "Traitement du fichier...",
"upload.validation.unsupportedFileType": "Type de fichier non pris en charge : {{files}}. Images prises en charge : JPG, PNG, GIF, WebP. Les documents pris en charge incluent PDF, Word, Excel, PowerPoint, Markdown, texte, CSV, JSON et fichiers de code.",
"upload.validation.videoSizeExceeded": "La taille du fichier vidéo ne doit pas dépasser 20 Mo. Taille actuelle : {{actualSize}}.",
"upload.validation.videoSizeExceeded": "La taille du fichier vidéo ne doit pas dépasser {{maxSize}}. Taille actuelle : {{actualSize}}.",
"viewMode.fullWidth": "Pleine largeur",
"viewMode.normal": "Standard",
"viewMode.wideScreen": "Grand écran",
+1 -1
View File
@@ -846,7 +846,7 @@
"upload.preview.status.pending": "Preparazione al caricamento...",
"upload.preview.status.processing": "Elaborazione file...",
"upload.validation.unsupportedFileType": "Tipo di file non supportato: {{files}}. Immagini supportate: JPG, PNG, GIF, WebP. Documenti supportati includono PDF, Word, Excel, PowerPoint, Markdown, testo, CSV, JSON e file di codice.",
"upload.validation.videoSizeExceeded": "La dimensione del file video non deve superare i 20MB. Dimensione attuale: {{actualSize}}.",
"upload.validation.videoSizeExceeded": "La dimensione del file video non deve superare i {{maxSize}}. Dimensione attuale: {{actualSize}}.",
"viewMode.fullWidth": "Larghezza completa",
"viewMode.normal": "Standard",
"viewMode.wideScreen": "Widescreen",
+1 -1
View File
@@ -846,7 +846,7 @@
"upload.preview.status.pending": "アップロードの準備中…",
"upload.preview.status.processing": "ファイル処理中…",
"upload.validation.unsupportedFileType": "サポートされていないファイルタイプ: {{files}}。サポートされている画像形式: JPG、PNG、GIF、WebP。サポートされているドキュメント形式: PDF、Word、Excel、PowerPoint、Markdown、テキスト、CSV、JSON、コードファイル。",
"upload.validation.videoSizeExceeded": "ビデオファイルは 20MB を超えることはできません。現在は {{actualSize}} です",
"upload.validation.videoSizeExceeded": "ビデオファイルは {{maxSize}} を超えることはできません。現在は {{actualSize}} です",
"viewMode.fullWidth": "全幅表示",
"viewMode.normal": "標準",
"viewMode.wideScreen": "ワイドスクリーン",
+1 -1
View File
@@ -846,7 +846,7 @@
"upload.preview.status.pending": "업로드 준비 중…",
"upload.preview.status.processing": "파일 처리 중…",
"upload.validation.unsupportedFileType": "지원되지 않는 파일 형식: {{files}}. 지원되는 이미지: JPG, PNG, GIF, WebP. 지원되는 문서: PDF, Word, Excel, PowerPoint, Markdown, 텍스트, CSV, JSON 및 코드 파일.",
"upload.validation.videoSizeExceeded": "비디오 파일은 20MB를 초과할 수 없습니다. 현재 {{actualSize}}입니다",
"upload.validation.videoSizeExceeded": "비디오 파일은 {{maxSize}}를 초과할 수 없습니다. 현재 {{actualSize}}입니다",
"viewMode.fullWidth": "전체 너비",
"viewMode.normal": "일반",
"viewMode.wideScreen": "와이드스크린",
+1 -1
View File
@@ -846,7 +846,7 @@
"upload.preview.status.pending": "Voorbereiden op upload...",
"upload.preview.status.processing": "Bestand verwerken...",
"upload.validation.unsupportedFileType": "Niet-ondersteund bestandstype: {{files}}. Ondersteunde afbeeldingen: JPG, PNG, GIF, WebP. Ondersteunde documenten zijn onder andere PDF, Word, Excel, PowerPoint, Markdown, tekst, CSV, JSON en codebestanden.",
"upload.validation.videoSizeExceeded": "De bestandsgrootte van de video mag niet groter zijn dan 20MB. Huidige grootte is {{actualSize}}.",
"upload.validation.videoSizeExceeded": "De bestandsgrootte van de video mag niet groter zijn dan {{maxSize}}. Huidige grootte is {{actualSize}}.",
"viewMode.fullWidth": "Volledige breedte",
"viewMode.normal": "Standaard",
"viewMode.wideScreen": "Widescreen",
+1 -1
View File
@@ -846,7 +846,7 @@
"upload.preview.status.pending": "Przygotowywanie do przesłania...",
"upload.preview.status.processing": "Przetwarzanie pliku...",
"upload.validation.unsupportedFileType": "Nieobsługiwany typ pliku: {{files}}. Obsługiwane obrazy: JPG, PNG, GIF, WebP. Obsługiwane dokumenty obejmują PDF, Word, Excel, PowerPoint, Markdown, tekst, CSV, JSON i pliki kodu.",
"upload.validation.videoSizeExceeded": "Rozmiar pliku wideo nie może przekraczać 20 MB. Obecny rozmiar pliku to {{actualSize}}.",
"upload.validation.videoSizeExceeded": "Rozmiar pliku wideo nie może przekraczać {{maxSize}}. Obecny rozmiar pliku to {{actualSize}}.",
"viewMode.fullWidth": "Pełna szerokość",
"viewMode.normal": "Standardowy",
"viewMode.wideScreen": "Szeroki ekran",
+1 -1
View File
@@ -846,7 +846,7 @@
"upload.preview.status.pending": "Preparando para envio...",
"upload.preview.status.processing": "Processando arquivo...",
"upload.validation.unsupportedFileType": "Tipo de arquivo não suportado: {{files}}. Imagens suportadas: JPG, PNG, GIF, WebP. Documentos suportados incluem PDF, Word, Excel, PowerPoint, Markdown, texto, CSV, JSON e arquivos de código.",
"upload.validation.videoSizeExceeded": "O tamanho do vídeo não deve exceder 20MB. Tamanho atual: {{actualSize}}.",
"upload.validation.videoSizeExceeded": "O tamanho do vídeo não deve exceder {{maxSize}}. Tamanho atual: {{actualSize}}.",
"viewMode.fullWidth": "Largura Total",
"viewMode.normal": "Padrão",
"viewMode.wideScreen": "Tela Larga",
+1 -1
View File
@@ -846,7 +846,7 @@
"upload.preview.status.pending": "Подготовка к загрузке...",
"upload.preview.status.processing": "Обработка файла...",
"upload.validation.unsupportedFileType": "Неподдерживаемый тип файла: {{files}}. Поддерживаемые изображения: JPG, PNG, GIF, WebP. Поддерживаемые документы: PDF, Word, Excel, PowerPoint, Markdown, текст, CSV, JSON и файлы кода.",
"upload.validation.videoSizeExceeded": "Размер видеофайла не должен превышать 20 МБ. Текущий размер: {{actualSize}}.",
"upload.validation.videoSizeExceeded": "Размер видеофайла не должен превышать {{maxSize}}. Текущий размер: {{actualSize}}.",
"viewMode.fullWidth": "Полная ширина",
"viewMode.normal": "Стандартный",
"viewMode.wideScreen": "Широкий экран",
+1 -1
View File
@@ -846,7 +846,7 @@
"upload.preview.status.pending": "Yüklemeye hazırlanıyor...",
"upload.preview.status.processing": "Dosya işleniyor...",
"upload.validation.unsupportedFileType": "Desteklenmeyen dosya türü: {{files}}. Desteklenen görseller: JPG, PNG, GIF, WebP. Desteklenen belgeler: PDF, Word, Excel, PowerPoint, Markdown, metin, CSV, JSON ve kod dosyaları.",
"upload.validation.videoSizeExceeded": "Video dosya boyutu 20MB'ı geçmemelidir. Mevcut dosya boyutu {{actualSize}}.",
"upload.validation.videoSizeExceeded": "Video dosya boyutu {{maxSize}}'ı geçmemelidir. Mevcut dosya boyutu {{actualSize}}.",
"viewMode.fullWidth": "Tam Genişlik",
"viewMode.normal": "Standart",
"viewMode.wideScreen": "Geniş Ekran",
+1 -1
View File
@@ -846,7 +846,7 @@
"upload.preview.status.pending": "Đang chuẩn bị tải lên...",
"upload.preview.status.processing": "Đang xử lý tệp...",
"upload.validation.unsupportedFileType": "Loại tệp không được hỗ trợ: {{files}}. Hình ảnh được hỗ trợ: JPG, PNG, GIF, WebP. Các tài liệu được hỗ trợ bao gồm PDF, Word, Excel, PowerPoint, Markdown, văn bản, CSV, JSON và tệp mã.",
"upload.validation.videoSizeExceeded": "Kích thước tệp video không được vượt quá 20MB. Kích thước hiện tại là {{actualSize}}.",
"upload.validation.videoSizeExceeded": "Kích thước tệp video không được vượt quá {{maxSize}}. Kích thước hiện tại là {{actualSize}}.",
"viewMode.fullWidth": "Toàn chiều rộng",
"viewMode.normal": "Chuẩn",
"viewMode.wideScreen": "Toàn màn hình",
+1 -1
View File
@@ -865,7 +865,7 @@
"upload.preview.status.pending": "准备上传…",
"upload.preview.status.processing": "文件处理中…",
"upload.validation.unsupportedFileType": "不支持的文件类型:{{files}}。支持的图片格式:JPG、PNG、GIF、WebP。支持的文档包括PDF、Word、Excel、PowerPoint、Markdown、文本、CSV、JSON和代码文件。",
"upload.validation.videoSizeExceeded": "视频文件不能超过 20MB。当前为 {{actualSize}}",
"upload.validation.videoSizeExceeded": "视频文件不能超过 {{maxSize}}。当前为 {{actualSize}}",
"viewMode.fullWidth": "全宽显示",
"viewMode.normal": "普通",
"viewMode.wideScreen": "宽屏",
+1 -1
View File
@@ -846,7 +846,7 @@
"upload.preview.status.pending": "準備上傳...",
"upload.preview.status.processing": "檔案處理中...",
"upload.validation.unsupportedFileType": "不支援的檔案類型:{{files}}。支援的圖片格式:JPG、PNG、GIF、WebP。支援的文件包括 PDF、Word、Excel、PowerPoint、Markdown、文字檔、CSV、JSON 和程式碼檔案。",
"upload.validation.videoSizeExceeded": "影片檔案大小不能超過 20MB,當前檔案大小為 {{actualSize}}",
"upload.validation.videoSizeExceeded": "影片檔案大小不能超過 {{maxSize}},當前檔案大小為 {{actualSize}}",
"viewMode.fullWidth": "全寬顯示",
"viewMode.normal": "一般",
"viewMode.wideScreen": "寬螢幕",
+1
View File
@@ -22,6 +22,7 @@
"@lobechat/business-model-bank": "workspace:*",
"@lobechat/business-model-runtime": "workspace:*",
"@lobechat/const": "workspace:*",
"@lobechat/ssrf-safe-fetch": "workspace:*",
"@lobechat/utils": "workspace:*",
"async-retry": "^1.3.3",
"dayjs": "^1.11.19",
@@ -1,9 +1,9 @@
// @vitest-environment node
import * as imageToBase64Module from '@lobechat/utils';
import { describe, expect, it, vi } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { ChatCompletionTool, OpenAIChatMessage, UserMessageContentPart } from '../../types';
import { parseDataUri } from '../../utils/uriParser';
import { isPublicExternalUrl, parseDataUri, validateExternalUrl } from '../../utils/uriParser';
import {
buildGoogleMessage,
buildGoogleMessages,
@@ -15,7 +15,9 @@ import {
// Mock the utils
vi.mock('../../utils/uriParser', () => ({
isPublicExternalUrl: vi.fn().mockReturnValue(false),
parseDataUri: vi.fn(),
validateExternalUrl: vi.fn().mockResolvedValue({ isValid: false, reason: 'mocked' }),
}));
vi.mock('../../utils/imageToBase64', () => ({
@@ -34,6 +36,10 @@ describe('google contextBuilders', () => {
});
describe('buildGooglePart', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should handle text type messages', async () => {
const content: UserMessageContentPart = {
text: 'Hello',
@@ -140,6 +146,161 @@ describe('google contextBuilders', () => {
expect(imageToBase64Module.imageUrlToBase64).toHaveBeenCalledWith(imageUrl);
});
it('should use fileData for external URL images on gemini-3+', async () => {
const imageUrl = 'https://example.com/image.png';
vi.mocked(parseDataUri).mockReturnValueOnce({
base64: null,
mimeType: null,
type: 'url',
});
vi.mocked(isPublicExternalUrl).mockReturnValueOnce(true);
vi.mocked(validateExternalUrl).mockResolvedValueOnce({
contentLength: 1024,
contentType: 'image/png',
isValid: true,
});
const imageToBase64Spy = vi
.spyOn(imageToBase64Module, 'imageUrlToBase64')
.mockResolvedValueOnce({
base64: 'mockBase64Data',
mimeType: 'image/png',
});
const content: UserMessageContentPart = {
image_url: { url: imageUrl },
type: 'image_url',
};
const result = await buildGooglePart(content, { model: 'gemini-3-flash-preview' });
expect(result).toEqual({
fileData: {
fileUri: imageUrl,
mimeType: 'image/png',
},
thoughtSignature: GEMINI_MAGIC_THOUGHT_SIGNATURE,
});
expect(imageToBase64Spy).not.toHaveBeenCalled();
});
it('should fallback to inlineData when external URL validation fails for HEIC', async () => {
const imageUrl = 'https://example.com/image.heic';
vi.mocked(parseDataUri).mockReturnValueOnce({
base64: null,
mimeType: null,
type: 'url',
});
vi.mocked(isPublicExternalUrl).mockReturnValueOnce(true);
vi.mocked(validateExternalUrl).mockResolvedValueOnce({
contentLength: 1024,
contentType: 'image/heic',
isValid: false,
reason: 'Unsupported content type: image/heic',
});
const imageToBase64Spy = vi
.spyOn(imageToBase64Module, 'imageUrlToBase64')
.mockResolvedValueOnce({
base64: 'mockBase64Data',
mimeType: 'image/heic',
});
const content: UserMessageContentPart = {
image_url: { url: imageUrl },
type: 'image_url',
};
const result = await buildGooglePart(content, { model: 'gemini-3-flash-preview' });
expect(result).toEqual({
inlineData: {
data: 'mockBase64Data',
mimeType: 'image/heic',
},
thoughtSignature: GEMINI_MAGIC_THOUGHT_SIGNATURE,
});
expect(imageToBase64Spy).toHaveBeenCalledWith(imageUrl);
});
it('should force inlineData for external URL images on gemini-2.5 and earlier', async () => {
const imageUrl = 'https://example.com/image.png';
vi.mocked(parseDataUri).mockReturnValueOnce({
base64: null,
mimeType: null,
type: 'url',
});
const imageToBase64Spy = vi
.spyOn(imageToBase64Module, 'imageUrlToBase64')
.mockResolvedValueOnce({
base64: 'mockBase64Data',
mimeType: 'image/png',
});
const content: UserMessageContentPart = {
image_url: { url: imageUrl },
type: 'image_url',
};
const result = await buildGooglePart(content, { model: 'gemini-2.5-flash' });
expect(result).toEqual({
inlineData: {
data: 'mockBase64Data',
mimeType: 'image/png',
},
thoughtSignature: GEMINI_MAGIC_THOUGHT_SIGNATURE,
});
expect(imageToBase64Spy).toHaveBeenCalledWith(imageUrl);
expect(isPublicExternalUrl).not.toHaveBeenCalled();
expect(validateExternalUrl).not.toHaveBeenCalled();
});
it('should throw when external URL exceeds size limit', async () => {
const imageUrl = 'https://example.com/large-image.png';
vi.mocked(parseDataUri).mockReturnValueOnce({
base64: null,
mimeType: 'image/png',
type: 'url',
});
vi.mocked(isPublicExternalUrl).mockReturnValueOnce(true);
vi.mocked(validateExternalUrl).mockResolvedValueOnce({
contentLength: 120 * 1024 * 1024,
contentType: 'image/png',
isTooLarge: true,
isValid: false,
reason: 'File too large: 120MB',
});
const imageToBase64Spy = vi
.spyOn(imageToBase64Module, 'imageUrlToBase64')
.mockResolvedValueOnce({
base64: 'mockBase64Data',
mimeType: 'image/png',
});
const content: UserMessageContentPart = {
image_url: { url: imageUrl },
type: 'image_url',
};
await expect(buildGooglePart(content, { model: 'gemini-3-flash' })).rejects.toThrow(
RangeError,
);
expect(imageToBase64Spy).not.toHaveBeenCalled();
});
it('should throw TypeError for unsupported image URL types', async () => {
const unsupportedImageUrl = 'unsupported://example.com/image.png';
@@ -182,6 +343,46 @@ describe('google contextBuilders', () => {
});
});
it('should use fileData for external URL videos on gemini-3+', async () => {
const videoUrl = 'https://example.com/video.mp4';
vi.mocked(parseDataUri).mockReturnValueOnce({
base64: null,
mimeType: null,
type: 'url',
});
vi.mocked(isPublicExternalUrl).mockReturnValueOnce(true);
vi.mocked(validateExternalUrl).mockResolvedValueOnce({
contentLength: 1024,
contentType: 'video/mp4',
isValid: true,
});
const imageToBase64Spy = vi
.spyOn(imageToBase64Module, 'imageUrlToBase64')
.mockResolvedValueOnce({
base64: 'mockVideoBase64Data',
mimeType: 'video/mp4',
});
const content: UserMessageContentPart = {
type: 'video_url',
video_url: { url: videoUrl },
};
const result = await buildGooglePart(content, { model: 'gemini-3-flash-preview' });
expect(result).toEqual({
fileData: {
fileUri: videoUrl,
mimeType: 'video/mp4',
},
thoughtSignature: GEMINI_MAGIC_THOUGHT_SIGNATURE,
});
expect(imageToBase64Spy).not.toHaveBeenCalled();
});
it('should return undefined for unsupported SVG image (base64)', async () => {
const svgBase64 =
'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjwvc3ZnPg==';
@@ -8,14 +8,15 @@ import { imageUrlToBase64, resolveImageMimeTypeFromBase64 } from '@lobechat/util
import type { ChatCompletionTool, OpenAIChatMessage, UserMessageContentPart } from '../../types';
import { safeParseJSON } from '../../utils/safeParseJSON';
import { parseDataUri } from '../../utils/uriParser';
import { isPublicExternalUrl, parseDataUri, validateExternalUrl } from '../../utils/uriParser';
const GOOGLE_SUPPORTED_IMAGE_TYPES = new Set([
'image/jpeg',
'image/jpg',
'image/png',
'image/gif',
'image/jpeg',
'image/jpg', // non-standard but widely used alias for image/jpeg
'image/webp',
'image/heic',
'image/heif',
]);
const isImageTypeSupported = (mimeType: string | null | undefined): mimeType is string =>
@@ -30,11 +31,66 @@ const isImageTypeSupported = (mimeType: string | null | undefined): mimeType is
*/
export const GEMINI_MAGIC_THOUGHT_SIGNATURE = 'skip_thought_signature_validator';
const getGeminiMajorVersion = (model?: string) => {
if (!model) return null;
// Examples:
// - gemini-3-flash-preview
// - gemini-2.5-flash
const match = model.match(/gemini-(\d+)(?:\.(\d+))?/i);
if (!match?.[1]) return null;
const major = Number.parseInt(match[1], 10);
return Number.isFinite(major) ? major : null;
};
/**
* External HTTP / Signed URLs support varies by model generation.
* In practice, Gemini 3+ supports `fileData.fileUri` for external URLs reliably,
* while earlier models often require `inlineData`.
* Returns false for unversioned model IDs (e.g. gemini-pro) to avoid request failures.
*/
const supportsExternalUrlFileData = (model?: string) => {
const major = getGeminiMajorVersion(model);
if (major === null) return false;
return major >= 3;
};
const buildExternalUrlFileDataPart = async (
url: string,
options?: { model?: string },
): Promise<Part | undefined> => {
if (!supportsExternalUrlFileData(options?.model) || !isPublicExternalUrl(url)) return undefined;
const validation = await validateExternalUrl(url);
if (validation.isValid) {
return {
fileData: {
fileUri: url,
mimeType: validation.contentType,
},
thoughtSignature: GEMINI_MAGIC_THOUGHT_SIGNATURE,
};
}
if (validation.isTooLarge) {
throw new RangeError(validation.reason || 'External URL file too large');
}
return undefined;
};
/**
* Convert OpenAI content part to Google Part format
*
* TODO: urlContext tool only supports files up to 34MB. In the future, we should
* detect file URLs in the conversation and use External URL feature (fileData.fileUri)
* for files larger than 34MB to avoid urlContext limitations.
* @see https://ai.google.dev/gemini-api/docs/file-input-methods
*/
export const buildGooglePart = async (
content: UserMessageContentPart,
options?: { model?: string },
): Promise<Part | undefined> => {
switch (content.type) {
default: {
@@ -67,12 +123,19 @@ export const buildGooglePart = async (
}
if (type === 'url') {
const { base64, mimeType } = await imageUrlToBase64(content.image_url.url);
const url = content.image_url.url;
if (!isImageTypeSupported(mimeType)) return undefined;
const externalUrlPart = await buildExternalUrlFileDataPart(url, options);
if (externalUrlPart) return externalUrlPart;
// Fallback: convert URL to base64 (for private/local URLs or failed validation)
const { base64: urlBase64, mimeType: urlMimeType } = await imageUrlToBase64(url);
const resolvedMimeType = urlMimeType || mimeType;
if (!isImageTypeSupported(resolvedMimeType)) return undefined;
return {
inlineData: { data: base64, mimeType },
inlineData: { data: urlBase64, mimeType: resolvedMimeType || 'image/png' },
thoughtSignature: GEMINI_MAGIC_THOUGHT_SIGNATURE,
};
}
@@ -95,12 +158,18 @@ export const buildGooglePart = async (
}
if (type === 'url') {
const url = content.video_url.url;
const externalUrlPart = await buildExternalUrlFileDataPart(url, options);
if (externalUrlPart) return externalUrlPart;
// Fallback: convert URL to base64
// Use imageUrlToBase64 for SSRF protection (works for any binary data including videos)
// Note: This might need size/duration limits for practical use
const { base64, mimeType } = await imageUrlToBase64(content.video_url.url);
const { base64: urlBase64, mimeType: urlMimeType } = await imageUrlToBase64(url);
return {
inlineData: { data: base64, mimeType },
inlineData: { data: urlBase64, mimeType: urlMimeType },
thoughtSignature: GEMINI_MAGIC_THOUGHT_SIGNATURE,
};
}
@@ -116,6 +185,7 @@ export const buildGooglePart = async (
export const buildGoogleMessage = async (
message: OpenAIChatMessage,
toolCallNameMap?: Map<string, string>,
options?: { model?: string },
): Promise<Content> => {
const content = message.content as string | UserMessageContentPart[];
@@ -191,7 +261,7 @@ export const buildGoogleMessage = async (
if (typeof content === 'string')
return [{ text: content, thoughtSignature: GEMINI_MAGIC_THOUGHT_SIGNATURE }];
const parts = await Promise.all(content.map(async (c) => await buildGooglePart(c)));
const parts = await Promise.all(content.map(async (c) => await buildGooglePart(c, options)));
return parts.filter(Boolean) as Part[];
};
@@ -204,7 +274,10 @@ export const buildGoogleMessage = async (
/**
* Convert messages from the OpenAI format to Google GenAI SDK format
*/
export const buildGoogleMessages = async (messages: OpenAIChatMessage[]): Promise<Content[]> => {
export const buildGoogleMessages = async (
messages: OpenAIChatMessage[],
options?: { model?: string },
): Promise<Content[]> => {
const toolCallNameMap = new Map<string, string>();
// Build tool call id to name mapping
@@ -220,7 +293,7 @@ export const buildGoogleMessages = async (messages: OpenAIChatMessage[]): Promis
const pools = messages
.filter((message) => message.role !== 'function')
.map(async (msg) => await buildGoogleMessage(msg, toolCallNameMap));
.map(async (msg) => await buildGoogleMessage(msg, toolCallNameMap, options));
const contents = await Promise.all(pools);
@@ -186,7 +186,7 @@ export class LobeGoogleAI implements LobeRuntimeAI {
thinkingLevel,
}) as ThinkingConfig;
const contents = await buildGoogleMessages(payload.messages);
const contents = await buildGoogleMessages(payload.messages, { model });
const controller = new AbortController();
const originalSignal = options?.signal;
@@ -328,7 +328,7 @@ export class LobeGoogleAI implements LobeRuntimeAI {
*/
async generateObject(payload: GenerateObjectPayload, options?: GenerateObjectOptions) {
// Convert OpenAI messages to Google format
const contents = await buildGoogleMessages(payload.messages);
const contents = await buildGoogleMessages(payload.messages, { model: payload.model });
const pricing = await getModelPricing(payload.model, this.provider);
// Handle tools-based structured output
@@ -1,6 +1,14 @@
import { describe, expect, it } from 'vitest';
import { ssrfSafeFetch } from '@lobechat/ssrf-safe-fetch';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { parseDataUri } from './uriParser';
import { parseDataUri, validateExternalUrl } from './uriParser';
vi.mock('@lobechat/ssrf-safe-fetch', () => ({
ssrfSafeFetch: vi.fn(),
}));
const mockHeadResponse = (headers: Record<string, string>, status = 200) =>
new Response(null, { headers, status, statusText: status === 200 ? 'OK' : 'Error' });
describe('parseDataUri', () => {
it('should parse a valid data URI', () => {
@@ -58,3 +66,113 @@ describe('parseDataUri', () => {
expect(result).toEqual({ base64: largePadding, mimeType: 'image/png', type: 'base64' });
});
});
describe('validateExternalUrl', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should accept a supported external URL with a valid content length', async () => {
vi.mocked(ssrfSafeFetch).mockResolvedValueOnce(
mockHeadResponse({ 'content-length': '1024', 'content-type': 'image/png' }),
);
const result = await validateExternalUrl('https://example.com/image.png');
expect(result).toEqual({
contentLength: 1024,
contentType: 'image/png',
isValid: true,
});
});
it('should normalize image/jpg to image/jpeg', async () => {
vi.mocked(ssrfSafeFetch).mockResolvedValueOnce(
mockHeadResponse({ 'content-length': '1024', 'content-type': 'image/jpg' }),
);
const result = await validateExternalUrl('https://example.com/image.jpg');
expect(result).toEqual({
contentLength: 1024,
contentType: 'image/jpeg',
isValid: true,
});
});
it('should accept supported external video URLs', async () => {
vi.mocked(ssrfSafeFetch).mockResolvedValueOnce(
mockHeadResponse({ 'content-length': '1024', 'content-type': 'video/mp4' }),
);
const result = await validateExternalUrl('https://example.com/video.mp4');
expect(result).toEqual({
contentLength: 1024,
contentType: 'video/mp4',
isValid: true,
});
});
it('should reject supported MIME types when Content-Length is missing', async () => {
vi.mocked(ssrfSafeFetch).mockResolvedValueOnce(
mockHeadResponse({ 'content-type': 'image/png' }),
);
const result = await validateExternalUrl('https://example.com/image.png');
expect(result).toEqual({
contentLength: 0,
contentType: 'image/png',
isValid: false,
reason: 'Missing or invalid Content-Length header',
});
});
it('should reject supported MIME types when Content-Length is invalid', async () => {
vi.mocked(ssrfSafeFetch).mockResolvedValueOnce(
mockHeadResponse({ 'content-length': 'unknown', 'content-type': 'image/png' }),
);
const result = await validateExternalUrl('https://example.com/image.png');
expect(result).toEqual({
contentLength: 0,
contentType: 'image/png',
isValid: false,
reason: 'Missing or invalid Content-Length header',
});
});
it('should reject unsupported MIME types', async () => {
vi.mocked(ssrfSafeFetch).mockResolvedValueOnce(
mockHeadResponse({ 'content-length': '1024', 'content-type': 'image/svg+xml' }),
);
const result = await validateExternalUrl('https://example.com/image.svg');
expect(result).toEqual({
contentLength: 1024,
contentType: 'image/svg+xml',
isValid: false,
reason: 'Unsupported content type: image/svg+xml',
});
});
it('should reject files larger than the external URL limit', async () => {
const tooLarge = 101 * 1024 * 1024;
vi.mocked(ssrfSafeFetch).mockResolvedValueOnce(
mockHeadResponse({ 'content-length': String(tooLarge), 'content-type': 'image/png' }),
);
const result = await validateExternalUrl('https://example.com/large.png');
expect(result).toEqual({
contentLength: tooLarge,
contentType: 'image/png',
isTooLarge: true,
isValid: false,
reason: `File too large: ${tooLarge} bytes (max ${100 * 1024 * 1024} bytes)`,
});
});
});
@@ -1,3 +1,5 @@
import { ssrfSafeFetch } from '@lobechat/ssrf-safe-fetch';
interface UriParserResult {
base64: string | null;
mimeType: string | null;
@@ -29,3 +31,181 @@ export const parseDataUri = (dataUri: string): UriParserResult => {
return { base64: null, mimeType: null, type: null };
}
};
/**
* MIME types supported by Google Gemini External URL feature
* @see https://ai.google.dev/gemini-api/docs/file-input-methods#supported-content-types
*/
const GOOGLE_EXTERNAL_URL_SUPPORTED_TYPES = new Set([
// Text file types
'text/html',
'text/css',
'text/plain',
'text/xml',
'text/csv',
'text/rtf',
'text/javascript',
// Application file types
'application/json',
'application/pdf',
// Image file types
'image/bmp',
'image/jpeg',
'image/png',
'image/webp',
// Video file types
'video/3gpp',
'video/avi',
'video/mp4',
'video/mpeg',
'video/mpg',
'video/quicktime',
'video/webm',
'video/wmv',
'video/x-flv',
]);
const normalizeExternalContentType = (contentType: string): string => {
// Some servers return non-standard alias `image/jpg` for JPEG files.
// Normalize it to the standard type to avoid unnecessary fallback to inline base64.
if (contentType === 'image/jpg') return 'image/jpeg';
return contentType;
};
const parseContentLength = (contentLength: string | null): number | null => {
const normalized = contentLength?.trim();
if (!normalized || !/^\d+$/.test(normalized)) return null;
const value = Number(normalized);
return Number.isSafeInteger(value) ? value : null;
};
/**
* Maximum file size limits for Google Gemini file input
* @see https://ai.google.dev/gemini-api/docs/file-input-methods#method-comparison
*
* External URLs: 100MB for all file types
* Inline data: 100MB general, 50MB for PDFs
*/
const MAX_EXTERNAL_URL_SIZE = 100 * 1024 * 1024; // 100MB for external URLs (all types)
const MAX_INLINE_DATA_SIZE = 100 * 1024 * 1024; // 100MB for inline data (general)
const MAX_INLINE_PDF_SIZE = 50 * 1024 * 1024; // 50MB for inline PDFs only
export { MAX_INLINE_DATA_SIZE, MAX_INLINE_PDF_SIZE };
export interface ExternalUrlValidation {
/** Content-Length from response headers */
contentLength: number;
/** Content-Type from response headers */
contentType: string;
/** Whether the URL was rejected due to size limit */
isTooLarge?: boolean;
/** Whether the URL is valid for external URL usage */
isValid: boolean;
/** Reason for invalid URL */
reason?: string;
}
/**
* Check if a URL is an external HTTP(S) URL
* SSRF protection is enforced by ssrfSafeFetch during validation
*/
export const isPublicExternalUrl = (url: string): boolean => {
try {
const parsed = new URL(url);
// Only allow http and https protocols
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return false;
}
return true;
} catch {
return false;
}
};
/**
* Validate an external URL for Google Gemini file input
* Performs a HEAD request to check Content-Length and Content-Type
*
* @param url - The URL to validate
* @returns Validation result with content info
*/
export const validateExternalUrl = async (url: string): Promise<ExternalUrlValidation> => {
try {
// Perform HEAD request to get headers without downloading the file
const res = await ssrfSafeFetch(
url,
{
headers: {
'User-Agent': 'LobeChat/1.0 (https://lobehub.com)',
},
method: 'HEAD',
},
{
allowIPAddressList: [],
allowPrivateIPAddress: false,
},
);
if (!res.ok) {
return {
contentLength: 0,
contentType: '',
isValid: false,
reason: `HTTP ${res.status}: ${res.statusText}`,
};
}
const contentLength = parseContentLength(res.headers.get('content-length'));
const contentType = normalizeExternalContentType(
(res.headers.get('content-type') || '').split(';')[0].trim().toLowerCase(),
);
// Check MIME type support
if (!GOOGLE_EXTERNAL_URL_SUPPORTED_TYPES.has(contentType)) {
return {
contentLength: contentLength || 0,
contentType,
isValid: false,
reason: `Unsupported content type: ${contentType}`,
};
}
if (contentLength === null) {
return {
contentLength: 0,
contentType,
isValid: false,
reason: 'Missing or invalid Content-Length header',
};
}
// Check file size - External URLs support 100MB for all file types
// (Unlike inline data where PDFs are limited to 50MB)
if (contentLength > MAX_EXTERNAL_URL_SIZE) {
return {
contentLength,
contentType,
isTooLarge: true,
isValid: false,
reason: `File too large: ${contentLength} bytes (max ${MAX_EXTERNAL_URL_SIZE} bytes)`,
};
}
return {
contentLength,
contentType,
isValid: true,
};
} catch (error) {
return {
contentLength: 0,
contentType: '',
isValid: false,
reason: `Failed to validate URL: ${error instanceof Error ? error.message : String(error)}`,
};
}
};
@@ -2,52 +2,56 @@ import { describe, expect, it } from 'vitest';
import { validateVideoFileSize } from './videoValidation';
const createMockFile = (size: number, type: string): File => ({ size, type }) as File;
describe('validateVideoFileSize', () => {
it('should return valid for non-video files', () => {
const mockFile = new File(['test'], 'test.txt', { type: 'text/plain' });
const mockFile = createMockFile(4, 'text/plain');
const result = validateVideoFileSize(mockFile);
expect(result.isValid).toBe(true);
expect(result.actualSize).toBeUndefined();
expect(result.maxSize).toBeUndefined();
});
it('should return valid for video files under 20MB', () => {
const mockVideoFile = new File(['x'.repeat(10 * 1024 * 1024)], 'video.mp4', {
type: 'video/mp4',
});
it('should return valid for video files under 100MB', () => {
const mockVideoFile = createMockFile(10 * 1024 * 1024, 'video/mp4');
const result = validateVideoFileSize(mockVideoFile);
expect(result.isValid).toBe(true);
expect(result.actualSize).toBe('10.0 MB');
});
it('should return invalid for video files over 20MB', () => {
const mockLargeVideoFile = new File(['x'.repeat(25 * 1024 * 1024)], 'large-video.mp4', {
type: 'video/mp4',
});
it('should return valid for video files under 100MB (25MB)', () => {
const mockLargeVideoFile = createMockFile(25 * 1024 * 1024, 'video/mp4');
const result = validateVideoFileSize(mockLargeVideoFile);
expect(result.isValid).toBe(false);
expect(result.isValid).toBe(true);
expect(result.actualSize).toBe('25.0 MB');
});
it('should return invalid for video files exactly at 20MB limit plus 1 byte', () => {
const mockBoundaryFile = new File(['x'.repeat(20 * 1024 * 1024 + 1)], 'boundary.mp4', {
type: 'video/mp4',
});
it('should return invalid for video files over 100MB', () => {
const mockLargeVideoFile = createMockFile(120 * 1024 * 1024, 'video/mp4');
const result = validateVideoFileSize(mockLargeVideoFile);
expect(result.isValid).toBe(false);
expect(result.actualSize).toBe('120.0 MB');
expect(result.maxSize).toBe('100.0 MB');
});
it('should return invalid for video files exactly at 100MB limit plus 1 byte', () => {
const mockBoundaryFile = createMockFile(100 * 1024 * 1024 + 1, 'video/mp4');
const result = validateVideoFileSize(mockBoundaryFile);
expect(result.isValid).toBe(false);
expect(result.actualSize).toBe('20.0 MB');
expect(result.actualSize).toBe('100.0 MB');
});
it('should return valid for video files exactly at 20MB limit', () => {
const mockBoundaryFile = new File(['x'.repeat(20 * 1024 * 1024)], 'boundary.mp4', {
type: 'video/mp4',
});
it('should return valid for video files exactly at 100MB limit', () => {
const mockBoundaryFile = createMockFile(100 * 1024 * 1024, 'video/mp4');
const result = validateVideoFileSize(mockBoundaryFile);
expect(result.isValid).toBe(true);
expect(result.actualSize).toBe('20.0 MB');
expect(result.actualSize).toBe('100.0 MB');
});
});
+3 -1
View File
@@ -1,10 +1,11 @@
import { formatSize } from '../format';
const VIDEO_SIZE_LIMIT = 20 * 1024 * 1024; // 20MB in bytes
const VIDEO_SIZE_LIMIT = 100 * 1024 * 1024; // 100MB in bytes
export interface VideoValidationResult {
actualSize?: string;
isValid: boolean;
maxSize?: string;
}
export const validateVideoFileSize = (file: File): VideoValidationResult => {
@@ -17,5 +18,6 @@ export const validateVideoFileSize = (file: File): VideoValidationResult => {
return {
actualSize: formatSize(file.size),
isValid,
maxSize: formatSize(VIDEO_SIZE_LIMIT),
};
};
@@ -412,7 +412,10 @@ const PlusAction = memo(() => {
const validation = validateVideoFileSize(file);
if (!validation.isValid) {
message.error(
t('upload.validation.videoSizeExceeded', { actualSize: validation.actualSize }),
t('upload.validation.videoSizeExceeded', {
actualSize: validation.actualSize,
maxSize: validation.maxSize,
}),
);
return false;
}
@@ -125,6 +125,7 @@ const FileUpload = memo(() => {
message.error(
t('upload.validation.videoSizeExceeded', {
actualSize: validation.actualSize,
maxSize: validation.maxSize,
}),
);
return false;
@@ -163,6 +164,7 @@ const FileUpload = memo(() => {
message.error(
t('upload.validation.videoSizeExceeded', {
actualSize: validation.actualSize,
maxSize: validation.maxSize,
}),
);
return false;
+1 -1
View File
@@ -968,7 +968,7 @@ export default {
'upload.validation.unsupportedFileType':
'Unsupported file type: {{files}}. Supported images: JPG, PNG, GIF, WebP. Supported documents include PDF, Word, Excel, PowerPoint, Markdown, text, CSV, JSON, and code files.',
'upload.validation.videoSizeExceeded':
'Video file size must not exceed 20MB. Current file size is {{actualSize}}.',
'Video file size must not exceed {{maxSize}}. Current file size is {{actualSize}}.',
'viewMode.fullWidth': 'Full Width',
'viewMode.normal': 'Standard',
'viewMode.wideScreen': 'Widescreen',