LiteLLM with a custom OpenAI-compatible endpoint
LiteLLM exists to stop you writing vendor-specific code. Point it at one OpenAI-compatible base URL and the same completion() call reaches every model your gateway exposes. Then add a fallback so a provider outage becomes a footnote rather than an incident.
What you need
- A gateway API key.
- The base URL:
https://aicomp.ai/v1. - At least one exact model ID from
GET /models, LiteLLM does not guess these.
curl https://aicomp.ai/v1/models \
-H "Authorization: Bearer sk-your-gateway-key"
A JSON list of model IDs means the key is good. Invalid token means it was copied wrong.
Step 1 — the smallest working call
Read this one carefully, because the prefix is where most people lose twenty minutes:
import os
from litellm import completion
# LiteLLM needs the provider prefix to know which wire format to speak.
# Everything OpenAI-compatible uses "openai/", including when you point it
# at a gateway rather than at OpenAI.
resp = completion(
model="openai/deepseek-v4-pro",
api_base=os.environ["GATEWAY_BASE_URL"], # https://aicomp.ai/v1
api_key=os.environ["GATEWAY_API_KEY"],
messages=[{"role": "user", "content": "Summarise this file."}],
max_tokens=400,
)
print(resp.choices[0].message.content)
The openai/ prefix selects the request format, not the vendor. With api_base set, it is the format that matters (the request goes where you told it to go).
Step 2 — streaming (what every chat UI needs)
from litellm import completion
resp = completion(
model="openai/deepseek-v4-pro",
api_base=os.environ["GATEWAY_BASE_URL"],
api_key=os.environ["GATEWAY_API_KEY"],
messages=[{"role": "user", "content": "Write the migration SQL."}],
stream=True,
)
for chunk in resp:
delta = chunk.choices[0].delta.content
if delta:
print(delta, end="", flush=True)
Step 3 — add a fallback before you need one
Fallbacks are cheap insurance and they compound well with mixed pricing: put an inexpensive model first and promote to a stronger one only when the cheap path fails.
from litellm import completion
# Cheap model first; if it errors, retry on the stronger one.
# The caller never sees which one answered.
resp = completion(
model="openai/deepseek-v4-flash",
api_base=os.environ["GATEWAY_BASE_URL"],
api_key=os.environ["GATEWAY_API_KEY"],
messages=[{"role": "user", "content": "Explain this stack trace."}],
fallbacks=[{"deepseek-v4-flash": ["deepseek-v4-pro"]}],
num_retries=2,
)
Step 4 — move it into the proxy when more than one process needs it
Once several services share the endpoint, repeating api_base per call is how drift starts. The proxy takes the same values once:
model_list:
- model_name: default
litellm_params:
model: openai/deepseek-v4-pro
api_base: https://aicomp.ai/v1
api_key: os.environ/GATEWAY_API_KEY
- model_name: cheap
litellm_params:
model: openai/deepseek-v4-flash
api_base: https://aicomp.ai/v1
api_key: os.environ/GATEWAY_API_KEY
litellm_settings:
drop_params: true # ignore params the upstream model does not accept
num_retries: 2
request_timeout: 60
# Routes: callers ask for "default", the proxy decides what that means today.
router_settings:
fallbacks: [{ default: ["cheap"] }]
Start it with litellm --config config.yaml --port 4000 and callers use one stable URL. Changing which model answers becomes a config edit instead of a deploy.
https://aicomp.ai/v1).
Create one free →
How this fails in practice
| Symptom | Cause | Fix |
|---|---|---|
LLM Provider NOT provided | Missing openai/ prefix | Prefix every model ID used with a custom base URL |
404 on /chat/completions | Trailing path doubled in api_base | End the base URL at /v1 |
| 400 about an unsupported parameter | Vendor-specific fields forwarded to a model that lacks them | drop_params: true in proxy settings |
| Cost logs disagree with your invoice | Built-in cost map does not know gateway rates | Compute cost from returned token usage and your own rates |
| Timeout under load | No retry or timeout set | num_retries and request_timeout in settings |
Why changing one URL is enough
An OpenAI-compatible client does not have a provider hardcoded into it. It holds a base
URL, and at request time it concatenates that base URL with a resource path —
/chat/completions, /models, then sends the same JSON body it would
have sent anywhere else, with your key in an Authorization: Bearer header. The
base URL is therefore the only thing that decides where the request physically goes.
That is why this change is one line and not a rewrite. Your message array, your tool
definitions, your streaming handling and your response parsing are all unchanged, because the
wire format is unchanged. What changes is who answers, and what they charge per token. The two
values that travel with the new destination are the base URL itself and the API key; the model
ID is the third, and it has to be one the new endpoint actually serves, which you can list with
a single call to /v1/models rather than guess at.
The practical consequence is that this is reversible. Keep the base URL in configuration rather than inline, and moving between providers is a config change you can undo in a minute — which is also the right way to test it: point a single non-critical call at the new endpoint first, confirm it in the usage log, then move the rest.
What a day of this actually costs
Fallback strategies look free until you read the invoice. Below is one heavy agent day (200k input, 60k output) which is the shape of work these tools generate:
| Model | Vendor | Rate in / out per 1M | Per day | Per month |
|---|---|---|---|---|
| gpt-5.6-luna | OpenAI | $0.1 / $0.6 | $0.06 | $1 |
| deepseek-v4-flash | DeepSeek | $0.15 / $0.6 | $0.07 | $1 |
| MiniMax-M3 | MiniMax | $0.15 / $0.6 | $0.07 | $1 |
| gemini-3.7-flash | $0.375 / $1.875 | $0.19 | $4 | |
| claude-haiku-4-5-20251001 | Anthropic | $0.5 / $2.5 | $0.25 | $5 |
| deepseek-v4-pro | DeepSeek | $0.66 / $1.98 | $0.25 | $5 |
| glm-5.3 | Zhipu | $0.7 / $2.2 | $0.27 | $5 |
| qwen3.8-max | Alibaba | $1 / $3 | $0.38 | $8 |
| claude-sonnet-5 | Anthropic | $1 / $5 | $0.50 | $10 |
| gpt-5.6-terra | OpenAI | $1 / $6 | $0.56 | $11 |
| kimi-k3 | Moonshot | $1.5 / $7.5 | $0.75 | $15 |
| claude-opus-5 | Anthropic | $2.5 / $12.5 | $1.25 | $25 |
| Workload | gpt-5.6-luna | claude-sonnet-5 | deepseek-v4-flash |
|---|---|---|---|
| Chat assistance 50 requests a day, 2k in / 500 out | $0 | $4 | $1 |
| Bulk processing 5,000 short jobs a day, 800 in / 300 out | $26 | $230 | $30 |
| Agent loop 20 turns a day, 60k in / 800 out | $3 | $26 | $4 |
One month at 20 working days, gateway rates checked 2026-11-09. These are the token volumes, not a quote Your own shape may be larger or smaller.
Worth reading both tables together: the agent-day table is one point on a curve, and this one shows that the curve does not rank models the same way everywhere. Where a workload is mostly input, the input rate decides it; where it is mostly output, the output rate does. A fallback chain that sends cheap traffic to one model and hard traffic to another is only saving money if the two models are actually cheaper on the shapes you are sending them.
FAQ
Why does LiteLLM reject my model name without the openai/ prefix?
The prefix is how LiteLLM picks the request format and response parser. With a custom base URL and no prefix, it has nothing to match against and raises a mapping error. Every OpenAI-compatible route uses openai/.
Can I set api_base once instead of on every call?
Yes — set it per entry in the proxy model_list under litellm_params, or set the corresponding provider environment variable in the process that calls LiteLLM. Per-call arguments win over both, which is useful when two departments need different endpoints.
Does LiteLLM still track cost correctly with a custom endpoint?
It tracks what it knows. Its built-in cost map is compiled from public list prices and will not know a gateway's rate for a model it has never seen, so budget numbers can be wrong by a wide margin. Log usage.prompt_tokens and usage.completion_tokens yourself and multiply by your own rates — see per-request spend attribution.
What does drop_params actually change?
It stops LiteLLM forwarding parameters the upstream model rejects. Without it, sending a vendor-specific field to a model that does not implement it turns a working call into a 400. Set it when you route the same request shape across several vendors.
Is the proxy required?
No. It matters once more than one process (or more than one language) needs the same endpoint and key. Until then, the plain Python call above is enough and one moving part fewer.
What is the LiteLLM base URL for an OpenAI-compatible endpoint?
It goes in api_base — per call as completion(model="openai/…", api_base="…"), or once per model in the proxy config under litellm_params. Include the version prefix your gateway expects (most OpenAI-compatible routes end in /v1); it is set independently of api_key, so a working key with a missing or wrong base URL still fails.
How do I set api_base in the LiteLLM proxy config.yaml?
Put it under litellm_params for each entry in model_list. That is the only place the proxy reads it from. A provider environment variable in the proxy process works as a fallback, but an explicit model_list entry wins and is far easier to audit once several models share one endpoint.
Does LiteLLM work with any OpenAI-compatible provider?
Yes. It routes on the model prefix rather than the vendor name, so anything speaking the OpenAI /chat/completions shape works — you only need to keep the openai/ prefix so LiteLLM knows which request format to build. One caveat: models your gateway exposes that LiteLLM has never seen need their rate added by hand, or its cost tracking will quietly report the wrong number.
Related
- All tools with one endpoint — the matrix
- Pydantic AI, the other Python library, and its route trap
- Aider (same routing), in the terminal
- Continue — per-role models in the IDE
- Vercel AI SDK with a custom endpoint
- LangChain with a base URL override
- Attribute spend per request yourself
- Ranking models by token mix, not input price
- Full price index