Перейти к содержимому
07 янв. 2026 г.·7 мин чтения

Контрактные тесты для OpenAI-совместимых провайдеров

Контрактные тесты для OpenAI-совместимых провайдеров помогают за час найти сбои в streaming, tools, embeddings и формате ошибок до релиза.

Контрактные тесты для OpenAI-совместимых провайдеров

Почему "OpenAI-совместимо" ломается в мелочах

Надпись "OpenAI-совместимо" часто означает только одно: у провайдера похожий эндпоинт и знакомые поля. Пока команда отправляет один короткий запрос в песочнице, разницы почти не видно. Проблемы начинаются позже, когда тот же код идет в прод и упирается в детали ответа.

Один и тот же запрос может вернуть разный JSON. Где-то usage приходит в конце, где-то его нет. У одного провайдера finish_reason лежит там, где его ждет SDK, у другого структура чуть сдвинута, и парсер уже падает.

Со streaming все еще неприятнее. На коротких ответах поток выглядит нормально, а на длинных может оборваться на середине, прислать события в другом порядке или смешать служебные поля с текстом. В итоге интерфейс зависает, логика сборки ответа теряет кусок текста, а подсчет токенов начинает врать.

С tools расхождения обычно всплывают не сразу. Провайдер пишет, что вызовы функций поддерживаются, но имя функции приходит в одном поле, аргументы - в другом, а иногда аргументы вообще пустые. Агент видит, что инструмент выбран, но выполнить его не может.

С embeddings ошибка бывает самой тихой. Запрос проходит успешно, массив чисел возвращается, только размерность уже не та, что ждет индекс или соседний сервис. Это особенно неприятно перед миграцией, когда команда меняет только base_url и рассчитывает не трогать остальной код.

Ошибки тоже похожи только снаружи. Текст может быть понятным, но коды, вложенность и сами поля различаются настолько, что ретраи, фолбэки и обработка rate limit начинают вести себя странно. Поэтому совместимость OpenAI API лучше считать не обещанием, а гипотезой, которую надо быстро проверить контрактными тестами.

Какого набора вызовов достаточно

Если вы меняете только base_url, не нужен большой стенд. Для первого прогона хватает пяти вызовов. Они быстро показывают, где совместимость есть только на словах.

Минимальный набор обычно такой:

  1. Обычный chat completion без streaming. Он проверяет базовый ответ: роль, текст, finish_reason, usage и общую форму JSON.
  2. Тот же запрос со streaming. Здесь видно, как приходят чанки, есть ли delta, не ломается ли порядок токенов и корректно ли завершается поток.
  3. Вызов с tools и обязательным tool_choice. Провайдеры часто принимают схему, но по-разному отдают arguments, tool_call_id или причину остановки.
  4. Запрос на embeddings с проверкой длины вектора. Если размер вектора прыгает между моделями или не совпадает с ожиданием, поиск и ранжирование потом ломаются тихо.
  5. Заведомо неверный запрос. Например, несуществующая модель или испорченный параметр. Такой тест нужен, чтобы проверить код ответа, структуру error и тело сообщения.

Эти вызовы лучше запускать на одной и той же тестовой модели там, где это возможно. Так проще понять, проблема в провайдере, в модели или в вашей обвязке. Для tools и embeddings иногда нужна отдельная модель, и это нормально.

Заранее зафиксируйте, что именно вы считаете успехом. Для обычного чата это не "получили хоть какой-то текст", а конкретный набор полей. Для streaming это не "поток пришел", а полный цикл от первого чанка до финального стоп-сигнала. Для ошибки это не "сервер ругнулся", а формат, который ваш клиент умеет разбирать.

Такой набор собирается за один вечер и часто экономит несколько дней после релиза.

Как собрать тесты за один вечер

Начните с малого: один каталог, несколько JSON-файлов и простой скрипт запуска. Для каждого сценария используйте один и тот же payload у всех провайдеров. Если менять текст запроса, параметры и модель одновременно, вы не поймете, что именно сломалось.

Зафиксируйте все, что влияет на ответ: model, temperature, max_tokens, tool_choice и формат ответа. Даже мелочь вроде другой температуры быстро портит сравнение. Для контрактных тестов это базовое правило.

Хороший минимальный набор включает один вызов chat completions без tools, один со streaming, один с tools и ожидаемым аргументом функции, один запрос embeddings на коротком тексте и один заведомо неверный запрос для проверки ошибок.

Сохраняйте два результата отдельно. Первый - сырой ответ провайдера целиком, со всеми полями, chunk-событиями и кодом ошибки. Второй - нормализованный результат, где вы приводите ответ к своей внутренней схеме. Если что-то пойдет не так, сырой ответ покажет причину, а нормализованный поможет быстро сравнить провайдеров между собой.

Статус 200 почти ничего не доказывает. Скрипт должен проверять схему: есть ли choices, пришел ли finish_reason, совпадает ли тип tool_call, не пустой ли embedding, есть ли message.content там, где вы его ждете. То же касается ошибок: HTTP-код, поле message, структура details.

Если команда меняет только base_url, прогоняйте набор два раза: до и после переключения. Для шлюза вроде AI Router это особенно удобно: можно сменить адрес на api.airouter.kz, оставить тот же SDK и те же промпты, а потом быстро сравнить поведение парсера, прокси и клиентской логики.

Как проверять streaming без ручного просмотра

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

Для такого теста хватит одного простого промпта и одного обычного запроса без streaming. Поставьте temperature: 0, чтобы итоговый текст был ближе к повторяемому. После этого сравните обычный ответ и собранный поток.

Что стоит измерять:

  • число чанков на ответ;
  • время до первого чанка и до первого токена;
  • порядок полей в delta;
  • наличие пустых чанков;
  • приход finish_reason.

В нормальном потоке сначала обычно приходит роль ассистента, потом части текста в delta.content, а в конце - finish_reason. Если роль приходит поздно, content идет раньше role или поток обрывается без финального сигнала, клиентский код начинает вести себя странно: интерфейс зависает, логика сборки ответа ломается, ретраи срабатывают зря.

Пустые чанки тоже стоит считать ошибкой или хотя бы отдельным предупреждением. Иногда провайдер шлет их между нормальными событиями, и это не страшно. Хуже, когда после пустого чанка поток закрывается и финал уже не приходит.

Отдельный тест должен сверять собранный streaming-ответ с обычным ответом без streaming. Сравнивайте итоговую строку после склейки всех delta.content. Если текст заметно расходится при одинаковом промпте и настройках, проблема часто не в модели, а в том, как провайдер режет и передает поток.

Еще один частый сбой связан с транспортом. Провайдер заявляет совместимость, но меняет SSE-формат. Такое надо помечать отдельно, а не смешивать с ошибками модели. Если клиент ждет стандартные SSE events с data, а получает свою обертку, формально поток есть, но SDK его уже не понимает.

Хороший тест на streaming заканчивается коротким отчетом: сколько чанков пришло, был ли первый токен вовремя, в каком порядке пришли role и delta, собрался ли финальный текст и дошел ли finish_reason. Этого обычно хватает, чтобы за несколько минут понять, можно ли подключать провайдера в прод.

Где чаще ломаются tools

Больше маршрутов для прогона
Гоняйте один и тот же сценарий через AI Router на 500+ моделях.

С tools сбои видны быстро: провайдер принимает запрос, но модель возвращает другое имя функции, меняет JSON аргументов или вместо вызова пишет обычный ответ. На тестовом чате это терпимо. В проде такой сдвиг ломает следующий шаг цепочки.

Сначала сравните то, что ваш код ждет, с тем, что приходит на самом деле. Имя функции должно совпадать посимвольно. Аргументы нужно парсить как JSON и проверять по типам: строка, число, массив, обязательные поля. tool_call_id лучше сохранять и сверять отдельно, если потом вы передаете результат функции в следующий запрос.

Один сценарий с одной функцией почти ничего не доказывает. Добавьте второй, где модели доступны две функции с похожими описаниями. Именно такой тест часто вскрывает странности: один провайдер выбирает не ту функцию, другой меняет порядок вызовов, третий смешивает tool call с обычным текстом.

Отдельно ловите случай, когда модель пишет что-то вроде "сейчас вызову функцию", но не делает вызов. Такой ответ выглядит правдоподобно, поэтому его легко пропустить глазами. Тест должен проверять не текст, а сам факт tool_call.

Полезно и специально ломать схему. Укажите required поле, которого нет в properties, или попросите модель вернуть аргумент неверного типа. Нормальный сервер быстро отвечает 400 и дает понятное тело ошибки. Слабая совместимость выглядит иначе: сервер молча принимает мусор, а ваш код падает уже после ответа модели.

Если вы меняете только base_url, именно tools чаще всего дают ложное чувство совместимости. Один удачный вызов тут почти ничего не значит.

Что смотреть в embeddings

Embeddings ломаются тише, чем chat completion. Запрос проходит, статус 200, в логах все спокойно, а поиск по базе вдруг дает странные совпадения. Поэтому мало проверить, что API просто вернул ответ. Нужно проверить, что ответ ведет себя одинаково в простых и граничных случаях.

Сначала смотрите на длину вектора. Один и тот же текст должен возвращать вектор одной и той же размерности в каждом прогоне. Если сегодня модель отдает 1536 чисел, а завтра 3072, векторный индекс начнет сбоить, даже если приложение формально не упало.

Потом проверьте порядок. Если вы отправили пакет из нескольких входов, выходы должны прийти в том же порядке. Иначе команда легко запишет embedding от одной строки в карточку другой. Такие ошибки обычно замечают уже по странной релевантности в поиске.

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

Отдельно проверьте типы данных. Вектор должен приходить как массив чисел, а не строк вроде "0.123". На глаз разница маленькая, но дальше это ломает сериализацию, метрики похожести и загрузку в базу.

Еще один полезный тест - время ответа на пакет. Измерьте задержку для одного входа и для пачки, например из 8 или 16 строк. Если батч работает почти так же быстро, это хороший знак. Если задержка растет в разы, провайдер, скорее всего, обрабатывает каждый элемент отдельно, и на большом потоке счет и время быстро выйдут из-под контроля.

Если вы сравниваете прямой доступ к провайдеру и тот же вызов через AI Router, такой набор сразу показывает, где расходится поведение, а где все совпадает до типов, порядка и размера вектора.

Как сверять формат ошибок

Ошибки ломают интеграцию чаще, чем обычные ответы. Провайдер может честно вернуть 401 или 429, но положить в тело не тот JSON, который ждет ваш клиент. Из-за этого ретраи, алерты и обработчики падений ведут себя странно.

Сначала сверяйте две вещи: HTTP-код и поле error.type. Код говорит, какой класс сбоя вы получили, а error.type нужен клиенту для ветвления логики. Если код 429, а тип ошибки похож на обычный invalid_request_error, несовместимость уже найдена.

Текст ошибки тоже смотрите, но не стройте на нем логику. Формулировки меняются чаще всего: один провайдер пишет invalid api key, другой authentication failed, третий добавляет имя проекта или региона. Для человека это полезно, для кода - плохая опора.

Минимальный прогон стоит разбить на несколько проверок: 401 для неверного или пустого ключа, 429 для лимитов, 400 для сломанного JSON или неизвестной модели и отдельный сценарий, где сервер возвращает не JSON, а HTML или plain text.

Последний случай многие пропускают. А зря. Стоит промежуточному слою вернуть HTML с 502 Bad Gateway, и клиент, который ждет JSON с полем error, падает уже на парсинге. Такой сбой трудно читать в логах, потому что он маскирует исходную причину.

Если ваш SDK читает заголовки rate limit, проверяйте и их. Некоторые клиенты ждут числа в конкретных заголовках и сами считают время до следующей попытки. Если заголовок пропал, переименован или приходит пустым, очередь ретраев быстро разъезжается.

Пример перед сменой провайдера

Сравните провайдеров быстрее
AI Router помогает гонять один набор проверок по разным маршрутам и видеть, где меняется контракт.

Команда меняет только base_url и ждет, что все останется как раньше. На бумаге это выглядит логично: тот же OpenAI-совместимый API, тот же SDK, те же промпты. На практике разница часто всплывает в первом же прогоне.

Сначала разработчики гоняют простой chat completion без streaming. Ответ приходит, тест зеленый, и появляется ложное чувство, что миграция почти готова. Через несколько минут включается streaming, первый чанк приезжает в неожиданном виде, и клиент падает еще до сборки полного ответа.

Следом проходит вызов с tools. Модель возвращает аргументы не как объект, а как строку с JSON внутри. Парсер ждал один формат, получил другой, и бизнес-логика останавливается. Потом доходит очередь до embeddings: запрос успешен, статус 200, ошибок нет, но размерность вектора не совпадает с той, на которую рассчитан индекс поиска. Поиск не падает сразу, а начинает молча давать странные результаты.

Вот почему короткий набор тестов полезнее длинного демо. Обычный чат, тот же запрос в streaming, один tool call, один embeddings-запрос и один плохой запрос на ошибку обычно дают команде реальный список работ до релиза.

Где команды чаще всего ошибаются

Самая частая ошибка проста: команда видит HTTP 200 и считает, что совместимость подтверждена. На деле ответ может приехать с битым JSON, лишним полем, пустым choices или оборванным streaming-потоком. Запрос формально успешен, а приложение падает уже на разборе ответа.

Вторая ловушка - сравнивать разные модели так, будто это один и тот же контракт. Если один тест идет на GPT-подобной модели, а другой на более слабой open weights модели, расхождения в tools, embeddings или формате ошибок ничего не доказывают. Сравнивать нужно одинаковые сценарии на максимально близких моделях.

Еще одна плохая привычка - не сохранять сырые ответы. Логи уровня "получили ошибку парсинга" почти бесполезны. Нужен полный raw response: заголовки, тело, куски stream по порядку, код ошибки, request id, время ответа. Иначе спор быстро сводится к догадкам.

Также стоит сразу разделять разные классы проблем. Сетевой сбой, таймаут и разрыв соединения - это одна история. Несовместимость схемы ответа, странное поведение tools, отличия в embeddings или другой формат ошибок - совсем другая. Если провайдер вернул 502 или соединение оборвалось, контракт тут ни при чем. Если он стабильно отдает tool_calls в другом виде или ломает SSE-события, это уже реальная несовместимость.

И еще одна типичная недоработка: гоняют только удачный сценарий. Прод ломается не на идеальном запросе, а когда модель получает невалидный tool, слишком длинный input или упирается в лимит. Поэтому минимальный набор должен включать и плохие случаи: 400, 401, 429, 500, пустой tool result, обрыв stream после нескольких токенов.

Короткий чек-лист перед подключением

Считайте расходы проще
AI Router тарифицирует API по ставкам провайдеров и выставляет ежемесячный инвойс в тенге.

Перед первым запросом полезнее не читать обещания провайдера, а прогнать один и тот же набор проверок. У OpenAI-совместимого API чаще ломается не сам ответ модели, а детали вокруг него: потоковые чанки, tools, embeddings и тело ошибки.

Проверьте несколько простых вещей:

  • используйте один и тот же payload во всех прогонах;
  • проверяйте отдельно streaming, tools, embeddings и ошибки;
  • валите тест по схеме ответа, а не по тексту модели;
  • заранее зафиксируйте, какие расхождения клиент переживет, а какие нет;
  • сохраняйте результат рядом с датой, моделью и провайдером.

Это звучит скучно, но такой минимум ловит реальные сбои очень быстро. Один провайдер может вернуть нормальный текст в streaming, но пропустить служебное поле в одном из чанков. Другой честно отвечает на chat completions, но меняет формат tool_calls. Третий отдает embeddings, только размер вектора отличается от того, что ждет ваш индекс.

С ошибками та же история. Если тест ждет поля error.type, error.message и HTTP-код, он поймает несовместимость за секунды. Если сравнивать только текст сообщения, проблему легко пропустить до первого падения в проде.

Отдельно договоритесь внутри команды о допусках. Пустой delta в streaming часто не мешает. Отсутствие tool_calls или другой формат массива - уже критично. Эти правила лучше записать сразу, иначе один инженер пометит прогон как успешный, а другой завернет того же провайдера.

Что делать после первых прогонов

Первый прогон почти всегда находит расхождения. Обычно это не громкие поломки, а мелкие сдвиги схемы: у одного провайдера tool_calls лежит в другом месте, у другого embeddings приходят с лишней оберткой, у третьего ошибка 429 выглядит как обычный 500 с текстом в теле ответа. Такие вещи лучше чинить сразу в адаптере, а не размазывать по всему приложению.

Если формат ответа не совпадает с тем, что ждет ваш код, добавьте тонкий слой нормализации. Пусть он приводит streaming, tools, embeddings и ошибки к одному виду до того, как данные попадут в бизнес-логику. Работа не самая веселая, но окупается быстро: потом смена провайдера не ломает полсервиса.

После первых исправлений перенесите набор тестов в CI. Запускайте его перед каждой сменой модели, провайдера и версии SDK. Один релиз с "почти совместимым" API легко съедает день команды, особенно если проблема видна только в streaming или при вызове tools.

Проверка только успешного ответа ничего не гарантирует. Держите отдельные прогоны на rate limits и таймауты. Стоит проверить хотя бы четыре вещи: 429 приходит с ожидаемым кодом и телом ошибки, таймаут клиента не ломает повторный запрос, streaming завершается предсказуемо после обрыва, а 5xx и ошибки валидации различаются по формату.

Если команда гоняет разных провайдеров через единый endpoint, например через AI Router, используйте один и тот же набор контрактных тестов для всех маршрутов. У AI Router в этом есть практический плюс: можно переключать провайдеров через один OpenAI-совместимый эндпоинт и быстро видеть, где различия идут от маршрута, а где - от вашего клиента.

Не пытайтесь покрыть все сразу. Начните с малого набора из 10-15 вызовов, который ловит самые частые сбои. Потом расширяйте его под свои сценарии: длинный контекст, пустые embeddings, отмену запроса, повтор после 503, несколько tools в одном ответе. Тогда у команды появится не архив примеров, а нормальный предохранитель перед любым переключением.

Часто задаваемые вопросы

Что вообще проверяют контрактные тесты у OpenAI-совместимого API?

Контрактные тесты проверяют не качество текста модели, а форму ответа API. Они ловят сдвиги в usage, finish_reason, streaming-событиях, tool_calls, embeddings и теле ошибок до релиза.

Каких запросов достаточно для первой проверки провайдера?

Для старта хватит пяти вызовов: обычный chat completion, тот же запрос со streaming, вызов с tools и жестким tool_choice, запрос на embeddings и один заведомо неверный запрос. Такой набор быстро показывает, где провайдер только похож на OpenAI API.

Почему статус 200 еще не доказывает совместимость?

Потому что 200 OK говорит только о том, что сервер ответил без HTTP-ошибки. JSON при этом может не совпасть с тем, что ждет ваш SDK, а поток может оборваться до finish_reason.

Как быстро проверить streaming без ручного просмотра?

Проще всего отправить один и тот же промпт дважды: один раз без streaming, второй раз с ним. Потом соберите delta.content в строку и сравните итог, а заодно проверьте порядок событий, пустые чанки и финальный стоп-сигнал.

Где обычно ломаются tools?

В tools чаще всего расходятся имя функции, формат arguments и наличие самого tool_call. Модель может написать, что вызовет функцию, но не сделать вызов, или вернуть JSON аргументов как строку, которую ваш код не разберет.

Как понять, что embeddings действительно совместимы?

С embeddings сначала смотрят на длину вектора, порядок результатов в батче и типы данных. Если размерность прыгает, порядок меняется или числа приходят строками, поиск и ранжирование начнут давать странные результаты.

Какие ошибки стоит тестировать в первую очередь?

Отдельно проверьте хотя бы 400, 401, 429 и один 5xx или не-JSON ответ. Ваш клиент должен разобрать HTTP-код, error.type, error.message и, если нужно, заголовки rate limit.

Нужно ли хранить raw responses из тестов?

Да, сохраняйте сырой ответ целиком: тело, заголовки, код, чанки stream по порядку и время ответа. Без raw response команда обычно спорит о симптомах вместо того, чтобы увидеть точное место сбоя.

Когда можно безопасно менять только base_url?

Только после прогона одинаковых контрактных тестов до и после переключения. Если вы меняете base_url на api.airouter.kz, но оставляете тот же SDK и промпты, тесты быстро покажут, переживет ли это ваш парсер и клиентская логика.

Что делать, если провайдер почти совместим, но ответы чуть отличаются?

Сначала добавьте тонкий слой нормализации между API и бизнес-логикой. Потом зафиксируйте найденные отличия тестами и гоняйте их в CI перед каждой сменой модели, провайдера или SDK.