En un cercador semàntic d'anime tinc un reranker que ordena vint-i-cinc candidats amb un LLM. Feia servir temperature: 0 des del primer dia, que és el més obvi quan vols un ordre reproduïble. Vaig canviar el model per un de raonament i la crida va començar a retornar error: aquell model rebutja temperature. No l'ignora, el rebutja.
L'arreglada no va ser esborrar la línia. Va ser substituir-la per reasoning_effort, treure'l a una constant amb low per defecte, i passar-lo també per l'altre lloc on es cridava el mateix model, un script d'ingesta que sintetitza premisses de franquícia. Tres fitxers, una variable d'entorn nova i un document per actualitzar. Per un paràmetre.
Això ha passat sis vegades en any i mig, i cada cop d'una manera diferent.
Els quatre tipus de trencament
Després de col·leccionar-les, es repeteixen sempre les mateixes quatre:
- El paràmetre que desapareix.
temperatureen models de raonament. No hi ha equivalent exacte:reasoning_effortno és el mateix, és una altra palanca. No migres el valor, en decideixes un de nou. - La forma del payload. Passar de Chat Completions a la Responses API per a un parser de visió no és canviar el nom del mètode.
messagespassa ainput,{"type": "text"}passa ainput_text, iimage_urldeixa de ser un objecte{url, detail}per ser una cadena ambdetailcom a germà al mateix nivell. - La forma de la resposta. En aquella mateixa migració,
response.choices[0].message.contentva passar aresponse.output_text. I el comptador de tokens va deixar de ser un: ara hi hainput_tokensioutput_tokensper separat. Això va obligar a canviar la signatura de la funció de-> stra-> tuple[str, int, int]i a acumular-los a mà en recollir els futures en paral·lel. - La qualitat canvia sense avisar. Aquest és el pitjor perquè no dona error. El model respon, el JSON valida, i els resultats són pitjors.
Les tres primeres les veus al primer desplegament. La quarta te la menges setmanes.
OpenRouter i LangChain mouen el problema
La reacció lògica és ficar una capa per sobre. Ho he fet dues vegades.
En un SaaS de transcripció tenia un lib/claude.ts amb un processWithClaude(text, prompt) cridant directe al SDK d'Anthropic. El vaig esborrar sencer i el vaig substituir per un AIService singleton contra OpenRouter, amb modelName i temperature com a paràmetres que viatgen des del FormData de l'endpoint. Guany real: canviar de model va passar a ser enviar una altra cadena a la petició.
En una app de xat amb projectes i contextos, la capa va ser LangChain, amb @langchain/anthropic i @langchain/google-genai per poder tenir un desplegable agrupat per proveïdor. Allà vaig aprendre l'altra meitat: la capa també es trenca. Un commit dels primers dies es diu literalment "update dependencies for @langchain/anthropic and zod to latest versions". Actualitzar a latest en abstraccions sobre APIs que es mouen és canviar un problema que entens per un que no.
El que fan aquestes capes és real però limitat: normalitzen la forma de la crida. No normalitzen les capacitats. Si el model A accepta temperature i el B no, la capa et deixa passar els dos paràmetres i l'error t'arriba igual, només que ara des de dins d'una llibreria, amb el stack trace tres nivells més avall.
El cost de canviar a mig producte
El dia que vaig canviar el model d'un generador d'horaris de google/gemini-3-flash-preview a deepseek/deepseek-v3.2, el diff va tocar tres fitxers: app/api/generate/route.ts, app/api/adjust-schedule/route.ts i app/api/verify-checks/route.ts. El mateix literal, copiat tres vegades dins de tres JSON.stringify diferents. Això no és una falla del proveïdor, és meva, i és el cost real: quan el nom del model està incrustat al punt de crida, migrar és un find-and-replace i resar per no deixar-te'n cap.
En un servei d'anàlisi de documents vaig intentar arreglar-ho centralitzant models i càlcul de cost en un fitxer de constants. Em va costar tres refactoritzacions en tres setmanes: una el dia 18, una altra l'endemà, i abans encara una més. El nom d'un dels commits porta l'errada posada, "reafactor constants", cosa que diu prou de com va anar la tarda. La capa de constants no va quallar a la primera perquè estava intentant ficar en la mateixa estructura dues coses diferents: quin model fer servir i quant costa.
Com aïllo avui la crida
Sense cerimònia. Tres regles.
El model mai no és un literal al punt de crida. Sempre una constant llegida d'entorn amb default, tipus RERANK_MODEL = process.env.RERANK_MODEL || "gpt-5.4-mini". Canviar de model en producció no hauria de requerir un desplegament.
Els paràmetres específics del model viatgen amb el model. Si un model necessita reasoning_effort i un altre temperature, això és configuració del model, no de la funció. Un objecte per model, no un paràmetre solt que arrossegues per sis signatures.
Defensa a la vora, sempre. Quan vaig afegir el timeout a les crides d'OpenAI, vaig ficar també això al batch d'embeddings:
if (embeddings.length !== input.length) {
throw new Error(
`Embedding provider returned ${embeddings.length} vectors for ${input.length} inputs`
);
}
Quatre línies. Converteixen un desalineament silenciós entre vectors i textos —que et deixa la base plena d'embeddings assignats a l'anime equivocat— en un error immediat. L'AbortSignal.timeout(30_000) del mateix commit fa el mateix amb les crides penjades.
Què dono per estable
Estable: que hi ha un endpoint HTTP, que accepta JSON, que retorna JSON. Aquí s'acaba.
No estable: el nom del model, els paràmetres de mostreig, la forma del missatge, la forma de la resposta, d'on ve el comptador de tokens, el preu, i que el model d'avui continuï existint d'aquí a sis mesos.
No dissenyo per al model que faig servir. Dissenyo per al dia que deixi de ser-hi.
