cd ../back to blog
$Guide//June 4, 2026//8 min read

「OpenAI 互換」が本当に一致しなければならないもの

OpenAI 互換はスペクトラム。ストリーミング・ツール・ビジョン・JSON はきれいに移植できるが、トークナイザーや埋め込み、Anthropic のキャッシュ・推論はネイティブ依存。何が一致し何がほころびるかを翻訳者の視点で解説。

「OpenAI 互換」は、AI インフラ市場でもっとも意味が膨らみすぎた言葉です。 「OpenAI SDK の向き先を当社の URL にすれば、基本的なチャット呼び出しが 返ってくる」 — これは簡単な 80% — を指すこともあれば、「あらゆる フィールド、あらゆるストリーミングイベント、あらゆるツール呼び出しの 往復、そしてあらゆる usage の数値が、あなたのコードが すでに期待しているとおりに振る舞う」を指すこともあります。この 2 つの あいだの隔たりにこそ、本番障害は潜んでいます。本稿はそのフィールド ガイドです — あなたの既存コードを無変更で動かすために本当に一致しなければならないもの、上流が違っても同一に振る舞うもの、 そして OpenAI 形状の背後にいるモデルが実は GPT ではなく Claude (Anthropic)や Gemini(Google)だったときに、こっそり食い違うものを 扱います。

Brievio は本物のファーストパーティ製モデルの前段に立つ OpenAI 互換 ゲートウェイなので、本稿は翻訳者の席から書いています — Anthropic の Messages API と Google の Vertex API の両方を、OpenAI 形状のパイプから 出さなければならない層からの視点です。抽象化がきれいに成り立つ場所と ほころびる場所を、はっきり書きます。決してほころばないふりをすることこそ、 午前 2 時に呼び出される原因だからです。

「互換」が本当に意味しなければならないこと

互換性はマーケティング上のチェックボックスではありません — それは、 あなたがすでに import した SDK との契約です。OpenAI の Python ライブラリと Node ライブラリは、ワイヤーフォーマットについて強い前提を置いています。 ゲートウェイはそのすべてを守って初めて互換と言えます:

  • リクエストスキーマ。 POST /v1/chat/completions modelmessages(role/content オブジェクトの リスト)、そして任意のつまみ — temperature max_tokenstop_pstop toolsresponse_format。未知のパラメータは 400 で弾くのではなく、受け取って無視すべきです。
  • レスポンスの外枠。 id object: "chat.completion"model choices[](各要素が messageindex finish_reason を持つ)、そして usage ブロックを 備えたオブジェクト。SDK は型付きオブジェクトへデシリアライズするので、 フィールドが欠ければ resp.choices[0].message.content は 誰か別の人のマシンで例外を投げます。
  • ストリーミングプロトコル。 Server-Sent Events で、 data: [DONE] センチネルとトークンごとの delta オブジェクトを伴うもの。これは「互換」ゲートウェイがもっともよく 微妙に取り違える点です。
  • エラー形状と HTTP コード。 429 はレート制限らしく 見えなければならず、400 は typemessage を 持つ error オブジェクトを運ばなければなりません。SDK の リトライ・バックオフのロジックはこれらを手がかりにします。

まずはベースライン — 誰もが正しくできる部分です。2 行を変えれば、 呼び出しは通常の completion オブジェクトを返します:

chat.py
# 肝心なのはここ: 2 行だけ変えれば、コードはそのままでいい。
# 同じ SDK、同じリクエスト形状、同じレスポンスオブジェクト — 背後のモデルだけが違う。
from openai import OpenAI

client = OpenAI(
    api_key="sk-brievio-...",
    base_url="https://api.brievio.com/v1",   # 以前は https://api.openai.com/v1
)

resp = client.chat.completions.create(
    model="claude-sonnet-4-6",               # 以前は gpt-4o
    messages=[
        {"role": "system", "content": "You are concise."},
        {"role": "user", "content": "Summarize the CAP theorem in two sentences."},
    ],
    temperature=0.2,
    max_tokens=300,
)

print(resp.choices[0].message.content)
print(resp.usage)        # prompt_tokens / completion_tokens / total_tokens — フィールドは同じ
# resp.id、resp.model、resp.choices[0].finish_reason もすべて存在し、OpenAI と同じ形状。

ゲートウェイがせめてこれすらできないなら、立ち去ってください。ただし これは最低限であって、ゴールではありません。面白い問いは、あなたの 実アプリが使う機能をオンにしたとき何が起きるか、です。

ストリーミング: 互換性がひそかにほころびる場所

ストリーミングは、技術的には存在していても実用上は壊れている、という ことがもっとも起きやすい機能です。SDK のストリーミングイテレータは 3 つを期待します: text/event-stream の content type、 choices[0].delta.content に逐次届くデルタ、そして ストリームを閉じるリテラルの data: [DONE] 行です。どれか ひとつでも取り違えると、症状はじつに厄介です — curl のテストでは動くのに、 本番ではハングします。

stream.py
# ストリーミングこそ、なまじの「互換」がほころびる場所。あなたのコードが依存する契約:
# - Content-Type: text/event-stream
# - 各イベントは "data: {json}\n\n" で、デルタは choices[0].delta.content に届く
# - ストリームはリテラルの "data: [DONE]\n\n" センチネルで終わる
stream = client.chat.completions.create(
    model="gemini-2-5-pro",
    messages=[{"role": "user", "content": "Explain B-trees in one paragraph."}],
    stream=True,
)

for chunk in stream:
    delta = chunk.choices[0].delta
    if delta.content:
        print(delta.content, end="", flush=True)

# ゲートウェイがこれらを誤るとクライアントが壊れる例:
#   - レスポンス全体をバッファリングしてから 1 チャンクで吐き出す(本物のストリーミングではない)
#   - [DONE] センチネルを省く(一部の SDK はそれを待ってハングする)
#   - usage が末尾にしか来ない — stream_options={"include_usage": True} を渡して取得する。

もっともよくある「偽ストリーミング」の失敗は、上流を呼び出してレスポンス全体を待ってから、それを 1〜2 個の大きなチャンクと して吐き出すゲートウェイです。SDK はエラーを出しません — あなたは ただストリーミングの肝心な点を失うだけです(最初のトークンまでの時間が ひどいままになる)。本物のゲートウェイは上流への接続を開いたまま保ち、 各トークンを到着しだい転送します。Claude なら、それは Anthropic の content_block_delta イベントを OpenAI の chat.completion.chunk イベントへリアルタイムで翻訳する ことを意味します。Gemini なら、同じ仕事を Vertex のストリーミング形式に 対して行います。出力はあなたのコードからは同一に見えますが、その下の 機構は本物のイベントごとの翻訳をこなしています。

知っておくべき本物の差がひとつ: ストリーム応答における usage です。 OpenAI は、stream_options={"include_usage": true} を渡したときにかぎり、最終チャンクに usage ブロックを 含めます。良いゲートウェイはどの上流に対してもこのフラグを尊重するので、 あなたのトークン会計コードはモデルごとに特別扱いをせずに済みます。 ストリーミングの完全な契約は チャット補完のドキュメントで確認できます。

ツールと関数呼び出し: 同じ形状、違うエンジン

ツール呼び出しは、OpenAI の抽象化がその真価を発揮する機能です — なぜなら 3 つのプロバイダーはまったく異なるネイティブ形式を持っており、 ゲートウェイがそのすべてを隠すからです。あなたは OpenAI の tools 配列を送り、メッセージに tool_calls を 受け取ります。そのあいだに起きているのは本物の翻訳です:

tools.py
# ツール / 関数呼び出し: リクエスト側は OpenAI とまったく同じ形状。
tools = [{
    "type": "function",
    "function": {
        "name": "get_weather",
        "description": "Get current weather for a city",
        "parameters": {
            "type": "object",
            "properties": {"city": {"type": "string"}},
            "required": ["city"],
        },
    },
}]

resp = client.chat.completions.create(
    model="claude-opus-4-7",
    messages=[{"role": "user", "content": "What's the weather in Tokyo?"}],
    tools=tools,
    tool_choice="auto",
)

# モデルは choices[0].message.tool_calls を返す — リストで、各要素が id、
# .function.name、.function.arguments(json.loads が必要な JSON *文字列*)を持つ。
for call in resp.choices[0].message.tool_calls or []:
    print(call.id, call.function.name, call.function.arguments)

# 続けて {"role": "tool", "tool_call_id": call.id, "content": result} の
# メッセージを追加し、もう一度呼び出す。このループは上流が Claude でも
# Gemini でも同一 — ゲートウェイが各プロバイダーのネイティブなツール形式を
# 出る側で OpenAI の tool_calls へ、入る側で逆へとマッピングする。

内部では、Anthropic は input オブジェクトを伴う tool_use コンテンツブロックを返し、Gemini は args を伴う functionCall パーツを返します。 ゲートウェイはその両方を OpenAI の tool_calls[] 形状へ マッピングします — OpenAI が arguments を、パース済み オブジェクトではなく json.loads が必要な JSON 文字列として渡す、という細部まで含めて。あなたの ツール実行ループ — 呼び出しを読み、関数を実行し、 role: "tool" メッセージを追加し、もう一度呼ぶ — は、どのファミリーを狙おうとバイト単位で同じです。それこそが 価値提案そのものです: エージェントを一度書き、文字列ひとつでモデルを 差し替える。

正直な但し書きを、それらは確かに存在するので:

  • 並列ツール呼び出し。 3 つのファミリーはいずれも 1 ターンで複数のツールを要求できますが、ある特定のプロンプトに対して どれだけ積極的にそうするかは異なります。正確な個数や順序がモデル間で そのまま移植されると思い込まないでください — 固定数ではなく、 リストとして扱いましょう。
  • strict / 構造化されたツールスキーマ。 OpenAI の strict: true による JSON スキーマ強制は OpenAI モデルの 機能です。Claude と Gemini では、ゲートウェイはあなたのスキーマを ツール定義として渡し、モデルはそれに忠実に従いますが、その保証は 上流のものであって、ゲートウェイがでっち上げられる魔法ではありません。
  • tool_choice の細かな違い。 auto と特定の関数を強制することはどこでも十分に サポートされていますが、変わった組み合わせは、実際に出荷する各モデルで 手早くテストする価値があります。

ビジョンと JSON モード: 素通し、ただし端には注意

ビジョンは OpenAI のマルチモーダルな content-parts 形式を使います — textimage_url のエントリを混在させたリスト です。画像をネイティブに見られるモデル(Gemini 2.5 Pro/Flash、Claude ファミリー)に対しては、ゲートウェイが画像を転送し、マルチモーダル 呼び出しはそのまま動きます。JSON モード — response_format: { type: "json_object" } — は出力をパース可能なオブジェクトへ制約します:

vision.py
# ビジョン: OpenAI のマルチモーダルな content-parts 形式を、画像をネイティブに
# 扱えるモデルへ素通しする。URL でも base64 のデータ URI でも動く。
resp = client.chat.completions.create(
    model="gemini-2-5-pro",
    messages=[{
        "role": "user",
        "content": [
            {"type": "text", "text": "What's in this chart? Give me the trend."},
            {"type": "image_url", "image_url": {
                "url": "https://example.com/q3-revenue.png",
            }},
        ],
    }],
)
print(resp.choices[0].message.content)

# JSON モード — 必ずパースできるオブジェクトを要求する:
resp = client.chat.completions.create(
    model="claude-sonnet-4-6",
    messages=[{"role": "user", "content": "Extract name and email as JSON."}],
    response_format={"type": "json_object"},
)
import json
data = json.loads(resp.choices[0].message.content)  # 毎回パースできる。

端がどこにあるか: 画像入力の上限(最大寸法、リクエストあたりの最大画像数、 受け付ける MIME タイプ)は、ゲートウェイがでっち上げるのではなく、各上流が 定めています — なので Gemini が拒否する 50MB の TIFF は、OpenAI 形状の 背後でも、翻訳されたエラーとともに拒否されます。そして json_object モードが保証するのは妥当な JSONであって、 あなたの特定のスキーマに一致する JSON ではありません。特定の構造が必要なら、 それをプロンプトで記述し、パース後に検証してください。これらは ゲートウェイのバグではありません — 下にあるモデルの契約が透けて 見えているだけであり、それこそ忠実な翻訳者に保ってほしいものです。

埋め込み、そして本当に移植できないもの

正直に名指しすべき面があと 2 つあります。埋め込み /v1/embeddings)は単純で安定しています — ですがベクトルは モデル間で互換ではありません。Gemini の埋め込みと OpenAI の 埋め込みは、次元数の異なる別々の空間に存在します。両者を 1 つの インデックスに混ぜたり、コサイン類似度を比較したりはできません。 埋め込みモデルを 1 つ選び、切り替えるならコーパス全体を埋め込み直して ください。API は互換ですが、数学は互換ではありません。

そして、どれだけ互換性の詰め物を重ねても覆い隠せないほころび — それを 運ぶ OpenAI フィールドが端から存在しない、プロバイダー固有の機能です:

  • Anthropic のプロンプトキャッシュ。 ネイティブな cache_control のブレークポイントは Anthropic の Messages API 上に存在します。OpenAI 形状の上では、代わりに OpenAI 流の自動 プレフィックスキャッシュが得られます。キャッシュを明示的に駆動するには、 ネイティブの /v1/messages エンドポイントを使います。 (どちらも Brievio で動きます — API ドキュメントを参照。)
  • トークナイザーはファミリーごとに異なる。 「1,000 トークン」は GPT、Claude、Gemini で同じ文字列長ではありません — それぞれ 独自のトークナイザーを持っています。なので、フィールド名は変わって いなくても、モデルを差し替えると max_tokens の予算も コスト見積もりもずれます。良いゲートウェイは各上流の正直なトークン数を usage に報告します。3 つのトークナイザーを 一致させることはできませんし、一致させたふりをするゲートウェイを 信用すべきではありません。
  • 拡張思考 / 推論。 Claude の拡張思考と Gemini の 思考モードは、OpenAI の推論とは異なる形で表れます。内容は通って きますが、フィールドの正確な配管はモデル固有なので、ある プロバイダーの推論形状を全部に対してハードコードしないでください。
  • システムプロンプトの意味論。 3 つとも system メッセージを受け付けますが、その重みづけや切り詰め方はわずかに 異なります。振る舞いは移植されますが、ビット単位で同一ではありません。 プロンプトはモデルごとにテストしてください。

良いゲートウェイはこれらをどう正規化するか

互換レイヤーの仕事は、共通の経路では忠実でロスのない翻訳者であり、 端では正直な翻訳者であることです。具体的にはこうです: リクエスト スキーマを双方向にマッピングし、ストリーミングイベントをセンチネル 込みでトークンごとに翻訳し、各プロバイダーのネイティブなツール形式を tool_calls へ・から変換し、finish_reason の 意味論を保ち、本物の画像をビジョン対応モデルへ素通しし、そして — ごまかしやすい部分ですが — 上乗せした数字ではなく上流の実際のトークン数を 報告する。Brievio では形状の背後にあるモデルは本物のファーストパーティ製で、 AWS Bedrock と Google Vertex まで辿れます。だからあなたが正規化している 振る舞いは、安価な代替品ではなく、本物のモデルの振る舞いです。それを 自分で確かめたいなら、 あなたの Claude は本物の Claude か にある 4 つのテストが 1 分ほどで済みます。

ここから、「互換」エンドポイントの上に何かを構築する人なら誰にでも 当てはまる原則が 2 つ導かれます。第一に、実際に使う機能を テストすること — チャット呼び出しが通ったところで、 ストリーミングが逐次フラッシュされるか、ツール呼び出しの ID が 往復するかは何も分かりません。第二に、ほころびを尊重すること: トークナイザー、埋め込み空間、キャッシュ構文、推論形状は上流の 性質であり、それらについて正直なゲートウェイこそ、本番で信頼できる ものです。互換性はスペクトラムであり、その有用な部分は、デモを 生き延びる部分ではなく、あなたの実ワークロードを生き延びる部分です。

具体的な結論

OpenAI SDK の向き先を https://api.brievio.com/v1 にし、モデル文字列を変え、 既存のテストスイートを走らせてください — hello-world ではなく、 あなたのスイートを。include_usage 付きでストリーミングを 動かし、ツール呼び出しの往復を 1 回行い、画像を 1 枚送り、 json_object を 1 回要求する。出荷予定のモデルでこの 4 つが すべて通れば、移行は本当に 2 行です。プロバイダー固有の機能が必要な ところ — 明示的な Anthropic キャッシュ、ネイティブな推論制御 — では、 その経路だけネイティブの エンドポイントに降り、それ以外はどこでも OpenAI 形状を保ちます。既存の OpenAI コードベースからの段階的な移植 手順が欲しいですか? まずは OpenAI SDK で Claude を呼び出すから始め、それから モデル一覧を眺めて、形状の背後で動かすものを 選んでください。