はじめに
OpenAI SDK を使うコードで、model を差し替えればだいたい動く、という感覚はかなり便利です。ただ、最近の実装を見ていると、model の差し替えと endpoint の差し替えを同じ場所で扱ってしまい、あとから自分で混乱することがあります。
私が特に危ないと思っているのは、Chat Completions と Responses API を同じ wrapper の中でなんとなく分岐する書き方です。どちらも OpenAI の API で、どちらにも同じ model が載ることがあります。しかし request body は違います。stream の event も違います。tool や state の扱いも違います。
今回は Flatkey AI のような OpenAI 互換 router を使う前提で、model ID だけで分岐しない ためのメモを書きます。実装は小さいですが、チームで後から見るとかなり効くと思います。
3行まとめ
- Chat Completions は
/v1/chat/completionsとmessages、Responses API は/v1/responsesとinputを別物として扱う。 -
modelから endpoint family を推測せず、client wrapper にfamilyを明示して渡す。 - Flatkey のような multi-model router では、Chat、Responses、Claude Messages、Gemini generateContent を表で確認してから smoke test する。
なぜ混ざるのか
混ざる理由は、名前が似ているからというより、SDK の呼び出し位置が近いからだと思います。
例えば backend 側に call_llm(model, prompt) のような関数があるとします。最初は Chat Completions だけだったので、中では client.chat.completions.create() を呼んでいました。その後、Responses API も使いたくなり、model が gpt-... なら Chat、o... なら Responses、のような分岐を足したくなります。
私はこの書き方を一度やりかけて、あとでやめました。model 名は provider や router の都合で変わりますし、同じ model が複数の endpoint family で使えることもあります。つまり、model はどの頭脳を使うかであって、どの protocol で話すかではありません。
ここを分けておかないと、次のような事故が起きます。
| 起きること | ありがちな原因 |
|---|---|
messages を /v1/responses に送る |
endpoint を変えたのに body を変えていない |
input を /v1/chat/completions に送る |
Responses 用の request を Chat に流している |
| stream parser が空振りする | Chat Completions chunk と Responses event を同じ parser で読む |
| tool call が最後まで進まない | function call の戻し方が family ごとに違う |
| Claude / Gemini 系で 404 になる | OpenAI 互換 path と provider-native path を混ぜている |
設計原則は三つだけにする
この話は細かく考えると、model catalog、provider、endpoint、tool support、streaming、billing、fallback などが全部絡みます。最初から全部をきれいに抽象化しようとすると、たぶん wrapper が大きくなりすぎます。私はまず、次の三つだけを守る方が現実的だと思っています。
一つ目は、model を protocol 判定に使わないことです。model は選びたい model alias であって、どの HTTP path に送るかを決めるものではありません。もちろん社内の命名規則として responses- のような prefix を付けることはできますが、それは補助情報であって source of truth にはしない方がよさそうです。
二つ目は、request shape を family ごとに閉じることです。Chat Completions 用の messages を作る関数、Responses 用の input を作る関数、Gemini 用の contents を作る関数を分けます。多少重複しても、混ざるよりは読みやすいです。私は共通化を急いで、あとでどの endpoint に何を送っているのか読めなくなる方が怖いです。
三つ目は、stream と tool を通常応答とは別に確認することです。非 stream の text が返っただけでは、移行完了とは言いにくいです。stream parser、tool call の戻し方、usage の取り方、request ID の見つけ方まで見て、初めて運用に乗せられると思います。
まず endpoint family を表にする
私は client wrapper を作る前に、先に endpoint family の表を置くようにしています。ここをコードより先に決めると、あとから review しやすいです。
2026-06-30 に確認した OpenAI docs と Flatkey の公開 pricing endpoint map では、少なくとも次のように family を分けて考えるのがよさそうでした。
| family | 代表 endpoint | 主な body の入口 | SDK 側で見たい呼び出し |
|---|---|---|---|
openai-chat |
/v1/chat/completions |
messages |
client.chat.completions.create(...) |
openai-responses |
/v1/responses |
input |
client.responses.create(...) |
anthropic-messages |
/v1/messages |
messages |
Claude Messages 系 client |
gemini-generate-content |
/v1beta/models/{model}:generateContent |
contents |
Gemini generateContent 系 client |
Flatkey の公開 pricing API では、POST /v1/chat/completions、POST /v1/responses、POST /v1/messages、POST /v1beta/models/{model}:generateContent などの endpoint map が返っていました。ただし、これは公開 catalog の dated evidence です。自分の key と model alias で全部使えることまでは意味しないので、最後は小さい smoke test が必要です。
curl で request shape を比べる
まず SDK を触る前に、curl の形で request shape を横に並べます。ここでは実行例というより、間違って混ぜないための型見本として置いています。
BASE_URL は自分の環境で表示されている値を使います。Flatkey の古い資料では https://router.flatkey.ai/v1 を見ることがあり、最近の公開ページでは https://console.flatkey.ai/v1 系の例も見ます。記事の文字列より、自分の dashboard や docs に出ている値を優先した方が安全です。
export BASE_URL="https://router.flatkey.ai/v1"
export API_KEY="sk-..."
export CHAT_MODEL="your-chat-model"
export RESPONSES_MODEL="your-responses-model"
Chat Completions
Chat Completions は path と body が chat/completions と messages です。
curl "$BASE_URL/chat/completions" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "'"$CHAT_MODEL"'",
"messages": [
{ "role": "developer", "content": "短く答えてください。" },
{ "role": "user", "content": "endpoint family の確認です。" }
]
}'
この request を見たとき、私が確認するのは model 名より先に endpoint と top-level key です。/chat/completions に messages がある。これで Chat Completions family だと読めます。
Responses API
Responses API は path が /responses で、body の入口は input です。OpenAI の API reference でも、Responses の create example は model と input を使う形になっています。
curl "$BASE_URL/responses" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "'"$RESPONSES_MODEL"'",
"instructions": "短く答えてください。",
"input": "endpoint family の確認です。"
}'
ここに messages を入れたくなったら、一度手を止めます。Chat の transcript 配列をそのまま流用したい気持ちはわかるのですが、Responses は output item、state、tool、stream の見方が違います。移行作業ならなおさら、別 family として扱った方があとで楽です。
Claude / Gemini 系
Claude や Gemini を混ぜる場合も同じです。Flatkey の公開 endpoint map では Claude Messages 系として /v1/messages、Gemini 系として /v1beta/models/{model}:generateContent が出ていました。Google の公式 docs でも Gemini の generateContent は models/*:generateContent という endpoint です。
# Claude Messages family の形だけを見る例
curl "$BASE_URL/messages" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "your-claude-model",
"max_tokens": 256,
"messages": [
{ "role": "user", "content": "endpoint family の確認です。" }
]
}'
# Gemini generateContent family の形だけを見る例
curl "$BASE_URL/v1beta/models/your-gemini-model:generateContent" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"contents": [
{
"parts": [
{ "text": "endpoint family の確認です。" }
]
}
]
}'
ここで大事なのは、Claude という model 名だから自動で /messages、Gemini という model 名だから自動で generateContent、と決めつけないことです。router によっては OpenAI 互換 path で provider model を呼べることもあります。逆に provider-native path が必要なこともあります。だから model 名ではなく、使う endpoint family を設定に持たせます。
変換関数を置くなら片方向にする
既存コードが Chat Completions の messages を中心にできている場合、Responses API へ移すときに messages から input へ変換したくなります。これは悪いことではありませんが、変換関数は片方向にして、名前をかなり露骨にするのがよさそうです。
例えば normalize_request() という名前だと、何に正規化しているのか後からわかりません。chatMessagesToResponsesInput() くらいの名前にしておくと、少なくとも Chat から Responses へ寄せていることが読めます。逆方向も必要なら別関数にします。
私はこの変換層に、model 選択や fallback の判断を入れないようにしています。変換層は形を変えるだけ、routing 層は family と model を選ぶだけ、実行層は SDK を呼ぶだけ、という分け方です。きれいすぎる設計ではないですが、障害時に読む場所が減ります。
SDK wrapper では model ではなく family を受け取る
私なら、wrapper の入口を model だけにしません。最低でも family と model を別々に渡します。
雑に言うと、こういう関数は後で怖いです。
def call_llm(model: str, prompt: str) -> str:
if model.startswith("gpt-"):
...
if model.startswith("claude-"):
...
この分岐は短期的には便利ですが、model alias が増えると破綻します。gpt でも Responses に投げたいことはありますし、Claude model を OpenAI-compatible Chat path に載せる router もありえます。
Python の小さい例
私は Python なら、まず family を enum 的に固定してから client を選びます。
from dataclasses import dataclass
from typing import Literal
from openai import OpenAI
EndpointFamily = Literal["openai-chat", "openai-responses"]
@dataclass(frozen=True)
class LLMRoute:
family: EndpointFamily
base_url: str
model: str
def call_openai_family(route: LLMRoute, prompt: str) -> str:
client = OpenAI(base_url=route.base_url)
if route.family == "openai-chat":
result = client.chat.completions.create(
model=route.model,
messages=[
{"role": "developer", "content": "短く答えてください。"},
{"role": "user", "content": prompt},
],
)
return result.choices[0].message.content or ""
if route.family == "openai-responses":
result = client.responses.create(
model=route.model,
instructions="短く答えてください。",
input=prompt,
)
return result.output_text
raise ValueError(f"unsupported endpoint family: {route.family}")
この例では Claude / Gemini 系までは入れていません。無理に同じ関数に詰め込むより、まず OpenAI の Chat と Responses を明示的に分ける方が読みやすいと思います。
TypeScript の小さい例
TypeScript なら discriminated union にしておくと、review で意図が見えやすいです。
import OpenAI from "openai";
type Route =
| {
family: "openai-chat";
baseURL: string;
model: string;
}
| {
family: "openai-responses";
baseURL: string;
model: string;
};
export async function callLLM(route: Route, prompt: string): Promise<string> {
const client = new OpenAI({
apiKey: process.env.FLATKEY_API_KEY,
baseURL: route.baseURL,
});
switch (route.family) {
case "openai-chat": {
const result = await client.chat.completions.create({
model: route.model,
messages: [
{ role: "developer", content: "短く答えてください。" },
{ role: "user", content: prompt },
],
});
return result.choices[0]?.message?.content ?? "";
}
case "openai-responses": {
const result = await client.responses.create({
model: route.model,
instructions: "短く答えてください。",
input: prompt,
});
return result.output_text;
}
}
}
ここで family が増えたら、switch に case を足します。少し面倒ですが、model の prefix で勝手に分岐するより、変更点が pull request に出るので安心です。
review で見る差分
endpoint family を明示すると、pull request の見方も少し変わります。私は次の差分が出たら、実装より先に設定を見ます。
| 差分 | review で聞くこと |
|---|---|
family が変わった |
request shape、stream parser、tool loop も同時に確認したか |
model だけが変わった |
同じ family で本当に使える alias か |
base_url が変わった |
/v1 を含む base URL なのか、endpoint path まで含めていないか |
| parser が変わった | Chat chunk と Responses event のどちらを読んでいるか |
| fallback が増えた | fallback 先も同じ family なのか、別 family へ落ちるのか |
この表を issue template や運用 runbook に置いておくと、reviewer が LLM API の細部を全部覚えていなくても会話しやすくなります。特に base_url に /chat/completions まで入れてしまう事故は、SDK が resource path を足す段階で見つかることが多いです。設定 review で先に止められるなら、その方が安いと思います。
Flatkey AI で確認するときのメモ
Flatkey AI は、複数 model/provider をひとつの key と dashboard で扱う gateway です。こういう router では、特に endpoint family を明示しておいた方がよいと思います。
私が確認するなら、次の順番にします。
| 確認 | 見るもの |
|---|---|
| base URL | dashboard / docs に表示されている現在の値 |
| endpoint family |
/chat/completions、/responses、/messages、generateContent のどれか |
| model alias | その family で使う alias |
| request shape |
messages、input、contents を混ぜていないか |
| stream parser | Chat chunk と Responses event を分けているか |
| observability | request が dashboard 側で追えるか |
特に base URL は、過去のメモや記事だけで固定しない方がよさそうです。公開ページや workspace の資料で router.flatkey.ai と console.flatkey.ai のように表記が揺れていることがあります。私はこういうとき、記事の文字列よりも現在の dashboard / docs を正とします。
ハマりどころ
私が自分のコード review で見たいのは、だいたい次の点です。
| ハマりどころ | 見直し方 |
|---|---|
model だけで分岐している |
family を設定に追加する |
| Chat と Responses の stream parser が同じ | event type や chunk shape ごとに分ける |
Responses に Chat の messages を渡している |
input に寄せるか、変換関数を明示する |
| Claude / Gemini を OpenAI path と混同している | provider-native family と OpenAI-compatible family を別設定にする |
| route が code に直書きされている | env / config / dashboard 由来の値に寄せる |
この中で一番小さい修正は、設定名を変えることです。OPENAI_MODEL だけではなく、LLM_ENDPOINT_FAMILY と LLM_MODEL に分けるだけでも、かなり事故が減ると思います。
LLM_ENDPOINT_FAMILY="openai-responses"
LLM_BASE_URL="https://router.flatkey.ai/v1"
LLM_MODEL="your-model"
この3つを deploy 環境ごとに並べると、reviewer が この model はこの endpoint に投げるのか と読めます。地味ですが、運用ではこういう地味な設定名が効く気がします。
まとめ
Responses API と Chat Completions は、どちらも OpenAI 系の API ですが、endpoint family と request shape は別です。model だけで分岐すると、移行期や multi-model router でかなり危なくなります。
私のおすすめは、まず family、base_url、model を別々に設定として持つことです。その上で、curl で request shape を横に並べ、SDK wrapper では family ごとに明示的に呼び出しを分けます。
Flatkey AI のような gateway を使う場合も、考え方は同じです。Chat、Responses、Claude Messages、Gemini generateContent を model 名の違い ではなく protocol の違い として扱う。これだけで、あとから読む人の負担がかなり下がると思います。
おわりに
この手の話は、動いている間はわりと見落とします。私も wrapper を薄くしようとして、逆に境界を曖昧にしかけました。
小さい設定名でも、障害時には手がかりになります。
まずは小さい表と family 設定だけでも入れておくと、移行や router 切り替えのときに助かると思います。間違いあったらコメントください。よろしくお願いします。