curl, jq, Python 3.10+ with the openai package, an API key from one router, and (optionally) vendor keys for a self-hosted proxy/v1/models and the pricing page, not from this text
export X=Y with $env:X="Y"curl (any modern build), jq 1.6+, Python 3.10+, pip install openai4000Term check: an API router (also called an aggregator or gateway) is a single HTTP endpoint that forwards your request to whichever model vendor you name, so your code talks to one URL instead of four SDKs. "OpenAI-compatible" means the endpoint accepts the same request shape as OpenAI's /v1/chat/completions, which is why the Python snippet in Step 2 works unchanged against any router that supports it.
There are two shapes of router. Pick one before you write code, because the credential model differs.
| Criterion | Hosted aggregator | Self-hosted proxy |
|---|---|---|
| What it is | A vendor-run API that already holds accounts with Anthropic, OpenAI, DeepSeek, Zhipu | A process you run (for example LiteLLM) that holds *your* vendor keys and exposes one local port |
| Credentials you manage | One router key | Four vendor keys plus a local proxy key |
| Free/cheap access | Depends on the router's current trial credits and per-model pricing — verify on its pricing page | You pay each vendor directly at list price; no markup and no free tier added |
| Billing visibility | One invoice, vendor-level detail varies | Per-vendor invoices, full detail |
| Data path | Prompts pass through a third party — check its retention policy | Prompts go only to the vendors you configured |
| Best for | Interns prototyping fast, one-key demos | Teams with existing vendor contracts, compliance constraints |
What each model family requires from the router:
| Model family | Endpoint contract | Verify in the docs |
|---|---|---|
| Claude (Anthropic) | OpenAI-compatible `/v1/chat/completions`, or a native Anthropic passthrough | Exact model IDs, per-1M-token input/output price, context window |
| GPT (OpenAI) | `/v1/chat/completions` | Which model IDs your key tier can call, rate limits |
| DeepSeek | `/v1/chat/completions` | Whether the free/trial quota applies, price per 1M tokens |
| GLM (Zhipu) | `/v1/chat/completions` | Provider prefix used in self-hosted configs, model IDs |
What to do: create a router account (or install the proxy), copy the base URL, store the key in an environment variable, and pull the live model list.
Why: every later step depends on three strings — base URL, key, model ID. Guessing the model ID is the single most common cause of a 404 two steps later, so read them from the API instead of from a blog post.
export ROUTER_BASE_URL="https://<router-base-url>/v1"
export ROUTER_API_KEY="<your-api-key>"
curl -s "$ROUTER_BASE_URL/models" \
-H "Authorization: Bearer $ROUTER_API_KEY" \
| jq -r '.data[].id' | sort | head -20
Example output (IDs are illustrative — yours will differ):
anthropic/<claude-model-id>
deepseek/<deepseek-model-id>
openai/<gpt-model-id>
zhipu/<glm-model-id>
Why this works: /v1/models is part of the OpenAI-compatible surface, so any router that claims compatibility must answer it. If you get HTML back instead of JSON, the base URL is wrong.
Write the four IDs you plan to use into a local file so the later steps stay reproducible:
cat > models.env <<'EOF'
CLAUDE_MODEL=<id-from-the-list-above>
GPT_MODEL=<id-from-the-list-above>
DEEPSEEK_MODEL=<id-from-the-list-above>
GLM_MODEL=<id-from-the-list-above>
EOF
How to check: source models.env && echo $CLAUDE_MODEL prints the ID, not an empty line.
Verification checklist for the router's docs page — tick all six before spending money:
/v1, not /api)
What to do: configure the OpenAI Python SDK with the router's base URL, then — if you chose the self-hosted proxy — describe all four models in one YAML file.
Why: a single client with a base_url override replaces four vendor SDKs. Swapping models becomes a string change, and a fallback becomes a retry, not a refactor.
import os
from openai import OpenAI
client = OpenAI(
base_url=os.environ["ROUTER_BASE_URL"],
api_key=os.environ["ROUTER_API_KEY"],
timeout=60.0,
)
MODELS = {
"claude": os.environ["CLAUDE_MODEL"],
"gpt": os.environ["GPT_MODEL"],
"deepseek": os.environ["DEEPSEEK_MODEL"],
"glm": os.environ["GLM_MODEL"],
}
def ask(family: str, prompt: str) -> str:
r = client.chat.completions.create(
model=MODELS[family],
messages=[{"role": "user", "content": prompt}],
max_tokens=128,
)
print(family, r.usage.prompt_tokens, r.usage.completion_tokens)
return r.choices[0].message.content
print(ask("deepseek", "Reply with the single word: ready"))
Example: running it prints deepseek 12 1 followed by ready — the three numbers are family, input tokens and output tokens. Those token counts are what you multiply by the per-1M prices from Step 1 to get the real cost of a call.
Why this works: base_url is the only field that changes; the request body stays in the OpenAI schema, and the router rewrites it into each vendor's native format.
If you run the self-hosted proxy instead, keep every credential on the server and give clients only a local key:
model_list:
- model_name: claude
litellm_params:
model: anthropic/<anthropic-model-id>
api_key: os.environ/ANTHROPIC_API_KEY
- model_name: gpt
litellm_params:
model: openai/<openai-model-id>
api_key: os.environ/OPENAI_API_KEY
- model_name: deepseek
litellm_params:
model: deepseek/<deepseek-model-id>
api_key: os.environ/DEEPSEEK_API_KEY
- model_name: glm
litellm_params:
model: <provider-prefix>/<glm-model-id>
api_key: os.environ/ZHIPU_API_KEY
litellm --config config.yaml --port 4000
Why this works: the proxy translates the OpenAI schema into each vendor's API and maps model_name to a real model ID, so your application only ever sends claude or glm. The vendor prefix for GLM and the exact CLI flag are version-specific — check the proxy's documentation if startup fails.
How to check: curl -s localhost:4000/v1/models -H "Authorization: Bearer <local-key>" | jq '.data | length' returns 4.

What to do: run a smoke test across all four families, measure round-trip time, and confirm the router returns a usage block — then break a key on purpose to see the error shape.
Why: a router can list a model and still fail on call (provider outage, quota exhausted, missing permission). You want that discovered in a test, not in a demo.
for m in "$CLAUDE_MODEL" "$GPT_MODEL" "$DEEPSEEK_MODEL" "$GLM_MODEL"; do
printf '%s ' "$m"
curl -s -o /dev/null -w '%{http_code} %{time_total}s\n' \
"$ROUTER_BASE_URL/chat/completions" \
-H "Authorization: Bearer $ROUTER_API_KEY" \
-H "Content-Type: application/json" \
-d "{\"model\":\"$m\",\"messages\":[{\"role\":\"user\",\"content\":\"Say OK\"}],\"max_tokens\":8}"
done
Example output:
anthropic/<claude-model-id> 200 1.842s
openai/<gpt-model-id> 200 0.914s
deepseek/<deepseek-model-id> 200 2.307s
zhipu/<glm-model-id> 200 1.115s
Four 200 lines mean the router is working end to end. Note the timings in a comment next to your config — they drift, and a sudden 10x jump usually means the router fell back to a slower provider silently.
Why this works: -o /dev/null throws away the body and -w prints only the status code and total time, so you get a clean matrix without parsing JSON.
How to check success on screen:
200ask("claude", "hi") from Step 2 returns text, not an exception401 with a JSON error body, confirming errors are not masked4000.Problem. Authentication fails on every model even though the key looks correct
Error code: 401 - {'error': {'message': 'Invalid API key', 'type': 'authentication_error'}}printf '%s' "$ROUTER_API_KEY" | wc -c and compare with the key length shown in the dashboard; re-export with export ROUTER_API_KEY="<key>" (no spaces around =, no quotes inside the value), then rerun the Step 1 curl.Problem. One model returns 404 while the others answer
Error code: 404 - {'error': {'message': 'model not found', 'type': 'invalid_request_error'}}curl -s "$ROUTER_BASE_URL/models" | jq -r '.data[].id' | grep -i glm and paste the exact string into models.env. Never hand-write the vendor prefix in the self-hosted config.Problem. Long prompts time out
openai.APITimeoutError: Request timed out.timeout=60.0 (or higher) in the client, cut max_tokens for the test call, and re-run the Step 3 timing loop to see whether the delay is one model or all four.You now have one OpenAI-compatible endpoint that reaches Claude, GPT, DeepSeek and GLM, plus a repeatable smoke test that reports status codes and latency per model. Next, add a fallback list in your client so a failing model retries on a second one, and re-check /v1/models and the pricing page before every release.
What is an API router in one sentence?
It is a single HTTP endpoint that forwards your request to different model vendors, so your code uses one URL, one key and one request format instead of four.
Is an OpenAI-compatible router slower than calling the vendor directly?
It adds one network hop, which is visible as extra latency in the time_total column of the Step 3 loop; measure your own numbers, since routing and retry behaviour differ per provider.
Can I use a router with free-tier access for production?
Only after reading the router's rate limits and retention policy — free tiers usually cap requests per minute and may log prompts; the tokens-per-minute figure is on the pricing or limits page.
Why does the same prompt give different token counts on different models?
Each vendor tokenises text differently, so usage.prompt_tokens varies; always multiply the returned counts by that specific model's per-1M price rather than assuming one rate.