The request succeeded. The response was valid JSON. The SDK stayed quiet. And yet the application collapsed.

This is the story of what happens when you treat an LLM provider swap like a configuration change instead of a structural gamble. You paste in a new base URL, swap the API key, and keep the request body identical because the docs promise an OpenAI-compatible endpoint. For a basic "hello world" prompt, it works. You celebrate. Then real traffic hits, and the seams split open.

The Illusion of Wire Compatibility

Compatibility at the HTTP layer is shallow. A 200 status code and a JSON body mean the server accepted your message. It does not mean the server thinks the same way as the previous one. OpenAI-compatible endpoints share a request shape, but they do not share a behavioral contract. Two providers can ingest identical payloads and return answers that diverge in subtle, destructive ways.

Your code makes assumptions. You assume message.content is a string because it always was before. You assume a tool call arrives with clean, parseable JSON. You assume finish_reason signals what you think it signals. These assumptions are invisible until they are fatal.

Consider the crash that started it all:

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

This line looks innocent. It worked for weeks. Then the new provider returned a tool call. In that moment, message.content was not an empty string. It was null. The actual payload lived inside message.tool_calls, but the parser had already moved on, calling .trim() on nothing. The API did not throw. The network layer did not complain. Your own parser killed the request.

Where Providers Quietly Diverge

The differences do not announce themselves in changelogs. They sit in the margins of the response object, waiting for edge cases.

Tool-call formatting. One provider sends tool arguments as a pre-validated JSON object. Another sends them as an escaped string inside a field. A third might split a long tool call across multiple streaming deltas, forcing you to buffer chunks before you can even see if the structure is valid. If your application expects a single parseable blob, it chokes.

Finish reasons. OpenAI uses specific strings like "stop", "length", "tool_calls", and "content_filter". A compatible provider might return "end_turn" or simply omit the field when the model hits the token ceiling. If your retry or fallback logic waits for "length" to detect truncation, it will sit idle while the user sees a half-finished answer.

Usage fields. Some providers strip token counts from streaming responses to shave milliseconds off latency. Others append usage only to the final chunk, or omit it entirely in non-streaming calls. If you charge customers per token and your accounting code expects usage.total_tokens to exist in every response object, your billing pipeline will silently record zeros.

Streaming behavior. Server-sent events are supposed to be standard, yet providers flush buffers at different frequencies. Event boundaries vary. One provider terminates a stream with a [DONE] signal. Another drops the connection cleanly with no sentinel at all. If your client blocks waiting for a specific closing marker, it hangs.

Errors and timeouts. A rate limit might arrive as a 429 with a retry-after header from one provider, and as a vague 502 from another. Some providers accept the request and then go quiet for two minutes before a network timeout. The OpenAI SDK will not magically normalize these into the exception types your logs expect.

Defensive Parsing for Unpredictable Shapes

The fix is not to trust the schema. The fix is to treat every response as a suspect.

Do not assume content is a string. Check it before you touch it.

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

Do not assume tool arguments are valid JSON. The model proposes an action. Your code must decide whether that proposal is safe enough to execute. Wrap every tool argument parse in a try-catch. If JSON.parse throws, treat the tool call as malformed garbage and route it to a failure handler. A hallucinated bracket or a missing quote should never bubble up as an unhandled exception.

If tool_calls exists but content is missing, your application should recognize a state transition. The user did not get a chat reply. The system got a work order. Those are two different paths, and your router should know the difference before it attempts string manipulation.

Behavioral Tests Before You Deploy

ഒരു "hi" സന്ദേശത്തിലൂടെ എൻഡ്‌പോയിന്റിലേക്ക് പിംഗ് ചെയ്യുന്നത് നെറ്റ്‌വർക്ക് പ്രവർത്തിക്കുന്നുണ്ടെന്ന് തെളിയിക്കുന്നു. എന്നാൽ അത് നിങ്ങളുടെ ആപ്ലിക്കേഷനെക്കുറിച്ച് ഒന്നും തെളിയിക്കുന്നില്ല.

പ്രൊഡക്ഷൻ ട്രാഫിക് റീഡയറക്ട് ചെയ്യുന്നതിന് മുമ്പ്, പുതിയ പ്രൊവൈഡർക്കെതിരെ ലക്ഷ്യമിട്ടുള്ള ഒരു ബിഹേവിയറൽ ടെസ്റ്റ് സ്യൂട്ട് (behavioral test suite) റൺ ചെയ്യുക:

  • സാധാരണ ടെക്സ്റ്റ് റെസ്പോൺസ് (Normal text response). content ഉണ്ടെന്നും, അത് ഒരു സ്ട്രിംഗ് ആണെന്നും, കാസ്റ്റിംഗ് എററുകൾ (casting errors) ഇല്ലാതെ നിങ്ങളുടെ സാനിറ്റൈസേഷൻ പൈപ്പ്‌ലൈനിലൂടെ (sanitization pipeline) കടത്തിവിടാൻ കഴിയുന്നുണ്ടെന്നും ഉറപ്പുവരുത്തുക.
  • ഫോഴ്സ്ഡ് ടൂൾ കോൾ (Forced tool call). tool_choice എന്നത് required എന്ന് സെറ്റ് ചെയ്യുക. പ്രൊവൈഡർ അത് പാലിക്കുന്നുണ്ടെന്ന് ഉറപ്പാക്കുക, കൂടാതെ content എന്നത് null ആയോ, ഒരു ശൂന്യമായ സ്ട്രിംഗ് (empty string) ആയോ, അതോ ഒരു മിസ്സിംഗ് കീ (missing key) ആയോ ആണ് ലഭിക്കുന്നത് എന്ന് പരിശോധിക്കുക. ഈ ഓരോ അവസ്ഥയ്ക്കും അതിന്റേതായ ഹാൻഡ്‌ലർ (handler) ആവശ്യമാണ്.
  • മാൽഫോംഡ് ടൂൾ ആർഗ്യുമെന്റ്സ് (Malformed tool arguments). ടൂൾ ആർഗ്യുമെന്റുകൾക്കുള്ളിൽ മോശം JSON നൽകുന്ന സാഹചര്യങ്ങൾ ഉൾപ്പെടുത്തുക. നിങ്ങളുടെ പാർസർ (parser) വർക്കർ ക്രാഷ് ചെയ്യുന്നതിന് പകരം അവയെ കൃത്യമായി നിരസിക്കുന്നുണ്ടെന്ന് ഉറപ്പാക്കുക.
  • ടോക്കൺ ലിമിറ്റിന് അടുത്തുള്ള റെസ്പോൺസ് (Response near the token limit). കോൺടെക്സ്റ്റ് വിൻഡോ (context window) പരമാവധി ഉപയോഗിച്ച് നോക്കുക. finish_reason പരിശോധിക്കുക. ട്രങ്കേഷൻ (truncation) സംഭവിക്കുമ്പോൾ പ്രൊവൈഡർ പ്രതീക്ഷിക്കാത്ത എന്തെങ്കിലും നൽകുന്നുണ്ടെങ്കിൽ, നിങ്ങളുടെ സമ്മറൈസേഷൻ (summarization) അല്ലെങ്കിൽ റീട്രൈ ലോജിക് (retry logic) എങ്ങനെ പ്രതികരിക്കണമെന്ന് അറിയണം.

ഇവ ഇന്റഗ്രേഷൻ ടെസ്റ്റുകളാണ് (integration tests), യൂണിറ്റ് ടെസ്റ്റുകളല്ല (unit tests). നിങ്ങളുടെ കോഡും പ്രൊവൈഡറുടെ സ്വഭാവവും തമ്മിലുള്ള യഥാർത്ഥ ബന്ധത്തെയാണ് ഇവ പരിശോധിക്കുന്നത്. മൈഗ്രേഷൻ പൂർത്തിയായി എന്ന് പറയുന്നതിന് മുമ്പ് ഇവ പാസ്സ് ചെയ്യുക.

ഒരു ഇന്റേണൽ കോൺട്രാക്ട് നിർമ്മിക്കുക (Build an Internal Contract)

പ്രൊവൈഡർമാരിലെ വ്യത്യാസങ്ങൾ നിങ്ങളുടെ നെറ്റ്‌വർക്ക് അതിർത്തിയിൽ അവസാനിക്കണം. അവ ബിസിനസ് ലോജിക്കിലേക്ക് (business logic) കടക്കാൻ അനുവദിക്കരുത്.

റോ (raw) SDK റെസ്പോൺസ് സ്വീകരിച്ച് നിങ്ങളുടെ ആപ്ലിക്കേഷന് ആവശ്യമുള്ള ഒരു ഒബ്ജക്റ്റ് നൽകുന്ന ഒരു നോർമലൈസേഷൻ ലെയർ (normalization layer) നിർമ്മിക്കുക. പ്രൊവൈഡർമാരുടെ പ്രത്യേകതകളെ ഒരു സ്ഥിരമായ ഇന്റേണൽ ഫോർമാറ്റിലേക്ക് മാപ്പ് ചെയ്യുക. പ്രൊവൈഡർ A ടൂൾ ആർഗ്യുമെന്റുകൾ സ്ട്രിംഗുകളായി നൽകുകയും പ്രൊവൈഡർ B ഒബ്ജക്റ്റുകളായി നൽകുകയും ചെയ്യുന്നുണ്ടെങ്കിൽ, നിങ്ങളുടെ മാപ്പർ (mapper) ഇവ രണ്ടിനെയും നിങ്ങളുടെ സ്വന്തം ToolRequest സ്ട്രക്ചറിലേക്ക് മാറ്റുന്നു. ഉപയോഗം (usage) ലഭ്യമല്ലെങ്കിൽ, നിങ്ങളുടെ മാപ്പർ അത് കണക്കാക്കുകയോ അല്ലെങ്കിൽ ആ കുറവ് അറിയിക്കുകയോ ചെയ്യണം, എന്നാൽ undefined നിങ്ങളുടെ കോസ്റ്റ്-ട്രാക്കിംഗ് (cost-tracking) മോഡ്യൂളുകളിലേക്ക് കടക്കാൻ അനുവദിക്കരുത്.

finish_reason എന്നത് സ്റ്റാൻഡേർഡ് അല്ലാത്തതാണെങ്കിൽ, അതിനെ നിങ്ങളുടെ സ്വന്തം ടെർമിനൽ സ്റ്റേറ്റുകളുടെ (terminal states) എനത്തിൽ (enum) മാറ്റുക: COMPLETE, TRUNCATED, TOOL_CALL, FILTERED. ഒരു തേർഡ് പാർട്ടി സെർവറിൽ നിന്നുള്ള റോ സ്ട്രിംഗുകൾ പരിശോധിക്കുന്നതിന് പകരം, ഈ ക്ലീൻ അബ്സ്ട്രാക്ഷനുകൾ (abstractions) അടിസ്ഥാനമാക്കി എന്ത് ചെയ്യണമെന്ന് നിങ്ങളുടെ ആപ്പ് തീരുമാനിക്കണം.

ഈ ലെയർ പ്രൊവൈഡർമാരെ മാറ്റുന്നത് ഒരു വലിയ തലവേദനയല്ലാതാക്കി മാറ്റുന്നു; പകരം അത് ഒരു സിംഗിൾ ഫയൽ മാറ്റം മാത്രമായി ചുരുക്കുന്നു. നിങ്ങൾ മാപ്പർ മാറ്റിയെഴുതുന്നു, ബിഹേവിയറൽ ടെസ്റ്റുകൾ റൺ ചെയ്യുന്നു, എന്നിട്ട് മുന്നോട്ട് പോകുന്നു. നിങ്ങളുടെ ആപ്ലിക്കേഷനിൽ മാറ്റങ്ങളൊന്നും വരുത്തേണ്ടി വരുന്നില്ല.

ഒരു ഡിപെൻഡൻസി അപ്‌ഗ്രേഡ്, അല്ലാതെ ഒരു കോൺഫിഗ് ട്യൂക്ക് അല്ല (A Dependency Upgrade, Not a Config Tweak)

LLM പ്രൊവൈഡർമാരെ മാറ്റുന്നത് ഒരു CDN എൻഡ്‌പോയിന്റുകൾ മാറ്റുന്നത് പോലെയല്ല. അത് നിങ്ങളുടെ ഡാറ്റാബേസ് PostgreSQL-ൽ നിന്ന് MySQL-ലേക്ക് മാറ്റുന്നത് പോലെയാണ്. ഒരേ കണക്ഷൻ സ്ട്രിംഗ് (connection string) എന്നാൽ ഒരേ ക്വറി ബിഹേവിയർ (query behavior) ആണെന്ന് നിങ്ങൾ ഒരിക്കലും കരുതുകയില്ല. നിങ്ങൾ ലോക്കിംഗ് സെമാന്റിക്സ് (locking semantics), മൈഗ്രേഷൻ പാത്തുകൾ (migration paths), ഇൻഡെക്സിംഗ് വൈക്സ് (indexing quirks) എന്നിവ പരിശോധിക്കും. LLM-കളും അർഹിക്കുന്നത് ഇതേ ബഹുമാനമാണ്. അവ സ്റ്റാൻഡേർഡ് API-കളായി അഭിനയിക്കുന്ന പ്രോബബിലിസ്റ്റിക് സിസ്റ്റങ്ങളാണ് (probabilistic systems), അവയുടെ റെസ്പോൺസുകൾ ഫോർമാറ്റിംഗ്, ട്രങ്കേഷൻ, കൺട്രോൾ ഫ്ലോ എന്നിവയെക്കുറിച്ചുള്ള അനുമാനങ്ങൾ ഉൾക്കൊള്ളുന്നു, ഇത് ഒരു നെറ്റ്‌വർക്ക് എറർ പോലും കാണിക്കാതെ നിങ്ങളുടെ ആപ്ലിക്കേഷനെ തകർക്കാൻ സാധ്യതയുണ്ട്.

ബഗ് ഒരിക്കലും കണക്ഷനിലായിരുന്നില്ല. കംപാറ്റിബിലിറ്റി എന്നാൽ ഒരേപോലെയാണ് എന്ന തെറ്റായ ധാരണയിലായിരുന്നു അത്. അത് ശരിയല്ല. രൂപം (shape) പരിശോധിക്കുക. എഡ്ജുകൾ (edges) ടെസ്റ്റ് ചെയ്യുക. കോൺട്രാക്ട് സ്വന്തമാക്കുക.


Source: The Bug Only Happened After I Switched LLM Providers

Community: GyaanSetu AI on Telegram