Два изображения, очередь и proxy: как мы подключили OpenAI Image Edits к российскому production-серверу

от автора

Для крупной федеральной компании из телеком-сектора наша команда разработала закрытую семейную платформу для сотрудников и их детей. Один родительский аккаунт объединяет несколько детских профилей, а внутри находятся тематические районы, викторины, мини-игры, творческие задания, личный прогресс, баллы и общий рейтинг.

Название заказчика и самой платформы я не раскрываю по NDA. Для этого разбора важнее масштаб задачи: речь идёт не об эксперименте с генерацией картинок, а о production-продукте, где AI-механика должна работать вместе с авторизацией, детскими профилями, расписанием контента, очередями, файловым хранилищем и правилами начисления баллов.

Одной из творческих активностей стала «виртуальная роспись» городского здания. Пользователь выбирал фотографию фасада, загружал собственный рисунок, а система превращала его в изображение на стенах. Здание должно было остаться узнаваемым, а рисунок — стать частью реальной архитектуры, а не потеряться в новой генерации.

На уровне интерфейса всё выглядит просто: выбрать основу, загрузить рисунок, дождаться результата. На уровне production-системы задача быстро перестаёт быть «вызовом AI API». Прямого доступа к OpenAI API с российского сервера нет. Сама операция длительная, работает с приватными файлами и может завершиться сетевой ошибкой, ограничением провайдера или отказом модерации.

Ниже — технический разбор этого конкретного потока: от React и очереди Laravel до небольшого собственного proxy-сервиса, встроенного в архитектуру основной платформы.

Почему text-to-image здесь не подходит

Обычная генерация по тексту подходит, когда достаточно передать идею: «нарисуй город будущего в детском стиле». Здесь были важны оба исходника.

Первое изображение задавало композицию: реальный фасад, ракурс, окна и геометрию здания. Второе содержало авторский рисунок пользователя. Если заменить их одним prompt, модель может создать красивую сцену, но не обязана сохранить ни конкретный фасад, ни узнаваемые детали рисунка. Для задания это уже другой результат.

Нужен был image-editing сценарий: два файла на входе, серверный prompt и управляемые параметры, готовое изображение на выходе.

Почему выбрали OpenAI

В других проектах мы применяли API GigaChat и Yandex. Выбор провайдера всегда зависел от конкретной механики, а не от идеи найти одну «лучшую нейросеть».

На момент реализации для этого задания требовался публичный API, который умел принимать два исходных изображения в режиме редактирования. В доступных нам контурах GigaChat и Yandex подходящего API для такой операции не было. OpenAI Image Edits решал именно нужную задачу, поэтому здесь выбрали его.

Однако основной backend работал на сервере в Москве. Из российской инфраструктуры OpenAI API недоступен, поэтому прямой вызов не был рабочим вариантом.

Почему собственный сервис, а не обычный сетевой proxy

Самый короткий путь — завернуть cURL через внешний HTTP- или SOCKS-proxy. Для прототипа этого может быть достаточно. В production появляется слишком много неуправляемых переменных: кто эксплуатирует промежуточный узел, что он логирует, как диагностировать его ошибки, где хранится доступ и когда изменятся ограничения.

Мы подняли свой небольшой application-level proxy на зарубежном VPS. В отличие от прозрачного сетевого туннеля, он принимает только один прикладной контракт: два изображения и несколько разрешённых параметров. Он сам проверяет подпись и файлы, вызывает конкретный endpoint OpenAI, нормализует ошибки и отдаёт предсказуемый JSON.

Это решение оказалось надёжнее для нашего контура не потому, что любой собственный сервис надёжнее готового proxy. Причина в контроле: известный код, свой мониторинг, собственный deploy, отсутствие общего публичного relay и возможность проверить весь маршрут от очереди до ответа OpenAI.

Ключ OpenAI хранится только на этом VPS. Российский backend знает секрет для server-to-server подписи, но не получает ключ провайдера. Браузер не знает ни одного из них.

Полный поток запроса

Интеграция состоит из трёх разных по ответственности частей:

React управляет пользовательским сценарием. Он показывает доступные фотографии-основы, отправляет выбранный идентификатор и рисунок, затем опрашивает endpoint статуса. Prompt, модель, качество результата и правила начисления баллов во frontend не попадают.

Laravel владеет пользователями, заданиями, состоянием генерации, временными исходниками, итоговыми файлами и прогрессом. Proxy ничего об этих сущностях не знает. Для него существует только один подписанный запрос.

Что происходит в Laravel до очереди

Запрос приходит в уже существующий endpoint выполнения задания как multipart/form-data. В нём три значимых поля:

task_id        - идентификатор текущего заданияbase_image_id  - выбранная серверная фотографияuser_image     - загруженный рисунок

Backend проверяет токен, принадлежность детского профиля родителю, доступность задания по расписанию и допустимость выбранной основы. Саму фотографию клиент не загружает: он передаёт только ID из списка, определённого backend. Это не позволяет подменить первый исходник произвольным URL.

Пользовательский файл ограничен 10 МБ и форматами PNG, JPEG и WebP. Проверяется не только расширение: Laravel читает MIME-тип и убеждается, что содержимое действительно декодируется как изображение.

Перед постановкой в очередь рисунок уменьшается до 1920×1920 с сохранением пропорций и сохраняется во временное приватное хранилище как WebP с качеством 90. Это снижает объём передачи и делает последующую обработку предсказуемее.

После этого создаётся отдельная запись генерации с UUID:

$generation = Generation::create([    'id'          => (string) Str::uuid(),    'user_id'     => $parent->id,    'profile_id'  => $child->id,    'activity_id' => $activity->id,    'task_id'     => $taskId,    'status'      => 'pending',    'image_a_path' => $privateBasePath,    'image_b_path' => $privateDrawingPath,]);ComposeImagesJob::dispatch($generation->id);

Фрагменты кода в статье сокращены и обезличены, но сохраняют реальную последовательность действий.

Синхронный API на этом заканчивает работу и возвращает UUID. Пользователь не ждёт OpenAI внутри исходного HTTP-запроса.

Зачем нужна отдельная таблица генераций

Можно было сохранить job_id в JSON-поле прогресса. Но генерация — самостоятельный длительный процесс, у которого есть исходники, результат, ошибка, число начисленных баллов и внешний request ID. Отдельная запись делает этот процесс наблюдаемым и восстанавливаемым.

Мы используем четыре состояния: pending, processing, succeeded, failed

Повторный submit, пока то же задание находится в pending или processing, не создаёт ещё одну job: API возвращает уже существующую генерацию. После succeeded повторное прохождение блокируется правилами задания. Это защищает от двойного клика, обновления страницы и повторной отправки формы браузером.

Polling также проверяет принадлежность профиля текущему родителю. Знание UUID само по себе не даёт доступа к чужому результату.

Что делает queue job

Job загружает запись вместе с заданием и профилем, переводит её в processing, открывает два приватных файла и вызывает клиент proxy. В текущей конфигурации у job до трёх попыток и отдельный timeout, превышающий ожидаемое время внешней операции.

Упрощённо обработчик выглядит так:

public function handle(ProxyClient $proxy, ResultService $results): void{    $generation = Generation::findOrFail($this->generationId);    if ($generation->status === 'succeeded') {        return;    }    $generation->update(['status' => 'processing']);    try {        $response = $proxy->compose(            prompt: $this->serverPrompt($generation),            imageA: $this->privatePath($generation->image_a_path),            imageB: $this->privatePath($generation->image_b_path),        );        $results->storeAndComplete($generation, $response);        $this->deleteTemporarySources($generation);    } catch (ProxyException $e) {        if ($e->retryable) {            throw $e;        }        $this->failAndCleanup($generation, $e->safeCode);    }}

Транспортный сбой, 5xx, rate limit или повреждённый ответ провайдера считаются временными ошибками и могут повторяться очередью. Неверный файл, ошибка подписи или отказ content policy являются терминальными: повтор того же запроса ничего не исправит.

Сохранение результата и начисление баллов

Proxy возвращает итоговое изображение в base64 вместе с MIME-типом и безопасным request ID провайдера. Laravel декодирует результат во временный файл и передаёт его в общий FileManager приложения.

FileManager отвечает за постоянное хранение и связи файла с заданием и детским профилем. После успешной загрузки сервис результата:

  • переводит генерацию в succeeded;

  • сохраняет ID итогового файла;

  • начисляет баллы за конкретное задание;

  • пересчитывает общий прогресс активности;

  • удаляет оба временных исходника.

В самой записи генерации хранится points_awarded. Перед начислением сервис проверяет его значение, поэтому повторный запуск уже завершённой job не должен повторно увеличить баланс.

Если успешно выполнена только одна часть составного задания, общий прогресс остаётся in_progress. Он становится completed лишь после появления результатов для всех обязательных частей. AI-вызов здесь встроен в обычную бизнес-модель платформы, а не существует отдельной демонстрацией.

Как устроен proxy изнутри

Сервис написан на PHP 8.3 без фреймворка. Для единственного рабочего маршрута полноценный Laravel или Symfony добавил бы больше инфраструктуры, чем полезного кода.

Точка входа собирает четыре зависимости:

$router = new Router(    verifier:  new HmacRequestVerifier($secret, $ttl, $nonceStore),    validator: new ImageRequestValidator($maxImageBytes),    openAi:    new OpenAIImageEditClient($apiKey, $timeout),    logger:    new SafeLogger($logFile),);$router->handle(Request::fromGlobals())->send();

Внутри рабочего маршрута последовательность почти буквально укладывается в четыре строки:

$verifier->verify($request);$imageRequest = $validator->validate($request);$result = $openAi->edit($imageRequest);return JsonResponse::ok($result);

В сервисе нет базы данных, пользователей, сессий и очереди. Есть один POST для композиции изображений, liveness endpoint для проверки процесса и readiness endpoint для проверки реальной готовности.

Readiness проверяет версию PHP, расширения cURL, fileinfo и JSON, наличие обязательных секретов, а также доступность директорий для nonce и логов. Поэтому мониторинг различает «PHP отвечает» и «сервис способен обработать подписанный запрос».

Подпись и защита от повторного запроса

Ограничить endpoint неизвестным URL недостаточно. Каждый multipart-запрос подписывается HMAC-SHA256 с общим секретом.

В подписываемые данные входят HTTP-метод, путь, timestamp, nonce, параметры генерации и SHA-256 хэши prompt и обоих файлов. Сами многомегабайтные изображения в строку подписи не включаются — включаются их хэши.

Proxy выполняет проверки в фиксированном порядке:

  1. Все заголовки подписи присутствуют.

  2. Timestamp попадает в короткое допустимое окно.

  3. Подпись сравнивается через hash_equals, то есть без обычного строкового сравнения.

  4. Nonce атомарно помечается использованным.

Для nonce не понадобилась база. Его SHA-256 становится именем временного файла, а файл создаётся в режиме x: операция проходит только если такого имени ещё нет. Просроченные записи удаляются при последующих запросах. Для одного клиента и небольшой нагрузки это проще отдельного Redis, но сохраняет защиту от replay-атак.

Проверка файлов и no-store

Proxy принимает ровно два файла. Любое лишнее файловое поле отклоняется. Для каждого файла проверяются ошибка загрузки, размер, MIME через finfo и возможность декодирования через getimagesize. Допустимы только PNG, JPEG и WebP.

После валидации небольшой cURL-клиент вручную собирает multipart body и отправляет его в OpenAI Image Edits. В реализованной версии используется gpt-image-2: оба исходника передаются как элементы image[], а модель, prompt, размер, качество и выходной формат добавляются отдельными полями. Заголовок Authorization: Bearer ... формируется только здесь. Сейчас результат запрашивается в WebP, чтобы не выполнять ещё одну конвертацию в основном приложении.

Важно точно понимать термин no-store. PHP временно размещает загруженные multipart-файлы в своей системной upload-директории на время запроса. Но код proxy не копирует их в постоянное хранилище, не создаёт записи в БД и не сохраняет ответ OpenAI. После завершения запроса сервису нечего чистить на прикладном уровне.

Логи также ограничены: внутренний request ID, HTTP-статус, длительность, безопасный код ошибки и request ID OpenAI. Prompt, байты файлов, base64-ответ, API-ключ и HMAC-подпись в журнал не попадают.

Нормализация ошибок

Основному backend не нужно знать весь формат ошибок OpenAI. Proxy преобразует ответы провайдера в небольшой стабильный набор кодов:

openai_rate_limited      -> можно повторитьopenai_transport_error   -> можно повторитьopenai_invalid_response  -> можно повторитьopenai_validation_error  -> завершить с ошибкойopenai_content_policy    -> завершить с безопасным сообщением

Например, 429 превращается во временную недоступность, 5xx — в транспортную ошибку, а отказ модерации — в отдельный терминальный код. Сырые диагностические поля доступны только в защищённых логах proxy. Пользователь получает нейтральное сообщение без внутренних деталей провайдера.

Таймауты должны совпасть по всей цепочке

Image editing может занимать минуты. Недостаточно поставить большой timeout только в Laravel HTTP client.

Запрос проходит последовательно через worker, HTTP-клиент Laravel, Nginx зарубежного VPS, PHP-FPM, cURL и OpenAI. Если хотя бы один лимит короче остальных, вместо контролируемого ответа появляется HTML-страница 504, которую основной backend должен ещё распознать как невалидный ответ.

Поэтому мы согласовали лимиты на всех уровнях и проверили их запросом с двумя реальными изображениями. Timeout queue job установлен выше ожидаемого внешнего вызова, а Nginx и PHP-FPM дают приложению завершить его без преждевременного обрыва.

Почему DigitalOcean, если основной сервер в Timeweb

Основное приложение осталось на московском сервере Timeweb. Для proxy всё равно требовалась зарубежная площадка, и здесь выбор определяла прежде всего эксплуатационная надёжность.

По нашему опыту, у Timeweb участились проблемы с uptime, особенно на зарубежных серверах. Для отдельного узла, от которого зависит вся AI-функция, добавлять ещё одну такую точку риска не хотелось. Поэтому proxy развернули на VPS у DigitalOcean.

Цена не была исходной причиной выбора. Уже при сравнении счетов обнаружилось, что подходящая конфигурация DigitalOcean на тот момент стоила примерно втрое дешевле зарубежного варианта Timeweb.

В результате российский сервер продолжает обслуживать пользователей и хранить данные, а зарубежный VPS выполняет одну изолированную операцию. Если AI-провайдер или инфраструктурные требования изменятся, эту границу можно заменить без переноса бизнес-логики.

Где использовался Codex

Оба репозитория разрабатывались в AI-assisted режиме с Codex. Архитектуру кода и данных, бизнес-логику, границы Laravel и proxy, состояния генерации, правила повторных попыток и критерии качества определял я. Codex выполнял конкретные задачи реализации под этим контролем: помогал писать сервисы, клиент интеграции, проверки, тесты и документацию в основном приложении и в proxy.

Для заказчика такой процесс может означать более быстрый и экономичный выпуск понятной функции без автоматического отказа от качества. Но это работает только пока инженер определяет решения и проверяет результат. Само наличие AI-ассистента ничего не гарантирует.

Итог

Задача начиналась как «совместить фотографию и рисунок». В production она потребовала state machine, очереди, приватного хранения, идемпотентного submit, контролируемых повторов, согласованных таймаутов и отдельной инфраструктурной границы.

Собственный proxy оказался не вторым backend и не универсальным шлюзом в интернет. Это небольшой защищённый адаптер к одному внешнему API. Он решает проблему доступа из российской инфраструктуры, принимает только известный контракт, не хранит пользовательские данные и отдаёт основному приложению стабильные ответы.
Чем меньше он знает о продукте, тем проще его тестировать, переносить и заменять. Вся бизнес-логика остаётся там, где ей и место: в основном backend.

ссылка на оригинал статьи https://habr.com/ru/articles/1060448/