commandcode/…) and Free Model (freemodel/…).
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 The method signatures must match LiteLLM’s dispatch exactly —
~/apps/litellm/custom_providers/commandcode_handler.py: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 The
litellm_settings: in ~/apps/litellm/config.yaml: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 and under Then add the real key to The
~/apps/litellm/docker-compose.yml, under the proxy service:environment::~/apps/litellm/.env::? form makes the container refuse to start without the key — the same pattern as the master key.4
Recreate the proxy
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 rawhttpx.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.
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.