AI チャットボットを4社対応にした話|OpenAI・Claude・Gemini・OpenRouter の差を1枚の層で吸収した設計

AI チャットボットを4社対応にした話|OpenAI・Claude・Gemini・OpenRouter の差を1枚の層で吸収した設計 AI
この記事は約9分で読めます。

OpenAI と Claude と Gemini と OpenRouter。この4社に同じチャットボットから話しかけるには、コードを何本書けばいいでしょうか。先に答えを書きます。アプリ側は1本でした。各社の違いは1枚の層の内側に閉じ込めて、アプリからは窓口を1つにする。アプリは「メッセージを送る」「返事を受け取る」しか知りません。相手が OpenAI なのか Claude なのかは、層の中だけが知っている。自作の WordPress プラグイン Rapls AI Chatbot を4社対応にしたときに行き着いた形です。窓口は1つ、アダプタは4枚。アプリは窓口を叩くだけで、裏で、選ばれたプロバイダのアダプタが各社の作法に翻訳して投げ、返ってきたものを共通の形に戻します。この記事は、その層を作ったときに私が実際に踏んだ差と地雷の記録です。

窓口1つ・アダプタ4枚のAIチャットボット全体像

これが、たどり着いた全体像です。

検証環境:Rapls AI Chatbot 1.8.0 / WordPress 7.0(ローカルは 7.0 RC4)/ PHP 8.3.30(Xserver)・8.3.23(ローカル)/ 確認日 2026年5月21日。対象は OpenAI、Claude(Anthropic)、Gemini(Google)、OpenRouter。

差を畳む場所を、1つに決める

層の入口は、1つのインターフェースです。各アダプタはこれを実装し、アプリはこの形しか知りません。鍵をセットする、モデルをセットする、メッセージを送って共通形の配列を受け取る、利用可能モデルを返す、鍵を検証する。要はこれだけの窓口です。

呼び出し側は、各アダプタを直接 new しません。raplsaich_create_ai_provider() というファクトリ1か所を通します。生成を1か所に集める。これで、どのプロバイダを使うかの分岐が、コードの隅々に散らばらずに済みます。利用者がやるのは、設定画面で AI プロバイダーを選んで鍵を入れる、それだけです。

入口は各社バラバラ、出口は1つ

まず認証が、素直に違います。OpenAI と OpenRouter は Authorization: Bearer 鍵(OpenRouter が OpenAI 互換だから)。Claude は x-api-key に鍵を入れて anthropic-version を添える。Gemini は ?key= のクエリで渡す。エンドポイントも、OpenAI が /v1/chat/completions、OpenRouter が /api/v1/chat/completions、Claude が /v1/messages、Gemini が /v1beta/models/{モデル}:generateContent と、モデル名を URL に埋めます。

4社×5項目(認証・エンドポイント・system・レスポンス・エラー)の差の一覧

5か所の差を、1枚に並べたものです。

私が一番うっかりしたのが system(モデルへの指示文)の置き場所でした。OpenAI と OpenRouter は messages の配列に role: “system” で混ぜます。会話の途中にも置ける。Claude は違って、system を messages に入れられません。body 直下の system という箱に1つだけ置きます。Gemini はさらに別で、指示文は systemInstruction という専用フィールド、ロールも user と model です。だから Claude アダプタでは、messages を回して system ロールだけ抜き出し、本文の箱へ寄せています。

ついでに、Claude は max_tokens が必須です。付け忘れると弾かれるので、既定 1000 で必ず入れます。

返ってくる形も揃っていません。OpenAI 互換は choices[0].message.content に答えが単一の文字列で入ります。Claude は content[] が typed block の配列なので、text ブロックを拾って連結する必要がある。だからアダプタの出口で、content[] を回して text ブロックだけ連結し、web 検索の citations は web_sources として吸い上げます。そして、どのプロバイダも最後は content、tokens_used、model、provider という同じ形に揃えて返す。入口は各社バラバラ、出口は1つ。アプリは、この共通の連想配列だけを受け取ります。

各社バラバラのレスポンスを共通フォーマットに正規化する図

入口の差を、出口で1つに揃えるところです。

エラーも畳みました。HTTP コードをそのまま上に流すと、アプリ側が各社のエラー形を知る羽目になります。そこで共通の例外型に翻訳しました。401(または authentication_error)は鍵が無効という例外へ。429 / 402、または quota・billing を含むメッセージは Quota_Exceeded 例外へ寄せ、retry-after ヘッダがあればその秒数を持たせる。通信そのものの失敗は Communication 例外へ。各アダプタが同じ例外型を投げるので、UI 側は一貫したリトライと案内を1か所で書けます。「OpenAI のときはこう、Claude のときはこう」を、画面側に持ち込まずに済みました。

temperature という地雷

実装で私がいちばん時間を取られたのが、ここでした。同じ temperature が、相手によっては即 400 になります。OpenAI の GPT-5 や o-series は temperature を 1 以外にできず、0.7 を付けて投げると「does not support 0.7 with this model」と返る。error.message に必ず temperature という語が入り、抜くと 200 が返ります。手当は、相手が分かるかどうかで2通りにしました。

OpenAI プロバイダは、モデルを事前に判別できます。だから予防的に、GPT-5 と推論系のときは temperature キー自体を付けません。

WP AI Client(Connectors)プロバイダは、そう簡単にいきません。WordPress 7.0 の Connectors は実体のモデルが実行時にルーティングされるので、投げる前に GPT-5 かどうか分からない。そこで、投げてみて、temperature を含む 400 が返ったら、1回だけ temperature を外して再送する形にしました。

モデルが分かる時は予防スキップ、分からない時は1回リトライ、という2通りの手当の図

相手が分かるか分からないかで、手当を分けています。

余談ですが、この「相手によって付けたり外したり」は、4社の窓口を1人でさばくときの気疲れと、よく似ています。だからこそ、層に肩代わりさせる価値がありました。利用者は temperature を指定するだけ。受け付けない相手のときは、層が黙って外して通します。

この自前の層は、しばらく主力でした。その後、WordPress 側に公式の AI 抽象(WP AI Client)が出てきたので、Connectors プロバイダとして取り込みました。ところが移行の途中で、エラーも出さずに会話の履歴が消えるという別の事故も踏んでいます。その顛末は WP AI Client へ移行したら、エラーも出さずに会話の履歴が消えた に書きました。抽象を自前で持つか、公式に預けるか。どちらにも固有のつまずきがあります。

最初の問いに戻る

4社に同じチャットボットから話しかけるには、コードを何本書けばいいか。冒頭の問いです。アプリ側は1本でした。認証の載せ方、system の置き場所、レスポンスの形、エラー、そして temperature の地雷。違いはぜんぶ、1枚の層の内側に畳みました。差を知る場所を1か所に集める、それだけの設計です。次に5社目が来ても、たぶん、アダプタを1枚書いて終わりにできます。そのときに新しい地雷を踏んだら、また、ここに書き足します。

関連記事

AI
この記事を書いた人
rapls

WordPressのプラグインを作っているフリーランスエンジニアです。Web開発はもう6年以上。WordPress.orgで Rapls AI Chatbot、Thanks Mail for Stripe、Rapls PDF Image Creator、Prime Cache の4本を公開し、保守を続けながら、日本語ロケールの翻訳エディター(PTE)も務めています。このブログに書くのは、現場で自分が実際にハマって、調べて、直した話です。

raplsをフォローする
raplsをフォローする

コメント

タイトルとURLをコピーしました