The whole point of OpenAI-compatible APIs is that the SDK just works — so when it 401s, the bug is almost always in the two lines you changed: base_url and api_key. Here are the failure modes in the order they actually occur.
The error
{
"error": {
"type": "invalid_api_key",
"message": "Invalid or missing API key.",
"code": "invalid_api_key"
}
}Causes and fixes at a glance
| Cause | Fix |
|---|---|
| base_url missing the /v1 suffix (or doubling it) | Most gateways want https://host/v1 exactly — the SDK appends /chat/completions itself. |
| Key from a different host | sk-… keys only authenticate against the service that minted them; check prefix ↔ host. |
| Corporate proxy / WAF stripping the Authorization header | Test from a clean network; configure the proxy to pass Authorization through. |
| OPENAI_API_KEY env var overriding your explicit key | The SDK reads env by default — a stale env var silently wins in some setups; pass api_key explicitly. |
Verify the exact URL the SDK is hitting
Print client.base_url and call GET /v1/models — the cheapest authenticated endpoint. If /models works, auth is fine and your error is elsewhere:
from openai import OpenAI
client = OpenAI(
base_url="https://api.kunavo.com/v1", # exactly one /v1
api_key="sk-kunavo-...", # explicit beats env vars
)
print(client.base_url)
print([m.id for m in client.models.list().data][:5])Curl the same host to rule the SDK out
If curl with Authorization: Bearer works and the SDK doesn't, compare the SDK's actual request (set OPENAI_LOG=debug) — nine times out of ten a proxy or env var rewrote something.
If you're calling through Kunavo
Kunavo's endpoint is strict-OpenAI-shaped at https://api.kunavo.com/v1 with Bearer auth, and GET /v1/models works as the auth smoke test. If your code runs against api.openai.com, pointing base_url at Kunavo is the only change — same SDK, same wire format, one key for Claude, Gemini, GPT and media models.
FAQ
401 vs 403 on a gateway — what's the difference?
401 = the credential itself wasn't accepted (missing/invalid key). 403 = the key is valid but not allowed to do that (disabled key, suspended account, model not permitted). Read the error body — compatible APIs put the reason in error.message.
Why does my code work locally but 401 in CI?
CI is a different environment: the secret isn't set, is set for a different service, or a proxy strips the header. Log repr(key[:12]) and the base_url from inside CI to see what's actually being sent.
More error semantics live in the error reference; getting a key takes a minute via sign up and the authentication docs.