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

これが、たどり着いた全体像です。
検証環境: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つのインターフェースです。各アダプタはこれを実装し、アプリはこの形しか知りません。鍵をセットする、モデルをセットする、メッセージを送って共通形の配列を受け取る、利用可能モデルを返す、鍵を検証する。要はこれだけの窓口です。
| 1 2 3 4 5 6 7 8 9 | interface RAPLSAICH_AI_Provider_Interface { public function set_api_key(string $key): void; public function set_model(string $model): void; // @return ['content'=>..., 'tokens_used'=>123, 'model'=>..., 'provider'=>...] public function send_message(array $messages, array $options = []): array; public function get_available_models(): array; public function validate_api_key(): bool; public function get_name(): string; } |
呼び出し側は、各アダプタを直接 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 に埋めます。

5か所の差を、1枚に並べたものです。
私が一番うっかりしたのが system(モデルへの指示文)の置き場所でした。OpenAI と OpenRouter は messages の配列に role: “system” で混ぜます。会話の途中にも置ける。Claude は違って、system を messages に入れられません。body 直下の system という箱に1つだけ置きます。Gemini はさらに別で、指示文は systemInstruction という専用フィールド、ロールも user と model です。だから Claude アダプタでは、messages を回して system ロールだけ抜き出し、本文の箱へ寄せています。
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | foreach ($messages as $msg) { if ($msg['role'] === 'system') { // Claude は messages に入れられない $system_message .= $msg['content'] . "\n"; continue; } $chat_messages[] = ['role' => $msg['role'], 'content' => $msg['content']]; } $body = [ 'model' => $this->model, 'max_tokens' => $options['max_tokens'] ?? 1000, // Anthropic は必須 'messages' => $chat_messages, ]; if ($system_message !== '') { $body['system'] = trim($system_message); // 専用フィールドへ分離 } |
ついでに、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 キー自体を付けません。
| 1 2 3 4 | // GPT-5 と o 系は temperature=1 のみ。該当時はキーごと付けない if (!$is_reasoning && !$this->is_gpt5_model()) { $body['temperature'] = (float) ($options['temperature'] ?? 0.7); } |
WP AI Client(Connectors)プロバイダは、そう簡単にいきません。WordPress 7.0 の Connectors は実体のモデルが実行時にルーティングされるので、投げる前に GPT-5 かどうか分からない。そこで、投げてみて、temperature を含む 400 が返ったら、1回だけ temperature を外して再送する形にしました。
| 1 2 3 4 5 6 7 8 9 | $text = $this->build_prompt($prompt, $system, $history, $options, false)->generate_text(); if (is_wp_error($text) && stripos($text->get_error_message(), 'temperature') !== false && isset($options['temperature']) ) { // Connectors の先が GPT-5 等で温度を弾いた時だけ、一度だけ外して再送 $text = $this->build_prompt($prompt, $system, $history, $options, true)->generate_text(); } |

相手が分かるか分からないかで、手当を分けています。
余談ですが、この「相手によって付けたり外したり」は、4社の窓口を1人でさばくときの気疲れと、よく似ています。だからこそ、層に肩代わりさせる価値がありました。利用者は temperature を指定するだけ。受け付けない相手のときは、層が黙って外して通します。
この自前の層は、しばらく主力でした。その後、WordPress 側に公式の AI 抽象(WP AI Client)が出てきたので、Connectors プロバイダとして取り込みました。ところが移行の途中で、エラーも出さずに会話の履歴が消えるという別の事故も踏んでいます。その顛末は WP AI Client へ移行したら、エラーも出さずに会話の履歴が消えた に書きました。抽象を自前で持つか、公式に預けるか。どちらにも固有のつまずきがあります。
最初の問いに戻る
4社に同じチャットボットから話しかけるには、コードを何本書けばいいか。冒頭の問いです。アプリ側は1本でした。認証の載せ方、system の置き場所、レスポンスの形、エラー、そして temperature の地雷。違いはぜんぶ、1枚の層の内側に畳みました。差を知る場所を1か所に集める、それだけの設計です。次に5社目が来ても、たぶん、アダプタを1枚書いて終わりにできます。そのときに新しい地雷を踏んだら、また、ここに書き足します。
関連記事
- WP AI Client へ移行したら、エラーも出さずに会話の履歴が消えた。自前の層から公式ライブラリへ移した続きです。
- Brainfuck のベンチで AI が満点を取った、けれど「8 8」で正体が割れた話。LLM の挙動を実機で確かめた別の記録です。



コメント