fix(claude_code_gateway): scope the protobuf body skip to the OTLP routes and match the metrics middleware on the route path

This commit is contained in:
mateo-berri 2026-09-18 17:23:59 -07:00
parent 01d8d3c218
commit 59f7a00cf6
6 changed files with 97 additions and 19 deletions

View file

@ -34,6 +34,7 @@ from litellm.constants import (
)
from litellm.proxy.anthropic_endpoints.endpoints import anthropic_response, count_tokens
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.common_utils.http_parsing_utils import _safe_set_request_parsed_body
GATEWAY_PREFIX: Final = "/claude_code_gateway"
_DEVICE_CODE_GRANT: Final = "urn:ietf:params:oauth:grant-type:device_code"
@ -349,21 +350,28 @@ async def managed_settings(request: Request) -> Response:
return Response(content=body.model_dump_json(), media_type="application/json", headers=headers)
async def _skip_otlp_body_parsing(request: Request) -> None:
_safe_set_request_parsed_body(request=request, parsed_body={})
_OTLP_AUTHENTICATED: Final = (Depends(_skip_otlp_body_parsing), *_AUTHENTICATED)
def _accept_otlp() -> Response:
ensure_gateway_enabled()
return Response(status_code=200)
@router.post("/v1/metrics", include_in_schema=False, dependencies=_AUTHENTICATED)
@router.post("/v1/metrics", include_in_schema=False, dependencies=_OTLP_AUTHENTICATED)
async def otlp_metrics() -> Response:
return _accept_otlp()
@router.post("/v1/logs", include_in_schema=False, dependencies=_AUTHENTICATED)
@router.post("/v1/logs", include_in_schema=False, dependencies=_OTLP_AUTHENTICATED)
async def otlp_logs() -> Response:
return _accept_otlp()
@router.post("/v1/traces", include_in_schema=False, dependencies=_AUTHENTICATED)
@router.post("/v1/traces", include_in_schema=False, dependencies=_OTLP_AUTHENTICATED)
async def otlp_traces() -> Response:
return _accept_otlp()

View file

@ -18,8 +18,6 @@ from litellm.types.router import Deployment
_FORM_CONTENT_TYPES: Final[frozenset[str]] = frozenset({"application/x-www-form-urlencoded", "multipart/form-data"})
_PROTOBUF_CONTENT_TYPES: Final[frozenset[str]] = frozenset({"application/x-protobuf", "application/protobuf"})
_ANNOTATION_QUALIFIERS: Final[frozenset[object]] = frozenset({Annotated, NotRequired, ReadOnly, Required})
@ -46,10 +44,6 @@ def is_json_content_type(content_type: str) -> bool:
return _normalize_media_type(content_type) == "application/json"
def _is_protobuf_content_type(content_type: str) -> bool:
return _normalize_media_type(content_type) in _PROTOBUF_CONTENT_TYPES
def _unqualified(annotation: object) -> object:
"""Which qualifiers ``get_type_hints`` already stripped varies by interpreter version, so peel them all."""
if get_origin(annotation) not in _ANNOTATION_QUALIFIERS:
@ -139,9 +133,7 @@ async def _read_request_body(request: Request | None) -> dict:
_request_headers: Final[dict] = _safe_get_request_headers(request=request)
content_type: Final = _request_headers.get("content-type", "")
if _is_protobuf_content_type(content_type):
parsed_body = {}
elif _is_form_content_type(content_type):
if _is_form_content_type(content_type):
try:
form_data: Final = await request.form()
except Exception as e:

View file

@ -7,6 +7,7 @@ from collections.abc import MutableMapping
from typing import Any, Final
from fastapi import Request
from starlette.routing import get_route_path
from starlette.types import ASGIApp, Receive, Scope, Send
import litellm
@ -15,6 +16,12 @@ from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
# Cache the header name at module level to avoid repeated enum attribute access
_AUTHORIZATION_HEADER: Final = SpecialHeaders.openai_authorization.value # "Authorization"
_METRICS_MOUNT: Final = "/metrics"
def _is_metrics_route(scope: Scope) -> bool:
route_path: Final = get_route_path(scope)
return route_path == _METRICS_MOUNT or route_path.startswith(_METRICS_MOUNT + "/")
class PrometheusAuthMiddleware:
@ -36,7 +43,7 @@ class PrometheusAuthMiddleware:
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
# Fast path: only inspect HTTP requests; pass through websocket/lifespan immediately
if scope["type"] != "http" or "/metrics" not in scope.get("path", ""):
if scope["type"] != "http" or not _is_metrics_route(scope):
await self.app(scope, receive, send)
return

View file

@ -21,6 +21,7 @@ from litellm.caching.dual_cache import DualCache
from litellm.proxy._types import ProxyException
from litellm.proxy.anthropic_endpoints import gateway_endpoints
from litellm.proxy.management_endpoints.ui_sso import _get_cli_sso_flow_cache_key, _set_cli_sso_flow
from litellm.proxy.middleware.prometheus_auth_middleware import PrometheusAuthMiddleware
_DEVICE_CODE_GRANT: Final = "urn:ietf:params:oauth:grant-type:device_code"
_MASTER_KEY: Final = "sk-master-key"
@ -107,6 +108,7 @@ def _gateway_env(
session_cache: Final = cache or DualCache(default_in_memory_ttl=600)
app: Final = FastAPI()
app.add_middleware(PrometheusAuthMiddleware)
app.include_router(gateway_endpoints.router)
async def _fake_auth() -> object:
@ -378,10 +380,11 @@ def test_otlp_endpoints_404_when_disabled(signal: str):
assert resp.status_code == 404
def test_otlp_protobuf_body_is_accepted_through_real_auth():
@pytest.mark.parametrize("signal", ["metrics", "logs", "traces"])
def test_otlp_protobuf_body_is_accepted_through_real_auth(signal: str):
with _gateway_env(real_auth=True) as (client, _):
resp = client.post(
"/claude_code_gateway/v1/metrics",
f"/claude_code_gateway/v1/{signal}",
content=_PROTOBUF_BODY,
headers={"Authorization": f"Bearer {_MASTER_KEY}", "Content-Type": "application/x-protobuf"},
)

View file

@ -574,10 +574,10 @@ async def test_lone_surrogate_escape_is_rejected_with_400(content: bytes):
@pytest.mark.asyncio
@pytest.mark.parametrize("media_type", ["application/x-protobuf", "application/protobuf"])
async def test_protobuf_body_is_left_unparsed(media_type: str):
request = _starlette_request(b"\x0a\x05hello\x12\x03{{{", media_type)
assert await _read_request_body(request) == {}
@pytest.mark.parametrize("media_type", ["application/x-protobuf", "application/protobuf", "application/octet-stream"])
async def test_json_body_under_a_binary_content_type_is_still_parsed(media_type: str):
request = _starlette_request(b'{"model": "claude-sonnet-5"}', media_type)
assert await _read_request_body(request) == {"model": "claude-sonnet-5"}
@pytest.mark.asyncio

View file

@ -51,6 +51,14 @@ def app_with_middleware():
async def embeddings():
return {"msg": "embeddings OK"}
@app.post("/claude_code_gateway/v1/metrics")
async def gateway_telemetry():
return {"msg": "gateway telemetry OK"}
@app.get("/metrics/detail")
async def metrics_detail():
return {"msg": "metrics detail OK"}
return app
@ -240,3 +248,63 @@ def test_non_metrics_requests_dont_trigger_auth(app_with_middleware, monkeypatch
response = client.get("/embeddings")
assert response.status_code == 200, response.text
assert response.json() == {"msg": "embeddings OK"}
def test_gateway_telemetry_path_is_not_treated_as_the_metrics_endpoint(app_with_middleware, monkeypatch):
monkeypatch.setattr(litellm, "require_auth_for_metrics_endpoint", True)
def should_not_be_called(*args, **kwargs):
raise Exception("Auth should not be called for the gateway telemetry route")
monkeypatch.setattr(
"litellm.proxy.middleware.prometheus_auth_middleware.user_api_key_auth",
should_not_be_called,
)
client = TestClient(app_with_middleware)
response = client.post("/claude_code_gateway/v1/metrics", content=b"\x0a\x05hello")
assert response.status_code == 200, response.text
assert response.json() == {"msg": "gateway telemetry OK"}
@pytest.mark.parametrize("path", ["/metrics", "/metrics/", "/metrics/detail"])
def test_metrics_paths_still_require_auth(app_with_middleware, monkeypatch, path):
monkeypatch.setattr(litellm, "require_auth_for_metrics_endpoint", True)
async def reject(*args, **kwargs):
raise Exception("Invalid API key")
monkeypatch.setattr(
"litellm.proxy.middleware.prometheus_auth_middleware.user_api_key_auth",
reject,
)
client = TestClient(app_with_middleware)
response = client.get(path)
assert response.status_code == 401, response.text
def test_metrics_under_a_root_path_still_requires_auth(monkeypatch):
monkeypatch.setattr(litellm, "require_auth_for_metrics_endpoint", True)
async def reject(*args, **kwargs):
raise Exception("Invalid API key")
monkeypatch.setattr(
"litellm.proxy.middleware.prometheus_auth_middleware.user_api_key_auth",
reject,
)
app = FastAPI(root_path="/litellm")
app.add_middleware(PrometheusAuthMiddleware)
@app.get("/metrics")
async def metrics():
return {"msg": "metrics OK"}
client = TestClient(app, root_path="/litellm")
response = client.get("/metrics")
assert response.status_code == 401, response.text