Żądanie zakończyło się sukcesem. Odpowiedź była poprawnym formatem JSON. SDK milczało. A mimo to aplikacja padła.

To historia tego, co się dzieje, gdy traktujesz zmianę dostawcy LLM jak zmianę konfiguracji, a nie strukturalne ryzyko. Wklejasz nowy adres URL podstawowy (base URL), podmieniasz klucz API i pozostawiasz ciało żądania bez zmian, ponieważ dokumentacja obiecuje punkt końcowy zgodny z OpenAI. W przypadku prostego promptu „hello world” wszystko działa. Świętujesz. Potem uderza realny ruch i szwy zaczynają pękać.

Iluzja kompatybilności na poziomie protokołu

Kompatybilność na warstwie HTTP jest powierzchowna. Kod statusu 200 i ciało JSON oznaczają, że serwer przyjął Twoją wiadomość. Nie oznacza to jednak, że serwer myśli w ten sam sposób co poprzedni. Punkty końcowe zgodne z OpenAI mają tę samą strukturę żądania, ale nie dzielą wspólnego kontraktu behawioralnego. Dwaj dostawcy mogą przyjąć identyczne ładunki (payloads), a mimo to zwrócić odpowiedzi, które różnią się w subtelny, niszczycielski sposób.

Twój kod opiera się na założeniach. Zakładasz, że message.content to ciąg znaków, bo tak było zawsze. Zakładasz, że wywołanie narzędzia (tool call) przychodzi z czystym, dającym się sparsować JSON-em. Zakładasz, że finish_reason sygnalizuje to, co myślisz, że sygnalizuje. Te założenia są niewidoczne, dopóki nie okażą się fatalne.

Rozważ awarię, która zapoczątkowała wszystko:

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

Ta linia wygląda niewinnie. Działała przez tygodnie. Potem nowy dostawca zwrócił wywołanie narzędzia. W tym momencie message.content nie było pustym ciągiem znaków. Było null. Rzeczywisty ładunek znajdował się w message.tool_calls, ale parser był już dalej, próbując wywołać .trim() na pustej wartości. API nie wyrzuciło błędu. Warstwa sieciowa nie zgłosiła problemu. To Twój własny parser zabił żądanie.

Gdzie dostawcy po cichu się różnią

Różnice nie ogłaszają się w dziennikach zmian (changelogs). Czekają na marginesach obiektu odpowiedzi, wypatrując przypadków brzegowych.

Formatowanie wywołań narzędzi (tool-calls). Jeden dostawca przesyła argumenty narzędzi jako wstępnie zweryfikowany obiekt JSON. Inny przesyła je jako ucieleśniony (escaped) ciąg znaków wewnątrz pola. Trzeci może podzielić długie wywołanie narzędzia na wiele fragmentów strumienia (streaming deltas), zmuszając Cię do buforowania części, zanim w ogóle dowiesz się, czy struktura jest poprawna. Jeśli Twoja aplikacja oczekuje pojedynczego, dającego się sparsować bloku danych, po prostu się „zatka”.

Powody zakończenia (finish reasons). OpenAI używa konkretnych ciągów znaków, takich jak "stop", "length", "tool_calls" i "content_filter". Kompatybilny dostawca może zwrócić "end_turn" lub po prostu pominąć to pole, gdy model osiągnie limit tokenów. Jeśli Twoja logika ponawiania prób (retry) lub mechanizm awaryjny (fallback) czeka na "length", aby wykryć ucięcie odpowiedzi, będzie bezczynnie czekać, podczas gdy użytkownik zobaczy niekompletną odpowiedź.

Pola zużycia (usage fields). Niektórzy dostawcy usuwają liczbę tokenów ze strumieniowych odpowiedzi, aby skrócić opóźnienia o milisekundy. Inni dołączają dane o zużyciu tylko do ostatniego fragmentu lub całkowicie je pomijają w wywołaniach niestrumieniowych. Jeśli pobierasz opłaty od klientów za tokeny, a Twój kod księgujący oczekuje, że usage.total_tokens będzie istnieć w każdym obiekcie odpowiedzi, Twój system rozliczeniowy po cichu będzie rejestrował zera.

Zachowanie strumieniowania (streaming behavior). Zdarzenia przesyłane przez serwer (Server-sent events) powinny być standardem, a jednak dostawcy czyszczą bufory z różną częstotliwością. Granice zdarzeń są różne. Jeden dostawca kończy strumień sygnałem [DONE]. Inny zrywa połączenie w sposób czysty, bez żadnego znacznika. Jeśli Twój klient blokuje się, czekając na konkretny znacznik zakończenia, zawiesi się.

Błędy i przekroczenia czasu oczekiwania (timeouts). Limit żądań (rate limit) może przyjść jako błąd 429 z nagłówkiem retry-after

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