Anthropic Messages, Chat Completions и Responses: как выбрать, преобразовать и учесть границы совместимости
HTTP 200 не означает, что агент совместим. Три полных цикла вызова инструмента показывают реальные различия Messages, Chat Completions и Responses, а также порядок миграции, тестирования и диагностики.
Содержание
Ответ 200 после смены endpoint подтверждает только принятый формат: инструменты, Schema и контекст уже могли перестать работать.
Успешную миграцию нужно оценивать как минимум на трёх уровнях:
- Формат принимается: сервер разбирает запрос и возвращает успешный статус.
- Поведение эквивалентно: инструменты вызываются, результаты правильно возвращаются модели, поток завершается полностью, а многоходовый контекст остаётся связным.
- Возможности сохранены: строгая Schema, нативное reasoning-состояние, управляемые провайдером инструменты, структурированный вывод и другие функции не были молча проигнорированы или понижены.
HTTP 200 подтверждает только первый уровень. Для запроса, который генерирует один текстовый ответ, простого преобразования нередко достаточно. Но для агента с инструментами, потоковыми аргументами, многоходовым состоянием или reasoning-моделью нужно проверять всю цепочку взаимодействия.
Практический вывод: выбирайте протокол по клиенту и нужным возможностям, а не по имени модели
| Сценарий | Более подходящая отправная точка | Почему |
|---|---|---|
Существующее приложение стабильно использует OpenAI SDK и messages | Chat Completions | Минимум изменений; существующий цикл сообщений и инструментов можно сохранить |
| Новый OpenAI-агент нуждается в hosted tools, типизированных Items или продолжении серверного состояния | Responses | OpenAI сейчас рекомендует этот интерфейс для новых проектов, а набор агентных возможностей шире |
| Claude Code, нативное приложение Claude или сценарий с возможностями, специфичными для Claude | Anthropic Messages | Блоки контента, результаты инструментов, thinking и другое поведение подчиняются нативному контракту Anthropic |
| Собственный шлюз или мульти-модельный роутер | Отдельный адаптер для каждого upstream-протокола | Один «универсальный JSON» не способен без потерь выразить все нативные возможности |
OpenAI продолжает поддерживать Chat Completions, поэтому стабильное рабочее приложение не обязано срочно переписываться только из-за появления более нового интерфейса. Миграция оправдана для новых проектов или когда нужны нативные возможности Responses. Anthropic Messages — тоже не OpenAI-интерфейс с переименованным полем messages: блоки контента, передача результатов инструментов, потоковые события и правила состояния образуют самостоятельный контракт.
Ключевые различия трёх API
В этой статье Completions означает именно Chat Completions, а не старый endpoint /v1/completions.
| Аспект | OpenAI Chat Completions | OpenAI Responses | Anthropic Messages |
|---|---|---|---|
| Endpoint | /v1/chat/completions | /v1/responses | /v1/messages |
| Основной ввод | messages | Items в input; также принимается простой ввод сообщениями | messages, обычно с отдельным полем верхнего уровня system |
| Основной вывод | choices[].message | Типизированные Items в output[] | Блоки контента в content[] |
| Определение инструмента | tools[].function | name и parameters находятся непосредственно в tools[] | В tools[] используется input_schema |
| Аргументы инструмента | function.arguments, JSON-строка | arguments, JSON-строка | tool_use.input, JSON-объект |
| ID для связывания | tool_calls[].id | call_id | tool_use.id |
| Возврат результата | role: "tool" + tool_call_id | function_call_output + call_id | tool_result + tool_use_id внутри user-сообщения |
| Многоходовое состояние | Приложение повторно отправляет историю сообщений | Повтор Items, previous_response_id или Conversations | Приложение повторно отправляет сообщения и блоки контента |
| Финальный структурированный вывод | response_format | text.format | output_config.format |
| Streaming | choices[].delta | Типизированные события Responses | События message/content block |
По таблице может показаться, что различаются только названия полей. На практике сбои чаще возникают во втором запросе: после того как модель сформировала вызов инструмента, как приложение должно выполнить его, какой ID сохранить и в какой роли и последовательности вернуть результат? Ниже один и тот же сценарий без побочных эффектов полностью разобран для всех трёх протоколов.
Единый пример: получить данные тестового тарифа
Пользователь спрашивает:
Найдите тариф
teamи скажите, поддерживается ли оплата перерасхода по факту использования.
Инструмент называется get_plan_info. Он читает фиксированные локальные данные и не создаёт внешних побочных эффектов, поэтому подходит для тестирования миграции протокола.
Данные тарифа ниже являются синтетическим учебным примером. Они не описывают реальные тарифы, цены или права OpenAI, Anthropic либо BetterToken. Три последовательности запросов и ответов демонстрируют структуру протокола и не являются журналом реальных API-вызовов.
Инструмент на стороне приложения можно реализовать как функцию, не зависящую от протокола:
from __future__ import annotations
import json
from typing import Any
PLAN_FIXTURES: dict[str, dict[str, Any]] = {
"team": {
"plan_code": "team",
"display_name": "Team",
"billing_mode": "usage_based",
"included_requests": 10_000,
"overage_allowed": True,
"source_version": "fixture-2026-09-01",
}
}
def execute_tool(name: str, raw_arguments: str | dict[str, Any]) -> str:
"""Выполняет учебный инструмент только для чтения и возвращает JSON-строку, которую можно сразу передать модели."""
if isinstance(raw_arguments, str):
arguments = json.loads(raw_arguments)
elif isinstance(raw_arguments, dict):
arguments = raw_arguments
else:
raise TypeError("аргументы инструмента должны быть JSON-строкой или объектом")
if name != "get_plan_info":
raise ValueError(f"неизвестный инструмент: {name}")
if set(arguments) != {"plan_code"}:
raise ValueError("get_plan_info принимает только plan_code")
plan_code = arguments["plan_code"]
if not isinstance(plan_code, str):
raise TypeError("plan_code должен быть строкой")
plan = PLAN_FIXTURES.get(plan_code)
if plan is None:
return json.dumps(
{"ok": False, "error": "plan_not_found", "plan_code": plan_code},
ensure_ascii=False,
)
return json.dumps({"ok": True, "data": plan}, ensure_ascii=False)
Даже если в запросе включена строгая Schema, приложение должно сохранять собственную проверку входных данных. Строгий режим ограничивает аргументы, сгенерированные моделью, но не заменяет проверку прав, допустимых значений, идемпотентность и защитные проверки бизнес-логики.
Chat Completions: полный цикл вызова инструмента
Первый запрос: попросить модель сформировать вызов инструмента
Ниже официальный endpoint OpenAI используется для демонстрации протокола. При подключении к совместимому сервису замените Base URL, способ авторизации и Model ID согласно документации этого провайдера.
curl https://api.openai.com/v1/chat/completions \
-H "Authorization: Bearer $OPENAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "YOUR_OPENAI_MODEL",
"messages": [
{
"role": "system",
"content": "Вы — помощник по тарифам. Отвечайте только по данным, полученным от инструмента, и не додумывайте."
},
{
"role": "user",
"content": "Найдите тариф team и скажите, поддерживается ли оплата перерасхода по факту использования."
}
],
"tools": [
{
"type": "function",
"function": {
"name": "get_plan_info",
"description": "Получить фиксированные тестовые данные по коду тарифа",
"strict": true,
"parameters": {
"type": "object",
"properties": {
"plan_code": {
"type": "string",
"enum": ["team"]
}
},
"required": ["plan_code"],
"additionalProperties": false
}
}
}
],
"tool_choice": "required",
"parallel_tool_calls": false
}'
Приложение должно прочитать tool_calls из assistant-сообщения. В следующем ответе оставлены только поля, необходимые для дальнейшего цикла:
{
"choices": [
{
"message": {
"role": "assistant",
"content": null,
"tool_calls": [
{
"id": "call_plan_001",
"type": "function",
"function": {
"name": "get_plan_info",
"arguments": "{\"plan_code\":\"team\"}"
}
}
]
},
"finish_reason": "tool_calls"
}
]
}
Нельзя потерять два значения:
tool_calls[0].id: во втором запросе его нужно без изменений вернуть какtool_call_id.function.arguments: это JSON-строка. Сначала разберите её, затем выполните собственную проверку Schema и бизнес-правил.
Выполните инструмент:
tool_result = execute_tool(
"get_plan_info",
"{\"plan_code\":\"team\"}",
)
Второй запрос: вернуть результат инструмента модели
В Chat Completions нужно сохранить assistant-сообщение с исходным вызовом инструмента, а затем добавить сообщение результата с role: "tool".
curl https://api.openai.com/v1/chat/completions \
-H "Authorization: Bearer $OPENAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "YOUR_OPENAI_MODEL",
"messages": [
{
"role": "system",
"content": "Вы — помощник по тарифам. Отвечайте только по данным, полученным от инструмента, и не додумывайте."
},
{
"role": "user",
"content": "Найдите тариф team и скажите, поддерживается ли оплата перерасхода по факту использования."
},
{
"role": "assistant",
"content": null,
"tool_calls": [
{
"id": "call_plan_001",
"type": "function",
"function": {
"name": "get_plan_info",
"arguments": "{\"plan_code\":\"team\"}"
}
}
]
},
{
"role": "tool",
"tool_call_id": "call_plan_001",
"content": "{\"ok\":true,\"data\":{\"plan_code\":\"team\",\"display_name\":\"Team\",\"billing_mode\":\"usage_based\",\"included_requests\":10000,\"overage_allowed\":true,\"source_version\":\"fixture-2026-09-01\"}}"
}
],
"tools": [
{
"type": "function",
"function": {
"name": "get_plan_info",
"description": "Получить фиксированные тестовые данные по коду тарифа",
"strict": true,
"parameters": {
"type": "object",
"properties": {
"plan_code": {
"type": "string",
"enum": ["team"]
}
},
"required": ["plan_code"],
"additionalProperties": false
}
}
}
]
}'
Пример финального сообщения:
{
"choices": [
{
"message": {
"role": "assistant",
"content": "Тариф Team поддерживает оплату перерасхода по факту использования. В тестовых данных включено 10 000 запросов, а overage_allowed имеет значение true."
},
"finish_reason": "stop"
}
]
}
Если адаптер преобразовал только первый пользовательский запрос, но не сохранил tool_calls из assistant-сообщения, либо записал неверный ID в tool_call_id, второй запрос уже не продолжает тот же вызов инструмента.
Responses: полный цикл вызова инструмента
Responses представляет сообщения, reasoning, вызовы инструментов и их результаты разными типами Item. Нельзя считать output[0] финальным текстом во всех случаях: обработка должна ветвиться по type каждого Item.
Первый запрос: получить от модели Item типа function_call
curl https://api.openai.com/v1/responses \
-H "Authorization: Bearer $OPENAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "YOUR_OPENAI_MODEL",
"instructions": "Вы — помощник по тарифам. Отвечайте только по данным, полученным от инструмента, и не додумывайте.",
"input": "Найдите тариф team и скажите, поддерживается ли оплата перерасхода по факту использования.",
"tools": [
{
"type": "function",
"name": "get_plan_info",
"description": "Получить фиксированные тестовые данные по коду тарифа",
"strict": true,
"parameters": {
"type": "object",
"properties": {
"plan_code": {
"type": "string",
"enum": ["team"]
}
},
"required": ["plan_code"],
"additionalProperties": false
}
}
],
"tool_choice": "required",
"parallel_tool_calls": false,
"store": false
}'
Пример Item с вызовом инструмента:
{
"id": "resp_plan_001",
"object": "response",
"output": [
{
"type": "function_call",
"id": "fc_plan_001",
"call_id": "call_plan_001",
"name": "get_plan_info",
"arguments": "{\"plan_code\":\"team\"}",
"status": "completed"
}
]
}
Для связывания результата используйте call_id. id: "fc_plan_001" — ID самого Item, он не должен подменять call_id.
Выполните инструмент:
tool_result = execute_tool(
"get_plan_info",
"{\"plan_code\":\"team\"}",
)
Второй запрос: вернуть function_call_output
Ниже используется ручное воспроизведение Items без серверного состояния, поэтому instructions, исходный запрос пользователя, вызов инструмента и его результат передаются повторно.
curl https://api.openai.com/v1/responses \
-H "Authorization: Bearer $OPENAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "YOUR_OPENAI_MODEL",
"instructions": "Вы — помощник по тарифам. Отвечайте только по данным, полученным от инструмента, и не додумывайте.",
"input": [
{
"role": "user",
"content": "Найдите тариф team и скажите, поддерживается ли оплата перерасхода по факту использования."
},
{
"type": "function_call",
"call_id": "call_plan_001",
"name": "get_plan_info",
"arguments": "{\"plan_code\":\"team\"}"
},
{
"type": "function_call_output",
"call_id": "call_plan_001",
"output": "{\"ok\":true,\"data\":{\"plan_code\":\"team\",\"display_name\":\"Team\",\"billing_mode\":\"usage_based\",\"included_requests\":10000,\"overage_allowed\":true,\"source_version\":\"fixture-2026-09-01\"}}"
}
],
"tools": [
{
"type": "function",
"name": "get_plan_info",
"description": "Получить фиксированные тестовые данные по коду тарифа",
"strict": true,
"parameters": {
"type": "object",
"properties": {
"plan_code": {
"type": "string",
"enum": ["team"]
}
},
"required": ["plan_code"],
"additionalProperties": false
}
}
],
"store": false
}'
Пример финального выходного Item:
{
"id": "resp_plan_002",
"object": "response",
"output": [
{
"type": "message",
"role": "assistant",
"content": [
{
"type": "output_text",
"text": "Тариф Team поддерживает оплату перерасхода по факту использования. В тестовых данных включено 10 000 запросов, а overage_allowed имеет значение true."
}
]
}
]
}
Если выбрано продолжение серверного состояния, можно разрешить сохранение первого ответа, а во втором запросе использовать следующий формат:
{
"model": "YOUR_OPENAI_MODEL",
"previous_response_id": "resp_plan_001",
"input": [
{
"type": "function_call_output",
"call_id": "call_plan_001",
"output": "{\"ok\":true,\"data\":{\"plan_code\":\"team\",\"overage_allowed\":true}}"
}
]
}
previous_response_id принадлежит upstream-сервису, создавшему ответ. Передать его другому провайдеру для продолжения нельзя. Он также не делает историю бесплатной: в текущей документации OpenAI прямо указано, что предыдущие входные token в цепочке по-прежнему тарифицируются как input.
Если ответ содержит reasoning Item, при stateless-воспроизведении нужно сохранить и соответствующий Item в соответствии с документацией. Нельзя удалить его ради «единого формата», а затем утверждать, что reasoning-контекст остался эквивалентным.
Anthropic Messages: полный цикл вызова инструмента
Messages представляет вызов инструмента блоком tool_use в assistant-контенте, а результат — блоком tool_result в следующем user-сообщении. Аргументы инструмента уже являются объектом, а не JSON-строкой, которую ещё нужно разбирать.
Первый запрос: попросить Claude вернуть tool_use
curl https://api.anthropic.com/v1/messages \
-H "x-api-key: $ANTHROPIC_API_KEY" \
-H "anthropic-version: 2023-06-01" \
-H "content-type: application/json" \
-d '{
"model": "YOUR_CLAUDE_MODEL",
"max_tokens": 512,
"system": "Вы — помощник по тарифам. Отвечайте только по данным, полученным от инструмента, и не додумывайте.",
"messages": [
{
"role": "user",
"content": "Найдите тариф team и скажите, поддерживается ли оплата перерасхода по факту использования."
}
],
"tools": [
{
"name": "get_plan_info",
"description": "Получить фиксированные тестовые данные по коду тарифа",
"strict": true,
"input_schema": {
"type": "object",
"properties": {
"plan_code": {
"type": "string",
"enum": ["team"]
}
},
"required": ["plan_code"],
"additionalProperties": false
}
}
],
"tool_choice": {
"type": "tool",
"name": "get_plan_info"
}
}'
Принудительный выбор конкретного инструмента зависит от поддержки выбранной моделью и настройками. Если целевая модель этого не поддерживает, используйте auto и на уровне приложения проверяйте, действительно ли вернулся вызов инструмента.
Пример ответа:
{
"id": "msg_plan_001",
"type": "message",
"role": "assistant",
"content": [
{
"type": "tool_use",
"id": "toolu_plan_001",
"name": "get_plan_info",
"input": {
"plan_code": "team"
}
}
],
"stop_reason": "tool_use"
}
Объект input можно сразу передать исполнителю инструмента:
tool_result = execute_tool(
"get_plan_info",
{"plan_code": "team"},
)
Второй запрос: поместить tool_result в непосредственно следующее user-сообщение
curl https://api.anthropic.com/v1/messages \
-H "x-api-key: $ANTHROPIC_API_KEY" \
-H "anthropic-version: 2023-06-01" \
-H "content-type: application/json" \
-d '{
"model": "YOUR_CLAUDE_MODEL",
"max_tokens": 512,
"system": "Вы — помощник по тарифам. Отвечайте только по данным, полученным от инструмента, и не додумывайте.",
"messages": [
{
"role": "user",
"content": "Найдите тариф team и скажите, поддерживается ли оплата перерасхода по факту использования."
},
{
"role": "assistant",
"content": [
{
"type": "tool_use",
"id": "toolu_plan_001",
"name": "get_plan_info",
"input": {
"plan_code": "team"
}
}
]
},
{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_plan_001",
"content": "{\"ok\":true,\"data\":{\"plan_code\":\"team\",\"display_name\":\"Team\",\"billing_mode\":\"usage_based\",\"included_requests\":10000,\"overage_allowed\":true,\"source_version\":\"fixture-2026-09-01\"}}"
}
]
}
],
"tools": [
{
"name": "get_plan_info",
"description": "Получить фиксированные тестовые данные по коду тарифа",
"strict": true,
"input_schema": {
"type": "object",
"properties": {
"plan_code": {
"type": "string",
"enum": ["team"]
}
},
"required": ["plan_code"],
"additionalProperties": false
}
}
]
}'
Пример финального ответа:
{
"id": "msg_plan_002",
"type": "message",
"role": "assistant",
"content": [
{
"type": "text",
"text": "Тариф Team поддерживает оплату перерасхода по факту использования. В тестовых данных включено 10 000 запросов, а overage_allowed имеет значение true."
}
],
"stop_reason": "end_turn"
}
Messages предъявляет чёткие требования к порядку: tool_result должен идти сразу после assistant-сообщения, содержащего соответствующий tool_use. Если один assistant-ход создаёт несколько клиентских вызовов инструментов, все соответствующие блоки результатов нужно вернуть в следующем user-сообщении и связать каждый через tool_use_id. Если то же user-сообщение содержит обычный текст, блоки результатов должны стоять перед ним.
Что переносится напрямую, а где преобразование неизбежно теряет данные
| Возможность | Оценка преобразования | Правильная обработка |
|---|---|---|
| Обычный пользовательский текст | Обычно переносится напрямую | Сохраняйте текст, порядок и мультимодальные типы, а не только видимые строки |
| Базовая Schema функции | Допускает изменение формы | Преобразуйте между function.parameters, Responses parameters и Messages input_schema, затем повторно проверяйте поддерживаемое подмножество JSON Schema |
| Аргументы инструмента | Требуется преобразование типа | Оба OpenAI-интерфейса обычно возвращают JSON-строку, Messages — объект. Перед бизнес-логикой нормализуйте, разбирайте и проверяйте данные |
| ID вызова инструмента | Семантику нужно сохранить, namespace повторно использовать нельзя | Храните внутренний canonical call ID вместе с исходным upstream-ID и возвращайте поле, соответствующее протоколу |
| Параллельные вызовы инструментов | Поддержать можно, но связывать по позиции в массиве нельзя | Сопоставляйте каждый результат через tool_call_id, call_id или tool_use_id |
| Инструкции system/developer | Возможны потери | Разделяйте глобальную область, этап разговора и один ход; если целевой протокол не выражает исходную область, явно понижайте возможность или отклоняйте запрос |
| Финальный структурированный вывод | Поля нельзя механически менять местами | Chat использует response_format, Responses — text.format, Messages — output_config.format |
| Потоковые аргументы инструментов | Нужен parser конкретного протокола | Накапливайте фрагменты по событиям и ID вызова, а JSON разбирайте только после события завершения |
| Серверное состояние между ходами | Универсального эквивалента нет | Такие ID, как previous_response_id, привязаны к исходному upstream; между upstream воспроизводите видимый контекст или используйте sticky routing |
| Состояние thinking/reasoning | Обычно не переносится без потерь | Сохраняйте opaque Item, thinking block, подпись или зашифрованный контент ровно так, как требует нативный протокол; не создавайте их самостоятельно |
| Hosted tools | Часто нет прямого эквивалента | Отдельно объявляйте поддержку и fallback для web search, file search, computer use, server tools и других функций |
| Генерация нескольких кандидатов | Эквивалента может не быть | Не предполагайте, что n из Chat Completions напрямую переносится в Responses; выполняйте несколько запросов на уровне приложения или меняйте поведение продукта |
Поэтому надёжная внутренняя абстракция шлюза — не огромный объект со всеми возможными полями. Семантику лучше моделировать раздельно: сообщения, область инструкций, определения инструментов, вызовы, результаты, state handles, потоковые события и непрозрачное нативное состояние. Если возможность нельзя выразить, возвращайте явный статус «не поддерживается» или «преобразование с потерями», а не молча удаляйте поле.
strict, response_format и text.format решают разные задачи
Одна из самых частых ошибок миграции — считать «валидные аргументы инструмента» и «финальный ответ в заданной форме JSON» одной функцией.
| Цель | Chat Completions | Responses | Anthropic Messages |
|---|---|---|---|
| Ограничить аргументы вызова инструмента | tools[].function.strict | tools[].strict | tools[].strict |
| Ограничить финальный вывод модели | response_format | text.format | output_config.format |
strict у инструмента ограничивает способ вызова функции моделью. Финальный структурированный вывод ограничивает содержимое, которое модель возвращает пользователю. Агенту могут потребоваться обе функции одновременно: сначала вызвать инструмент со строгими аргументами, затем вернуть результат по фиксированной JSON Schema.
В текущей документации OpenAI есть и малозаметное различие значений по умолчанию:
- В Chat Completions вызовы функций по умолчанию нестрогие.
- Если в Responses опустить
strict, сервис попытается нормализовать Schema в строгий режим. При несовместимости он может вернуться к нестрогому режиму и показатьstrict: falseв разобранном определении инструмента.
Чтобы намерение было явным и не зависело от различий интерфейсов, в production-запросах следует осознанно указывать strict: true или strict: false. Строгая Schema должна также выполнять соответствующие требования: например, запрещать дополнительные свойства объекта и полностью перечислять обязательные поля.
Ещё важнее то, что слой совместимости может принять поле, но не применить ограничение. В официальной документации Anthropic по совместимости с OpenAI SDK указано, что в этом конкретном слое игнорируются, среди прочего, function strict, response_format и reasoning_effort, а большинство неподдерживаемых полей не вызывает ошибку. Поэтому запрос может вернуть 200, хотя Schema или настройка reasoning фактически не сработала.
Это не означает, что нативный Anthropic Messages не имеет соответствующих возможностей. Нативный Messages поддерживает строгий ввод инструментов и использует output_config.format для финального JSON. Диагностику нужно начинать с вопроса: вызывается нативный Messages или OpenAI-compatible слой?
system, developer и область действия инструкций нельзя сохранить простым объединением строк
OpenAI-подобные интерфейсы допускают разные роли в истории сообщений, а Responses дополнительно предоставляет instructions. Anthropic Messages традиционно использует поле верхнего уровня system. На сентябрь 2026 года некоторые актуальные модели также поддерживают role: "system" в середине диалога, но не все модели, а расположение и порядок относительно вызовов инструментов ограничены.
Одновременно слой совместимости Anthropic с OpenAI SDK собирает system/developer-сообщения из диалога, объединяет их переносами строк и помещает в единый system prompt в начале. Запрос становится исполнимым, но исходные время действия и область меняются. Developer-инструкция, которая должна начать действовать только с восьмого хода, после переноса в начало может изменить семантику первых семи ходов.
Более безопасный адаптер сначала разделяет три области внутри приложения:
- Глобальные инструкции: действуют на весь разговор.
- Инструкции этапа разговора: действуют начиная с определённого хода.
- Инструкции одного хода: управляют только текущей задачей.
Переносите инструкцию только тогда, когда целевой протокол способен выразить ту же область. В противном случае выбирайте явную стратегию: закрепите запрос за моделью с нужной возможностью, понизьте инструкцию и зафиксируйте различие либо отклоните миграцию. Молчаливое объединение требует мало кода, но часто приводит к ситуации «запрос успешен, поведение изменилось».
Streaming нужно разбирать как конечный автомат, а не просто склеивать текстовые token
Все три интерфейса поддерживают streaming, но события не эквивалентны:
- Chat Completions обычно собирает текст и фрагменты
tool_callsизchoices[].delta. - Responses отправляет типизированные события, например
response.output_text.delta,response.function_call_arguments.delta,response.function_call_arguments.done,response.completedиerror. - Messages использует
message_start,content_block_start,content_block_delta,content_block_stop,message_delta,message_stop; аргументы инструмента приходят фрагментами черезinput_json_delta.partial_json.
Аргументы инструмента могут быть разделены так:
{"plan_
code":"te
am"}
Ни один фрагмент сам по себе не является валидным JSON. Накапливайте их по ID вызова или индексу блока контента и разбирайте только после получения события завершения аргументов:
from __future__ import annotations
import json
from collections import defaultdict
from typing import Any
class ToolArgumentAssembler:
def __init__(self) -> None:
self._buffers: dict[str, list[str]] = defaultdict(list)
def add_delta(self, call_id: str, fragment: str) -> None:
self._buffers[call_id].append(fragment)
def finish(self, call_id: str) -> dict[str, Any]:
if call_id not in self._buffers:
raise KeyError(f"неизвестный call_id: {call_id}")
raw = "".join(self._buffers.pop(call_id))
value = json.loads(raw)
if not isinstance(value, dict):
raise TypeError("аргументы инструмента после декодирования должны быть объектом")
return value
def discard(self, call_id: str) -> None:
self._buffers.pop(call_id, None)
Адаптер также должен фиксировать явное конечное состояние:
created -> receiving -> completed
\-> failed
\-> disconnected
disconnected — не то же самое, что completed. Anthropic Messages может передать event: error внутри потока уже после успешного установления HTTP-соединения; у Responses также есть отдельные события ошибок. Если смотреть только на начальный HTTP-статус или считать закрытие соединения естественным завершением, можно обрезать аргументы инструмента или финальный ответ.
Парсер событий должен быть устойчив и к неизвестным типам: записывать и пропускать события, не влияющие на поддерживаемую возможность, а не аварийно завершать весь клиент при каждом новом серверном событии.
Многоходовое состояние и reasoning-состояние нельзя подделать
Chat Completions и традиционные сценарии Messages обычно требуют, чтобы приложение повторно передавало историю. Responses также может поддерживать серверное состояние через previous_response_id или Conversations. Их представление о «предыдущем ходе» не взаимозаменяемо.
Получив state ID, шлюз может выбрать только одну из трёх корректных стратегий:
- Sticky routing: последующие запросы направляются тому же upstream, который создал состояние.
- Полное воспроизведение: повторно отправляются все сообщения, вызовы инструментов, результаты и элементы нативного состояния, которые разрешено воспроизводить.
- Явный отказ: если целевой upstream не способен продолжить состояние, возвращается диагностируемая ошибка, а клиент начинает разговор заново.
Нельзя передавать OpenAI previous_response_id в Anthropic или выдавать внутренний ID разговора шлюза за state handle, понятный другому провайдеру.
Reasoning-состояние также нельзя перенести простым переименованием полей:
- В stateless-сценарии или при определённых настройках хранения данных Responses может вернуть зашифрованные reasoning Items, которые нужно повторно передать в следующем запросе.
- В Anthropic thinking-процессе могут присутствовать thinking block, подписи и другое непрозрачное состояние. При инструментах и многоходовых разговорах его нужно сохранять по правилам нативной документации.
- На сентябрь 2026 года ручной режим Anthropic
thinking.type: "enabled"сbudget_tokensпомечен устаревшим для моделей поколения 4.6 и отклоняется поколением 4.7 и выше; более новые модели используют adaptive thinking и соответствующее управление effort.
Поэтому нельзя создавать постоянное правило, приравнивающее OpenAI reasoning_effort к Anthropic budget_tokens. Корректное описание возможности должно включать целевую модель, её текущий режим thinking и поведение при отсутствии поддержки.
Параллельные вызовы инструментов: связывайте по ID, а не по позиции в массиве
За один ход модель может запросить несколько инструментов. Время выполнения различается, поэтому результаты могут вернуться в другом порядке. Адаптеру нужна связь примерно такого вида:
canonical_call_id
-> provider
-> provider_call_id
-> tool_name
-> validated_arguments
-> execution_status
-> result
При возврате результатов:
- Chat Completions создаёт для каждого результата сообщение с
role: "tool"и соответствующимtool_call_id. - Responses создаёт для каждого результата Item
function_call_outputи соответствующийcall_id. - Messages размещает соответствующие блоки
tool_resultв непосредственно следующем user-ходе и заполняет для каждого свойtool_use_id.
Во время миграционного тестирования сначала задайте parallel_tool_calls: false, отладьте путь с одним инструментом и только затем включайте параллельность. В production инструменты с побочными эффектами — отправка писем, списание средств, создание ресурсов — дополнительно требуют ключей идемпотентности. Сетевой retry, разрыв потока или upstream replay способны доставить один и тот же смысловой вызов повторно; по тексту, сгенерированному моделью, нельзя определить, выполнялся ли он уже.
Почему HTTP 200 недостаточно для проверки совместимости
Полезный миграционный тест должен охватывать как минимум следующие пути:
| Тест | Критерий прохождения |
|---|---|
| Обычный текст | Содержимое читаемо, область system/developer работает ожидаемо |
| Один вызов инструмента | Имя, аргументы, ID вызова, результат и финальный ответ образуют полный цикл |
| Параллельные вызовы | Каждый результат связан по ID, без смешения и потерь |
| Потоковые аргументы | Фрагменты полностью собираются, а после завершения JSON разбирается |
| Строгая Schema инструмента | Недопустимые поля и типы отклоняются ожидаемо или понижение объявляется явно |
| Финальный структурированный вывод | Ответ соответствует заданной Schema, а не просто «выглядит как JSON» |
| Ошибка выполнения инструмента | Модель получает структурированную ошибку, не зацикливается и не выдумывает успех |
| Многоходовое продолжение | Второй ход может ссылаться на факты первого, а правила переключения состояния определены |
| reasoning/thinking | Заявленный режим работает, нативное состояние не удалено и не подделано |
| Ошибки в потоке и разрыв соединения | Клиент различает завершение, ошибку и прерывание соединения |
| Контролируемая ошибка API | Тип ошибки, request ID и политика retry остаются диагностируемыми |
Используйте фиксированный ввод и фиксированные tool fixtures, а для каждого протокола отдельно записывайте:
- эквивалентен ли финальный бизнес-результат;
- полный ли цикл вызова и возврата результата;
- задержки P50 и P95;
- usage для input, output и cache;
- тип ошибки, request ID и конечное состояние;
- какие возможности были явно понижены.
Не записывайте API Key, полный чувствительный prompt или приватный вывод пользователя. Журнал ошибок должен как минимум сохранять HTTP status, upstream error type/code, краткое сообщение, request ID, endpoint, протокол, Model ID и конечное состояние stream. Иначе model_not_found, отсутствие прав и несовместимый путь легко превращаются в один недиагностируемый 400.
Диагностика по симптомам: где сломался агент
| Симптом | Частая причина | Как проверить и исправить |
|---|---|---|
Запрос возвращает 200, но модель никогда не вызывает инструмент | Определение инструмента не отправлено, tool_choice проигнорирован, модель не поддерживает инструменты или prompt недостаточно явный | Выведите финальный исходящий запрос; проверьте целевую модель и слой совместимости; оставьте в тесте один инструмент только для чтения и принудительно или явно запросите его вызов |
| Модель вернула вызов, но приложение его не выполняет | Приложение всё ещё читает старое поле, например только message.content | Читайте tool_calls, Item function_call или block tool_use согласно протоколу |
| JSON аргументов не разбирается | Потоковый фрагмент принят за полный JSON либо объект повторно разбирается как строка | Дождитесь события завершения аргументов; сначала определите, строка это или объект |
При strict всё равно появляются лишние поля | Слой совместимости молча игнорирует поле, Schema не удовлетворяет strict-режиму или запрос идёт не на нативный endpoint | Проверьте итоговый endpoint и документацию; задайте strict явно; добавьте regression-тест с намеренным нарушением Schema |
| Второй запрос сообщает об отсутствии результата инструмента | ID вызова не совпадает либо первый assistant/tool Item не сохранён | Сохраняйте upstream-вызов и его ID без изменений; возвращайте результат непосредственно там, где требует протокол |
Messages возвращает tool_use ids ... without tool_result | tool_result не идёт сразу после вызова либо перед ним вставлен обычный текст | Поместите все соответствующие блоки tool_result в следующее user-сообщение и расположите их перед необязательным текстом |
| Streaming зависает или приходит только половина аргументов | Клиент ждёт только маркер окончания текста и не обрабатывает конечные состояния аргументов и ошибок | Реализуйте отдельный автомат событий для каждого протокола и различайте completed, failed, error и disconnected |
| Второй ход забывает первый | Пропущены история, вызов или result Item; либо previous_response_id принадлежит другому upstream | Воспроизводите полный видимый контекст или используйте sticky routing; не передавайте state ID между провайдерами |
| После смены интерфейса system-инструкция начинает действовать слишком рано | Слой совместимости перенёс system/developer из середины разговора в начало | Моделируйте область инструкций; если перенос без потерь невозможен, явно понижайте возможность или используйте нативный протокол |
| Инструмент выполняется дважды | Запрос повторён, поток после разрыва воспроизведён или нет контроля идемпотентности | В тестах используйте инструменты только для чтения; для production-инструментов с побочными эффектами формируйте ключ идемпотентности из canonical call ID |
| Финальное содержимое является JSON, но поля иногда исчезают | В prompt лишь сказано «верни JSON», структурированный вывод не включён | Используйте response_format, text.format или output_config.format нужного интерфейса, затем повторно проверяйте ответ в приложении |
Более надёжный порядок миграции
- Определите протокол, который клиент действительно отправляет. Не судите по имени модели. Зафиксируйте полный endpoint, метод SDK, верхнеуровневые поля запроса и типы stream-событий.
- Перечислите поведение, которое нужно сохранить. Как минимум: инструменты, параллельные вызовы, строгая Schema, финальный структурированный вывод, многоходовое состояние, streaming и thinking/reasoning.
- Сначала используйте нативный протокол. Если функцию можно реализовать через нативный Messages или Responses, по возможности избегайте дополнительного слоя совместимости.
- Создайте матрицу возможностей преобразования. Для каждой функции укажите: полная поддержка, поддержка с потерями или отсутствие поддержки; результат должен быть виден вызывающей стороне.
- Прогоните полный цикл из двух запросов на fixture без побочных эффектов. Недостаточно доказать, что один запрос возвращает текст: выполните инструмент и верните результат модели.
- Затем проверьте параллельность, streaming и ошибки. Инструменты с побочными эффектами и реальный трафик включайте только после прохождения нормального пути.
- Увеличивайте трафик постепенно и сравнивайте метрики. Наблюдайте точность, задержку, usage, ошибки и повторное выполнение инструментов, а не только долю HTTP-успеха.
Как выбрать соответствующую точку подключения BetterToken
BetterToken предоставляет разные пути для разных клиентов. Протокол всё равно определяется wire contract, который фактически использует клиент:
- Chat Completions: полный URL запроса —
https://www.bettertoken.ai/v1/chat/completions. Для SDK и инструментов, которые сами добавляют путь, Base URL обычно равенhttps://www.bettertoken.ai/v1. См. справочник Chat Completions API. - Codex / Responses: в текущей документации Codex используются
base_url = "https://www.bettertoken.ai/v1"иwire_api = "responses"; Codex самостоятельно добавляет/responses. См. инструкцию по подключению Codex. - Claude Code / Messages: в текущей документации используется
ANTHROPIC_BASE_URL=https://bettertoken.aiбез/v1в Base URL; клиент самостоятельно добавляет/v1/messages. См. инструкцию по подключению Claude Code.
Один Dashboard, API Key или имя модели не превращает три протокола в один формат. Для готового инструмента выбирайте протокол, которого он ожидает. Для собственного агента сверяйте нужные возможности с полными циклами и матрицей приёмки из этой статьи.
Частые вопросы
OpenAI-compatible означает полную копию OpenAI API?
Нет. Обычно это означает, что определённые endpoints и структуры данных доступны OpenAI-подобным клиентам. Модели, параметры, streaming-события, инструменты, структурированный вывод, hosted tools и семантику ошибок нужно проверять отдельно.
Достаточно заменить только Base URL и API Key?
Иногда — для простых текстовых запросов, уже использующих тот же wire contract. Для агента с инструментами всё равно нужно проверить определения инструментов, возврат результатов во втором запросе, stream-события, строгую Schema, состояние и ошибки. Если клиент ожидает Responses, одного /chat/completions недостаточно; если он ожидает Messages, OpenAI-подобный endpoint не адаптируется автоматически.
Можно ли одним универсальным адаптером преобразовывать все три протокола?
Он может покрыть обычный текст и часть цикла function tools, но не должен заявлять полную поддержку без потерь. Provider-managed state, hosted tools, непрозрачное thinking/reasoning-состояние, некоторые области system и модель-специфические возможности часто не имеют универсального эквивалента. Адаптер должен показывать матрицу возможностей и сведения о понижениях.
Почему unit-тесты проходят, а настоящий агент всё равно не работает?
Многие тесты имитируют только первый ответ модели. Они не проверяют возврат результата во втором запросе, параллельные вызовы, потоковые фрагменты и продолжение состояния. Расширьте тест до цепочки «запрос пользователя → вызов инструмента моделью → выполнение в приложении → возврат результата → финальный ответ» — именно так обнаруживаются реальные ошибки протокола.
Что мигрировать в первую очередь: Chat Completions или Responses?
Стабильное приложение на Chat Completions может продолжать работу и переходить по функциям в порядке бизнес-ценности. Новый OpenAI-агент или сценарий, которому явно нужны типизированные Items, hosted tools либо состояние Responses, разумнее сразу строить на Responses. Критерии выбора — возможности и стоимость миграции, а не «новизна» названия интерфейса.