Бонусы за приглашения

Как работают бонусы за приглашения

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

Anthropic Messages, Chat Completions и Responses: как выбрать, преобразовать и учесть границы совместимости

HTTP 200 не означает, что агент совместим. Три полных цикла вызова инструмента показывают реальные различия Messages, Chat Completions и Responses, а также порядок миграции, тестирования и диагностики.

Содержание

Ответ 200 после смены endpoint подтверждает только принятый формат: инструменты, Schema и контекст уже могли перестать работать.

Успешную миграцию нужно оценивать как минимум на трёх уровнях:

  1. Формат принимается: сервер разбирает запрос и возвращает успешный статус.
  2. Поведение эквивалентно: инструменты вызываются, результаты правильно возвращаются модели, поток завершается полностью, а многоходовый контекст остаётся связным.
  3. Возможности сохранены: строгая Schema, нативное reasoning-состояние, управляемые провайдером инструменты, структурированный вывод и другие функции не были молча проигнорированы или понижены.

HTTP 200 подтверждает только первый уровень. Для запроса, который генерирует один текстовый ответ, простого преобразования нередко достаточно. Но для агента с инструментами, потоковыми аргументами, многоходовым состоянием или reasoning-моделью нужно проверять всю цепочку взаимодействия.

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

СценарийБолее подходящая отправная точкаПочему
Существующее приложение стабильно использует OpenAI SDK и messagesChat CompletionsМинимум изменений; существующий цикл сообщений и инструментов можно сохранить
Новый OpenAI-агент нуждается в hosted tools, типизированных Items или продолжении серверного состоянияResponsesOpenAI сейчас рекомендует этот интерфейс для новых проектов, а набор агентных возможностей шире
Claude Code, нативное приложение Claude или сценарий с возможностями, специфичными для ClaudeAnthropic MessagesБлоки контента, результаты инструментов, thinking и другое поведение подчиняются нативному контракту Anthropic
Собственный шлюз или мульти-модельный роутерОтдельный адаптер для каждого upstream-протоколаОдин «универсальный JSON» не способен без потерь выразить все нативные возможности

OpenAI продолжает поддерживать Chat Completions, поэтому стабильное рабочее приложение не обязано срочно переписываться только из-за появления более нового интерфейса. Миграция оправдана для новых проектов или когда нужны нативные возможности Responses. Anthropic Messages — тоже не OpenAI-интерфейс с переименованным полем messages: блоки контента, передача результатов инструментов, потоковые события и правила состояния образуют самостоятельный контракт.

Ключевые различия трёх API

В этой статье Completions означает именно Chat Completions, а не старый endpoint /v1/completions.

АспектOpenAI Chat CompletionsOpenAI ResponsesAnthropic Messages
Endpoint/v1/chat/completions/v1/responses/v1/messages
Основной вводmessagesItems в input; также принимается простой ввод сообщениямиmessages, обычно с отдельным полем верхнего уровня system
Основной выводchoices[].messageТипизированные Items в output[]Блоки контента в content[]
Определение инструментаtools[].functionname и parameters находятся непосредственно в tools[]В tools[] используется input_schema
Аргументы инструментаfunction.arguments, JSON-строкаarguments, JSON-строкаtool_use.input, JSON-объект
ID для связыванияtool_calls[].idcall_idtool_use.id
Возврат результатаrole: "tool" + tool_call_idfunction_call_output + call_idtool_result + tool_use_id внутри user-сообщения
Многоходовое состояниеПриложение повторно отправляет историю сообщенийПовтор Items, previous_response_id или ConversationsПриложение повторно отправляет сообщения и блоки контента
Финальный структурированный выводresponse_formattext.formatoutput_config.format
Streamingchoices[].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 CompletionsResponsesAnthropic Messages
Ограничить аргументы вызова инструментаtools[].function.stricttools[].stricttools[].strict
Ограничить финальный вывод моделиresponse_formattext.formatoutput_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, шлюз может выбрать только одну из трёх корректных стратегий:

  1. Sticky routing: последующие запросы направляются тому же upstream, который создал состояние.
  2. Полное воспроизведение: повторно отправляются все сообщения, вызовы инструментов, результаты и элементы нативного состояния, которые разрешено воспроизводить.
  3. Явный отказ: если целевой 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_resulttool_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 нужного интерфейса, затем повторно проверяйте ответ в приложении

Более надёжный порядок миграции

  1. Определите протокол, который клиент действительно отправляет. Не судите по имени модели. Зафиксируйте полный endpoint, метод SDK, верхнеуровневые поля запроса и типы stream-событий.
  2. Перечислите поведение, которое нужно сохранить. Как минимум: инструменты, параллельные вызовы, строгая Schema, финальный структурированный вывод, многоходовое состояние, streaming и thinking/reasoning.
  3. Сначала используйте нативный протокол. Если функцию можно реализовать через нативный Messages или Responses, по возможности избегайте дополнительного слоя совместимости.
  4. Создайте матрицу возможностей преобразования. Для каждой функции укажите: полная поддержка, поддержка с потерями или отсутствие поддержки; результат должен быть виден вызывающей стороне.
  5. Прогоните полный цикл из двух запросов на fixture без побочных эффектов. Недостаточно доказать, что один запрос возвращает текст: выполните инструмент и верните результат модели.
  6. Затем проверьте параллельность, streaming и ошибки. Инструменты с побочными эффектами и реальный трафик включайте только после прохождения нормального пути.
  7. Увеличивайте трафик постепенно и сравнивайте метрики. Наблюдайте точность, задержку, 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. Критерии выбора — возможности и стоимость миграции, а не «новизна» названия интерфейса.

Готовы оптимизировать LLM workflow?

Подключите единый API, управляйте ключами и контролируйте расходы на AI-модели в BetterToken.

Начать бесплатно