Anthropic Messages・Chat Completions・Responses:選び方、変換方法、互換性の限界
HTTP 200 が返っても Agent が互換とは限りません。3 種類の完全なツール往復を通じて Messages、Chat Completions、Responses の違いと、移行・検証・障害切り分けの方法を解説します。
目次
同じ Agent でも、endpoint とフィールド名を差し替えたあとにリクエストが 200 を返し続ける一方で、ツールを実行しない、Schema に合わない JSON を返す、複数ターンの文脈を突然失う、といったことがあります。これは通常、モデルの性能が落ちたのではなく、アプリケーションが異なる 3 つのプロトコルを同じインターフェースとして扱った結果です。
移行が成功したかどうかは、少なくとも次の 3 段階で確認する必要があります。
- 形式が受理される: サーバーがリクエストを解析でき、成功ステータスを返す。
- 動作が等価である: ツールが呼び出され、結果が正しく返却され、ストリームが最後まで完了し、複数ターンの文脈が維持される。
- 機能が維持される: 厳密な Schema、ネイティブな推論状態、ホスト型ツール、構造化出力などが黙って無視されたり、機能低下したりしない。
HTTP 200 が証明するのは最初の段階だけです。単一のテキストを生成するだけなら、単純な変換で足りる場合があります。しかし Agent、ツール呼び出し、ストリーミング引数、複数ターン状態、推論モデルを使う場合は、対話の全経路を検証しなければなりません。
先に結論:モデル名ではなく、クライアントと必要機能に合わせてプロトコルを選ぶ
| シナリオ | 適した出発点 | 理由 |
|---|---|---|
既存アプリが OpenAI SDK と messages を安定運用している | Chat Completions | 変更が最小で、既存のメッセージ履歴とツールループを維持できる |
| 新しい OpenAI Agent でホスト型ツール、型付き Items、サーバー側状態継続が必要 | Responses | OpenAI は現在、新規プロジェクトに推奨しており、Agent 向け機能がより充実している |
| Claude Code、Claude ネイティブアプリ、Claude 固有機能に依存する処理 | Anthropic Messages | コンテンツブロック、ツール結果、thinking などが Anthropic のネイティブ契約に従う |
| 独自ゲートウェイや複数モデルのルーター | upstream ごとに個別アダプターを持つ | 1 つの「万能 JSON」で、すべてのネイティブ機能を無損失に表現することはできない |
OpenAI は現在も Chat Completions をサポートしています。そのため、安定稼働しているシステムを「新しい API が出た」という理由だけで直ちに書き換える必要はありません。新規開発、または Responses 固有の機能が必要になった時点で移行するほうが合理的です。Anthropic Messages も、OpenAI 形式のフィールド名を messages に変更しただけの API ではありません。コンテンツブロック、ツール結果の返却、ストリームイベント、状態管理はそれぞれ独立した契約です。
3 つの API の中心的な違い
本記事でいう Completions は Chat Completions を指し、旧 /v1/completions endpoint ではありません。
| 観点 | OpenAI Chat Completions | OpenAI Responses | Anthropic Messages |
|---|---|---|---|
| Endpoint | /v1/chat/completions | /v1/responses | /v1/messages |
| 主な入力 | messages | input Items。単純なメッセージ入力も可 | messages。通常は上位に独立した system を持つ |
| 主な出力 | choices[].message | output[] 内の型付き Items | content[] 内のコンテンツブロック |
| ツール定義 | tools[].function | tools[] に name と parameters を直接置く | 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 | user メッセージ内の tool_result + tool_use_id |
| 複数ターン状態 | アプリ側でメッセージ履歴を再送 | Items の再送、previous_response_id、または Conversations | アプリ側でメッセージとコンテンツブロックを再送 |
| 最終構造化出力 | response_format | text.format | output_config.format |
| ストリーミング | choices[].delta | 型付き Responses イベント | message/content block イベント |
表だけを見るとフィールド名の違いに見えますが、実際に壊れやすいのは 2 回目のリクエストです。モデルがツール呼び出しを返したあと、アプリはどう実行し、どの ID を保持し、どの role と順序で結果を返すのか。 以下では、副作用のない同一タスクを 3 つのプロトコルで最後まで実行します。
共通例:テスト用プランを問い合わせる
ユーザーの質問は次のとおりです。
teamプランを調べ、上限超過後に従量課金できるか教えてください。
ツール名は get_plan_info です。固定されたローカルデータを読むだけで外部への副作用がないため、プロトコル移行テストに適しています。
以下のプラン情報は教材用の合成データです。OpenAI、Anthropic、BetterToken の実際のプラン、価格、権利を表すものではありません。3 組のリクエストとレスポンスはプロトコル構造を示すための例であり、実 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:完全なツール往復
1 回目のリクエスト:モデルにツール呼び出しを生成させる
以下では、プロトコルの説明に OpenAI の公式 endpoint を使います。互換サービスへ接続する場合は、提供元のドキュメントに従って 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
}'
アプリケーションが読む必要があるのは assistant メッセージ内の tool_calls です。以下は後続処理に必要なフィールドだけを残した例です。
{
"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"
}
]
}
失ってはいけない値が 2 つあります。
tool_calls[0].id:2 回目のリクエストでtool_call_idとしてそのまま返す。function.arguments:JSON の文字列です。まず parse し、その後にアプリ独自の Schema と業務検証を行う。
ツールを実行します。
tool_result = execute_tool(
"get_plan_info",
"{\"plan_code\":\"team\"}",
)
2 回目のリクエスト:ツール結果をモデルへ返す
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"
}
]
}
アダプターが 1 回目の user メッセージだけを変換し、assistant の tool_calls を保存しなかった場合や、誤った ID を tool_call_id に入れた場合、2 回目のリクエストは同じツール呼び出しの続きではありません。
Responses:完全なツール往復
Responses は、メッセージ、推論、ツール呼び出し、ツール結果を異なる Item type として表現します。output[0] を常に最終テキストとして扱ってはいけません。各 Item の type に応じて処理を分岐します。
1 回目のリクエスト:function_call Item を返させる
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" は Item 自体の ID であり、call_id の代わりにはなりません。
ツールを実行します。
tool_result = execute_tool(
"get_plan_info",
"{\"plan_code\":\"team\"}",
)
2 回目のリクエスト: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 です。"
}
]
}
]
}
サーバー側で状態を継続する場合は、1 回目のレスポンス保存を許可し、2 回目のリクエストを次のようにできます。
{
"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 の現行ドキュメントでは、チェーン内の以前の input token も引き続き input として課金されると明記されています。
レスポンスに reasoning Item が含まれる場合、ステートレス再送ではドキュメントの指示に従い、その Item も保持する必要があります。「統一形式」にするため削除しながら、推論文脈が等価だと主張することはできません。
Anthropic Messages:完全なツール往復
Messages では、ツール呼び出しを assistant コンテンツ内の tool_use block として表し、その結果を次の user メッセージ内の tool_result block として返します。ツール引数はすでにオブジェクトであり、parse 前の JSON 文字列ではありません。
1 回目のリクエスト: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"},
)
2 回目のリクエスト:直後の user メッセージに tool_result を置く
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 は、対応する tool_use を含む assistant メッセージの直後に置かなければなりません。1 つの assistant ターンで複数のクライアント側ツールが呼ばれた場合、次の user メッセージで対応する結果ブロックをすべて返し、tool_use_id で 1 対 1 に対応付けます。同じ user メッセージに通常の文章も含める場合、ツール結果ブロックを文章より前に置きます。
直接変換できるものと、必ず損失が生じるもの
| 機能 | 変換の判断 | 正しい扱い |
|---|---|---|
| 通常のユーザーテキスト | 多くの場合は直接変換可能 | 表示文字列だけでなく、テキスト、順序、マルチモーダル種別を保持する |
| 基本的な関数 Schema | 形を変えて変換可能 | function.parameters、Responses の parameters、Messages の input_schema 間で変換し、対応する JSON Schema サブセットを再検証する |
| ツール引数 | 型変換が必要 | OpenAI の 2 API は通常 JSON 文字列、Messages はオブジェクトを返す。業務コードへ渡す前に正規化、parse、検証する |
| ツール呼び出し 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 を parse する |
| サーバー側の複数ターン状態 | 汎用的な等価物がない | previous_response_id などは元の upstream に結び付く。upstream を変える場合は可視文脈を再送するか sticky routing を使う |
| thinking/reasoning 状態 | 通常は無損失変換できない | opaque Item、thinking block、署名、暗号化内容をネイティブ仕様どおり保持し、独自生成しない |
| ホスト型ツール | 直接の等価物がない場合が多い | web search、file search、computer use、server tools などについて個別に対応状況と代替方法を示す |
| 複数候補の生成 | 等価物がない場合がある | Chat Completions の n を Responses に直接変換できると仮定せず、アプリ側で複数リクエストするか製品動作を変更する |
したがって、ゲートウェイの最も堅牢な内部抽象は、あらゆるフィールドを 1 つの巨大オブジェクトへ詰め込むことではありません。メッセージ、指示の作用範囲、ツール定義、ツール呼び出し、結果、状態ハンドル、ストリームイベント、不透明なネイティブ状態を別々にモデル化します。表現できない機能がある場合は、フィールドを黙って削除せず、「非対応」または「損失を伴う変換」と明示します。
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 は、モデルが関数をどう呼ぶかを制約します。最終構造化出力は、ユーザーに返す内容を制約します。Agent によっては両方が必要です。厳密な引数でツールを呼び、最終結果を固定 JSON Schema で返すという流れです。
OpenAI の現行ドキュメントには、見落としやすいデフォルト差もあります。
- Chat Completions の関数呼び出しは、デフォルトでは非厳密です。
- Responses で
strictを省略すると、サービスは Schema を厳密モードへ正規化しようとします。互換性がない場合は非厳密へ戻り、parse 後のツール定義にstrict: falseと表示されることがあります。
意図を明確にし、インターフェース固有のデフォルトに依存しないため、production リクエストでは strict: true または strict: false を明示します。厳密 Schema は、追加プロパティを禁止し、必須フィールドをすべて列挙するなど、対応する条件も満たす必要があります。
さらに重要なのは、互換レイヤーがフィールドを受理しても制約を実行しない場合があることです。Anthropic の公式 OpenAI SDK compatibility ドキュメントには、この特定のレイヤーでは function strict、response_format、reasoning_effort などが無視され、多くの未対応フィールドもエラーを出さないとあります。したがって 200 が返っても、Schema や推論設定が実際には適用されていない可能性があります。
これは Anthropic のネイティブ Messages に同等機能がないという意味ではありません。ネイティブ Messages は厳密なツール入力に対応し、最終 JSON の制約には output_config.format を使います。切り分けの最初の質問は、「ネイティブ Messages を呼んでいるのか、OpenAI 互換レイヤーを呼んでいるのか」です。
system、developer、指示の作用範囲は文字列連結だけでは保てない
OpenAI 形式ではメッセージ履歴内に複数の role を置け、Responses には instructions もあります。Anthropic Messages は従来、上位の system を使ってきました。2026 年 9 月時点では、一部の現行モデルが会話途中の role: "system" にも対応していますが、すべてのモデルではなく、挿入位置やツール呼び出しとの順序にも制約があります。
一方、Anthropic の OpenAI SDK compatibility レイヤーは、会話中の system/developer メッセージを集め、改行で連結し、冒頭の 1 つの system prompt へ繰り上げます。リクエストは処理できますが、元の時間順序と作用範囲が変わります。8 ターン目からだけ有効になるはずの developer 指示が冒頭へ移されると、最初の 7 ターンの意味にも影響しかねません。
安全なアダプターでは、まずアプリ内部で次の 3 層を区別します。
- グローバル指示: 会話全体に適用する。
- 会話フェーズ指示: 特定ターン以降に継続適用する。
- 単一ターン指示: 現在のタスクだけを制御する。
対象プロトコルで同じ作用範囲を表現できる場合だけ変換します。表現できない場合は、対応モデルへ固定 routing する、指示を降格して差異を記録する、移行を拒否する、のいずれかを明示的に選びます。黙って連結する方法は実装が簡単ですが、「リクエストは成功したのに動作が変わった」という問題を起こしやすくなります。
ストリーミングはテキスト token の連結ではなく状態機械として解析する
3 つの API はすべてストリーミングに対応しますが、イベントは等価ではありません。
- Chat Completions は通常、
choices[].deltaからテキストとtool_calls断片を集めます。 - 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 またはコンテンツブロックの index ごとに蓄積し、その引数の完了イベントを受け取ってから parse します。
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 は HTTP 接続が成功したあとでも、ストリーム内の event: error で失敗を通知することがあります。Responses にも独立したエラーイベントがあります。最初の HTTP status だけを見る、または接続終了を正常完了とみなす実装では、ツール引数や最終回答が途中で切れる可能性があります。
イベント parser は未知のイベント type も許容すべきです。現在の対応機能に影響しないイベントはログへ残して読み飛ばし、サーバーにイベントが追加されるたびにクライアント全体を停止させないようにします。
複数ターン状態と推論状態は捏造できない
Chat Completions と従来の Messages は、通常アプリケーションが履歴を再送します。Responses は previous_response_id または Conversations でサーバー側状態も維持できます。3 つの「前のターン」は同じものではありません。
ゲートウェイが状態 ID を受け取った場合、正しい選択肢は次の 3 つだけです。
- Sticky routing: 以後のリクエストも、その状態を作成した同じ upstream へ送る。
- 完全再送: 合法的に再送できるメッセージ、ツール呼び出し、結果、ネイティブ状態をすべて再送する。
- 明示的な拒否: 対象 upstream が継続できない場合、診断可能なエラーを返し、クライアントに会話をやり直させる。
OpenAI の previous_response_id を Anthropic へそのまま渡してはいけません。ゲートウェイ内部の会話 ID を、別プロバイダーが理解できる状態ハンドルのように扱うこともできません。
推論状態も、フィールド名を変えるだけでは移せません。
- ステートレス構成や特定のデータ保持設定では、Responses が次のリクエストで再送すべき暗号化 reasoning Item を返す場合があります。
- Anthropic の thinking 処理には thinking block、署名、その他の不透明な状態が含まれる場合があります。ツール利用や複数ターンでは、ネイティブドキュメントの要件どおり保持します。
- 2026 年 9 月時点で、Anthropic の手動
thinking.type: "enabled"とbudget_tokensの組み合わせは 4.6 世代モデルで非推奨、4.7 以降では拒否されます。新しいモデルは adaptive thinking と対応する effort 制御を使います。
したがって、OpenAI の reasoning_effort と Anthropic の budget_tokens を常に等価とするルールは作れません。正しい機能定義には、対象モデル、現在の thinking モード、未対応時のフォールバック方針が必要です。
並列ツール呼び出し:配列順ではなく ID で対応付ける
モデルは 1 ターンで複数のツールを要求できます。実行時間が違えば、結果の返却順も呼び出し順と一致しません。アダプターは次のような対応関係を保持します。
canonical_call_id
-> provider
-> provider_call_id
-> tool_name
-> validated_arguments
-> execution_status
-> result
結果を返すときは次のようにします。
- Chat Completions は結果ごとに
role: "tool"メッセージを作り、対応するtool_call_idを入れる。 - Responses は結果ごとに
function_call_outputItem を作り、対応するcall_idを入れる。 - Messages は直後の user ターンに対応する
tool_resultblocks を置き、それぞれにtool_use_idを入れる。
移行テストでは、まず parallel_tool_calls: false で単一ツール経路を完成させ、その後に並列処理を有効にします。メール送信、課金、リソース作成など副作用のある production ツールには、冪等性キーも必要です。ネットワーク retry、ストリーム切断、upstream replay により、同じ意味の呼び出しが再び届くことがあります。モデルが生成した文章だけで、すでに実行済みかどうかを判定してはいけません。
HTTP 200 だけでは互換性を検証できない理由
実用的な移行テストでは、少なくとも次の経路を確認します。
| テスト項目 | 合格基準 |
|---|---|
| 通常テキスト | 内容が読め、system/developer の作用範囲が想定どおりである |
| 単一ツール呼び出し | ツール名、引数、呼び出し ID、結果、最終回答が完全な往復を形成する |
| 並列ツール呼び出し | 各結果が ID で正しく対応し、混同や欠落がない |
| ストリーミング引数 | 断片を完全に組み立て、完了後に JSON を parse できる |
| 厳密なツール Schema | 不正なフィールドや型が想定どおり拒否されるか、降格が明示される |
| 最終構造化出力 | 「JSON のように見える」だけでなく、指定 Schema を満たす |
| ツール実行エラー | モデルが構造化エラーを受け取り、無限再呼び出しや成功の捏造をしない |
| 複数ターン継続 | 2 ターン目が 1 ターン目の事実を参照でき、状態切り替えルールが明確である |
| reasoning/thinking | 対応を宣言したモードが動作し、ネイティブ状態が削除・捏造されない |
| ストリーム内エラーと切断 | クライアントが完了、失敗、接続中断を区別できる |
| 制御された API エラー | error type、request ID、retry 方針を診断できる |
固定の入力とツール fixture を使い、3 プロトコルそれぞれについて次を記録します。
- 最終的な業務結果が等価か。
- ツール呼び出しと結果返却が完全か。
- P50、P95 latency。
- input、output、cache 関連の usage。
- error type、request ID、終端状態。
- 明示的に降格した機能。
API Key、完全な機密 prompt、ユーザーの非公開出力をログに残してはいけません。エラーログには少なくとも HTTP status、upstream error type/code、短いエラーメッセージ、request ID、endpoint、プロトコル、Model ID、ストリーム終端状態を保存します。そうしないと、model_not_found、権限不足、パス非互換がすべて原因不明の 400 にまとめられてしまいます。
症状から切り分ける:Agent はどこで壊れたか
| 症状 | よくある原因 | 確認と修正 |
|---|---|---|
200 が返るがモデルがツールを呼ばない | ツール定義を送っていない、tool_choice が無視された、モデルがツール非対応、prompt が弱い | 最終的な送信リクエストを出力し、対象モデルと互換レイヤーを確認する。テストでは読み取り専用ツール 1 つだけを提示し、呼び出しを強制または明示要求する |
| モデルはツール呼び出しを返したがアプリが実行しない | message.content だけを見るなど旧フィールドを読んでいる | プロトコルに応じて tool_calls、function_call Item、tool_use block を読む |
| ツール引数 JSON を parse できない | ストリーム断片を完全な JSON とみなした、またはオブジェクトを文字列として再 parse した | 引数完了イベントを待ち、値が文字列かオブジェクトか先に確認する |
strict を設定しても余分なフィールドが出る | 互換レイヤーが黙って無視、Schema が厳密条件を満たさない、ネイティブ endpoint を呼んでいない | 最終 endpoint とドキュメントを確認し、strict を明示する。意図的に Schema 違反を発生させる回帰テストを追加する |
| 2 回目のリクエストでツール結果不足と出る | 呼び出し ID が違う、最初の assistant/tool Item を保存していない | upstream の呼び出しと ID をそのまま保存し、プロトコルが要求する位置で直後に返す |
Messages が tool_use ids ... without tool_result を返す | tool_result がツール呼び出し直後にない、または通常文が前にある | 対応する tool_result を次の user メッセージにまとめ、任意の文章より前に置く |
| ストリーミングが止まる、引数が途中までしか来ない | テキスト終了だけを待ち、ツール引数やエラー終端を処理していない | 3 プロトコル別にイベント状態機械を実装し、completed、failed、error、disconnected を区別する |
| 2 ターン目が 1 ターン目を忘れる | 履歴、ツール呼び出し、result Items が欠落、または previous_response_id が別 upstream のもの | 可視文脈全体を再送するか sticky routing を維持し、プロバイダー間で状態 ID を渡さない |
| インターフェース変更後、system 指示が早すぎる段階から効く | 互換レイヤーが会話途中の system/developer を冒頭へ移した | 指示の作用範囲をモデル化し、無損失変換できない場合は降格を明示するかネイティブプロトコルを固定する |
| ツールが二重実行される | Request retry、切断後の replay、冪等性制御不足 | テストは読み取り専用ツールを使う。production の副作用ツールは canonical call ID から冪等性キーを作る |
| 最終内容は JSON だがフィールドが時々欠ける | prompt で「JSON を返せ」と言うだけで構造化出力を有効にしていない | 対応 API の response_format、text.format、output_config.format を使い、アプリ側でも再検証する |
より安全な移行手順
- クライアントが実際に送るプロトコルを確認する。 モデル名で判断せず、完全な endpoint、SDK method、リクエスト上位フィールド、ストリームイベント type を記録する。
- 維持すべき動作を列挙する。 少なくともツール、並列呼び出し、厳密 Schema、最終構造化出力、複数ターン状態、ストリーミング、thinking/reasoning を含める。
- まずネイティブプロトコルを使う。 ネイティブ Messages または Responses で実現できる機能は、追加互換レイヤーを避ける。
- 変換機能マトリクスを作る。 各項目を完全対応、損失あり対応、非対応に分類し、caller から結果を確認できるようにする。
- 副作用のない fixture で完全な 2 リクエスト往復を実行する。 「1 回のリクエストでテキストが返る」だけで終わらず、ツールを実行して結果を返す。
- その後、並列、ストリーミング、エラー経路を試す。 正常経路が通ってから副作用ツールと実トラフィックを有効にする。
- 段階的にトラフィックを増やして指標を比較する。 HTTP 成功率だけでなく、正確性、latency、usage、エラー、ツール二重実行を監視する。
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 reference を参照してください。 - 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を使い、Base URL 末尾に/v1を付けません。クライアントが/v1/messagesを追加します。Claude Code 接続ガイド を参照してください。
同じ Dashboard、API Key、モデル名を使っても、3 つのプロトコルが同じ形式になるわけではありません。既存ツールを接続する場合は、そのツールが要求するプロトコルを選びます。独自 Agent を開発する場合は、本記事の完全往復と受け入れマトリクスで必要機能を検証してください。
よくある質問
OpenAI-compatible は OpenAI API の完全な複製ですか?
いいえ。通常は、特定の endpoints とデータ構造を OpenAI 形式のクライアントから呼べるという意味です。モデル、パラメーター、ストリームイベント、ツール、構造化出力、ホスト型ツール、エラーセマンティクスは個別に確認する必要があります。
Base URL と API Key だけを置き換えればよいですか?
同じ wire contract を使う単純なテキストリクエストなら、可能な場合があります。ツール Agent では、ツール定義、2 回目の結果返却、ストリームイベント、厳密 Schema、状態、エラーを引き続き検証します。クライアントが Responses を要求する場合、/chat/completions だけでは足りません。Messages を要求する場合も、OpenAI 形式 endpoint が自動変換してくれるわけではありません。
1 つの汎用アダプターで 3 プロトコルを相互変換できますか?
通常テキストと一部の関数ツールループは扱えますが、全機能を無損失で対応すると宣言すべきではありません。provider-managed state、ホスト型ツール、不透明な thinking/reasoning 状態、一部の system 作用範囲、モデル固有機能には汎用的な等価物がないことが多いためです。アダプターは機能マトリクスと降格情報を公開する必要があります。
Unit test は通るのに、実際の Agent が失敗するのはなぜですか?
多くのテストは最初のモデルレスポンスしか模擬せず、2 回目のツール結果返却、並列呼び出し、ストリーム断片、状態継続を検証していません。「ユーザーリクエスト → モデルのツール呼び出し → アプリ実行 → ツール結果返却 → 最終回答」までテストを拡張すると、実際のプロトコル問題を発見できます。
移行では Chat Completions と Responses のどちらを優先すべきですか?
安定した Chat Completions アプリはそのまま運用し、業務価値に応じて機能単位で移行できます。新しい OpenAI Agent、または型付き Items、ホスト型ツール、Responses の状態機能が明確に必要な処理は、最初から Responses を使うほうが適しています。判断基準はインターフェース名の新旧ではなく、必要機能と移行コストです。