fix(prometheus): count provider-side 401s in LLM failure and deployment metrics

The invalid virtual key skip no longer applies to an error the LLM provider
returned while the deployment was called with its own credential, so a 401
for a bad deployment credential increments litellm_llm_api_failed_requests_metric,
litellm_deployment_failure_responses and litellm_deployment_total_requests and
marks litellm_deployment_state as a partial outage again. Proxy-gate rejections
and a failing credential that belongs to the caller (a rejected per-user
credential, an api_key sent with the request, a per-user GitHub Copilot
session) keep the skip
This commit is contained in:
yucheng-berri 2026-10-15 03:02:54 -07:00
parent 35970c3eac
commit 16aa0ef1c6
6 changed files with 1071 additions and 60 deletions

View file

@ -14,6 +14,7 @@ from functools import partial
from types import MappingProxyType
from typing import TYPE_CHECKING, Annotated, Any, Final, Literal, Protocol, TypeAlias, TypeVar, cast
import openai
from pydantic import BaseModel, Field, TypeAdapter, ValidationError
from typing_extensions import ReadOnly, TypedDict, assert_never
@ -22,11 +23,13 @@ from litellm._internal_context import with_service_target
from litellm._logging import print_verbose, verbose_logger
from litellm.constants import (
AUTH_OBJECTS_TARGET,
LITELLM_LOGGING_NO_UPSTREAM_LLM_CALL,
PROMETHEUS_OVERFLOW_SERIES_LABEL_VALUE,
PROXY_LLM_PROVIDER_FALLBACK,
PROXY_REJECTED_BEFORE_ROUTING_KEY,
)
from litellm.exceptions import (
CallerCredentialAuthenticationError,
validate_rate_limit_category,
validate_rate_limit_type,
)
@ -49,6 +52,7 @@ from litellm.litellm_core_utils.core_helpers import (
from litellm.litellm_core_utils.service_tier_utils import (
get_service_tier_from_standard_logging_payload,
)
from litellm.llms.github_copilot.per_user_auth import github_copilot_user_session_from
from litellm.models.end_user import LiteLLM_EndUserTable
from litellm.models.team import LiteLLM_TeamTable
from litellm.models.user import LiteLLM_UserTable
@ -324,6 +328,36 @@ def _get_proxy_llm_router() -> Router | None:
return llm_router
_LOGGED_FIELDS: Final = TypeAdapter(Mapping[str, object])
def _logged_fields(value: object) -> Mapping[str, object]:
try:
return _LOGGED_FIELDS.validate_python(value)
except ValidationError:
return MappingProxyType({})
def _used_a_caller_credential(logged_call: Mapping[str, object]) -> bool:
"""True when the provider was called with a credential that belongs to the caller rather than the
deployment: an api_key the caller sent with the request, or the caller's own GitHub Copilot connection."""
litellm_params: Final = _logged_fields(logged_call.get("litellm_params"))
credential_fields: Final = _logged_fields(litellm_params.get("proxy_server_request")).get("credential_fields")
return (
isinstance(credential_fields, tuple) and "api_key" in credential_fields
) or github_copilot_user_session_from(litellm_params) is not None
def _is_deployment_failure(exception: openai.APIError, logged_call: Mapping[str, object]) -> bool:
"""True for an error raised while calling the deployment with its own credential, on a request the proxy
did not reject itself."""
return not (
isinstance(exception, CallerCredentialAuthenticationError)
or bool(logged_call.get(LITELLM_LOGGING_NO_UPSTREAM_LLM_CALL))
or _used_a_caller_credential(logged_call)
)
def _bounded_requested_model_label(requested_model: object, router_originated: bool = False) -> str | None:
"""
Bound ``requested_model`` label cardinality: names the router recognizes
@ -2769,6 +2803,8 @@ class PrometheusLogger(CustomLogger):
information from various callback function signatures and determines if the request
represents an invalid API key attempt that should be filtered from metrics.
An error raised while calling the deployment with its own credential is never skipped.
Args:
kwargs: Dictionary potentially containing exception and other data
user_api_key_dict: User API key authentication object (currently unused)
@ -2788,6 +2824,11 @@ class PrometheusLogger(CustomLogger):
if exception is None and kwargs:
exception = kwargs.get("exception")
if isinstance(exception, openai.APIError) and _is_deployment_failure(
exception, _LOGGED_FIELDS.validate_python(kwargs or {})
):
return False
if self._is_invalid_api_key_request(status_code, exception=exception):
verbose_logger.debug(
"Skipping Prometheus metrics for invalid API key request: status_code=%s, exception=%s",

View file

@ -0,0 +1,122 @@
"""One SDK process for the per-user GitHub Copilot metric cells: a provider 401 on a caller's own Copilot session
and one on the deployment's own key, then a report of the deployment series on that process's default registry.
Run as a script by ``run_copilot_session_worker`` so each process registers the Prometheus metrics exactly once."""
from __future__ import annotations
import asyncio
import json
import subprocess
import sys
from dataclasses import dataclass
from typing import Final
import litellm
import openai
from litellm.integrations.prometheus import PrometheusLogger
from litellm.llms.github_copilot.per_user_auth import GithubCopilotUserSession
from prometheus_client import REGISTRY
from pydantic import TypeAdapter
COPILOT_DEPLOYMENT: Final = "copilot-per-user-deployment"
OPERATOR_DEPLOYMENT: Final = "operator-key-deployment"
CALLER_SESSION_TOKEN: Final = "caller-copilot-session-token"
OPERATOR_KEY: Final = "rotated-operator-key"
FAILURES: Final = "litellm_deployment_failure_responses_total"
TOTALS: Final = "litellm_deployment_total_requests_total"
LLM_FAILED: Final = "litellm_llm_api_failed_requests_metric_total"
STATE: Final = "litellm_deployment_state"
DEPLOYMENT_SERIES: Final = frozenset({FAILURES, TOTALS, LLM_FAILED, STATE})
WORKER_TIMEOUT_SECONDS: Final = 120
@dataclass(frozen=True, slots=True)
class Outcome:
deployment: str
status: int | None
@dataclass(frozen=True, slots=True)
class Series:
metric: str
model_id: str
value: float
@dataclass(frozen=True, slots=True)
class WorkerReport:
outcomes: tuple[Outcome, ...]
series: tuple[Series, ...]
def values(self, metric: str, model_id: str) -> tuple[float, ...]:
return tuple(s.value for s in self.series if s.metric == metric and s.model_id == model_id)
_REPORT: Final = TypeAdapter(WorkerReport)
def run_copilot_session_worker(api_base: str) -> WorkerReport:
completed: Final = subprocess.run(
(sys.executable, "-P", __file__, api_base),
capture_output=True,
text=True,
timeout=WORKER_TIMEOUT_SECONDS,
check=False,
)
assert completed.returncode == 0, completed.stderr[-3000:]
return _REPORT.validate_json(completed.stdout.splitlines()[-1])
def _deployment_series() -> tuple[Series, ...]:
return tuple(
Series(metric=sample.name, model_id=str(sample.labels.get("model_id", "")), value=float(sample.value))
for family in REGISTRY.collect()
for sample in family.samples
if sample.name in DEPLOYMENT_SERIES
)
def _router(api_base: str) -> litellm.Router:
return litellm.Router(
model_list=[
{
"model_name": COPILOT_DEPLOYMENT,
"litellm_params": {
"model": "github_copilot/gpt-4o",
"litellm_credential_name": "copilot-per-user",
"github_copilot_auth_type": "per_user_oauth",
},
"model_info": {"id": COPILOT_DEPLOYMENT},
},
{
"model_name": OPERATOR_DEPLOYMENT,
"litellm_params": {"model": "openai/gpt-5.6", "api_base": api_base, "api_key": OPERATOR_KEY},
"model_info": {"id": OPERATOR_DEPLOYMENT},
},
],
num_retries=0,
)
async def _attempt(router: litellm.Router, model: str, **extra: object) -> Outcome:
try:
await router.acompletion(model=model, messages=[{"role": "user", "content": f"{model} worker"}], **extra)
except openai.APIStatusError as error:
return Outcome(deployment=model, status=error.status_code)
return Outcome(deployment=model, status=None)
async def main(api_base: str) -> None:
litellm.callbacks = [PrometheusLogger()] # test-quality-ok: this worker process exits right after the report
session: Final = GithubCopilotUserSession(token=CALLER_SESSION_TOKEN, api_base=api_base)
router: Final = _router(api_base)
outcomes: Final = (
await _attempt(router, COPILOT_DEPLOYMENT, github_copilot_user_session=session),
await _attempt(router, OPERATOR_DEPLOYMENT),
)
report: Final = WorkerReport(outcomes=outcomes, series=_deployment_series())
sys.stdout.write(json.dumps(_REPORT.dump_python(report)) + "\n")
if __name__ == "__main__":
asyncio.run(main(sys.argv[1]))

View file

@ -0,0 +1,488 @@
from __future__ import annotations
import asyncio
import json
import threading
import uuid
from collections import Counter
from collections.abc import Iterator, Sequence
from dataclasses import dataclass
from hashlib import sha256
from pathlib import Path
from typing import Final
import httpx
import openai
import pytest
import yaml
from integration._support.client import Gateway, Scenario, eventually, gateway_from_environment
from integration._support.database import read_rows, write_rows
from integration._support.mcp import ENTRY_POINTS, McpCaller, McpPeer, echo_tool, register_mcp, scripted_peer
from integration._support.process import owned_proxy
from integration._support.prometheus_series import Sample, scrape
from integration._support.wire import Reply, Request, Wire, wire_server
from pydantic import JsonValue
RUN: Final = uuid.uuid4().hex[:8]
REJECTS: Final = "provider-rejects-credential"
HELD_REJECTS: Final = "provider-rejects-credential-when-released"
KEY_NOT_VALID: Final = "provider-key-not-valid"
RATE_LIMITS: Final = "provider-rate-limits"
RECOVERS: Final = "provider-rejects-once"
HEALTHY: Final = "provider-healthy"
AGENT_365_GUARDRAIL: Final = f"agent-365-{RUN}"
FAILURES: Final = "litellm_deployment_failure_responses_total"
TOTALS: Final = "litellm_deployment_total_requests_total"
LLM_FAILED: Final = "litellm_llm_api_failed_requests_metric_total"
STATE: Final = "litellm_deployment_state"
PROXY_FAILED: Final = "litellm_proxy_failed_requests_metric_total"
SUCCESSES: Final = "litellm_deployment_success_responses_total"
DEPLOYMENT_SERIES: Final = (FAILURES, TOTALS, LLM_FAILED, STATE)
ENDPOINT_CELLS: Final = (
"chat",
"chat-stream",
"messages",
"messages-stream",
"responses",
"responses-stream",
"embeddings",
)
REJECTED_CELLS: Final = (*ENDPOINT_CELLS, "sdk-sync", "sdk-async", "repeated", "fallback", "burst", "caller-api-key")
GATED_CELLS: Final = ("invalid-key", "missing-key", "expired-key", "blocked-key", "no-model-access")
HEALTHY_CELLS: Final = (*GATED_CELLS, "healthy", "key-rpm-limit")
GUARDRAIL_ROWS: Final = (
"SELECT COALESCE(jsonb_path_query_first(metadata, "
"'$.guardrail_information[*] ? (@.guardrail_name == $name).guardrail_status', "
"jsonb_build_object('name', %s::text)) #>> '{}', 'none') AS status "
'FROM "LiteLLM_SpendLogs" WHERE api_key = %s AND call_type = %s'
)
def name(cell: str) -> str:
return f"prometheus-{cell}-{RUN}"
def identity(cell: str) -> str:
return f"{name(cell)}-deployment"
def _error(status: int, message: str, kind: str, code: str) -> Reply:
return Reply(status=status, body=json.dumps({"error": {"message": message, "type": kind, "code": code}}).encode())
def _completion() -> Reply:
return Reply(
body=json.dumps(
{
"id": f"chatcmpl-{uuid.uuid4().hex}",
"object": "chat.completion",
"created": 1,
"model": "gpt-4o-mini",
"choices": [{"index": 0, "message": {"role": "assistant", "content": "ok"}, "finish_reason": "stop"}],
"usage": {"prompt_tokens": 3, "completion_tokens": 1, "total_tokens": 4},
}
).encode()
)
class Provider:
"""Answers by upstream model name. `HELD_REJECTS` waits for `release` before rejecting the credential."""
def __init__(self, release: threading.Event) -> None:
self._lock: Final = threading.Lock()
self._seen: Final[Counter[str]] = Counter()
self._release: Final = release
def __call__(self, request: Request) -> Reply:
model: Final = str(json.loads(request.body)["model"])
with self._lock:
self._seen[model] += 1
attempt: Final = self._seen[model]
if model == HELD_REJECTS:
assert self._release.wait(timeout=30), "held provider reply was never released"
if model in (REJECTS, HELD_REJECTS) or (model == RECOVERS and attempt == 1):
return _error(401, "Incorrect API key provided: sk-rotat*****", "invalid_request_error", "invalid_api_key")
if model == KEY_NOT_VALID:
return _error(400, "API key not valid. Please pass a valid API key.", "invalid_request_error", "400")
if model == RATE_LIMITS:
return _error(429, "Rate limit reached for requests", "rate_limit_error", "rate_limit_exceeded")
return _completion()
def deployment(cell: str, upstream_model: str, api_base: str) -> dict[str, JsonValue]:
return {
"model_name": name(cell),
"litellm_params": {
"model": f"openai/{upstream_model}",
"api_key": f"sk-deployment-credential-{cell}",
"api_base": api_base,
},
"model_info": {"id": identity(cell)},
}
def config(directory: Path, upstream: Wire) -> Path:
api_base: Final = f"{upstream.url}/v1"
configuration: Final = yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text())
configuration["model_list"] = [
*configuration["model_list"],
*(deployment(cell, REJECTS, api_base) for cell in REJECTED_CELLS),
*(deployment(cell, HEALTHY, api_base) for cell in HEALTHY_CELLS),
deployment("held", HELD_REJECTS, api_base),
deployment("key-not-valid", KEY_NOT_VALID, api_base),
deployment("rate-limited", RATE_LIMITS, api_base),
deployment("recovers", RECOVERS, api_base),
{
"model_name": name("caller-credential"),
"litellm_params": {
"model": "github_copilot/gpt-4o",
"litellm_credential_name": f"copilot-per-user-{RUN}",
"github_copilot_auth_type": "per_user_oauth",
},
"model_info": {"id": identity("caller-credential")},
},
]
configuration["guardrails"] = [
{
"guardrail_name": AGENT_365_GUARDRAIL,
"litellm_params": {
"guardrail": "agent_365",
"mode": "pre_mcp_call",
"default_on": True,
"tenant_id": "00000000-0000-4000-8000-0000000a3650",
"client_id": "synthetic-client-id",
"client_secret": "synthetic-client-secret",
},
}
]
configuration["litellm_settings"]["callbacks"] = ["prometheus"]
configuration.setdefault("router_settings", {}).update({"num_retries": 0, "disable_cooldowns": True})
path: Final = directory / "prometheus-provider-auth-failure.yaml"
path.write_text(yaml.safe_dump(configuration))
return path
@dataclass(frozen=True, slots=True)
class Rig:
gateway: Gateway
upstream: Wire
@property
def api_base(self) -> str:
return f"{self.upstream.url}/v1"
@pytest.fixture(scope="module")
def rig(tmp_path_factory: pytest.TempPathFactory) -> Iterator[Rig]:
directory: Final = tmp_path_factory.mktemp("prometheus-provider-auth-failure")
released: Final = threading.Event()
released.set()
with (
wire_server(Provider(released)) as upstream,
gateway_from_environment() as shared,
owned_proxy(
shared, directory, {"PROXY_CONFIG_RELOAD_INTERVAL_SECONDS": "2"}, config=config(directory, upstream)
) as owned,
):
yield Rig(owned, upstream)
def series(samples: Sequence[Sample], metric: str, **labels: str) -> tuple[Sample, ...]:
return tuple(
sample
for sample in samples
if sample.name == metric and all(sample.labels.get(label) == value for label, value in labels.items())
)
def values(samples: Sequence[Sample], metric: str, **labels: str) -> list[float]:
return [sample.value for sample in series(samples, metric, **labels)]
def until(gateway: Gateway, metric: str, **labels: str) -> tuple[Sample, ...]:
return eventually(lambda: scrape(gateway), lambda samples: bool(series(samples, metric, **labels)), 30)
def messages(cell: str) -> list[JsonValue]:
return [{"role": "user", "content": f"{cell} {uuid.uuid4().hex}"}]
def send(gateway: Gateway, cell: str, model: str | None = None, key: str | None = None) -> httpx.Response:
asked: Final = model or name(cell)
stream: Final = cell.endswith("-stream")
if cell.startswith("messages"):
anthropic: Final = {"model": asked, "max_tokens": 16, "messages": messages(cell), "stream": stream}
return gateway.request("POST", "/v1/messages", anthropic, key=key)
if cell.startswith("responses"):
return gateway.request("POST", "/v1/responses", {"model": asked, "input": cell, "stream": stream}, key=key)
if cell == "embeddings":
return gateway.request("POST", "/v1/embeddings", {"model": asked, "input": cell}, key=key)
return gateway.request(
"POST", "/v1/chat/completions", {"model": asked, "messages": messages(cell), "stream": stream}, key=key
)
def assert_reached_with_the_deployment_credential(rig: Rig, cell: str, attempts: int = 1) -> None:
reached: Final = rig.upstream.drain()
assert [request.headers["authorization"] for request in reached] == [
f"Bearer sk-deployment-credential-{cell}"
] * attempts, [request.target for request in reached]
def recorded(samples: Sequence[Sample], **labels: str) -> list[tuple[str, dict[str, str], float]]:
return [(sample.name, dict(sample.labels), sample.value) for sample in series_of(samples, **labels)]
def series_of(samples: Sequence[Sample], **labels: str) -> tuple[Sample, ...]:
return tuple(
sample
for sample in samples
if sample.name.startswith("litellm_")
and all(sample.labels.get(label) == value for label, value in labels.items())
)
def assert_recorded_as_a_deployment_failure(rig: Rig, cell: str, status: str, count: float = 1.0) -> None:
samples: Final = until(rig.gateway, FAILURES, model_id=identity(cell))
observed: Final = recorded(samples, model_id=identity(cell))
assert values(samples, FAILURES, model_id=identity(cell), exception_status=status) == [count], observed
assert values(samples, TOTALS, model_id=identity(cell)) == [count], observed
assert values(samples, LLM_FAILED, model_id=identity(cell)) == [count], observed
assert values(samples, STATE, model_id=identity(cell)) == [1.0], observed
@pytest.mark.parametrize("cell", ENDPOINT_CELLS)
def test_a_provider_401_on_every_endpoint_counts_as_a_deployment_failure_and_a_partial_outage(
rig: Rig, cell: str
) -> None:
rig.upstream.drain()
response: Final = send(rig.gateway, cell)
assert response.status_code == 401, response.text
assert_reached_with_the_deployment_credential(rig, cell)
assert_recorded_as_a_deployment_failure(rig, cell, "401")
def test_a_provider_401_seen_by_the_sync_openai_client_is_recorded(rig: Rig) -> None:
rig.upstream.drain()
with (
openai.OpenAI(
base_url=f"{rig.gateway.client.base_url}/v1",
api_key=rig.gateway.key,
max_retries=0,
http_client=httpx.Client(trust_env=False),
) as client,
pytest.raises(openai.AuthenticationError, match="Incorrect API key provided"),
):
client.chat.completions.create(model=name("sdk-sync"), messages=[{"role": "user", "content": "sync"}])
assert_reached_with_the_deployment_credential(rig, "sdk-sync")
assert_recorded_as_a_deployment_failure(rig, "sdk-sync", "401")
def test_a_provider_401_seen_by_the_async_openai_client_is_recorded(rig: Rig) -> None:
async def ask() -> None:
async with openai.AsyncOpenAI(
base_url=f"{rig.gateway.client.base_url}/v1",
api_key=rig.gateway.key,
max_retries=0,
http_client=httpx.AsyncClient(trust_env=False),
) as client:
await client.chat.completions.create(model=name("sdk-async"), messages=[{"role": "user", "content": "a"}])
rig.upstream.drain()
with pytest.raises(openai.AuthenticationError, match="Incorrect API key provided"):
asyncio.run(ask())
assert_reached_with_the_deployment_credential(rig, "sdk-async")
assert_recorded_as_a_deployment_failure(rig, "sdk-async", "401")
def test_each_repeated_provider_401_is_counted_once(rig: Rig) -> None:
rig.upstream.drain()
assert [send(rig.gateway, "repeated").status_code for _ in range(3)] == [401, 401, 401]
assert_reached_with_the_deployment_credential(rig, "repeated", attempts=3)
assert_recorded_as_a_deployment_failure(rig, "repeated", "401", count=3.0)
def test_a_provider_401_rescued_by_a_fallback_still_counts_against_the_rejected_deployment(rig: Rig) -> None:
rig.upstream.drain()
response: Final = rig.gateway.request(
"POST",
"/v1/chat/completions",
{"model": name("fallback"), "messages": messages("fallback"), "fallbacks": [name("healthy")]},
)
assert response.status_code == 200, response.text
assert [request.headers["authorization"] for request in rig.upstream.drain()] == [
"Bearer sk-deployment-credential-fallback",
"Bearer sk-deployment-credential-healthy",
]
assert_recorded_as_a_deployment_failure(rig, "fallback", "401")
rescued: Final = eventually(
lambda: scrape(rig.gateway), lambda samples: values(samples, SUCCESSES, model_id=identity("healthy")) == [1.0]
)
assert values(rescued, FAILURES, model_id=identity("healthy")) == []
def test_a_provider_auth_message_on_a_non_401_status_is_recorded(rig: Rig) -> None:
rig.upstream.drain()
response: Final = send(rig.gateway, "key-not-valid")
assert response.status_code == 400, response.text
assert_reached_with_the_deployment_credential(rig, "key-not-valid")
assert_recorded_as_a_deployment_failure(rig, "key-not-valid", "400")
def test_a_provider_429_is_recorded_as_before(rig: Rig) -> None:
rig.upstream.drain()
response: Final = send(rig.gateway, "rate-limited")
assert response.status_code == 429, response.text
assert_reached_with_the_deployment_credential(rig, "rate-limited")
assert_recorded_as_a_deployment_failure(rig, "rate-limited", "429")
def test_a_deployment_marked_down_by_a_provider_401_is_healthy_again_after_a_success(rig: Rig) -> None:
rig.upstream.drain()
assert send(rig.gateway, "recovers").status_code == 401
assert_recorded_as_a_deployment_failure(rig, "recovers", "401")
recovered: Final = send(rig.gateway, "recovers")
assert recovered.status_code == 200, recovered.text
assert len(rig.upstream.drain()) == 2
eventually(
lambda: scrape(rig.gateway),
lambda samples: values(samples, STATE, model_id=identity("recovers"), api_base=rig.api_base) == [0.0],
)
def test_a_per_caller_credential_rejection_stays_out_of_the_deployment_metrics(rig: Rig) -> None:
rig.upstream.drain()
response: Final = send(rig.gateway, "caller-credential")
assert response.status_code == 401, response.text
assert "Connect GitHub Copilot" in response.text, response.text
assert rig.upstream.drain() == ()
samples: Final = until(rig.gateway, PROXY_FAILED, requested_model=name("caller-credential"))
assert values(samples, FAILURES, model_id=identity("caller-credential")) == []
assert values(samples, TOTALS, model_id=identity("caller-credential")) == []
assert values(samples, LLM_FAILED, model_id=identity("caller-credential")) == []
assert values(samples, STATE, model_id=identity("caller-credential")) == []
def deployment_series(samples: Sequence[Sample]) -> frozenset[tuple[str, tuple[tuple[str, str], ...]]]:
return frozenset((sample.name, sample.identity()) for sample in samples if sample.name in DEPLOYMENT_SERIES)
def test_a_provider_401_on_an_api_key_the_caller_sent_stays_out_of_the_deployment_metrics(rig: Rig) -> None:
caller_key: Final = f"sk-caller-supplied-{uuid.uuid4().hex}"
before: Final = deployment_series(scrape(rig.gateway))
rig.upstream.drain()
response: Final = rig.gateway.request(
"POST",
"/v1/chat/completions",
{"model": name("caller-api-key"), "messages": messages("caller-api-key"), "api_key": caller_key},
)
assert response.status_code == 401, response.text
assert [request.headers["authorization"] for request in rig.upstream.drain()] == [f"Bearer {caller_key}"]
samples: Final = until(rig.gateway, PROXY_FAILED, requested_model=name("caller-api-key"))
assert deployment_series(samples) - before == frozenset(), recorded(samples, requested_model=name("caller-api-key"))
def _gated_key(scenario: Scenario, cell: str) -> str | None:
if cell == "invalid-key":
return f"sk-not-a-virtual-key-{uuid.uuid4().hex}"
if cell == "missing-key":
return None
if cell == "no-model-access":
return scenario.key(models=[name("healthy")])
key: Final = scenario.key(models=[name(cell)])
if cell == "expired-key":
write_rows(
"UPDATE \"LiteLLM_VerificationToken\" SET expires = now() - interval '1 hour' WHERE token = %s",
(sha256(key.encode()).hexdigest(),),
)
return key
scenario.gateway.post("/key/block", {"key": key})
return key
def _gated(gateway: Gateway, cell: str, key: str | None) -> httpx.Response:
if key is None:
return gateway.client.post("/v1/chat/completions", json={"model": name(cell), "messages": messages(cell)})
return send(gateway, cell, key=key)
def recorded_before(cell: str) -> list[tuple[str, str, str, float]]:
if cell in ("invalid-key", "expired-key"):
return []
return sorted([(FAILURES, "", "", 1.0), (TOTALS, "", "", 1.0), (LLM_FAILED, identity(cell), "", 1.0)])
def deployment_rows(samples: Sequence[Sample], cell: str) -> list[tuple[str, str, str, float]]:
return sorted(
(sample.name, sample.labels.get("model_id", ""), sample.labels.get("api_base", ""), sample.value)
for sample in samples
if sample.name in DEPLOYMENT_SERIES
and name(cell) in (sample.labels.get("requested_model"), sample.labels.get("model"))
)
@pytest.mark.parametrize("cell", GATED_CELLS)
def test_a_request_the_proxy_rejects_itself_never_marks_the_deployment_down_and_is_recorded_as_before(
rig: Rig, cell: str
) -> None:
with rig.gateway.scenario() as scenario:
key: Final = _gated_key(scenario, cell)
rig.upstream.drain()
response: Final = _gated(rig.gateway, cell, key)
assert response.status_code == (403 if cell == "no-model-access" else 401), response.text
assert rig.upstream.drain() == ()
samples: Final = until(rig.gateway, PROXY_FAILED, requested_model=name(cell))
assert deployment_rows(samples, cell) == recorded_before(cell), recorded(samples, requested_model=name(cell))
def test_a_key_rate_limit_rejected_at_the_proxy_is_still_counted_as_before(rig: Rig) -> None:
with rig.gateway.scenario() as scenario:
key: Final = scenario.key(models=[name("key-rpm-limit")], rpm_limit=1)
rig.upstream.drain()
admitted: Final = send(rig.gateway, "key-rpm-limit", key=key)
assert admitted.status_code == 200, admitted.text
limited: Final = send(rig.gateway, "key-rpm-limit", key=key)
assert limited.status_code == 429, limited.text
assert len(rig.upstream.drain()) == 1
samples: Final = until(rig.gateway, FAILURES, requested_model=name("key-rpm-limit"), exception_status="429")
assert values(samples, FAILURES, requested_model=name("key-rpm-limit"), exception_status="429") == [1.0]
eventually(
lambda: scrape(rig.gateway),
lambda observed: (
values(observed, STATE, model_id=identity("key-rpm-limit"), api_base=rig.api_base) == [0.0]
),
)
@pytest.fixture(scope="module")
def mcp_peer() -> Iterator[McpPeer]:
with scripted_peer(echo_tool("add")) as peer:
yield peer
def test_an_mcp_tool_call_the_proxy_rejects_for_the_caller_stays_out_of_the_llm_failure_metrics(
rig: Rig, mcp_peer: McpPeer
) -> None:
alias: Final = "a365" + RUN
with rig.gateway.scenario() as scenario:
server: Final = register_mcp(scenario, mcp_peer, alias)
key: Final = scenario.key(object_permission={"mcp_servers": [server]})
hashed: Final = sha256(key.encode()).hexdigest()
outcomes: Final = {
entry: McpCaller(rig.gateway, key, entry, alias).call(f"{alias}-add", {"entry": entry}, server)
for entry in ENTRY_POINTS
}
assert all(
outcome.error is not None and "Agent 365 guardrail rejected" in outcome.raw for outcome in outcomes.values()
), {entry: outcome.raw for entry, outcome in outcomes.items()}
eventually(
lambda: read_rows(GUARDRAIL_ROWS, (AGENT_365_GUARDRAIL, hashed, "call_mcp_tool")),
lambda rows: [row["status"] for row in rows] == ["guardrail_intervened"] * len(ENTRY_POINTS),
seconds=70,
)
samples: Final = scrape(rig.gateway)
assert values(samples, LLM_FAILED, hashed_api_key=hashed) == []
assert values(samples, FAILURES, hashed_api_key=hashed) == []
assert values(samples, TOTALS, hashed_api_key=hashed) == []

View file

@ -0,0 +1,147 @@
import threading
import uuid
from collections.abc import Iterator, Sequence
from concurrent.futures import ThreadPoolExecutor
from contextlib import contextmanager
from dataclasses import dataclass
from pathlib import Path
from typing import Final
import httpx
import psutil
from integration._support.client import Gateway, eventually
from integration._support.process import OwnedProxy, owned_proxy_process
from integration._support.prometheus_series import Sample, scrape
from integration._support.wire import Wire, wire_server
from test_prometheus_provider_auth_failure import (
FAILURES,
LLM_FAILED,
STATE,
TOTALS,
Provider,
config,
identity,
name,
send,
values,
)
BURST: Final = 12
ENDPOINTS: Final = ("chat", "chat-stream", "messages", "responses")
@dataclass(frozen=True, slots=True)
class ChaosRig:
owned: OwnedProxy
upstream: Wire
release: threading.Event
@contextmanager
def _rig(gateway: Gateway, tmp_path: Path) -> Iterator[ChaosRig]:
release: Final = threading.Event()
prom_dir: Final = tmp_path / "prom"
prom_dir.mkdir()
with (
wire_server(Provider(release)) as upstream,
owned_proxy_process(
gateway,
tmp_path,
{"PROMETHEUS_MULTIPROC_DIR": str(prom_dir)},
config=config(tmp_path, upstream),
workers=2,
) as owned,
):
try:
yield ChaosRig(owned, upstream, release)
finally:
release.set()
def _status(gateway: Gateway, cell: str, model: str, key: str | None = None) -> int:
try:
return send(gateway, cell, model=model, key=key).status_code
except httpx.HTTPError:
return -1
def _holds_a_connection_to(pid: int, port: int) -> bool:
return any(connection.raddr[1:] == (port,) for connection in psutil.Process(pid).net_connections(kind="tcp"))
def _workers_calling_upstream(rig: ChaosRig) -> frozenset[int]:
port: Final = int(rig.upstream.url.rsplit(":", 1)[1])
return frozenset(pid for pid in rig.owned.worker_pids() if _holds_a_connection_to(pid, port))
def _counted(samples: Sequence[Sample], cell: str) -> tuple[float, float, float]:
return (
sum(values(samples, FAILURES, model_id=identity(cell))),
sum(values(samples, TOTALS, model_id=identity(cell))),
sum(values(samples, LLM_FAILED, model_id=identity(cell))),
)
def test_a_burst_of_provider_401s_and_proxy_rejects_across_two_workers_counts_only_the_provider_401s(
gateway: Gateway, tmp_path: Path
) -> None:
with _rig(gateway, tmp_path) as rig:
rig.release.set()
workers: Final = eventually(rig.owned.worker_pids, lambda pids: len(pids) == 2)
invalid: Final = f"sk-not-a-virtual-key-{uuid.uuid4().hex}"
calls: Final[tuple[tuple[str, str, str | None], ...]] = tuple(
(ENDPOINTS[index % len(ENDPOINTS)], name("burst"), None) for index in range(BURST)
) + tuple(("chat", name("invalid-key"), invalid) for _ in range(BURST))
def call(cell: str, model: str, key: str | None) -> int:
return _status(rig.owned.gateway, cell, model, key)
with ThreadPoolExecutor(max_workers=8) as pool:
statuses: Final = tuple(pool.map(call, *zip(*calls, strict=True)))
assert statuses == (401,) * (2 * BURST), statuses
assert len(rig.upstream.drain()) == BURST
samples: Final = eventually(
lambda: scrape(rig.owned.gateway),
lambda observed: _counted(observed, "burst") == (BURST, BURST, BURST),
seconds=70,
return_last_on_timeout=True,
)
assert _counted(samples, "burst") == (BURST, BURST, BURST), (workers, _counted(samples, "burst"))
assert set(values(samples, STATE, model_id=identity("burst"))) == {1.0}
assert values(samples, FAILURES, requested_model=name("invalid-key")) == []
assert values(samples, LLM_FAILED, model=name("invalid-key")) == []
def test_a_worker_killed_while_provider_401s_are_in_flight_loses_no_answered_failure(
gateway: Gateway, tmp_path: Path
) -> None:
with _rig(gateway, tmp_path) as rig:
eventually(rig.owned.worker_pids, lambda pids: len(pids) == 2)
with ThreadPoolExecutor(max_workers=8) as pool:
futures: Final = tuple(pool.submit(_status, rig.owned.gateway, "chat", name("held")) for _ in range(BURST))
eventually(lambda: rig.upstream.received.qsize() >= 8, bool, seconds=30)
victim: Final = min(eventually(lambda: _workers_calling_upstream(rig), bool))
with rig.owned.replacing_worker(victim) as kill:
kill()
rig.release.set()
statuses: Final = tuple(future.result() for future in futures)
reached: Final = len(rig.upstream.drain())
answered: Final = statuses.count(401)
lost: Final = statuses.count(-1)
assert answered + lost == BURST and lost > 0, statuses
samples: Final = eventually(
lambda: scrape(rig.owned.gateway),
lambda observed: answered <= _counted(observed, "held")[0] <= reached,
seconds=70,
return_last_on_timeout=True,
)
counted: Final = _counted(samples, "held")
assert answered <= counted[0] <= reached and counted[0] == counted[1] == counted[2], (statuses, counted)
follow_up: Final = _status(rig.owned.gateway, "chat", name("held"))
assert follow_up == 401
recounted: Final = eventually(
lambda: _counted(scrape(rig.owned.gateway), "held"),
lambda observed: observed[0] == counted[0] + 1,
seconds=70,
)
assert recounted == (counted[0] + 1,) * 3

View file

@ -0,0 +1,71 @@
"""A provider 401 on a caller's own per-user GitHub Copilot session is the caller's credential failing, so the
shared Copilot deployment keeps no failure, request or outage series for it, while a provider 401 on the
deployment's own key in the same process is counted against that deployment."""
from __future__ import annotations
import json
from collections.abc import Iterator
from typing import Final
import pytest
from integration._support.copilot_session_prometheus_worker import (
CALLER_SESSION_TOKEN,
COPILOT_DEPLOYMENT,
DEPLOYMENT_SERIES,
FAILURES,
LLM_FAILED,
OPERATOR_DEPLOYMENT,
OPERATOR_KEY,
STATE,
TOTALS,
Outcome,
WorkerReport,
run_copilot_session_worker,
)
from integration._support.wire import Reply, Request, wire_server
@pytest.fixture(scope="module")
def ran() -> Iterator[tuple[WorkerReport, tuple[Request, ...]]]:
def reject(request: Request) -> Reply:
assert request.method == "POST", request.method
return Reply(
status=401,
body=json.dumps({"error": {"message": "bad credentials", "type": "authentication_error"}}).encode(),
)
with wire_server(reject) as upstream:
report: Final = run_copilot_session_worker(upstream.url)
yield report, upstream.drain()
def test_both_calls_reach_the_provider_with_their_own_credential_and_fail_with_its_401(
ran: tuple[WorkerReport, tuple[Request, ...]],
) -> None:
report, received = ran
assert report.outcomes == (
Outcome(deployment=COPILOT_DEPLOYMENT, status=401),
Outcome(deployment=OPERATOR_DEPLOYMENT, status=401),
)
assert [r.headers.get("authorization") for r in received] == [
f"Bearer {CALLER_SESSION_TOKEN}",
f"Bearer {OPERATOR_KEY}",
]
def test_a_provider_401_on_a_callers_copilot_session_stays_out_of_the_shared_deployment_metrics(
ran: tuple[WorkerReport, tuple[Request, ...]],
) -> None:
report, _ = ran
assert {s.metric for s in report.series if s.model_id == COPILOT_DEPLOYMENT} & DEPLOYMENT_SERIES == set()
def test_a_provider_401_on_the_deployments_own_key_counts_as_a_deployment_failure(
ran: tuple[WorkerReport, tuple[Request, ...]],
) -> None:
report, _ = ran
assert report.values(FAILURES, OPERATOR_DEPLOYMENT) == (1.0,)
assert report.values(TOTALS, OPERATOR_DEPLOYMENT) == (1.0,)
assert report.values(LLM_FAILED, OPERATOR_DEPLOYMENT) == (1.0,)
assert report.values(STATE, OPERATOR_DEPLOYMENT) == (1.0,)

View file

@ -2,7 +2,8 @@
Unit tests for Prometheus invalid API key request filtering.
Tests the 401 detection helpers, that LLM-level metrics skip invalid API key
requests, and that the proxy-level failed request counter still records them.
requests rejected at the proxy gate while recording a provider's 401, and that
the proxy-level failed request counter still records them.
"""
from unittest.mock import Mock, patch
@ -11,7 +12,10 @@ import pytest
from fastapi import HTTPException
from prometheus_client import REGISTRY
import litellm
from litellm.constants import LITELLM_LOGGING_NO_UPSTREAM_LLM_CALL
from litellm.integrations.prometheus import PrometheusLogger
from litellm.llms.github_copilot.per_user_auth import GithubCopilotUserSession
from litellm.proxy._types import ProxyErrorTypes, ProxyException, UserAPIKeyAuth
@ -48,18 +52,13 @@ class TestExtractStatusCode:
(ExceptionWithStatusCode, 401, 401),
],
)
def test_extract_from_exception(
self, prometheus_logger, exception_class, code_value, expected
):
def test_extract_from_exception(self, prometheus_logger, exception_class, code_value, expected):
exception = exception_class(code_value)
assert prometheus_logger._extract_status_code(exception=exception) == expected
def test_extract_from_kwargs(self, prometheus_logger):
exception = ExceptionWithCode("401")
assert (
prometheus_logger._extract_status_code(kwargs={"exception": exception})
== 401
)
assert prometheus_logger._extract_status_code(kwargs={"exception": exception}) == 401
def test_extract_from_enum_values(self, prometheus_logger):
enum_values = Mock(status_code="401")
@ -79,50 +78,133 @@ class TestInvalidAPIKeyDetection:
],
)
def test_status_code_detection(self, prometheus_logger, status_code, expected):
assert (
prometheus_logger._is_invalid_api_key_request(status_code=status_code)
== expected
)
assert prometheus_logger._is_invalid_api_key_request(status_code=status_code) == expected
def test_auth_error_message_detection(self, prometheus_logger):
exception = AssertionError(
"LiteLLM Virtual Key expected. Received=invalid-key-12345, expected to start with 'sk-'."
)
assert (
prometheus_logger._is_invalid_api_key_request(
status_code=None, exception=exception
)
is True
)
assert prometheus_logger._is_invalid_api_key_request(status_code=None, exception=exception) is True
def test_non_auth_exception_not_detected(self, prometheus_logger):
exception = ValueError("Some other error")
assert (
prometheus_logger._is_invalid_api_key_request(
status_code=None, exception=exception
)
is False
)
assert prometheus_logger._is_invalid_api_key_request(status_code=None, exception=exception) is False
class TestSkipMetricsValidation:
"""Test high-level validation method that orchestrates detection and extraction."""
def test_skip_for_401_exception(self, prometheus_logger):
"""Test full flow: extraction -> detection -> skip decision."""
exception = ExceptionWithCode("401")
assert (
prometheus_logger._should_skip_metrics_for_invalid_key(exception=exception)
is True
)
@pytest.mark.parametrize(
"exception,proxy_gate,expected",
[
(ExceptionWithCode("401"), True, True),
(AssertionError("expected to start with 'sk-'"), True, True),
(ExceptionWithStatusCode(429), True, False),
(ExceptionWithCode("401"), False, True),
(AssertionError("expected to start with 'sk-'"), False, True),
(
litellm.AuthenticationError(
message="Incorrect API key provided: sk-rotat*****",
llm_provider="openai",
model="gpt-4o-mini",
),
False,
False,
),
(
litellm.AuthenticationError(
message="API key not valid. Please pass a valid API key.",
llm_provider="gemini",
model="gemini-2.5-flash",
),
False,
False,
),
(
litellm.AuthenticationError(
message="Authentication Error, Invalid proxy server token passed.",
llm_provider="openai",
model="gpt-4o-mini",
),
True,
True,
),
(
litellm.CallerCredentialAuthenticationError(
message="GitHub Copilot connection is missing for this user",
llm_provider="github_copilot",
model="gpt-4o",
),
False,
True,
),
(
HTTPException(status_code=401, detail="caller did not present a token"),
False,
True,
),
(
litellm.BadRequestError(
message="API key not valid. Please pass a valid API key.",
llm_provider="gemini",
model="gemini-2.5-flash",
),
False,
False,
),
],
ids=[
"proxy-gate-401",
"proxy-gate-auth-message",
"proxy-gate-429",
"unmarked-proxy-401",
"unmarked-auth-message",
"provider-401",
"provider-auth-message",
"proxy-gate-litellm-auth-error",
"caller-credential-401",
"unmarked-http-exception-401",
"provider-400-auth-message",
],
)
def test_skip_except_for_provider_errors_the_proxy_did_not_raise(
self, prometheus_logger, exception, proxy_gate, expected
):
kwargs = {"exception": exception}
if proxy_gate:
kwargs[LITELLM_LOGGING_NO_UPSTREAM_LLM_CALL] = True
assert prometheus_logger._should_skip_metrics_for_invalid_key(kwargs=kwargs) is expected
def test_skip_for_auth_error_message(self, prometheus_logger):
"""Test full flow: exception message -> detection -> skip decision."""
exception = AssertionError("expected to start with 'sk-'")
assert (
prometheus_logger._should_skip_metrics_for_invalid_key(exception=exception)
is True
@pytest.mark.parametrize(
"litellm_params,expected",
[
({"proxy_server_request": {"credential_fields": ("api_key",)}}, True),
(
{
"github_copilot_user_session": GithubCopilotUserSession(
token="caller-session-token", api_base="https://api.githubcopilot.com"
)
},
True,
),
({"proxy_server_request": {"credential_fields": ("headers",)}}, False),
({"proxy_server_request": {"credential_fields": "api_key"}}, False),
],
ids=[
"caller-supplied-api-key",
"caller-copilot-session",
"caller-supplied-headers-only",
"malformed-credential-fields",
],
)
def test_skip_a_provider_401_on_a_credential_the_caller_owns(self, prometheus_logger, litellm_params, expected):
exception = litellm.AuthenticationError(
message="Incorrect API key provided: sk-calle*****",
llm_provider="openai",
model="gpt-4o-mini",
)
kwargs = {"exception": exception, "litellm_params": litellm_params}
assert prometheus_logger._should_skip_metrics_for_invalid_key(kwargs=kwargs) is expected
def test_no_skip_for_valid_request(self, prometheus_logger):
assert prometheus_logger._should_skip_metrics_for_invalid_key() is False
@ -147,19 +229,13 @@ class TestAsyncHooks:
),
],
)
async def test_post_call_failure_hook_counts_401_without_key_hash(
self, prometheus_logger, exception
):
async def test_post_call_failure_hook_counts_401_without_key_hash(self, prometheus_logger, exception):
unauthenticated = UserAPIKeyAuth(request_route="/v1/chat/completions")
unauthenticated.api_key = "notakeyatall"
with (
patch.object(
prometheus_logger, "litellm_proxy_failed_requests_metric"
) as mock_failed,
patch.object(
prometheus_logger, "litellm_proxy_total_requests_metric"
) as mock_total,
patch.object(prometheus_logger, "litellm_proxy_failed_requests_metric") as mock_failed,
patch.object(prometheus_logger, "litellm_proxy_total_requests_metric") as mock_total,
):
await prometheus_logger.async_post_call_failure_hook(
request_data={"model": "test-model"},
@ -176,9 +252,7 @@ class TestAsyncHooks:
mock_total.labels.return_value.inc.assert_called_once()
@pytest.mark.asyncio
async def test_post_call_failure_hook_keeps_resolved_identity_labels_for_401(
self, prometheus_logger
):
async def test_post_call_failure_hook_keeps_resolved_identity_labels_for_401(self, prometheus_logger):
expired_key = UserAPIKeyAuth(
api_key="sk-expired",
key_alias="expired-alias",
@ -191,9 +265,7 @@ class TestAsyncHooks:
code=401,
)
with patch.object(
prometheus_logger, "litellm_proxy_failed_requests_metric"
) as mock_failed:
with patch.object(prometheus_logger, "litellm_proxy_failed_requests_metric") as mock_failed:
await prometheus_logger.async_post_call_failure_hook(
request_data={"model": "test-model"},
original_exception=exception,
@ -207,7 +279,7 @@ class TestAsyncHooks:
assert failed_labels["team"] == "team-1"
@pytest.mark.asyncio
async def test_log_failure_event_skips_401(self, prometheus_logger):
async def test_log_failure_event_skips_401_rejected_at_the_proxy_gate(self, prometheus_logger):
exception = ExceptionWithCode("401")
kwargs = {
"model": "test-model",
@ -220,20 +292,90 @@ class TestAsyncHooks:
},
"exception": exception,
"litellm_params": {},
LITELLM_LOGGING_NO_UPSTREAM_LLM_CALL: True,
}
with (
patch.object(
prometheus_logger, "litellm_llm_api_failed_requests_metric"
) as mock_failed,
patch.object(
prometheus_logger, "set_llm_deployment_failure_metrics"
) as mock_deployment,
patch.object(prometheus_logger, "litellm_llm_api_failed_requests_metric") as mock_failed,
patch.object(prometheus_logger, "set_llm_deployment_failure_metrics") as mock_deployment,
):
await prometheus_logger.async_log_failure_event(
kwargs=kwargs, response_obj=None, start_time=None, end_time=None
)
mock_failed.labels.assert_not_called()
mock_deployment.assert_not_called()
def _provider_auth_failure_kwargs() -> dict:
exception = litellm.AuthenticationError(
message="Incorrect API key provided: sk-rotat*****",
llm_provider="openai",
model="gpt-4o-mini",
)
return {
"model": "gpt-4o-mini",
"standard_logging_object": {
"metadata": {
"user_api_key_hash": "test-key",
"user_api_key_alias": "test-alias",
"user_api_key_user_id": "test-user",
"user_api_key_team_id": "test-team",
"user_api_key_team_alias": "test-team-alias",
},
"model_group": "rotated-credential",
"model_id": "deployment-1",
"api_base": "https://api.openai.com/v1",
"custom_llm_provider": "openai",
},
"exception": exception,
"litellm_params": {"custom_llm_provider": "openai", "metadata": {}},
}
def _metric_values(name: str, model_id: str) -> list[float]:
return [
sample.value
for sample in _registry_samples()
if sample.name == name and sample.labels.get("model_id") == model_id
]
def _registry_samples():
for metric in REGISTRY.collect():
yield from metric.samples
class TestProviderAuthFailures:
"""A 401 raised by the provider is an upstream failure and is recorded."""
@pytest.mark.asyncio
async def test_log_failure_event_records_a_provider_401(self, prometheus_logger):
await prometheus_logger.async_log_failure_event(
kwargs=_provider_auth_failure_kwargs(),
response_obj=None,
start_time=None,
end_time=None,
)
assert _metric_values("litellm_llm_api_failed_requests_metric_total", "deployment-1") == [1.0]
assert _metric_values("litellm_deployment_failure_responses_total", "deployment-1") == [1.0]
assert _metric_values("litellm_deployment_state", "deployment-1") == [1.0]
def test_deployment_failure_metrics_record_a_provider_401(self, prometheus_logger):
prometheus_logger.set_llm_deployment_failure_metrics(_provider_auth_failure_kwargs())
failures = [
sample for sample in _registry_samples() if sample.name == "litellm_deployment_failure_responses_total"
]
assert [
(
sample.labels["model_id"],
sample.labels["exception_status"],
sample.labels["api_provider"],
sample.value,
)
for sample in failures
] == [("deployment-1", "401", "openai", 1.0)]
assert _metric_values("litellm_deployment_total_requests_total", "deployment-1") == [1.0]
assert _metric_values("litellm_deployment_state", "deployment-1") == [1.0]