Нормализация изображений API должна быть отдельным слоем
Нормализация изображений API сводит URL, base64 и файлы к одному контракту, сохраняет порядок контента и закрывает SSRF, ошибки MIME и доступа.

Форматы изображений нельзя передавать к модели в том виде, в каком их прислал клиент. URL, data URL с base64 и файл с идентификатором выглядят как три способа сказать «вот картинка», но для шлюза это три разных обязательства: скачать объект, разобрать бинарные данные или найти сохраненный ресурс.
Нормализация изображений API должна происходить до выбора провайдера и до сериализации OpenAI-совместимого запроса. Тогда приложение сохраняет порядок частей сообщения, единообразно проверяет безопасность и получает один контракт, а не набор веток, которые расходятся с каждым новым endpoint.
Самая неприятная ошибка здесь редко выглядит как ошибка обработки картинки. Пользователь пишет: «На первом фото упаковка, на втором дефект. Сравни их». Сервис группирует изображения отдельно от текста, повторно сортирует их по имени файла и отправляет модели уже другую последовательность. Модель отвечает убедительно, но анализирует не те объекты. Логи при этом показывают два валидных изображения и успешный ответ. Поэтому порядок контента относится к данным запроса, а не к детали интерфейса.
Один внутренний контракт лучше трех форматов
Внутренний контракт должен описывать изображение как отдельную часть сообщения с позицией, источником и уже проверенными метаданными. Он не должен повторять форму image_url из одного API, потому что другой endpoint может ожидать input_image, file_id или вообще отдельную загрузку.
Практичный контракт выглядит так:
type NormalizedImage = {
kind: "image";
position: number;
source: "remote_url" | "inline_bytes" | "managed_file";
mimeType: "image/jpeg" | "image/png" | "image/webp" | "image/gif";
bytes?: Uint8Array;
remoteUrl?: string;
fileId?: string;
sha256: string;
originalName?: string;
detail?: "low" | "high" | "auto";
};
type NormalizedPart =
| { kind: "text"; position: number; text: string }
| NormalizedImage;
type NormalizedMessage = {
role: "system" | "developer" | "user" | "assistant";
parts: NormalizedPart[];
};
Здесь position не дублирует индекс массива ради красоты. Он нужен, если части проходят разные асинхронные операции. URL требует скачивания, base64 требует декодирования, file_id требует чтения метаданных. Результаты будут готовы в произвольном порядке. После этого нельзя просто добавлять готовые элементы через push().
Контракт также отделяет происхождение от представления. Два входа могут содержать одинаковые байты, хотя один пришел из URL, а второй из base64. Их дальнейшая обработка должна зависеть от правил маршрута и хранения, но не от того, как фронтенд упаковал картинку.
Не пытайтесь сделать внутренним форматом «всегда URL». Для inline-данных вам придется временно где-то размещать файл. Для приватного файла URL может оказаться бесполезен выбранной модели. Для внешнего URL придется решать, кому разрешено его скачивать. Универсальная строка быстро превращается в универсальную проблему.
Порядок частей сообщения нельзя восстанавливать догадками
Порядок должен возникнуть в момент разбора исходного сообщения и доживать до исходящего payload. Не сортируйте изображения по имени, хешу, времени загрузки или типу. Не переносите весь текст перед всеми изображениями, даже если так проще построить запрос.
Возьмем пользовательское содержимое Chat Completions:
[
{"type":"text","text":"На первом фото ценник."},
{"type":"image_url","image_url":{"url":"https://cdn.example.org/price.jpg"}},
{"type":"text","text":"На втором фото полка. Найди расхождение."},
{"type":"image_url","image_url":{"url":"data:image/png;base64,iVBORw0KGgo..."}}
]
Нормализатор должен сразу создать четыре части с позициями 0, 1, 2, 3. Скачивание price.jpg и декодирование PNG можно запускать параллельно, но собирать итог нужно по position.
const settled = await Promise.all(
rawParts.map((part, position) => normalizePart(part, position))
);
const parts = settled
.flat()
.sort((a, b) => a.position - b.position);
Этот пример намеренно простой. В реальном коде не используйте flat() без правил: одна входная часть либо дает ровно одну нормализованную часть, либо возвращает ошибку всего сообщения. Если функция иногда разворачивает один элемент в несколько, она должна присваивать дочерним элементам стабильные позиции вроде 2.0 и 2.1, либо хранить отдельный массив внутри исходной части. Иначе порядок останется «обычно правильным», а это плохое свойство для данных.
Особенно опасна оптимизация, при которой шлюз собирает textParts и imageParts в разные коллекции, а потом строит [...textParts, ...imageParts]. Она кажется безобидной в тестах с одной картинкой. В многошаговых визуальных инструкциях она меняет смысл запроса.
URL нужно скачивать как недоверенный сетевой ввод
Внешний URL удобен клиенту, но он превращает ваш сервис в HTTP-клиент, которому пользователь задает цель. Это классический путь к SSRF: запросам к локальным сервисам, внутренним сетям и metadata endpoints облака.
Проверка url.startsWith("https://") не решает задачу. Адрес может вести на публичный домен, который отдает редирект во внутреннюю сеть. DNS-имя может резолвиться в приватный IP. Сервер может отдать огромный файл или бесконечно перенаправлять запрос.
Минимальная политика загрузчика должна включать следующее:
- Принимать только
https:и, если это необходимо для старой инфраструктуры, отдельно разрешенныйhttp:. - Отклонять логин и пароль в URL, нестандартные схемы, пустой host и слишком длинные адреса.
- После DNS-разрешения блокировать loopback, private, link-local, multicast и служебные диапазоны адресов.
- Проверять каждый редирект заново и ставить небольшой лимит на их число.
- Ограничивать время подключения, время чтения, размер тела и допустимые MIME-типы.
Проверяйте не только заголовок Content-Type. Сервер может назвать HTML-документ image/jpeg, а прокси может вернуть страницу ошибки с кодом 200. После скачивания прочитайте сигнатуру бинарного файла. Для JPEG это начинается с FF D8 FF, у PNG есть фиксированная восьмибайтная сигнатура, WebP использует контейнер RIFF с маркером WEBP. Библиотека определения MIME по байтам избавляет от самодельной таблицы, но ее результат все равно надо сопоставить с разрешенным списком.
Скачивание URL не обязано сохранять картинку в постоянное хранилище. Для одного запроса можно держать проверенные байты во временном объекте с коротким сроком жизни. Если приложение хочет повторно использовать ресурс, создайте управляемый файл и запишите владельца, хеш, размер и время удаления.
Base64 требует декодирования до валидации
Base64 не является форматом изображения. Это текстовая упаковка байтов, и считать ее картинкой до декодирования нельзя. Строка может быть чистой base64, data URL или просто поврежденным текстом, который случайно проходит поверхностную проверку.
Разбор data URL должен разделять заголовок и полезную нагрузку строго по первой запятой. Для изображения ожидайте форму data:<mime>;base64,<payload>. Не принимайте молча data:text/html;base64,... только потому, что поле называется image_url.
function parseDataUrl(value: string) {
const comma = value.indexOf(",");
if (comma < 0) throw new InputError("invalid_image_reference");
const header = value.slice(0, comma).toLowerCase();
const payload = value.slice(comma + 1);
const match = /^data:(image\/(jpeg|png|webp|gif));base64$/.exec(header);
if (!match || payload.length === 0) {
throw new InputError("invalid_image_reference");
}
if (!/^[a-z0-9+/=\r\n]+$/i.test(payload)) {
throw new InputError("invalid_image_reference");
}
return { declaredMimeType: match[1], payload };
}
После этого декодируйте байты с ограничением размера. Лимит надо проверять не только по длине строки. Base64 увеличивает объем примерно на треть, но пробелы и переносы меняют длину текста, а атака направлена на память после декодирования. Потоковый декодер или предварительный расчет ожидаемого размера защищает лучше, чем вызов Buffer.from() на строке неизвестной длины.
Затем определите фактический MIME-тип по сигнатуре. Если клиент объявил PNG, а байты выглядят как JPEG, есть два разумных варианта: отклонить запрос как несогласованный или заменить заявленный тип фактическим и записать событие аудита. Для систем с документами и медицинскими изображениями я предпочитаю отклонение. Несовпадение редко бывает случайностью, а последующая диагностика стоит дороже.
Не перекодируйте изображение без причины. Повторное сохранение JPEG ухудшает его, может удалить полезный цветовой профиль и отнимает процессор. Перекодирование оправдано, когда вы сознательно удаляете метаданные, приводите неподдерживаемый тип к разрешенному или ограничиваете размеры пикселей.
Файл это ресурс с жизненным циклом, а не просто другой URL
file_id дает клиенту короткий запрос и избавляет его от передачи одних байтов снова. Но идентификатор не означает, что файл можно безусловно приложить к каждой модели. Endpoint может принять file_id, поддерживать только URL или потребовать отдельную форму контента.
В справочнике OpenAI Responses API изображение может быть передано через image_url или file_id в элементе input_image. Там же файлы описаны отдельным типом input_file. В старом стиле Chat Completions структура контентных частей отличается. Схема похожа по смыслу, но не по полям. Это причина держать адаптеры endpoint отдельно, а не расползать условными операторами по всему приложению.
Хранилище файлов должно отвечать минимум на четыре вопроса:
- Кто владеет объектом и имеет право указать его ID.
- Какие байты и MIME-тип были подтверждены при загрузке.
- До какого времени объект доступен.
- Какой маршрут может получить его исходные байты или временную ссылку.
Нельзя принимать file_id и подставлять его в запрос без авторизации. Иначе пользователь одного тенанта сможет угадывать или получать идентификаторы объектов другого. UUID уменьшает вероятность угадывания, но не заменяет проверку владельца.
Полезно разделить два состояния. «Загружен» означает, что байты дошли до хранилища. «Готов для модели» означает, что вы проверили MIME-тип, размер, декодируемость, антивирусные правила при их наличии и политику владельца. Модель должна видеть только второе состояние.
Публичные документы OpenAI по Uploads API описывают многочастную загрузку как промежуточный объект, который после завершения становится File. Это хороший ориентир для собственной модели состояний: не давайте клиенту использовать ресурс, который еще собирается по частям или не прошел проверку.
Адаптер endpoint должен строить провайдерский payload последним
Нормализованный объект не надо выдавать наружу как есть. На последней границе адаптер выбирает поддержку конкретного маршрута и строит нужную форму. Это место, где оправдана таблица возможностей модели: принимает ли она внешние URL, data URL, управляемые файлы, какой уровень детализации понимает и какой максимальный размер допускает ваш маршрут.
Для Responses-подобного endpoint исходящее содержимое может выглядеть так:
{
"role": "user",
"content": [
{"type":"input_text","text":"Сравни маркировку на двух упаковках."},
{
"type":"input_image",
"image_url":"https://media.example.net/tmp/2f7c.jpg",
"detail":"high"
},
{
"type":"input_image",
"file_id":"file_01HXYZ...",
"detail":"high"
}
]
}
Для Chat Completions-подобного endpoint та же семантика часто требует другой формы:
{
"role": "user",
"content": [
{"type":"text","text":"Сравни маркировку на двух упаковках."},
{
"type":"image_url",
"image_url":{"url":"https://media.example.net/tmp/2f7c.jpg","detail":"high"}
},
{
"type":"image_url",
"image_url":{"url":"data:image/jpeg;base64,/9j/4AAQ...","detail":"high"}
}
]
}
Второй пример не означает, что любой совместимый сервер принимает data URL. Он показывает, почему нельзя назвать один JSON «OpenAI-форматом» и закончить обсуждение. Совместимость обычно относится к маршруту и набору полей, а не к полной одинаковости поведения всех провайдеров.
Если модель принимает только URL, адаптер может создать подписанную временную ссылку на проверенные байты. Если маршрут принимает только inline-данные, адаптер может кодировать байты в base64. Если ни один вариант не проходит правила маршрута, верните ошибку до обращения к модели. Попытка «авось провайдер поймет» создает дорогие и труднообъяснимые сбои.
AI Router имеет смысл подключать именно на этой границе: приложение сохраняет один OpenAI-совместимый вызов, а правила выбора доступного маршрута остаются вне бизнес-кода. Это не отменяет нормализацию на вашей стороне, потому что только приложение знает исходный порядок, владельца файла и смысл пользовательской операции.
Проверка изображения должна учитывать байты, пиксели и стоимость
Файл может быть маленьким по размеру, но огромным после распаковки пикселей. Изображение с экстремальными размерами способно занять много памяти при декодировании, даже если сжатый PNG выглядит безобидно. До передачи модели прочитайте ширину, высоту, число кадров для анимированных форматов и ориентацию.
Проверка должна отделять допустимость от пригодности. JPEG может быть технически валидным, но не годиться для задачи, если на фотографии слишком мало пикселей для чтения мелкого текста. Шлюз не обязан угадывать цель пользователя, зато может передать detail, если выбранный API его поддерживает, и записать в аудит нормализованные размеры.
Считайте SHA-256 по подтвержденным байтам. Этот хеш помогает:
- убрать повторные загрузки одной картинки;
- связать запросы с объектом без хранения исходного URL в аналитике;
- повторить расследование ошибки на том же ресурсе;
- обнаружить, что два разных file_id содержат одинаковые данные.
Не используйте хеш как единственное право доступа. Хеш предсказуем для известного файла и сам по себе не является секретом. Он нужен для идентификации содержимого, а не для авторизации.
Отдельно решите вопрос с EXIF. Метаданные камеры могут содержать координаты, время съемки и модель устройства. Если изображение приходит из приложения для осмотра объектов, страховых случаев или медицинской работы, передача EXIF дальше часто не нужна. Удаляйте его в контролируемом этапе преобразования, но не делайте этого молча, если клиент рассчитывает на ориентацию изображения. Сначала примените ориентацию к пикселям, затем удалите тег.
Ошибки должны показывать клиенту, что исправить
Провайдерские ошибки редко дают хороший контракт для вашего клиента. Один сервер напишет unsupported image, другой вернет HTML через прокси, третий откажется от file_id без указания причины. Нормализатор должен ловить проблемы раньше и возвращать ограниченный набор предсказуемых кодов.
Я бы начал с таких кодов:
{
"error": {
"code": "unsupported_media_type",
"message": "Поддерживаются JPEG, PNG, WebP и GIF.",
"param": "messages[0].content[3]"
}
}
invalid_image_reference означает неверный data URL, пустой file_id или недопустимый URL. remote_fetch_denied означает, что ссылка нарушила сетевую политику. image_too_large говорит о превышении вашего лимита байтов или пикселей. file_not_found и file_access_denied нельзя объединять, если клиент имеет право знать, что объект существовал. Для внешнего клиента безопаснее ответить одинаково, а точную причину оставить в аудите.
В журнале храните request ID, tenant ID, позицию элемента, тип источника, заявленный и фактический MIME-тип, размер, хеш и выбранный маршрут. Не пишите base64 в логи. Не оставляйте в них полные временные URL с токенами. Логи должны помогать восстановить цепочку обработки, а не становиться вторым незащищенным хранилищем изображений.
Тестируйте переходы между форматами, а не только успешный JPEG
Один тест с публичным JPEG проверяет почти ничего. Нужна матрица переходов: URL в data URL, file_id во временный URL, base64 в управляемый файл, а также отклонения на каждом этапе.
Минимальный набор тестов должен включать сообщение с текстом между двумя изображениями, URL с редиректом в запрещенную сеть, data URL с ложным MIME-типом, файл другого тенанта и картинку, у которой размеры пикселей не укладываются в политику. Для каждого случая проверяйте не только код ответа, но и отсутствие обращения к провайдеру при локальной ошибке.
Добавьте тест на конкуренцию. Пусть первое изображение скачивается медленно, а второе декодируется мгновенно. Итоговый массив обязан остаться в исходном порядке. Именно этот тест ловит соблазн собрать результат по времени завершения операций.
Хороший слой преобразования не делает модель умнее. Он гарантирует, что модель увидит те же объекты, в том же порядке и с теми же границами доступа, которые задал пользователь. Для мультимодального приложения это часть корректности ответа, а не вспомогательная сантехника.
Часто задаваемые вопросы
Нужно ли вообще нормализовать URL, base64 и файлы для LLM?
Нет. Внешний URL заставляет ваш шлюз или провайдера скачивать объект по сети, base64 раздувает JSON-запрос, а file_id добавляет отдельный жизненный цикл файла. Если спрятать различия без проверок, ошибки проявятся в продакшене как перепутанные изображения, таймауты и утечки данных.
Как сохранить порядок нескольких изображений в одном сообщении?
Для внутреннего контракта лучше передавать порядковый номер элемента, например position, и хранить части сообщения в исходной последовательности. Не собирайте текст и изображения в разные массивы с последующим склеиванием: именно там обычно пропадает смысл фраз вроде «сравни первое и второе фото».
Можно ли определить MIME-тип изображения по расширению файла?
Если клиент присылает чистую base64-строку, шлюз не знает, JPEG это, PNG или вообще произвольный бинарный поток. MIME-тип должен прийти отдельным полем или быть определен после декодирования по сигнатуре файла. Поле filename полезно для аудита, но не доказывает тип содержимого.
Как защитить загрузчик изображений по URL от SSRF?
Проверяйте схему, имя хоста, DNS-результат после резолвинга, IP-адрес назначения, лимит редиректов и размер ответа. Запрещайте loopback, private, link-local и metadata-адреса, а затем повторяйте проверку после каждого редиректа. Одной проверки строки URL недостаточно.
Чем input_image в Responses API отличается от image_url в Chat Completions?
Это разные формы протокола. В Chat Completions часто встречается content part типа image_url с вложенным объектом image_url, а в Responses API используется input_image с полем image_url или file_id. Внутренняя модель должна быть одной, а адаптеры должны строить конкретный формат только на границе выбранного endpoint.
Можно ли отправлять base64-картинку прямо в OpenAI-совместимый API?
Да, если ваш выбранный endpoint и модель принимают data URL, а шлюз не меняет его в процессе. Но для крупных или повторно используемых изображений загрузка файла обычно удобнее: JSON остается небольшим, а идентификатор файла можно логировать и удалять по политике хранения.
Поддерживают ли все OpenAI-совместимые провайдеры file_id для изображений?
Не всегда. Поддержка file_id зависит от endpoint, модели и реализации совместимого провайдера. Поэтому шлюз должен уметь превратить внутренний объект file в разрешенный для маршрута вариант, например во временный URL или data URL, либо вернуть понятную ошибку до вызова модели.
Как дедуплицировать одинаковые изображения в запросах?
Обычно это повторная загрузка одного и того же изображения, повторная попытка запроса или дублирование после ретрая клиента. Считайте хеш декодированных байтов, а не текст base64: пробелы, переносы строк и разные представления data URL не должны создавать новые объекты.
Как управлять жизненным циклом файлов, отправленных модели?
Идентификатор файла должен иметь владельца, срок хранения, время последнего использования и причину удаления. Не делайте file_id вечным ссылочным ключом в базе приложения. Когда файл больше не нужен для запроса, оценки или расследования инцидента, удаляйте его по явной задаче очистки.
Какие ошибки должен возвращать слой преобразования изображений?
Нормализатор должен вернуть код, который можно исправить клиенту: invalid_image_reference, unsupported_media_type, image_too_large, remote_fetch_denied или file_not_found. Не превращайте любую проблему в 500 и не пересылайте наружу сырой текст ошибки провайдера, где могут оказаться URL, внутренние адреса или детали маршрутизации.