本番のログに、見慣れないエラーが流れていました。あるプロバイダだけ、レート制限の扱いがこちらの想定とずれている。原因を追ううちに、4社のAPIを1つのプラグインから叩くというのは、成功したときではなく、失敗したときに初めて素顔を見せるのだと分かってきました。4社対応の抽象化レイヤーを作った話の、その後の記録です。作ったレイヤーの裏で、各社がどう暴れたか。

エラー本文は、拍子抜けするほど揃っていた
OpenAI、Claude、Gemini、OpenRouter。この4社からエラーが返ってきたとき、エラーの本文を取り出すパスは、まったく同じでした。
| 1 | $data['error']['message'] // 4社まったく同じ |
最初はこれで安堵しました。JSONの形が社ごとにばらばらだろうと身構えていたのに、少なくとも「何かおかしい」というメッセージ本体は、同じ場所に、同じ形で入っている。共通のパーサーが素直に書けそうだ、と。
では、その隣は揃っていたか?
揃っていませんでした。エラー本文のすぐ隣にある、「これはどの種類のエラーか」を示すフィールド。ここが4社で四分五裂していました。
| 1 2 3 4 | OpenAI error.type + error.code invalid_request_error / model_not_found Claude error.type のみ rate_limit_error Gemini error.status RESOURCE_EXHAUSTED OpenRouter error.code のみ(数値) 429 |
OpenAIは種別を文字列のtypeで持ち、加えて細分のcodeも持つ。Claudeはtypeだけ。Geminiはtypeでもcodeでもなく、statusという別名のフィールドに大文字スネークケースの定数を入れてくる。OpenRouterに至っては、種別を数値のcodeひとつで表す。同じ「エラーの種類」という概念に、フィールド名が4つ、値の作法が4通り。本文は揃えてくれたのに、その一歩隣で崩れる。
共通パーサーは、書けそうに見えて書けない
種別フィールドが揃わないと何が困るか。「レート制限に当たったのか」「モデル名が間違っているのか」「課金の問題か」を、コードで一律に判定できません。OpenAIならtypeを見る、Claudeもtype、Geminiはstatus、OpenRouterはcodeの数値、と社ごとに分岐を書けば動きはします。動きはしますが、そこにモデルの追加や仕様変更が重なると、分岐は静かに腐っていきます。
それで最終的にどこへ着地したかを、正直に書きます。抽象化レイヤーのいちばん深いところは、こうなっています。
| 1 2 3 | stripos($error_message, 'quota') !== false || stripos($error_message, 'billing') !== false || stripos($error_message, 'exceeded') !== false |
構造化されたフィールドで判定しきれなかった結果、最後は英語のエラーメッセージそのものを文字列で探しています。「quota」「billing」「exceeded」という単語が本文に含まれているか。4社の種別フィールドを見比べた末に、いちばん確実だったのが、人間向けに書かれた英語メッセージへの部分一致でした。皮肉な着地です。そして自覚的な妥協でもあります。各社がこの英語の文言を変えたら、ここは黙って壊れる。壊れると分かっている場所を、承知の上で残している。抽象化とは、こういう「どこで妥協したか」の記録なのだと思います。
もう一段深い驚き:4社どころか、1社の中に3方言あった
ここまでは「4社の差」の話でした。ところが掘っていくと、社をまたぐ前に、OpenAI1社の中で成功レスポンスの形が割れていました。返答テキストを取り出す場所が、3通りあるのです。
| 1 2 3 | $data['choices'][0]['message']['content'] // chat/completions $data['output'][…]['content'][…]['text'] // Responses API $data['choices'][0]['text'] // legacy |

従来のchat/completions、新しいResponses API、そして古いlegacy。同じ会社の、同じ「返答本文」という概念が、APIの世代ごとに別の場所に入っている。極めつけは、消費トークン数のフィールド名まで社内で分裂していたことです。
| 1 2 | prompt_tokens / completion_tokens // chat/completions input_tokens / output_tokens // Responses API |

片方はprompt/completion、もう片方はinput/output。課金に直結する数字の名前が、同じ会社の中で二重化している。4社対応の話だと思って始めたのに、1社の中に3方言があった。差異は社と社のあいだだけにあるのではなく、1社の時間軸のなかにも積もっていました。Claudeはcontent配列をtextタイプで絞って連結、Geminiはcandidatesのpartsを連結、と社ごとの作法もあるうえに、その1社の中でも世代が割れる。共通化の敵は、他社ではなく「時間」だったのかもしれません。
結局、抽象化レイヤーは何を抽象化していたのか

作る前は、4社のAPIを1つのインターフェースの裏に隠せば、呼び出す側は差を気にしなくて済む、と考えていました。実際その通りに動いています。ただ、レイヤーの内側で起きていたのは「差を消す」ことではなく「差を1か所に集めて、妥協ごと引き受ける」ことでした。エラー種別のstripos、成功レスポンスの3方言分岐、トークン名の二重化への対応。表から見えないところに、各社と各世代の癖が沈殿している。
この記事では、成功レスポンスとエラー解析だけを扱いました。同じ話は429の解釈(同じHTTPコードで、待つべき社と別モデルへ逃がすべき社がある)や、モデル一覧の取得(一覧APIを持たない社がある)にも、それぞれ別の形で存在します。それは次の記事に回します。ひとつ問いを置いて終わります。あなたが複数のAPIを1枚のレイヤーで束ねているなら、その最深部は、構造化フィールドで判定できていますか。それとも、いつのまにか英語メッセージへの文字列マッチに落ちていませんか。
関連: AIチャットボットを4社対応にした話|OpenAI・Claude・Gemini・OpenRouterを1つの形に(この記事の前編)。



コメント