リクエストは成功した。レスポンスは有効なJSONだった。SDKは何も言わなかった。それなのに、アプリケーションは崩壊した。

これは、LLMプロバイダーの切り替えを、構造的なギャンブルではなく、単なる設定変更のように扱ったときに何が起こるかという物語である。ドキュメントにOpenAI互換のエンドポイントであると記載されているため、新しいbase URLを貼り付け、APIキーを入れ替え、リクエストボディはそのままにする。基本的な「hello world」のプロンプトなら、うまくいく。あなたは祝杯をあげる。しかし、実際のトラフィックが流れ込むと、継ぎ目が裂け始める。

通信互換性の錯覚

HTTPレイヤーでの互換性は浅い。ステータスコード200とJSONボディは、サーバーがメッセージを受け付けたことを意味する。しかし、それはサーバーが以前のものと同じように思考していることを意味しない。OpenAI互換のエンドポイントはリクエストの形状を共有しているが、振る舞いの契約(behavioral contract)を共有しているわけではない。2つのプロバイダーが全く同じペイロードを受け取っても、微妙かつ破壊的な形で異なる回答を返す可能性がある。

コードは仮定に基づいている。以前は常にそうだったから、message.contentは文字列であると仮定する。ツールコールはクリーンでパース可能なJSONで届くと仮定する。finish_reasonは自分が思っている通りの信号を送ると仮定する。これらの仮定は、致命的な事態になるまで目に見えない。

すべての始まりとなったクラッシュを考えてみよう:

const text = response.choices[0].message.content.trim();

この行は無害に見える。数週間は動作していた。しかし、新しいプロバイダーがツールコールを返した。その瞬間、message.contentは空の文字列ではなく、nullだった。実際のペイロードはmessage.tool_callsの中にあったが、パーサーはすでに先に進んでおり、何もないものに対して.trim()を呼び出していた。APIはエラーを投げなかった。ネットワークレイヤーも文句を言わなかった。あなた自身のパーサーがリクエストを殺したのだ。

プロバイダーが静かに乖離する場所

その違いは変更履歴(changelog)に明記されることはない。それらはレスポンスオブジェクトの余白に潜み、エッジケースが訪れるのを待っている。

ツールコールのフォーマット。 あるプロバイダーは、ツール引数を事前に検証済みのJSONオブジェクトとして送信する。別のプロバイダーは、フィールド内のエスケープされた文字列として送信する。また別のプロバイダーは、長いツールコールを複数のストリーミング・デルタに分割するかもしれず、構造が有効かどうかを確認する前にチャンクをバッファリングすることを強いる。アプリケーションが単一のパース可能な塊(blob)を期待している場合、処理は停止する。

終了理由(Finish reasons)。 OpenAIは、"stop""length""tool_calls""content_filter"といった特定の文字列を使用する。互換性のあるプロバイダーは、モデルがトークン上限に達したときに"end_turn"を返したり、単にフィールドを省略したりする可能性がある。リトライやフォールバックのロジックが、切り捨てを検知するために"length"を待っている場合、ユーザーが未完成の回答を見ている間、ロジックは何もせず待機し続けることになる。

使用量(Usage)フィールド。 レイテンシを数ミリ秒削るために、ストリーミングレスポンスからトークン数を削除するプロバイダーもある。また、最終的なチャンクにのみ使用量を付加したり、非ストリーミング呼び出しでは完全に省略したりするプロバイダーもある。トークン単位で顧客に課金しており、会計コードがすべてのレスポンスオブジェクトにusage.total_tokensが存在することを期待している場合、請求パイプラインは静かに「ゼロ」を記録することになる。

ストリーミングの挙動。 サーバー送信イベント(SSE)は標準であるはずだが、プロバイダーによってバッファをフラッシュする頻度が異なる。イベントの境界も様々だ。あるプロバイダーは[DONE]シグナルでストリームを終了させるが、別のプロバイダーはセンチネル(番兵)なしで接続をクリーンに切断する。クライアントが特定の終了マーカーを待ってブロックしている場合、ハングアップしてしまう。

エラーとタイムアウト。 レート制限は、あるプロバイダーからはretry-afterヘッダーを伴う429として届き、別のプロバイダーからは曖昧な502として届くかもしれない。リクエストを受け付けた後、ネットワークタイムアウトが発生するまで2分間沈黙するプロバイダーもある。OpenAI SDKが、これらをログが期待する例外タイプへと魔法のように正規化してくれることはない。

予測不能な形状に対する防御的パース

解決策は、スキーマを信頼することではない。すべてのレスポンスを「容疑者」として扱うことである。

contentが文字列であると仮定してはいけない。触れる前にチェックすること。

const content = response.choices?.[0]?.message?.content;
const text = typeof content === "string" ? content.trim() : "";

ツール引数が有効なJSONであると仮定してはいけない。モデルはアクションを提案する。その提案が実行するのに十分安全かどうかを判断するのは、あなたのコードである。すべてのツール引数のパースをtry-catchで囲むこと。もしJSON.parseが例外を投げたら、そのツールコールを不正なゴミとして扱い、失敗ハンドラーにルーティングする。幻覚(hallucination)による括弧の誤りや引用符の欠落が、未処理の例外としてバブルアップ(伝播)してはならない。

もしtool_callsが存在するがcontentが欠落している場合、アプリケーションはそれが状態遷移であることを認識すべきである。ユーザーはチャットの返信を受け取ったのではなく、システムが作業指示(work order)を受け取ったのだ。これらは2つの異なるパスであり、ルーターは文字列操作を試みる前にその違いを理解しておく必要がある。

デプロイ前の振る舞いテスト

Pinging the endpoint with a "hi" message proves the network works. It proves nothing about your application.

Before you redirect production traffic, run a targeted behavioral test suite against the new provider:

  • Normal text response. Verify that content exists, is a string, and can be passed through your sanitization pipeline without casting errors.
  • Forced tool call. Set tool_choice to required. Confirm the provider honors it, and check whether content arrives as null, an empty string, or a missing key. Each of those states needs its own handler.
  • Malformed tool arguments. Inject scenarios where the model returns broken JSON inside tool arguments. Ensure your parser rejects them gracefully instead of crashing the worker.
  • Response near the token limit. Push the context window. Check the finish_reason. If the provider returns something unexpected when truncation happens, your summarization or retry logic must know how to react.

These are integration tests, not unit tests. They exercise the real relationship between your code and the provider's personality. Pass them before you call the migration done.

Build an Internal Contract

Provider differences should stop at your network boundary. Do not let them leak into business logic.

Create a normalization layer that consumes the raw SDK response and emits an object your application actually owns. Map provider-specific eccentricities into a stable internal format. If Provider A returns tool arguments as strings and Provider B returns objects, your mapper flattens both into your own ToolRequest structure. If usage is missing, your mapper either estimates it or flags the gap, but it never lets undefined seep into your cost-tracking modules.

If finish_reason is nonstandard, translate it into your own enum of terminal states: COMPLETE, TRUNCATED, TOOL_CALL, FILTERED. Your app should decide what to do based on these clean abstractions, not by sniffing raw strings from a third-party server.

This layer turns provider swaps from a game of whack-a-mole into a single-file change. You rewrite the mapper, run the behavioral tests, and move on. Your application remains untouched.

A Dependency Upgrade, Not a Config Tweak

Switching LLM providers is not like swapping CDN endpoints. It is closer to changing your database from PostgreSQL to MySQL. You would never assume the same connection string means identical query behavior. You would test locking semantics, migration paths, and indexing quirks. LLMs deserve the same respect. They are probabilistic systems masquerading as standard APIs, and their responses carry assumptions about formatting, truncation, and control flow that can shatter your application without raising a single network error.

The bug was never in the connection. It was in the assumption that compatibility means sameness. It does not. Validate the shape. Test the edges. Own the contract.


Source: The Bug Only Happened After I Switched LLM Providers

Community: GyaanSetu AI on Telegram