headroom/tests/test_litellm_caller_key.py
Tejas Chopra 2f2950a626
fix(litellm): don't forward a caller key the target cannot accept (#2883)
Split out of #2852 at review request: that PR is bounded upstream calls
plus
measured hot-path costs, and this is an authentication/routing change
that
belongs on its own scope. #2852 now carries only the timeout work.

## The bug

A routing extension can rewrite the model across families mid-request
(`claude-opus-5` → `gpt-5-mini`). The caller's key does not travel with
that
rewrite, so the proxy forwards `sk-ant-...` to OpenAI and earns a
guaranteed
401. Downstream that is indistinguishable from *"the cheap model failed
the
task"* — it scores as a quality regression against the router, not as a
bug.

Dropping the `api_key` kwarg instead lets litellm fall back to the
target
provider's own env credential, which is the only key that can work.

## Why this cut is different from the one that was rejected

The first version returned `not provider.startswith("anthropic")`, so
**any**
non-`sk-ant-` credential was dropped against an Anthropic-class target —
a
plain Bearer token against an Anthropic-compatible or custom gateway
lost its
key and fell back to an env credential that may not exist.

That direction is the dangerous one. A false refusal breaks a deployment
that
was working; a missed refusal just leaves today's 401. So this refuses
on
**positive evidence only**:

| credential | target | forwarded? |
|---|---|---|
| `sk-ant-…` | `openai` / `azure` / `gemini` | **no** — cannot possibly
authenticate |
| `sk-ant-…` | anthropic | yes |
| `sk-ant-…` | unrecognised / unclassifiable model | yes — pass-through
|
| anything else | anything | yes — pass-through, unchanged |

`sk-ant-` is Anthropic's documented vendor-specific prefix, which is
what makes
it classifiable. `sk-` is not: a dozen vendors mint that shape.
Everything the
string cannot settle keeps main's behaviour.

The reject list is explicit rather than inverted (`not anthropic`)
because an
unrecognised provider is usually a compatible or self-hosted gateway.
Marked in
the code as a hand-kept tuple with the registry-lookup upgrade path
noted.

Bedrock / Vertex / SageMaker are unaffected — all four dispatch sites
already
skip credential forwarding for them entirely (env-based auth).

## Verification

`tests/test_litellm_caller_key.py`, 12 cases — the refusal, the
Anthropic
target, the unknown provider, `get_llm_provider` raising, and each
unclassifiable credential shape asserted against **both** target
families.
Those last ones fail against the rejected version.

Applied at all four dispatch sites (Anthropic non-stream/stream, OpenAI
non-stream/stream).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 20:12:41 -07:00

67 lines
2.5 KiB
Python

"""A caller's key must not be dropped unless we are certain it cannot work.
The proxy forwards the inbound credential to the upstream provider. When a
routing extension rewrites the model across families mid-request, that key stops
matching the target and the 401 that follows is indistinguishable, downstream,
from "the cheap model failed the task".
Refusing to forward is the fix, but it is also the more dangerous direction: a
false positive silently strips a credential from a deployment that was working,
and litellm then falls back to an env key that may not exist. So the rule is
positive evidence only -- an unrecognised credential always travels.
"""
from __future__ import annotations
import pytest
from headroom.backends.litellm import _caller_key_travels_to
ANTHROPIC_KEY = "sk-ant-api03-abc123"
@pytest.mark.parametrize(
"model",
["gpt-5-mini", "gpt-4o", "azure/gpt-4", "gemini/gemini-2.0-flash"],
)
def test_anthropic_key_is_refused_for_a_provider_that_cannot_accept_it(model: str) -> None:
"""The bug this exists for: claude-* rewritten to a non-Anthropic target."""
pytest.importorskip("litellm")
assert _caller_key_travels_to(model, ANTHROPIC_KEY) is False
@pytest.mark.parametrize(
"model",
["claude-opus-4-5-20251101", "anthropic/claude-sonnet-4-5-20250929"],
)
def test_anthropic_key_travels_to_anthropic(model: str) -> None:
pytest.importorskip("litellm")
assert _caller_key_travels_to(model, ANTHROPIC_KEY) is True
@pytest.mark.parametrize(
"key",
[
"sk-proj-openai-style", # a dozen vendors mint this shape
"Bearer-ish-opaque-token", # a plain gateway token
"hf_abc123",
"sk-ant", # near miss, not the prefix
"",
],
)
def test_only_the_anthropic_prefix_is_ever_classified(key: str) -> None:
"""Everything else is unclassifiable from the string, so it passes through.
This is the regression the review caught: the first version returned
`not provider.startswith("anthropic")`, which dropped every one of these
against an Anthropic-class target.
"""
assert _caller_key_travels_to("gpt-5-mini", key) is True
assert _caller_key_travels_to("claude-opus-4-5-20251101", key) is True
def test_unknown_provider_keeps_the_pass_through() -> None:
"""A compatible or self-hosted gateway we cannot classify must not lose its
key -- including when `get_llm_provider` raises on the model string."""
assert _caller_key_travels_to("some-self-hosted-thing", ANTHROPIC_KEY) is True
assert _caller_key_travels_to("", ANTHROPIC_KEY) is True