Совместимость SDK после замены base_url: где она ломается
Совместимость SDK после замены base_url часто ломается не на авторизации, а на стриминге, вызовах инструментов и JSON-схемах. Разберем типовые сбои.

Почему замена base_url не сохраняет поведение
На словах все просто: меняете base_url, оставляете тот же SDK, и приложение должно работать как раньше. На практике так бывает редко. SDK скрывает от команды много мелких деталей: как он собирает HTTP-запрос, какие поля добавляет по умолчанию, как читает поток событий и что делает, если провайдер вернул ответ чуть в другом виде.
Из-за этого одинаковый вызов в коде не гарантирует одинаковый результат. Запрос может завершиться успешно, но поведение уже будет другим. Один провайдер вернет tool_call в ожидаемом формате, другой добавит поле иначе. Один поток режет ответ на удобные чанки, другой - по-своему. Снаружи все выглядит как "совместимо", а внутри расходятся детали, на которых и держится прод.
Это особенно заметно, когда команда переходит на OpenAI-совместимый шлюз. Например, сервис переводят на AI Router, оставляют прежний SDK и тот же код, потому что эндпоинт совместим. Базовый запрос на генерацию текста проходит, и миграция кажется завершенной. Потом чат перестает стабильно стримить длинные ответы, агент теряет аргументы функции, а JSON иногда не проходит валидацию. Дело не в одном баге. Просто совпал только самый простой сценарий.
Первый сбой часто приходит уже в проде. Тестовый запрос обычно короткий, без стриминга, без инструментов и без строгой схемы ответа. Пользователи делают другое: задают длинные вопросы, обрывают соединение, повторяют запросы, отправляют данные с пустыми полями. Именно там и видна разница между "запрос выполнился" и "система ведет себя так же, как раньше".
Один удачный запрос почти ничего не доказывает. Полезнее смотреть не на сам факт ответа, а на повторяемость поведения. Совпадают ли поля ошибки, порядок событий в стриме, формат аргументов инструмента, обработка schema strict mode, таймауты и ретраи? Если не проверить это заранее, команда узнает о расхождениях в самый неудобный момент, когда трафик уже пошел и откат стоит дороже, чем нормальная проверка до релиза.
Что ломается первым в стриминге
После замены base_url код часто подключается с первого раза, а ломается уже чтение чанков. SDK отправляет запрос как раньше, но обработчик стрима нередко ждет слишком точный порядок полей. На тесте это легко пропустить. В проде интерфейс вдруг показывает пустой ответ, дублирует токены или зависает до конца генерации.
Чаще всего проблема начинается с формата delta. Один провайдер кладет текст в delta.content, другой сначала присылает delta.role, потом отдельные куски content, а третий добавляет служебные поля, которые ваш код не ждал. Если парсер считает, что каждый chunk содержит текст, он начинает склеивать null, пустые строки или просто падает.
С role путаница тоже обычна. Команда пишет простой код: получили первый chunk, взяли role, создали сообщение, начали выводить текст. На практике первым может прийти пустой chunk, потом role, потом уже сам текст. Иногда текст приходит раньше, чем интерфейс успел создать контейнер для ответа, и пользователю кажется, что стрим не работает.
С usage история похожая. Во многих интеграциях хочется видеть токены прямо по ходу генерации, но часть OpenAI-совместимых шлюзов и провайдеров отдает usage только в самом конце. Если лимиты, биллинг или внутренняя статистика завязаны на промежуточные значения, до завершения стрима вы видите нули и делаете неверные выводы.
Пустые чанки ломают особенно много кода. Они нормальны: шлюз может держать соединение живым, буферизовать ответ или передавать служебное событие без текста. Простой парсер видит пустое событие и решает, что ответ закончился. После этого сервис обрывает вывод на полуслове.
И еще одна частая ошибка - слишком раннее ожидание finish_reason. Некоторые реализации присылают его только в последнем событии, когда весь текст уже собран. Если приложение закрывает поток после первого подозрительного chunk или ждет finish_reason в каждом сообщении, часть ответа теряется.
Надежный парсер ведет себя спокойнее. Он пропускает пустые чанки, собирает текст только из полей, где он правда есть, не привязывается к порядку role, content и usage, а завершает стрим по финальному событию или по закрытию потока. В тестовой среде полезно сохранять еще и сырые чанки. Потом именно они помогают понять, где расходится поведение.
Если после смены base_url стриминг "почти работает", проблема обычно не в SDK. Почти всегда ломаются скрытые ожидания вашего парсера о том, как именно должны приходить события.
Где путаются вызовы инструментов
После замены base_url чаще всего ломается не сам чат, а логика вокруг инструментов. Текст модель еще может вернуть похоже, а tool_calls быстро показывают разницу между провайдерами и моделями.
Первая ловушка - идентификатор вызова. Один SDK спокойно принимает call_abc123, другой ожидает UUID, третий хранит ID как непрозрачную строку и не спорит с форматом. Если ваш код режет ID по длине, ищет знакомый префикс или связывает его со своим шаблоном, сбой появится там, где модель формально ответила правильно.
Вторая проблема - аргументы инструмента в стриме. Не все API присылают готовый JSON одним куском. Часто модель отправляет имя инструмента сразу, а arguments собираются по частям: сначала {\"order_id\":, потом значение, потом закрывающая скобка. Если сервис пытается распарсить JSON слишком рано, он либо падает с ошибкой, либо запускает инструмент с пустыми полями.
Это хорошо видно на простом сценарии. Допустим, бот поддержки должен вызвать get_order_status. При стриминге SDK уже увидел имя функции, но номер заказа еще не доехал полностью. Один клиент дождется финального фрагмента, другой вызовет обработчик сразу. Итог разный, хотя запрос был один и тот же.
С параллельными вызовами путаницы еще больше. Часть моделей умеет вернуть два инструмента в одном ответе, часть выдает только один, даже если вы просили параллельный режим. Шлюз может выровнять формат, но не добавит поведение, которого нет у апстрима. Поэтому даже через единый эндпоинт команды все равно упираются в реальные различия между моделями.
Отдельно стоит проверить retry. Если сеть оборвалась после того, как инструмент уже сработал, SDK может повторить запрос и запустить тот же вызов еще раз. Для чтения данных это неприятно, но терпимо. Для отправки письма, создания заявки или списания бонусов это уже двойное действие.
В коде лучше сразу заложить несколько простых правил: принимать ID вызова как обычную строку без лишних проверок формата, собирать arguments до полного JSON и только потом запускать инструмент, а для побочных действий держать идемпотентность. Еще полезно иметь запасной сценарий без параллельных вызовов и проверять не только данные вызова, но и finish_reason или его аналог.
С финальным статусом тоже нет общего порядка. Один SDK ждет tool_calls, другой считает нормой stop, а в стриме статус может появиться только в последнем чанке. Из-за этого цикл агента иногда обрывается раньше времени или, наоборот, ждет ответ, который уже не придет. На практике выигрывают команды, которые отдельно тестируют каждый шаг tool calling на живой модели.
Почему structured output расходится со схемой
После замены base_url многие ждут, что JSON Schema будет работать одинаково везде. Обычно это не так. Один провайдер держит схему почти как контракт, а другой воспринимает ее как пожелание, которое модель иногда игнорирует.
Проблема видна не сразу. SDK спокойно возвращает успешный ответ, а ваш сервис падает на следующем шаге, когда пытается разобрать поля по типам. Для разработчика это выглядит странно: запрос успешный, модель ответила, а бизнес-логика не может использовать результат.
Самый частый сбой простой. Модель добавляет текст вокруг JSON. Вместо чистого объекта вы получаете что-то вроде "Готово, вот результат:" и затем сам JSON, иногда еще и в markdown-блоке. Человек такой ответ прочитает без труда, а парсер обычно нет.
С типами проблем не меньше. Число приходит строкой, булево поле приходит как "true", массив превращается в одну строку через запятую. Если код ждет price: number и approved: boolean, такой ответ уже нельзя считать корректным, даже если на глаз он выглядит нормально.
Глубокие схемы ломаются чаще простых. Когда в ответе есть вложенные объекты, списки объектов, enum и много необязательных полей, модель чаще пропускает часть структуры или меняет тип на одном из уровней. Плоская схема из нескольких полей обычно держится лучше.
Это особенно заметно, когда команда отправляет один и тот же запрос на разные модели через единый OpenAI-совместимый шлюз. Эндпоинт один, SDK тот же, а поведение по схеме разное, потому что различие сидит не в SDK, а в модели и в том, как провайдер реализовал structured output.
Риск снижают довольно приземленные вещи: делать схему проще, не просить модель добавлять поясняющий текст, проверять типы после ответа и держать запасной путь, если JSON не прошел валидацию. Еще один полезный прием - гонять тесты не на идеальном примере, а на реальных промптах и данных. Именно на них схема обычно и разваливается.
Полезно смотреть на это так: SDK проверяет, что ответ пришел, а ваш парсер проверяет, что ответ пригоден для работы. Это два разных уровня совместимости.
Как проверять совместимость по шагам
Когда команда меняет только base_url, различия чаще всего прячутся не в коде SDK, а в ответах сервера. Один и тот же запрос может дать тот же текст, но другой порядок чанков, иной формат tool_calls или другой код ошибки при retry.
Проверять это лучше на маленьком повторяемом стенде. Не меняйте модель, промпт, температуру и версию SDK по ходу теста. Иначе вы будете сравнивать сразу несколько переменных и быстро запутаетесь.
Рабочая схема простая. Возьмите один живой сценарий: обычный текстовый запрос, один стриминговый ответ и один вызов инструмента. Отправьте этот набор через старый и новый base_url с одинаковыми заголовками, таймаутами и параметрами. Сохраните сырые HTTP-ответы: статус, заголовки, тело, event stream и время между чанками. Для стрима пишите в файл каждый chunk отдельно, а потом сохраняйте итоговое собранное тело. После этого разберите результаты по четырем группам: текст, tool_calls, ошибки и поведение retry.
Логи приложения для этого обычно слабоваты. Они часто скрывают пустые дельты, служебные поля, finish_reason, разницу между null и отсутствующим полем, а иногда и реальный текст ошибки. Если шлюз совместим только "в целом", расхождения всплывут именно здесь.
Удобно держать простую таблицу сравнения. В одной колонке старый base_url, в другой новый. Сравнивайте первую задержку, полный time to last token, структуру tool_calls, JSON в structured output, коды 4xx и 5xx, а также поведение после 429 или timeout. Уже на этом этапе обычно видно, где клиентский код начнет расходиться с ожиданиями.
Если команда переводит сервис на AI Router, такой тест лучше делать не на абстрактном "hello world", а на одном рабочем маршруте. Возьмите запрос из продового чата поддержки или внутреннего copilot, уберите персональные данные и прогоните его десятки раз. Так вы увидите не только то, пришел ли ответ, но и может ли сервис стабильно пережить стрим, tool_call и повтор запроса.
Если различия нашлись, не пытайтесь чинить все сразу. Сначала добейтесь одинакового поведения на нестриминговом тексте, потом на стриме, и только после этого включайте инструменты и schema-ответы. Такой порядок обычно экономит часы отладки.
Простой пример миграции в рабочем сервисе
Команда поддержки переводит чат-ассистента на новый OpenAI-совместимый шлюз, например AI Router. В коде меняют только base_url, оставляют тот же SDK, те же промпты и тот же интерфейс оператора. На демо все выглядит спокойно: обычные текстовые ответы приходят сразу, без сюрпризов.
Проблемы начинаются не на простом вопросе вроде "какой у вас график работы", а на живом трафике. На таком кейсе хорошо видно, где заканчивается совместимость.
Сначала сыпется стриминг. В интерфейсе оператор видит поток ответа по токенам, но после миграции текст иногда "съедает" куски фразы: клиент получает начало предложения, потом резкий скачок к концу. Причина часто не в модели, а в том, как шлюз и SDK собирают stream events, delta-поля и маркеры завершения.
Следом всплывает вызов CRM. Ассистент должен один раз запросить карточку клиента по номеру телефона, но после retry тот же инструмент уходит повторно. В итоге CRM создает дубль заметки или дважды ставит один и тот же тег. Если команда не ввела идемпотентность на стороне инструмента, ошибка быстро становится дорогой.
Потом ломается самый неприятный кусок - структурированный ответ. Чат должен вернуть JSON для карточки клиента: имя, статус договора, последний контакт, причина обращения. Модель формально отвечает в JSON, но один раз кладет дату строкой, другой раз отдает null там, где валидатор ждет массив. На экране оператора это выглядит как "карточка не загрузилась", хотя текст ответа кажется нормальным.
Чинят это обычно не большим рефакторингом, а несколькими точными проверками: сравнивают сырые stream events до и после миграции, запрещают повторный запуск инструмента без уникального request_id, валидируют JSON до передачи в интерфейс и логируют не только текст ответа, но и тело tool_call.
После этого команда оставляет простые текстовые ответы на общем маршруте, а для CRM и JSON вводит отдельные тесты на реальных сценариях поддержки. Один идеальный вопрос почти ничего не доказывает.
Нормальный результат миграции выглядит скучно. Оператор не замечает смену шлюза, CRM не получает дублей, а карточка клиента проходит валидатор каждый раз. Именно это и нужно проверять первым, даже если замена base_url заняла пять минут.
Ошибки, которые команды делают чаще всего
Самая частая ошибка проста: команда меняет base_url и ждет, что SDK сам выровняет все различия. На демо это нередко работает. В живом сервисе всплывают детали протокола, формата событий и поведения модели, которые SDK не скрывает.
Вторая ошибка встречается почти в каждом проекте: тесты гоняют только обычный режим без стрима. Ответ пришел целиком, JSON распарсился, значит все хорошо. Но в проде продукт живет на стриминге, а там порядок чанков, finish_reason, частичные tool_calls и даже пустые события могут отличаться.
Третья ошибка тише, но дороже. В коде остаются опции, которые понимает только один провайдер: свой формат response_schema, нестандартный параметр tool_choice, особый флаг для reasoning или seed. Через один шлюз такой запрос может пройти к части моделей и сломаться на другой.
Четвертая проблема связана с парсингом. Команды часто пишут один универсальный парсер и прогоняют через него все модели подряд. Это удобно до первого расхождения: одна модель кладет аргументы инструмента строкой, другая объектом, третья отдает JSON, который формально валиден, но не совпадает со схемой по типам полей.
Помогают довольно простые меры: прогонять один и тот же сценарий со стримингом и без него, отдельно тестировать tool_calls на нескольких моделях, логировать сырые события до парсера, держать allowlist поддерживаемых параметров и валидировать JSON схемой, а не "на глаз".
Еще один промах встречается постоянно: таймауты, ретраи и rate limits оставляют как были. После смены маршрута меняется не только ответ модели, но и профиль задержки. Если раньше сервис ждал 15 секунд и делал две попытки, после миграции этого может не хватить для длинного стрима или вызова инструмента.
Хорошее правило скучное, но рабочее: считать совместимым только то, что прошло ваш набор тестов на вашей модели, в вашем режиме и с вашими лимитами. Все остальное лучше считать гипотезой.
Быстрые проверки перед запуском
Если сервис уже отвечает через новый шлюз, это еще не значит, что поведение совпало. Совместимость чаще всего ломается на мелочах: другой параметр в запросе, лишний retry или парсер, который ждет идеальный JSON и падает на первом же пустом чанке.
Перед запуском в прод лучше сверить не только код, но и фактический трафик. Особенно если команда хочет сохранить прежние SDK и промпты без переписывания.
Перед релизом полезно вручную проверить несколько вещей. Сравните модель, temperature и max_tokens в старом и новом вызове. Ошибка банальная, но частая: base_url поменяли, а имя модели или лимит токенов остались от прошлого провайдера. Проверьте заголовки, таймауты и retry. Один и тот же SDK может по-разному вести себя при 30 и 120 секундах ожидания, а повторный запрос может дать другой ответ даже с тем же промптом. Сохраняйте сырые запросы и ответы хотя бы на этапе теста. Без этого команда обычно спорит о симптомах вместо того, чтобы сравнить JSON по полям. И обязательно прогоните схему инструмента на реальных данных, а не на примере из документации. Пользовательский ввод быстро находит несовпадения в типах, enum и обязательных полях.
Одинаковые настройки дают больше, чем кажется. Если на старом провайдере стояла temperature 0.2, а на новом SDK подставил 1.0 по умолчанию, вы получите не просто другой стиль текста. Модель может выбрать другой инструмент, иначе заполнить поля или вообще выйти за схему.
Логи лучше смотреть попарно: старый ответ рядом с новым, один и тот же вход, один и тот же request_id внутри вашего сервиса. Тогда видно, где именно расходится поведение. Иногда проблема не в модели, а в том, что прокси отрезал заголовок, клиент раньше времени закрыл соединение или библиотека молча включила повтор запроса.
На практике часто хватает короткого набора: один обычный чат-запрос, один запрос со стримингом, один вызов инструмента и один structured output по строгой схеме. Если хотя бы один из четырех сценариев нестабилен, выпуск лучше остановить на день, чем потом чинить это в бою.
Что делать дальше
Соберите маленький набор тестов и гоняйте его каждый раз, когда меняете шлюз, модель или настройки. Совместимость почти всегда ломается не в "большом" запросе, а в мелочах, на которые код уже успел тихо завязаться.
Минимальный набор обычно такой: обычный запрос без стриминга, стриминг с проверкой всех чанков и финального события, вызов инструмента с реальным JSON в аргументах, структурированный ответ по JSON Schema с валидацией и повтор запроса после таймаута или обрыва соединения. Этого хватает, чтобы быстро поймать большую часть расхождений.
Для критичных функций оставьте запасной путь. Если инструмент не вызвался, сервис может временно перейти на обычный ответ. Если JSON не прошел схему, код может сохранить сырой текст и пометить задачу для повторной обработки. Это не очень красиво, зато спасает прод, когда новый провайдер ведет себя чуть иначе.
Отдельно зафиксируйте поля, на которые опирается ваш код. Часто команда думает, что использует только content, а потом выясняется, что в логике сидят finish_reason, tool_calls, role, usage, ID ответа или порядок полей в delta. Такие зависимости лучше описать прямо в контракте сервиса и проверить тестом, а не памятью команды.
Перед переключением посмотрите значения по умолчанию у шлюза. temperature, max_tokens, режим JSON, параллельные tool_calls, ретраи, таймауты и формат стриминга могут отличаться даже при одном и том же SDK. Один незаметный дефолт легко меняет поведение сильнее, чем сама модель.
Если вы тестируете миграцию через AI Router, полезно прогнать один и тот же набор сценариев по нескольким провайдерам и по моделям, которые сервис хостит на своей GPU-инфраструктуре. Так быстрее видно, где различие в самом маршруте, а где - в конкретной модели. Для команд в Казахстане это еще и удобный способ проверять поведение через один OpenAI-совместимый endpoint api.airouter.kz, не переписывая SDK и клиентский код.
Рабочий порядок простой: сначала фиксируете контракт, потом прогоняете тесты, потом включаете новый маршрут на небольшой доле трафика. Если один сценарий падает, не спорьте с SDK. Либо добавьте тонкий адаптер под это отличие, либо уберите зависимость от спорного поля до того, как она сломает сервис в рабочий день.