Claude Opus 5 is now live on CometAPI โ†’

How to Build AI Apps That Aren't Locked to One Provider

CometAPI
AnnaJun 7, 2026
How to Build AI Apps That Aren't Locked to One Provider

Vendor lock-in in AI apps usually doesn't happen all at once. It creeps in โ€” a direct import openai here, a hardcoded model name there, a response field you parse without checking if other providers return the same thing. Six months later, switching providers means rewriting half your backend.

The four ways lock-in happens

Most developers think lock-in means "I'm using the OpenAI SDK." That's the least dangerous kind. The real traps are subtler:

Lock-in typeHow it happensConsequence
SDK lock-infrom openai import OpenAI everywhereSwitching SDK means touching every file
Model name lock-inmodel="gpt-4o" hardcoded in business logicEvery model change is a code change
Parameter lock-inUsing logprobs, n>1, or reasoning_effortThese don't exist on Claude or Gemini
Response format lock-inParsing provider-specific response fieldsDifferent providers return different shapes

The goal isn't to eliminate all of these โ€” some are acceptable tradeoffs. The goal is to know which ones you're taking on.

Use an OpenAI-compatible endpoint as your abstraction layer

The cleanest way to avoid SDK lock-in is to use a single OpenAI-compatible endpoint that routes to multiple providers. You keep the OpenAI SDK, but the backend can be any provider.

CometAPI does this โ€” one endpoint, one key, 500+ models across OpenAI, Anthropic, Google, DeepSeek, xAI, and others:

import osfrom openai import OpenAIfrom dotenv import load_dotenvโ€‹load_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,)

Switching from GPT to Claude to Gemini is a one-line change:

# 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=[...])

Note: Model names like gpt-5.4 and claude-sonnet-4-6 are CometAPI's platform identifiers โ€” they work through https://api.cometapi.com/v1 only, not through OpenAI or Anthropic's APIs directly. See the full model list for the complete catalog and pricing.

Keep model names out of your business logic

Model names scattered through your code is the most common form of lock-in. The fix is a central config that reads from environment variables:

# config.py โ€” one place to change model assignmentsimport osโ€‹MODEL_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")

Your business logic never references a model name directly:

from config import MODEL_CONFIGโ€‹def 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

To switch the summarization model across your entire app, change one environment variable. No grep, no find-and-replace.

Wrap the response so your code doesn't depend on provider-specific fields

Different providers return slightly different response shapes. If you parse raw API responses throughout your codebase, you're locked to that provider's format.

Wrap it into a normalized 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: intโ€‹def 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, ย   )

Now your business logic works with AIResponse objects, not raw API responses. If a provider changes their response format, you fix it in one place.

Add streaming support to the wrapper

For chat interfaces, you'll want streaming. The wrapper handles it as a separate path:

from typing import Iteratorโ€‹def 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)

Know which parameters create lock-in

Some parameters only exist on specific providers. Using them is fine โ€” just know you're making a deliberate choice:

ParameterWorks onLock-in risk
logprobsGPT onlyHigh โ€” no equivalent on Claude or Gemini
n > 1GPT, Gemini (not Claude)Medium โ€” Claude requires looping
reasoning_effortGPT o-series onlyHigh โ€” no equivalent elsewhere
temperature > 1.0GPT, Gemini (not Claude)Low โ€” Claude caps at 1.0
toolsAll major providersNone โ€” safe to use
response_formatAll major providersLow โ€” minor schema differences

If you're using logprobs for confidence scoring, you're locked to GPT for that feature. That's a reasonable tradeoff โ€” just document it so the next developer knows why.

Make the provider endpoint configurable

Hard-coding base_url="https://api.cometapi.com/v1" is still a form of lock-in. Make it an environment variable:

# .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

The client initialization from Step 1 already reads from these variables. Switching between CometAPI and a direct provider connection is now a config change, not a code change.

Node.js version

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);}

What lock-in is acceptable

Not all lock-in is worth fighting. Some tradeoffs make sense:

  • Using the OpenAI SDK โ€” It's the de facto standard. Most providers support it. Low-risk lock-in.
  • Provider-specific features you actually need โ€” If you need logprobs, use them. Isolate that code so it's easy to find and replace later.
  • Fine-tuned models โ€” A fine-tuned model is inherently tied to one provider. That's expected.

The lock-in worth avoiding is the accidental kind โ€” model names in business logic, raw response parsing spread across files, API keys hardcoded in source.

What's next

You now have an abstraction layer that keeps provider details out of your business logic. The last article in this series covers what happens when things go wrong: how to debug failed generations, interpret error codes, and build error handling that actually tells you what broke.

Next: How to Debug Failed AI API Generations

FAQ

Q: What's the difference between SDK lock-in and model lock-in?

SDK lock-in means your code imports a specific library and would need to change if you switched SDKs. Model lock-in means model names are scattered through your business logic. SDK lock-in is less dangerous because most providers now support the OpenAI SDK format. Model lock-in is more insidious because it's harder to find and fix.

Q: If I use CometAPI, am I just trading OpenAI lock-in for CometAPI lock-in?

Partially. You're trading direct provider lock-in for a proxy layer. The upside: one key, one endpoint, easy model switching. The risk: if CometAPI has an outage, all your providers go down together. The mitigation is already in the code above โ€” AI_BASE_URL is an environment variable. If you need to bypass CometAPI and call a provider directly, it's a config change, not a code change.

Q: Can I use Claude's extended thinking or OpenAI's reasoning_effort through this pattern?

Yes, pass them as **kwargs to call_model. Just know that if you route that task to a different model, those parameters will be ignored or cause an error. Document which tasks use provider-specific features so the next developer knows why.

Q: How do I handle Claude's temperature cap at 1.0 when routing between Claude and GPT**?**

Keep temperature at or below 1.0 to stay in the safe range for both. If you need higher temperature for creative tasks on GPT specifically, route those tasks to GPT explicitly in MODEL_CONFIG rather than letting them fall through the generic router.

Q: Should I abstract the image and video generation APIs the same way?

The same principles apply โ€” central config, normalized response wrapper, no provider-specific fields in business logic. Image and video APIs have more structural differences (async vs sync, different parameter sets) so the abstraction layer takes more work. Start with text, then extend the pattern once the structure is proven.

Q: What about context window differences between models?

This is a real risk when routing. GPT-5.5 has a 1M token context window, Claude models support up to 200K, and Gemini 3.5 Flash supports up to 1M. If you route a long document task to a model with a shorter context window, the input gets truncated silently. Add a context length check before routing if your tasks involve long inputs โ€” or always route long-context tasks to a specific model in MODEL_CONFIG rather than letting them fall through to a default.

Ready to cut AI development costs by 20%?

Start free in minutes. Free trial credits included. No credit card required.

Read More