Vendor lock-in in AI-apps ontstaat meestal niet in één keer. Het sluipt erin — hier een directe import openai, daar een hardgecodeerde modelnaam, een responseveld dat je parse zonder te checken of andere providers hetzelfde teruggeven. Zes maanden later betekent van provider wisselen dat je de helft van je backend moet herschrijven.
De vier manieren waarop lock-in ontstaat
De meeste developers denken dat lock-in betekent: "Ik gebruik de OpenAI-SDK." Dat is de minst gevaarlijke soort. De echte valkuilen zijn subtieler:
| Type lock-in | Hoe het gebeurt | Gevolg |
|---|---|---|
| SDK-lock-in | from openai import OpenAI overal | Overschakelen van SDK vereist aanpassingen in elk bestand |
| Modelnaam-lock-in | model="gpt-4o" hardgecodeerd in businesslogica | Elke modelwijziging is een codewijziging |
| Parameter-lock-in | Gebruik van logprobs, n>1 of reasoning_effort | Die bestaan niet op Claude of Gemini |
| Response-formaat-lock-in | Providerspecifieke responsevelden parsen | Verschillende providers leveren verschillende structuren |
Het doel is niet om al deze vormen uit te bannen — sommige zijn acceptabele trade-offs. Het doel is te weten welke je bewust neemt.
Gebruik een OpenAI-compatibel endpoint als je abstractielaag
De schoonste manier om SDK-lock-in te vermijden is een enkel OpenAI-compatibel endpoint te gebruiken dat naar meerdere providers routeert. Je behoudt de OpenAI-SDK, maar de backend kan elke provider zijn.
CometAPI doet dit — één endpoint, één key, 500+ modellen over OpenAI, Anthropic, Google, DeepSeek, xAI en anderen:
import osfrom openai import OpenAIfrom dotenv import load_dotenvload_dotenv()api_key = os.environ.get("AI_API_KEY")if not api_key: raise ValueError("AI_API_KEY environment variable is not set")client = OpenAI( base_url=os.environ.get("AI_BASE_URL", "https://api.cometapi.com/v1"), api_key=api_key,)
Overschakelen van GPT naar Claude naar Gemini is een wijziging van één regel:
# Beforeresponse = client.chat.completions.create(model="gpt-5.4", messages=[...])# After — same code, different modelresponse = client.chat.completions.create(model="claude-sonnet-4-6", messages=[...])
Let op: Modelnamen zoals gpt-5.4 en claude-sonnet-4-6 zijn CometAPI-platformidentificaties — ze werken alleen via https://api.cometapi.com/v1, niet rechtstreeks via de API’s van OpenAI of Anthropic. Zie de volledige modellenlijst voor de complete catalogus en prijzen.
Houd modelnamen uit je businesslogica
Modelnamen verspreid door je code is de meest voorkomende vorm van lock-in. De oplossing is een centrale config die uit omgevingsvariabelen leest:
# config.py — one place to change model assignmentsimport osMODEL_CONFIG = { "summarize": os.environ.get("MODEL_SUMMARIZE", "claude-opus-4-7"), "code": os.environ.get("MODEL_CODE", "gpt-5.4"), "classify": os.environ.get("MODEL_CLASSIFY", "claude-haiku-4-5"), "chat": os.environ.get("MODEL_CHAT", "gpt-5.4-mini"),}# Validate at startup — fail fast rather than getting mysterious API errorsfor task, model in MODEL_CONFIG.items(): if not model: raise ValueError(f"Model config for '{task}' is not set")
Je businesslogica verwijst nooit rechtstreeks naar een modelnaam:
from config import MODEL_CONFIGdef summarize(text: str) -> str: response = client.chat.completions.create( model=MODEL_CONFIG["summarize"], messages=[{"role": "user", "content": f"Summarize: {text}"}], max_tokens=300 # move to config in production ) return response.choices[0].message.content
Om het samenvattingsmodel in je hele app te wisselen, verander je één omgevingsvariabele. Geen grep, geen zoeken-en-vervangen.
Normaliseer de response zodat je code niet afhankelijk is van providerspecifieke velden
Verschillende providers geven net iets andere responsevormen terug. Als je overal in je codebase ruwe API-responses parse, zit je vast aan het formaat van die provider.
Pak het in een genormaliseerde dataclass:
from dataclasses import dataclassfrom typing import Optionalfrom openai import OpenAI, APIStatusError, APIConnectionError, APITimeoutErrorfrom openai.types.chat import ChatCompletionimport logging@dataclassclass AIResponse: content: str model: str input_tokens: int output_tokens: intdef call_model(task: str, messages: list, **kwargs) -> AIResponse: """ Single entry point for all LLM calls. Returns a normalized AIResponse regardless of which model handled it. Raises on 4xx (client errors). Logs and re-raises on 5xx/network errors. """ model = MODEL_CONFIG.get(task, "gpt-5.4-mini") if not model: raise ValueError(f"No model configured for task '{task}'") try: response: ChatCompletion = client.chat.completions.create( model=model, messages=messages, **kwargs ) except APIStatusError as e: logging.error(f"API error for task={task} model={model}: {e.status_code} {e.message}") raise except (APIConnectionError, APITimeoutError) as e: logging.error(f"Network error for task={task} model={model}: {e}") raise # content is None when the model triggers a tool call instead of returning text content = response.choices[0].message.content or "" # usage is None in streaming mode — default to 0 if not available usage = response.usage input_tokens = usage.prompt_tokens if usage else 0 output_tokens = usage.completion_tokens if usage else 0 logging.info( f"task={task} model={model} " f"input_tokens={input_tokens} output_tokens={output_tokens}" ) return AIResponse( content=content, model=response.model, input_tokens=input_tokens, output_tokens=output_tokens, )
Je businesslogica werkt nu met AIResponse-objecten, niet met ruwe API-responses. Als een provider zijn responseformaat wijzigt, los je het op één plek op.
Voeg streaming-ondersteuning toe aan de wrapper
Voor chatinterfaces wil je streaming. De wrapper handelt dit af via een apart pad:
from typing import Iteratordef stream_model(task: str, messages: list, **kwargs) -> Iterator[str]: """ Stream tokens from the routed model. Note: streaming doesn't return usage data. Fallback is not supported in streaming mode — you've already started yielding tokens before you know if the full request succeeds. """ model = MODEL_CONFIG.get(task, "gpt-5.4-mini") if not model: raise ValueError(f"No model configured for task '{task}'") stream = client.chat.completions.create( model=model, messages=messages, stream=True, **kwargs ) for chunk in stream: delta = chunk.choices[0].delta.content if delta: yield delta# Usagefor token in stream_model("chat", [{"role": "user", "content": "Hello"}]): print(token, end="", flush=True)
Weet welke parameters lock-in veroorzaken
Sommige parameters bestaan alleen bij specifieke providers. Ze gebruiken is prima — weet gewoon dat je een bewuste keuze maakt:
| Parameter | Werkt op | Lock-in-risico |
|---|---|---|
| logprobs | GPT only | Hoog — geen equivalent op Claude of Gemini |
| n > 1 | GPT, Gemini (niet Claude) | Middelmatig — Claude vereist een loop |
| reasoning_effort | GPT o-series only | Hoog — geen equivalent elders |
| temperature > 1.0 | GPT, Gemini (niet Claude) | Laag — Claude cappt op 1.0 |
| tools | All major providers | Geen — veilig om te gebruiken |
| response_format | All major providers | Laag — kleine schemaverschillen |
Als je logprobs gebruikt voor betrouwbaarheidscores, zit je voor die feature vast aan GPT. Dat is een redelijke trade-off — documenteer het zodat de volgende developer weet waarom.
Maak het provider-endpoint configureerbaar
base_url="https://api.cometapi.com/v1" hardcoden is nog steeds een vorm van lock-in. Maak er een omgevingsvariabele van:
# .env — using CometAPIAI_BASE_URL=https://api.cometapi.com/v1AI_API_KEY=your_cometapi_key# To switch to OpenAI directly, change two lines:# AI_BASE_URL=https://api.openai.com/v1# AI_API_KEY=your_openai_key
De client-initialisatie uit Stap 1 leest al uit deze variabelen. Wisselen tussen CometAPI en een directe providerverbinding is nu een configwijziging, geen codewijziging.
Node.js-versie
import OpenAI from 'openai';const apiKey = process.env.AI_API_KEY;if (!apiKey) throw new Error('AI_API_KEY is not set');const client = new OpenAI({ baseURL: process.env.AI_BASE_URL ?? 'https://api.cometapi.com/v1', apiKey,});// Model IDs are CometAPI platform identifiers — see cometapi.com/modelsconst MODEL_CONFIG = { summarize: process.env.MODEL_SUMMARIZE ?? 'claude-opus-4-7', code: process.env.MODEL_CODE ?? 'gpt-5.4', classify: process.env.MODEL_CLASSIFY ?? 'claude-haiku-4-5', chat: process.env.MODEL_CHAT ?? 'gpt-5.4-mini',};// Validate at startupfor (const [task, model] of Object.entries(MODEL_CONFIG)) { if (!model) throw new Error(`Model config for '${task}' is not set`);}/** * Single entry point for all LLM calls. * Returns normalized response. Raises on 4xx, logs and re-raises on 5xx/network. */async function callModel(task, messages, options = {}) { const model = MODEL_CONFIG[task] ?? 'gpt-5.4-mini'; let response; try { response = await client.chat.completions.create({ model, messages, ...options, }); } catch (err) { // Don't swallow errors — log and re-raise console.error(`API error task=${task} model=${model}:`, err.message); throw err; } // content is null when model triggers a tool call const content = response.choices[0].message.content ?? ''; // usage may be absent in some configurations const inputTokens = response.usage?.prompt_tokens ?? 0; const outputTokens = response.usage?.completion_tokens ?? 0; console.log(`task=${task} model=${model} input=${inputTokens} output=${outputTokens}`); return { content, model: response.model, inputTokens, outputTokens };}/** * Stream tokens from the routed model. * Usage data is not available in streaming mode. */async function* streamModel(task, messages, options = {}) { const model = MODEL_CONFIG[task] ?? 'gpt-5.4-mini'; const stream = await client.chat.completions.create({ model, messages, stream: true, ...options, }); for await (const chunk of stream) { const delta = chunk.choices[0]?.delta?.content; if (delta) yield delta; }}// Usage — blockingconst result = await callModel('classify', [ { role: 'user', content: 'Positive or negative? "Loved it!"' }]);console.log(result.content);// Usage — streamingfor await (const token of streamModel('chat', [ { role: 'user', content: 'Hello' }])) { process.stdout.write(token);}
Welke lock-in is acceptabel
Niet alle lock-in is het waard om tegen te vechten. Sommige trade-offs zijn logisch:
- Het gebruik van de OpenAI SDK — Het is de de facto standaard. De meeste providers ondersteunen het. Lock-in met laag risico.
- Providerspecifieke features die je echt nodig hebt — Als je
logprobsnodig hebt, gebruik ze. Isoleer die code zodat het later makkelijk te vinden en te vervangen is. - Fijngetunede modellen — Een fijngetuned model is per definitie gekoppeld aan één provider. Dat is te verwachten.
De lock-in die je wilt vermijden is de onbedoelde — modelnamen in businesslogica, ruwe responseparsing verspreid over bestanden, API-keys hardgecodeerd in de broncode.
Wat nu
Je hebt nu een abstractielaag die providerdetails buiten je businesslogica houdt. Het laatste artikel in deze serie behandelt wat er gebeurt als het misgaat: hoe mislukte generaties te debuggen, foutcodes te interpreteren en error handling te bouwen die daadwerkelijk vertelt wat er misging.
Volgende: How to Debug Failed AI API Generations
FAQ
Q: Wat is het verschil tussen SDK-lock-in en modellock-in?
SDK-lock-in betekent dat je code een specifieke library importeert en aangepast moet worden als je van SDK wisselt. Modellock-in betekent dat modelnamen door je businesslogica verspreid zijn. SDK-lock-in is minder gevaarlijk omdat de meeste providers nu het OpenAI-SDK-formaat ondersteunen. Modellock-in is verraderlijker omdat het moeilijker te vinden en te repareren is.
Q: Als ik CometAPI gebruik, ruil ik dan niet gewoon OpenAI -lock-in in voor CometAPI-lock-in?
Gedeeltelijk. Je ruilt directe provider-lock-in in voor een proxylaag. Het voordeel: één key, één endpoint, makkelijk modellen wisselen. Het risico: als CometAPI een storing heeft, gaan al je providers tegelijk down. De mitigatie staat al in de bovenstaande code — AI_BASE_URL is een omgevingsvariabele. Als je CometAPI moet omzeilen en direct een provider aanroepen, is dat een configwijziging, geen codewijziging.
Q: Kan ik Claude’s extended thinking of OpenAI’s reasoning_effort via dit patroon gebruiken?
Ja, geef ze door als **kwargs aan call_model. Weet alleen dat als je die taak naar een ander model routeert, die parameters genegeerd worden of een error veroorzaken. Documenteer welke taken providerspecifieke features gebruiken zodat de volgende developer weet waarom.
Q: Hoe ga ik om met Claude’s temperature -limiet van 1,0 bij routering tussen Claude en GPT**?**
Houd temperature op of onder 1,0 om binnen de veilige marge voor beide te blijven. Als je voor creatieve taken specifiek hogere temperatuur op GPT nodig hebt, routeer die taken expliciet naar GPT in MODEL_CONFIG in plaats van ze door de generieke router te laten vallen.
Q: Moet ik de image- en videogeneratie-API’s op dezelfde manier abstraheren?
Dezelfde principes gelden — centrale config, genormaliseerde response-wrapper, geen providerspecifieke velden in businesslogica. Image- en video-API’s hebben meer structurele verschillen (async vs. sync, verschillende parametersets), dus de abstractielaag kost meer werk. Begin met tekst en breid het patroon uit zodra de structuur staat.
Q: Hoe zit het met context window -verschillen tussen modellen?
Dit is een reëel risico bij routeren. GPT-5.5 heeft een contextvenster van 1M tokens, Claude-modellen ondersteunen tot 200K, en Gemini 3.5 Flash ondersteunt tot 1M. Als je een langdocumenttaak routeert naar een model met een korter contextvenster, wordt de input stilzwijgend afgekapt. Voeg een controle van de contextlengte toe vóór het routeren als je taken lange input bevatten — of routeer taken met lange context altijd naar een specifiek model in MODEL_CONFIG in plaats van ze door een default te laten vallen.
