0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Responses API と Chat Completions の endpoint を混ぜないためのメモ

0
Posted at

はじめに

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/completionsmessages、Responses API は /v1/responsesinput を別物として扱う。
  • 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 も使いたくなり、modelgpt-... なら 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/completionsPOST /v1/responsesPOST /v1/messagesPOST /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/completionsmessages です。

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/completionsmessages がある。これで Chat Completions family だと読めます。

Responses API

Responses API は path が /responses で、body の入口は input です。OpenAI の API reference でも、Responses の create example は modelinput を使う形になっています。

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 だけにしません。最低でも familymodel を別々に渡します。

雑に言うと、こういう関数は後で怖いです。

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/messagesgenerateContent のどれか
model alias その family で使う alias
request shape messagesinputcontents を混ぜていないか
stream parser Chat chunk と Responses event を分けているか
observability request が dashboard 側で追えるか

特に base URL は、過去のメモや記事だけで固定しない方がよさそうです。公開ページや workspace の資料で router.flatkey.aiconsole.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_FAMILYLLM_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 でかなり危なくなります。

私のおすすめは、まず familybase_urlmodel を別々に設定として持つことです。その上で、curl で request shape を横に並べ、SDK wrapper では family ごとに明示的に呼び出しを分けます。

Flatkey AI のような gateway を使う場合も、考え方は同じです。Chat、Responses、Claude Messages、Gemini generateContent を model 名の違い ではなく protocol の違い として扱う。これだけで、あとから読む人の負担がかなり下がると思います。

おわりに

この手の話は、動いている間はわりと見落とします。私も wrapper を薄くしようとして、逆に境界を曖昧にしかけました。

小さい設定名でも、障害時には手がかりになります。

まずは小さい表と family 設定だけでも入れておくと、移行や router 切り替えのときに助かると思います。間違いあったらコメントください。よろしくお願いします。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?