「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にmodel、messages(role/content オブジェクトの リスト)、そして任意のつまみ —temperature、max_tokens、top_p、stop、tools、response_format。未知のパラメータは 400 で弾くのではなく、受け取って無視すべきです。 - レスポンスの外枠。
id、object: "chat.completion"、model、choices[](各要素がmessage、index、finish_reasonを持つ)、そしてusageブロックを 備えたオブジェクト。SDK は型付きオブジェクトへデシリアライズするので、 フィールドが欠ければresp.choices[0].message.contentは 誰か別の人のマシンで例外を投げます。 - ストリーミングプロトコル。 Server-Sent Events で、
data: [DONE]センチネルとトークンごとのdeltaオブジェクトを伴うもの。これは「互換」ゲートウェイがもっともよく 微妙に取り違える点です。 - エラー形状と HTTP コード。 429 はレート制限らしく 見えなければならず、400 は
typeとmessageを 持つerrorオブジェクトを運ばなければなりません。SDK の リトライ・バックオフのロジックはこれらを手がかりにします。
まずはベースライン — 誰もが正しくできる部分です。2 行を変えれば、 呼び出しは通常の completion オブジェクトを返します:
# 肝心なのはここ: 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 のテストでは動くのに、 本番ではハングします。
# ストリーミングこそ、なまじの「互換」がほころびる場所。あなたのコードが依存する契約:
# - 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 を 受け取ります。そのあいだに起きているのは本物の翻訳です:
# ツール / 関数呼び出し: リクエスト側は 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 形式を使います — text と image_url のエントリを混在させたリスト です。画像をネイティブに見られるモデル(Gemini 2.5 Pro/Flash、Claude ファミリー)に対しては、ゲートウェイが画像を転送し、マルチモーダル 呼び出しはそのまま動きます。JSON モード — response_format: { type: "json_object" } — は出力をパース可能なオブジェクトへ制約します:
# ビジョン: 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 を呼び出すから始め、それから モデル一覧を眺めて、形状の背後で動かすものを 選んでください。