This page is for the admin. It explains how to add a provider that LiteLLM has no built-in knowledge of — so the dashboard shows the provider’s real name instead of a borrowed one. Use this when a provider speaks the OpenAI wire format but is not one of LiteLLM’s built-in providers. Two live examples on this gateway: Command Code (commandcode/…) and Free Model (freemodel/…).
Without this registration, the only way to reach such a provider is the openai/ prefix. That works, but the dashboard then labels the model “OpenAI” — which is false and confusing. This page fixes the label properly.

The pieces

A custom provider needs three things. All three live on the Anchor Node, not in the dashboard: The dashboard cannot create or edit any of these. LiteLLM only supports custom providers through the config file. Everything else — model entries, pools, teams, keys — stays in the dashboard and the database.

Step by step: adding Command Code

1

Write the handler

Create ~/apps/litellm/custom_providers/commandcode_handler.py:
The method signatures must match LiteLLM’s dispatch exactly — CustomLLM dispatches these as static methods. Two mistakes that each cost a debugging session here: stripping the provider prefix a second time inside the handler (LiteLLM already removed it), and forgetting the acompletion variant (the dashboard’s “Test Connection” uses it).
2

Register the provider in config.yaml

Under litellm_settings: in ~/apps/litellm/config.yaml:
The custom_handler path is relative to the container’s working directory — that is why the handlers directory is mounted into the container (next step).
3

Mount the handlers and pass the key

In ~/apps/litellm/docker-compose.yml, under the proxy service:
and under environment::
Then add the real key to ~/apps/litellm/.env:
The :? form makes the container refuse to start without the key — the same pattern as the master key.
4

Recreate the proxy

A plain restart is enough for a config change, but a new volume mount needs the recreate. Health check afterwards: curl http://127.0.0.1:4000/health/liveliness from the box.
5

Register model entries with the new prefix

In the dashboard: Models → + Add Model. LiteLLM Model is now commandcode/deepseek-v4.1-flash — the real provider name first. Public name as usual (see Models, pools and fallbacks).
6

Test, both ways

Test through the dashboard’s Test Connection button on the model’s page, and through the public name:
Both paths must succeed. The dashboard test exercises the provider path; the public-name test exercises the router path. One green and one red means the pool is broken, not the provider.

Free Model — the same pattern, shorter

Free Model differs only in the details, which shows how reusable the pattern is: The handler body is otherwise identical — copy one, rename the class, change the endpoint and the env var name.

What breaks, and what it looks like

Raising errors the router can act on

A handler that works is not yet a handler that behaves in a pool. The router decides whether to cool an entry down by reading the status code off the exception it catches. A raw httpx.HTTPStatusError carries no status the router recognises, so a provider returning 429 looks to the router like an ordinary failure: the entry is never cooled down, it keeps its share of the traffic, and every request routed to it fails until a human notices. Map the response to LiteLLM’s own exception types before raising, and pass the provider’s headers through on rate limits — that is where retry-after lives, and it is what lets the router hold a quota-exhausted entry until the quota actually resets rather than retrying it every minute.
Call it from every path that touches the network — completion, acompletion, streaming and astreaming — not only the one you tested by hand.
LiteLLM reads the headers off exception.headers first and exception.response.headers second, so passing headers= explicitly on RateLimitError is the reliable form. The effect is visible in Models, pools and fallbacks: with the header honoured, a member that reports a 3.6-day quota reset is held out for 3.6 days instead of being retried every 60 seconds.

Forwarding reasoning and tool calls while streaming

Two fields do not survive a naive streaming handler, and both fail quietly — the call succeeds and the content is simply missing. GenericStreamingChunk accepts exactly these keys: text, tool_use, is_finished, finish_reason, usage, index, provider_specific_fields. Anything else is dropped without a warning.
A blank reply is usually not the provider’s fault. Reasoning tokens are counted against max_tokens. A model given a small budget can spend all of it thinking and return empty content with finish_reason: "length" — which reads exactly like a broken handler. Check finish_reason before blaming the integration.

Where things live afterwards