Перейти к содержимому
7 мин чтения

Как свести Chat Completions и Responses API в одном шлюзе?

Chat Completions и Responses API можно свести в одном шлюзе, если нормализовать сообщения, tool calls, JSON Schema, streaming и ошибки.

Как свести Chat Completions и Responses API в одном шлюзе?

Два API-контракта в одном LLM-шлюзе нельзя свести к замене messages на input. Такой перевод проходит демо с одним пользовательским вопросом, а затем портит вызовы функций, потоковую выдачу, строгую JSON Schema и повторные запросы. Шлюз должен переводить не JSON-форму, а смысл операции: кто и что сказал, какие инструменты разрешены, какой результат требуется и к какому незавершенному вызову относится следующий фрагмент данных.

Это важно для команд, которые не хотят переписывать каждое приложение при смене модели или поставщика. Старый сервис может говорить на Chat Completions, новый агент может использовать Responses API, а внутренняя маршрутизация должна оставаться общей. Хорошая граница здесь простая: внешний контракт принадлежит клиенту, внутренний контракт принадлежит шлюзу, контракт поставщика принадлежит адаптеру. Если смешать эти три уровня, любая новая функция превращается в исключение из исключения.

Что именно должен объединять шлюз

Шлюз должен объединять семантику запроса и ответа, а не делать вид, что два JSON-объекта изоморфны. Chat Completions строит результат вокруг choices[] и одного сообщения ассистента в каждой ветке. Responses API возвращает набор типизированных выходных элементов, среди которых могут быть текст, вызовы функций, отказы и другие элементы выполнения. В обычной переписке разницу легко не заметить. В агентном цикле она определяет, можно ли продолжить работу без потери состояния.

У шлюза нужен собственный канонический запрос. Его не обязаны видеть клиенты, но именно его стоит валидировать, логировать и передавать маршрутизатору. В нем полезно разделить пять вещей:

  • инструкцию для модели и историю разговора;
  • части пользовательского и ассистентского содержимого;
  • определения инструментов, правила выбора и ограничения;
  • требуемую форму финального ответа;
  • состояние операции: идентификаторы вызовов, продолжение предыдущего ответа, потоковый режим и метаданные трассировки.

Плохой внутренний объект обычно выглядит как слегка расширенный messages[]. Он быстро обрастает полями response_format, previous_response_id, tool_outputs, reasoning, provider_payload и несколькими булевыми флагами. Через несколько месяцев никто уже не может сказать, какие поля обязательны и на каком этапе они имеют смысл.

Лучше ввести явную модель операции. Например, текстовая инструкция не должна притворяться пользовательским сообщением только потому, что один из внешних API кладет ее в массив сообщений. Результат функции не должен становиться «сообщением ассистента»: его автором не является модель, и он относится к конкретному call_id.

{
  "model_hint": "general-reasoning",
  "instructions": [
    {"kind": "text", "text": "Отвечай по правилам кредитного продукта."}
  ],
  "turns": [
    {
      "role": "user",
      "parts": [
        {"kind": "text", "text": "Можно ли досрочно погасить заем?"}
      ]
    }
  ],
  "tools": [
    {
      "kind": "function",
      "name": "find_loan_terms",
      "description": "Возвращает условия займа по типу продукта.",
      "parameters": {
        "type": "object",
        "properties": {
          "product": {"type": "string"}
        },
        "required": ["product"],
        "additionalProperties": false
      },
      "strict": true
    }
  ],
  "tool_policy": {"mode": "auto"},
  "output_contract": {"kind": "text"},
  "stream": true
}

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

Сообщения нельзя сводить к массиву строк

Текстовые сообщения похожи только на поверхности. В Chat Completions клиент обычно передает messages с ролями developer, system, user, assistant и tool. В Responses API вход может содержать сообщения и отдельные элементы, а инструкция может жить в instructions. Кроме того, API умеет продолжать работу через идентификатор предыдущего ответа, что меняет источник контекста.

Документация OpenAI для Chat Completions описывает endpoint как генерацию ответа по списку сообщений. Справочник Responses API, напротив, моделирует работу как создание ответа с набором входных и выходных элементов. Не считайте это косметикой. Первое представление удобно для привычного чата, второе лучше выражает ход выполнения агента.

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

Роли тоже требуют дисциплины. Я обычно использую такие правила:

  • developer и system попадают в отдельную коллекцию инструкций с сохранением исходного порядка;
  • user и assistant становятся ходами диалога;
  • tool не становится обычным ходом, а связывается с вызовом инструмента;
  • неизвестная роль вызывает ошибку схемы, а не преобразуется в user;
  • name и прочие вспомогательные поля хранятся в метаданных хода, если их способен выразить целевой контракт.

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

Не обещайте полную прозрачность там, где ее нет. Режим продолжения через серверный идентификатор ответа нельзя без потерь представить в stateless Chat Completions. Есть два честных варианта: хранить собственный журнал канонических ходов и восстанавливать историю либо объявить эту возможность доступной только для Responses API. Первый вариант дает переносимость, но увеличивает объем контекста и ответственность за хранение данных. Второй проще в эксплуатации, но контракт становится шире.

Вызов инструмента состоит из трех связанных событий

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

В Chat Completions определение функции вложено в объект tools[] как function. Модель возвращает tool_calls[] в сообщении ассистента, а клиент продолжает разговор сообщением role: "tool" с tool_call_id. В Responses API определение функции обычно плоское: имя, описание, JSON Schema параметров и настройка строгости. Вызов приходит отдельным элементом function_call, а результат отправляют отдельным входным элементом function_call_output с тем же call_id.

Схема ниже показывает минимальный перевод определения функции. Шлюз не должен переписывать JSON Schema «для красоты»: он обязан сохранить ее как есть и отдельно проверить, какой поднабор схемы принимает целевая модель.

{
  "tools": [
    {
      "type": "function",
      "function": {
        "name": "find_loan_terms",
        "description": "Возвращает условия займа по типу продукта.",
        "parameters": {
          "type": "object",
          "properties": {
            "product": {"type": "string"}
          },
          "required": ["product"],
          "additionalProperties": false
        },
        "strict": true
      }
    }
  ]
}

Для Responses-совместимого адаптера эта же каноническая функция должна стать объектом такого смысла:

{
  "tools": [
    {
      "type": "function",
      "name": "find_loan_terms",
      "description": "Возвращает условия займа по типу продукта.",
      "parameters": {
        "type": "object",
        "properties": {
          "product": {"type": "string"}
        },
        "required": ["product"],
        "additionalProperties": false
      },
      "strict": true
    }
  ]
}

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

Вот критический фрагмент, который должен пережить оба направления перевода:

{
  "call_id": "call_8f31",
  "name": "find_loan_terms",
  "arguments_raw": "{\"product\":\"consumer_loan\"}",
  "status": "requested"
}

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

{
  "kind": "tool_result",
  "call_id": "call_8f31",
  "output": "{\"early_repayment\":true,\"fee\":0}",
  "status": "completed"
}

Адаптер Chat Completions превратит его в role: "tool", tool_call_id: "call_8f31". Адаптер Responses API превратит его в type: "function_call_output", call_id: "call_8f31". call_id нельзя заменять собственным UUID в одном из направлений. Клиент, модель и аудит должны видеть одну цепочку.

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

Структурированный ответ и JSON mode решают разные задачи

Структурированный ответ означает контракт на форму результата, а не просьбу «верни JSON». Это различие часто упускают, когда шлюз пытается унифицировать response_format одним булевым полем json=true.

В Chat Completions старый JSON mode задается как response_format: {"type":"json_object"}. Он добивается синтаксически корректного JSON, но не доказывает соответствие нужной схеме. Для схемы нужен режим json_schema с именем схемы, ее описанием, объектом schema и настройкой strict. В документации OpenAI прямо разделены JSON mode и Structured Outputs, причем для моделей с поддержкой схемы рекомендуют второй вариант.

В Responses API смысл схемы живет в настройке текстового формата. Поэтому в канонической модели полезно хранить не внешнее поле response_format, а объект результата:

{
  "output_contract": {
    "kind": "json_schema",
    "name": "loan_answer",
    "strict": true,
    "schema": {
      "type": "object",
      "properties": {
        "eligible": {"type": "boolean"},
        "reason": {"type": "string"}
      },
      "required": ["eligible", "reason"],
      "additionalProperties": false
    }
  }
}

Такой объект можно отрисовать в response_format для Chat Completions и в text.format для Responses API. Но шлюз должен сделать еще один шаг: сопоставить контракт с возможностями конкретной модели. У разных моделей и провайдеров различается поддержка строгого режима, вложенных схем, отдельных ключевых слов JSON Schema и сочетания schema output с function calling.

Худшая политика звучит привлекательно: если провайдер не поддерживает strict, убрать флаг и продолжить. Команда выбирает ее, потому что хочет «максимум успешных ответов». На деле она меняет обещание API. Клиент рассчитывает распарсить результат без восстановительных эвристик, а получает объект с лишним полем, строкой вместо массива или текстом до JSON. В банке, медицине и автоматизации это не мелкая деградация, а другой режим риска.

Разделите возможности на три класса:

  • точная поддержка: шлюз отправляет запрос без изменения семантики;
  • контролируемое преобразование: шлюз меняет форму, но сохраняет объявленный смысл;
  • неподдерживаемая возможность: шлюз возвращает ошибку до вызова модели.

Например, перевод response_format.json_schema в text.format.json_schema может быть контролируемым преобразованием. Удаление strict: true не может. Если вы все же вводите режим best effort, он должен требовать явного флага клиента и возвращать признак понижения гарантии в ответе или в трассировочных метаданных.

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

Потоковая выдача требует двух машин состояний

Меньше привязки к провайдеру
Используйте один OpenAI-совместимый контракт для моделей от десятков провайдеров.

Streaming между контрактами нельзя реализовать заменой имени SSE-события. И Chat Completions, и Responses API передают данные по частям, но эти части несут разную структуру и появляются в разном порядке.

В Chat Completions клиент ждет последовательность chunk-объектов, где текст часто приходит в choices[0].delta.content, а аргументы функции могут накапливаться фрагментами внутри delta.tool_calls. В Responses API поток состоит из типизированных событий, которые могут отдельно сообщать о создании ответа, добавлении output item, дельте текста, дельте аргументов вызова и завершении.

Шлюз должен построить внутреннюю машину состояний ответа. Для каждого выходного элемента она держит идентификатор, тип, индекс, накопленный текст, накопленные аргументы и статус. Затем внешний сериализатор выпускает события в нужном виде. Это выглядит тяжелее, чем прямой прокси, но иначе у вас появится классическая ошибка: текст успел уйти клиенту, а функция пришла позднее и шлюз уже объявил finish_reason: "stop".

Минимальные состояния полезно сделать явными:

  1. started: шлюз принял запрос и зафиксировал контракт ответа.
  2. emitting: приходят части текста, отказа или аргументов инструмента.
  3. awaiting_tool: модель закончила текущий ответ вызовами функций.
  4. completed, failed или cancelled: операция получила окончательный статус.

Не пытайтесь воссоздавать токенизацию чужого API. Если Responses-провайдер отдал дельту текста крупным фрагментом, Chat-адаптер может выпустить ее одним delta.content. Клиенту обычно важен порядок символов, а не размер каждого чанка. И наоборот, если Chat-провайдер отдает части аргументов вызова, Responses-адаптер должен сохранить один call_id и передавать дельты аргументов как части одного элемента.

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

Ошибки должны сохранять причину, а не только HTTP-код

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

Снаружи для OpenAI-совместимого endpoint разумно держать знакомую форму:

{
  "error": {
    "message": "Модель не поддерживает strict JSON Schema для выбранного маршрута.",
    "type": "invalid_request_error",
    "param": "response_format",
    "code": "capability_not_supported"
  }
}

Внутри добавьте неизменяемые поля, которые клиенту необязательно видеть: gateway_request_id, client_request_id, route_id, идентификатор провайдера, код исходной ошибки, номер попытки и класс повторяемости. Не складывайте в поле message сырые тела ответа провайдера. Там регулярно оказываются части промпта, данные инструмента или внутренние детали маршрута.

Практическая классификация может быть такой:

  • ошибка валидации контракта: не повторять, исправить запрос;
  • отказ по возможностям модели: не повторять тем же маршрутом, разрешить явный fallback только по политике клиента;
  • лимит шлюза или провайдера: повторять с контролируемой задержкой, если запрос идемпотентен;
  • временная ошибка поставщика: повторять с лимитом попыток и бюджетом времени;
  • ошибка исполнения инструмента: возвращать ее в модель как результат инструмента только если политика приложения это допускает.

Последний пункт часто реализуют неверно. Если внутренний сервис поиска вернул 500, нельзя автоматически превращать это в HTTP-ошибку LLM-запроса. Иногда агент способен сообщить пользователю о временной недоступности и предложить другой путь. Иногда приложение не имеет права раскрывать наличие такого сервиса или детали сбоя. Это решение относится к политике инструмента, а не к адаптеру API.

Также отделяйте ошибку маршрутизации от ошибки модели. Шлюз мог отклонить запрос из-за регионального правила, ограничений data residency или отсутствия модели с нужной функцией. Модель при этом ничего не сделала. В отчетах по качеству такие случаи нельзя записывать в «ошибки модели», иначе вы начнете лечить промпт там, где надо менять правило выбора маршрута.

Совместимость должна быть матрицей, а не обещанием

Один endpoint для маршрутов
AI Router принимает существующие OpenAI-совместимые вызовы через единый endpoint.

Фраза «мы поддерживаем OpenAI API» не описывает реальную совместимость. Она скрывает десятки сочетаний: текстовый чат, изображения, tool choice, несколько вызовов, строгая схема, streaming, продолжение состояния, метаданные, причины остановки и usage. Один маршрут может хорошо работать с текстом и функциями, но не поддерживать строгий формат ответа. Другой отлично отдает JSON Schema, но не умеет серверное продолжение диалога.

Составьте матрицу способностей для каждого адаптера и маршрута. Строки должны быть конкретными: text, image input, function call, parallel function calls, strict function schema, json schema output, streaming, server-side state, usage details. Столбцы: принимает ли шлюз возможность, передает ли ее без изменения, преобразует ли, может ли проверить результат и какой код вернет при отказе.

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

{
  "route": "provider-a/model-x",
  "capabilities": {
    "function_call": true,
    "parallel_function_calls": true,
    "strict_function_schema": false,
    "json_schema_output": true,
    "streaming": true,
    "server_side_state": false
  }
}

Когда клиент присылает strict: true для инструмента, маршрутизатор отсекает этот маршрут еще до сетевого вызова. Если политика разрешает fallback, он выбирает только маршруты с нужной возможностью. Если нет, возвращает понятную ошибку. Это лучше, чем получить от провайдера расплывчатый 400, который не объясняет, какое поле потеряло гарантию.

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

Тестируйте перевод на цепочках, а не на одиночных запросах

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

Первый сценарий: пользовательский текст, один вызов функции, результат функции, финальный текст. Проверьте, что имя функции, сырой JSON аргументов и call_id остаются одинаковыми при маршруте Chat Completions в Responses API и обратно.

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

Третий: строгая JSON Schema с запрещенными дополнительными полями. Проверьте три ветки: модель поддерживает схему, модель не поддерживает схему и модель вернула невалидный результат. Во второй ветке шлюз обязан отказать до генерации. В третьей он обязан вернуть ошибку контракта, а не передать битый JSON дальше.

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

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

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

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

Состояние, аудит и хранение данных нельзя оставлять адаптеру

Защитите PII на границе
Маскирование PII применяется на уровне шлюза до дальнейшей обработки запроса.

Responses API делает серверное состояние удобным, но удобство не снимает вопросов о хранении и аудите. Если шлюз поддерживает previous_response_id, он должен понимать, где живет история: у внешнего поставщика, у самого шлюза или в обоих местах. Эти варианты имеют разную стоимость, задержку, правила удаления и юридические последствия.

В документации OpenAI по контролю данных отдельно описаны правила хранения для /v1/chat/completions и /v1/responses; для Responses API состояние приложения по умолчанию может храниться на стороне платформы. Это хороший пример того, почему нельзя объявлять два endpoint одинаковыми только из-за похожего результата. Политика хранения относится к контракту операции, а не к полю model.

Для систем в Казахстане и Центральной Азии добавьте к канонической операции метки обработки: класс данных, требования к data residency, разрешенные регионы, срок аудита, маскирование PII и допустимость внешнего состояния. Маршрутизатор должен читать эти метки до выбора модели. Если сделать это после адаптации запроса, чувствительные данные уже могут оказаться не там, где им разрешено быть.

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

Не прячьте это в «технические детали». Когда шлюз сводит два контракта, он получает больше контекста, чем каждый отдельный endpoint: историю, инструменты, схему ответа и маршрут. Значит, он становится местом, где правила доступа должны быть яснее, а не мягче.

Не стройте универсальный перевод там, где нужен явный отказ

Шлюз выигрывает не тем, что принимает любой JSON, а тем, что предсказуемо принимает поддерживаемый смысл. Chat Completions и Responses API можно обслуживать одной архитектурой, если у нее есть каноническая операция, строгая связь инструментов с результатами, матрица возможностей и отдельные машины состояний для потока.

Оставьте клиентам привычный контракт там, где он им нужен. Не делайте из старого Chat Completions искусственно бедный Responses API и не притворяйтесь, что новые возможности всегда помещаются в старый choices[0].message. Каждый невыразимый случай должен иметь одно из трех решений: собственное состояние шлюза, явно ограниченный режим или понятная ошибка.

Если у вас уже есть прокси, начните с самого неприятного теста, а не с простого чата: два параллельных tool call, потоковые аргументы и строгая JSON Schema в одной цепочке. Если этот сценарий проходит без подмены идентификаторов, раннего stop и молчаливого снятия strict, основа у шлюза правильная.

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

Можно ли поддерживать Chat Completions и Responses API одним бэкендом?

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

Можно ли просто перевести system в instructions?

Обычно нет. Роль system и поле instructions похожи по назначению, но отличаются местом в запросе и жизненным циклом, особенно при продолжении ответа через previous_response_id. Храните инструкцию отдельно в канонической модели и явно описывайте правило сборки для каждого контракта.

Чем отличается возврат результата инструмента в двух API?

В Chat Completions результат функции передают сообщением с role: tool и tool_call_id. В Responses API это самостоятельный элемент function_call_output с call_id. Шлюз обязан сохранять идентификатор вызова без пересоздания, иначе модель не сможет сопоставить результат со своим вызовом.

Одинаково ли работают Structured Outputs в обоих контрактах?

Нет, если под структурированным ответом вы понимаете строгое соблюдение схемы. В Chat Completions схема лежит в response_format, а в Responses API она задается в text.format. Нормализуйте схему, имя и признак strict, а затем проверяйте, поддерживает ли выбранная модель нужный режим.

Можно ли без буфера преобразовать streaming между API?

Поток нельзя переводить посимвольно. Chat Completions передает изменения внутри choices[].delta, а Responses API использует типизированные события для текста, аргументов функций и статуса ответа. Адаптер должен собирать состояние ответа и выпускать события в форме, которую ожидает клиент.

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

Не молча. Если клиент запросил строгую JSON Schema, обязательный вызов инструмента или серверный поиск, а модель этого не умеет, верните понятную ошибку совместимости до запуска генерации. Тихое ослабление контракта создает ошибки, которые выглядят как ошибки модели, хотя их внес шлюз.

Какие поля нельзя потерять при преобразовании запросов?

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

Почему нельзя объявить Responses API полной заменой Chat Completions?

Потому что это скрывает цену миграции и ломает предсказуемость. Responses API может выражать состояние и набор выходных элементов шире, чем один assistant message, а Chat Completions ожидает именно сообщение в choices. Поддерживайте урезанный режим осознанно и документируйте ограничения.

Как связать ошибки шлюза с запросом клиента?

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

Как AI Router помогает при переходе между двумя контрактами?

Если приложение уже использует OpenAI SDK и Chat Completions, ему достаточно сменить base_url на api.airouter.kz для совместимых сценариев. Новые возможности Responses API лучше вводить через явный адаптер приложения или через контракт, который шлюз способен проверить до маршрутизации.