La richiesta è andata a buon fine. La risposta era un JSON valido. L'SDK è rimasto in silenzio. Eppure, l'applicazione è crollata.

Questa è la storia di ciò che accade quando si tratta il passaggio da un provider LLM come un semplice cambio di configurazione invece che come una scommessa strutturale. Si incolla un nuovo URL di base, si sostituisce la chiave API e si mantiene il corpo della richiesta identico perché la documentazione promette un endpoint compatibile con OpenAI. Per un prompt "hello world" di base, funziona. Si festeggia. Poi arriva il traffico reale e le cuciture si lacerano.

L'illusione della compatibilità a livello di rete

La compatibilità a livello HTTP è superficiale. Un codice di stato 200 e un corpo JSON significano che il server ha accettato il messaggio. Non significa che il server ragioni allo stesso modo del precedente. Gli endpoint compatibili con OpenAI condividono la struttura della richiesta, ma non condividono un contratto comportamentale. Due provider possono ricevere payload identici e restituire risposte che divergono in modi sottili e distruttivi.

Il tuo codice fa delle assunzioni. Presumi che message.content sia una stringa perché è sempre stato così in precedenza. Presumi che una tool call arrivi con un JSON pulito e analizzabile. Presumi che finish_reason segnali ciò che pensi che segnali. Queste assunzioni sono invisibili finché non diventano fatali.

Considera il crash che ha dato inizio a tutto:

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

Questa riga sembra innocua. Ha funzionato per settimane. Poi il nuovo provider ha restituito una tool call. In quel momento, message.content non era una stringa vuota. Era null. Il payload effettivo risiedeva all'interno di message.tool_calls, ma il parser era già andato avanti, chiamando .trim() su nulla. L'API non ha generato errori. Il livello di rete non si è lamentato. Il tuo stesso parser ha ucciso la richiesta.

Dove i provider divergono silenziosamente

Le differenze non si annunciano nei changelog. Si annidano nei margini dell'oggetto di risposta, in attesa di casi limite.

Formattazione delle tool call. Un provider invia gli argomenti degli strumenti come un oggetto JSON pre-validato. Un altro li invia come una stringa con caratteri di escape all'interno di un campo. Un terzo potrebbe dividere una lunga tool call in più delta di streaming, costringendoti a bufferizzare i chunk prima ancora di poter vedere se la struttura è valida. Se la tua applicazione si aspetta un singolo blocco analizzabile, va in crash.

Motivi di conclusione (finish reasons). OpenAI utilizza stringhe specifiche come "stop", "length", "tool_calls" e "content_filter". Un provider compatibile potrebbe restituire "end_turn" o semplicemente omettere il campo quando il modello raggiunge il limite di token. Se la tua logica di retry o fallback attende "length" per rilevare la troncamento, rimarrà inattiva mentre l'utente vede una risposta a metà.

Campi di utilizzo (usage fields). Alcuni provider rimuovono il conteggio dei token dalle risposte in streaming per ridurre la latenza di millisecondi. Altri aggiungono l'utilizzo solo all'ultimo chunk, o lo omettono del tutto nelle chiamate non in streaming. Se addebiti ai clienti i token e il tuo codice di contabilità si aspetta che usage.total_tokens esista in ogni oggetto di risposta, la tua pipeline di fatturazione registrerà silenziosamente degli zeri.

Comportamento dello streaming. Gli eventi inviati dal server (Server-sent events) dovrebbero essere standard, eppure i provider svuotano i buffer con frequenze diverse. I confini degli eventi variano. Un provider termina uno stream con un segnale [DONE]. Un altro chiude la connessione pulitamente senza alcun sentinella. Se il tuo client si blocca in attesa di un marker di chiusura specifico, si pianta.

Errori e timeout. Un limite di frequenza (rate limit) potrebbe arrivare come un 429 con un header retry-after da un provider, e come un vago 502 da un altro. Alcuni provider accettano la richiesta e poi restano in silenzio per due minuti prima di un timeout di rete. L'SDK di OpenAI non normalizzerà magicamente questi eventi nei tipi di eccezione che i tuoi log si aspettano.

Parsing difensivo per strutture imprevedibili

La soluzione non è fidarsi dello schema. La soluzione è trattare ogni risposta come un sospettato.

Non dare per scontato che content sia una stringa. Controllala prima di toccarla.

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

Non dare per scontato che gli argomenti degli strumenti siano JSON validi. Il modello propone un'azione. Il tuo codice deve decidere se tale proposta è abbastanza sicura da essere eseguita. Avvolgi ogni parsing degli argomenti degli strumenti in un try-catch. Se JSON.parse lancia un errore, tratta la tool call come spazzatura malformata e indirizzala a un gestore di errori. Una parentesi allucinata o una virgoletta mancante non dovrebbero mai risalire come un'eccezione non gestita.

Se tool_calls esiste ma content manca, la tua applicazione dovrebbe riconoscere una transizione di stato. L'utente non ha ricevuto una risposta in chat. Il sistema ha ricevuto un ordine di lavoro. Questi sono due percorsi diversi, e il tuo router dovrebbe conoscere la differenza prima di tentare una manipolazione di stringhe.

Test comportamentali prima del deploy

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