mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-21 00:21:49 +00:00
fix(claude_code_gateway): single-use device codes across replicas, protobuf telemetry, CLI user route access
This commit is contained in:
parent
60e67102a2
commit
87ac68709c
8 changed files with 337 additions and 61 deletions
|
|
@ -5235,6 +5235,12 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"claude_code_gateway": {
|
||||
"components": {
|
||||
"schemas": {}
|
||||
},
|
||||
"paths": {}
|
||||
},
|
||||
"claude_code_marketplace": {
|
||||
"components": {
|
||||
"schemas": {
|
||||
|
|
@ -19394,7 +19400,7 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"description": "\n Unified rate-limit error.\n\n Every rate-limit condition surfaced by litellm \u2014 whether it originated from\n an upstream LLM provider, a vendor batch endpoint, or one of litellm's own\n proxy-side limiters (parallel-requests, dynamic-rate, batch-rate, budget,\n max-iterations, etc.) \u2014 is raised as an instance of this class.\n\n The :attr:`category` attribute lets callers distinguish the source. See\n :class:`RateLimitErrorCategory` for the available values.\n "
|
||||
"description": "\nUnified rate-limit error.\n\nEvery rate-limit condition surfaced by litellm \u2014 whether it originated from\nan upstream LLM provider, a vendor batch endpoint, or one of litellm's own\nproxy-side limiters (parallel-requests, dynamic-rate, batch-rate, budget,\nmax-iterations, etc.) \u2014 is raised as an instance of this class.\n\nThe :attr:`category` attribute lets callers distinguish the source. See\n:class:`RateLimitErrorCategory` for the available values.\n"
|
||||
},
|
||||
"500": {
|
||||
"content": {
|
||||
|
|
|
|||
|
|
@ -508,6 +508,8 @@ class LiteLLMRoutes(enum.Enum):
|
|||
anthropic_routes = [
|
||||
"/v1/messages",
|
||||
"/v1/messages/count_tokens",
|
||||
"/claude_code_gateway/v1/messages",
|
||||
"/claude_code_gateway/v1/messages/count_tokens",
|
||||
"/v1/skills",
|
||||
"/v1/skills/{skill_id}",
|
||||
"/claude-code/marketplace.json",
|
||||
|
|
@ -885,6 +887,11 @@ class LiteLLMRoutes(enum.Enum):
|
|||
# of; a caller who administers none gets an empty result set.
|
||||
"/organization/daily/activity",
|
||||
"/user/available_roles", # read-only role metadata; any authenticated user may read
|
||||
# Claude Code gateway: the signed-in CLI fetches its managed settings and posts its own telemetry
|
||||
"/claude_code_gateway/managed/settings",
|
||||
"/claude_code_gateway/v1/metrics",
|
||||
"/claude_code_gateway/v1/logs",
|
||||
"/claude_code_gateway/v1/traces",
|
||||
"/user/list", # org admins checked in endpoint; non-admins get 403
|
||||
"/management/v1/users/bulk_delete", # proxy admins delete anyone, org admins only their orgs' users; others 403
|
||||
"/model/{model_id}/update",
|
||||
|
|
|
|||
|
|
@ -23,8 +23,10 @@ from typing import Final
|
|||
|
||||
from fastapi import APIRouter, Depends, Request, Response
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel, Field, TypeAdapter
|
||||
from pydantic import BaseModel, Field, TypeAdapter, ValidationError
|
||||
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.caching.dual_cache import DualCache
|
||||
from litellm.constants import (
|
||||
CLI_JWT_EXPIRATION_HOURS,
|
||||
CLI_SSO_SESSION_TTL_SECONDS,
|
||||
|
|
@ -45,9 +47,10 @@ _POST_ONLY: Final = ["POST"] # mutable-ok: FastAPI's add_api_route only accepts
|
|||
|
||||
class _GatewaySessionData(BaseModel):
|
||||
user_id: str
|
||||
user_role: str | None = None
|
||||
user_role: str | None
|
||||
models: list[str] = Field(default_factory=list)
|
||||
teams: tuple[str, ...] = ()
|
||||
team_details: object | None = None
|
||||
|
||||
|
||||
class _OAuthErrorBody(BaseModel):
|
||||
|
|
@ -212,19 +215,51 @@ async def device_authorization(request: Request) -> JSONResponse:
|
|||
def _mint_access_token_from_flow(flow: Mapping[str, object]) -> str:
|
||||
from litellm.proxy._types import LiteLLM_UserTable
|
||||
from litellm.proxy.auth.auth_checks import ExperimentalUIJWTToken
|
||||
from litellm.proxy.management_endpoints.ui_sso import selected_cli_sso_team_detail
|
||||
|
||||
raw_session_data: Final = flow.get("session_data")
|
||||
if not isinstance(raw_session_data, dict):
|
||||
raise _oauth_error(status_code=400, error="authorization_pending")
|
||||
try:
|
||||
session_data: Final = _GatewaySessionData.model_validate(flow.get("session_data"))
|
||||
except ValidationError as err:
|
||||
verbose_proxy_logger.warning("Claude Code gateway login session is malformed: %s", err)
|
||||
raise _oauth_error(
|
||||
status_code=400, error="invalid_grant", description="The login session is malformed; sign in again"
|
||||
) from err
|
||||
|
||||
session_data: Final = _GatewaySessionData.model_validate(raw_session_data)
|
||||
team_id: Final = session_data.teams[0] if session_data.teams else None
|
||||
selected_team: Final = selected_cli_sso_team_detail(team_details=session_data.team_details, team_id=team_id)
|
||||
if selected_team is None:
|
||||
raise _oauth_error(
|
||||
status_code=400,
|
||||
error="invalid_grant",
|
||||
description=f"Could not resolve the model grants for team {team_id}; sign in again",
|
||||
)
|
||||
|
||||
user_info: Final = LiteLLM_UserTable(
|
||||
user_id=session_data.user_id,
|
||||
user_role=session_data.user_role,
|
||||
models=session_data.models,
|
||||
)
|
||||
return ExperimentalUIJWTToken.get_cli_jwt_auth_token(user_info=user_info, team_id=team_id)
|
||||
return ExperimentalUIJWTToken.get_cli_jwt_auth_token(
|
||||
user_info=user_info,
|
||||
team_id=team_id,
|
||||
team_alias=selected_team.team_alias,
|
||||
team_models=selected_team.team_models,
|
||||
team_model_aliases=selected_team.team_model_aliases,
|
||||
max_budget=None,
|
||||
)
|
||||
|
||||
|
||||
async def _claim_device_code(device_code: str, cache: DualCache) -> bool:
|
||||
from litellm.proxy.management_endpoints.ui_sso import (
|
||||
_get_cli_sso_flow_cache_key, # pyright: ignore[reportPrivateUsage] # shared device-flow helper
|
||||
)
|
||||
|
||||
claims: Final = await cache.async_increment_cache(
|
||||
key=f"{_get_cli_sso_flow_cache_key(device_code)}:claimed",
|
||||
value=1,
|
||||
ttl=CLI_SSO_SESSION_TTL_SECONDS,
|
||||
)
|
||||
return claims == 1
|
||||
|
||||
|
||||
async def _handle_device_code_grant(device_code: str | None) -> JSONResponse:
|
||||
|
|
@ -249,12 +284,15 @@ async def _handle_device_code_grant(device_code: str | None) -> JSONResponse:
|
|||
if not flow.get("sso_complete") or not flow.get("user_code_verified"):
|
||||
return _oauth_error_response(_oauth_error(status_code=400, error="authorization_pending"))
|
||||
|
||||
if not await _claim_device_code(device_code, cli_sso_session_cache):
|
||||
return _oauth_error_response(_oauth_error(status_code=400, error="expired_token"))
|
||||
|
||||
await cli_sso_session_cache.async_delete_cache(key=_get_cli_sso_flow_cache_key(device_code))
|
||||
try:
|
||||
access_token: Final = _mint_access_token_from_flow(flow)
|
||||
except _OAuthError as err:
|
||||
return _oauth_error_response(err)
|
||||
|
||||
cli_sso_session_cache.delete_cache(key=_get_cli_sso_flow_cache_key(device_code))
|
||||
body: Final = _AccessTokenBody(access_token=access_token, expires_in=CLI_JWT_EXPIRATION_HOURS * _SECONDS_PER_HOUR)
|
||||
return JSONResponse(content=body.model_dump())
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ 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})
|
||||
|
||||
|
||||
|
|
@ -44,6 +46,10 @@ 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:
|
||||
|
|
@ -133,7 +139,9 @@ 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_form_content_type(content_type):
|
||||
if _is_protobuf_content_type(content_type):
|
||||
parsed_body = {}
|
||||
elif _is_form_content_type(content_type):
|
||||
try:
|
||||
form_data: Final = await request.form()
|
||||
except Exception as e:
|
||||
|
|
|
|||
|
|
@ -5,58 +5,161 @@ Covers the OAuth device-flow surface (RFC 8414 discovery, RFC 8628 device
|
|||
authorization + token), managed settings, OTLP ingestion, and the enable flag.
|
||||
"""
|
||||
|
||||
from contextlib import contextmanager
|
||||
from typing import Any, Iterator, Optional
|
||||
from unittest.mock import patch
|
||||
import asyncio
|
||||
from collections.abc import Iterator, Mapping
|
||||
from contextlib import ExitStack, contextmanager
|
||||
from types import MappingProxyType
|
||||
from typing import Final
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
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
|
||||
from litellm.proxy.management_endpoints.ui_sso import _get_cli_sso_flow_cache_key, _set_cli_sso_flow
|
||||
|
||||
_DEVICE_CODE_GRANT: Final = "urn:ietf:params:oauth:grant-type:device_code"
|
||||
_MASTER_KEY: Final = "sk-master-key"
|
||||
_MINT: Final = "litellm.proxy.auth.auth_checks.ExperimentalUIJWTToken.get_cli_jwt_auth_token"
|
||||
_PROTOBUF_BODY: Final = b"\x0a\x05hello\x12\x03{{{"
|
||||
_COMPLETED_SESSION: Final = MappingProxyType(
|
||||
{
|
||||
"user_id": "user-123",
|
||||
"user_role": "internal_user",
|
||||
"models": ["claude-sonnet-4-5"],
|
||||
"teams": ["team-a"],
|
||||
"team_details": [
|
||||
{
|
||||
"team_id": "team-a",
|
||||
"team_alias": "Team A",
|
||||
"team_models": ["claude-sonnet-4-5"],
|
||||
"team_model_aliases": None,
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class _SharedRedisFake:
|
||||
def __init__(self) -> None:
|
||||
self.values: Mapping[str, object] = MappingProxyType({})
|
||||
self.counters: Mapping[str, float] = MappingProxyType({})
|
||||
|
||||
def set_cache(self, key: str, value: object, **kwargs: object) -> None:
|
||||
self.values = MappingProxyType({**self.values, key: value})
|
||||
|
||||
def get_cache(self, key: str, **kwargs: object) -> object:
|
||||
return self.values.get(key)
|
||||
|
||||
def delete_cache(self, key: str) -> None:
|
||||
self.values = MappingProxyType({name: value for name, value in self.values.items() if name != key})
|
||||
|
||||
async def async_delete_cache(self, key: str) -> None:
|
||||
self.delete_cache(key)
|
||||
|
||||
async def async_increment(self, key: str, value: float, **kwargs: object) -> float:
|
||||
incremented: Final = self.counters.get(key, 0) + value
|
||||
self.counters = MappingProxyType({**self.counters, key: incremented})
|
||||
return incremented
|
||||
|
||||
|
||||
def _replica(redis: _SharedRedisFake) -> DualCache:
|
||||
return DualCache(redis_cache=redis, default_in_memory_ttl=600) # pyright: ignore[reportArgumentType] # duck-typed Redis double
|
||||
|
||||
|
||||
def _real_auth_proxy_attrs() -> Mapping[str, object]:
|
||||
proxy_logging_obj: Final = MagicMock()
|
||||
proxy_logging_obj.internal_usage_cache.dual_cache = AsyncMock()
|
||||
proxy_logging_obj.post_call_failure_hook = AsyncMock(return_value=None)
|
||||
return MappingProxyType(
|
||||
{
|
||||
"master_key": _MASTER_KEY,
|
||||
"prisma_client": None,
|
||||
"user_api_key_cache": DualCache(),
|
||||
"proxy_logging_obj": proxy_logging_obj,
|
||||
"llm_router": None,
|
||||
"llm_model_list": [],
|
||||
"user_custom_auth": None,
|
||||
"litellm_proxy_admin_name": "admin",
|
||||
"jwt_handler": None,
|
||||
"open_telemetry_logger": None,
|
||||
"model_max_budget_limiter": MagicMock(),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _gateway_env(
|
||||
*,
|
||||
enabled: bool = True,
|
||||
managed_settings: Optional[dict[str, Any]] = None,
|
||||
managed_settings: Mapping[str, object] | None = None,
|
||||
cache: DualCache | None = None,
|
||||
real_auth: bool = False,
|
||||
) -> Iterator[tuple[TestClient, DualCache]]:
|
||||
general_settings: dict[str, Any] = {"enable_claude_code_gateway": enabled}
|
||||
if managed_settings is not None:
|
||||
general_settings["claude_code_gateway_managed_settings"] = managed_settings
|
||||
cache = DualCache(default_in_memory_ttl=600)
|
||||
general_settings: Final = {
|
||||
"enable_claude_code_gateway": enabled,
|
||||
**({} if managed_settings is None else {"claude_code_gateway_managed_settings": dict(managed_settings)}),
|
||||
}
|
||||
session_cache: Final = cache or DualCache(default_in_memory_ttl=600)
|
||||
|
||||
app = FastAPI()
|
||||
app: Final = FastAPI()
|
||||
app.include_router(gateway_endpoints.router)
|
||||
|
||||
async def _fake_auth() -> Any:
|
||||
async def _fake_auth() -> object:
|
||||
return object()
|
||||
|
||||
app.dependency_overrides[gateway_endpoints.user_api_key_auth] = _fake_auth
|
||||
|
||||
with patch("litellm.proxy.proxy_server.general_settings", general_settings), patch(
|
||||
"litellm.proxy.proxy_server.cli_sso_session_cache", cache
|
||||
):
|
||||
with ExitStack() as stack:
|
||||
stack.enter_context(
|
||||
patch( # test-quality-ok: the gateway reads this proxy_server module global and has no injection seam
|
||||
"litellm.proxy.proxy_server.general_settings", general_settings
|
||||
)
|
||||
)
|
||||
stack.enter_context(
|
||||
patch( # test-quality-ok: the CLI SSO flow cache is this proxy_server module global shared with ui_sso
|
||||
"litellm.proxy.proxy_server.cli_sso_session_cache", session_cache
|
||||
)
|
||||
)
|
||||
if real_auth:
|
||||
for name, value in _real_auth_proxy_attrs().items():
|
||||
stack.enter_context(patch(f"litellm.proxy.proxy_server.{name}", value))
|
||||
else:
|
||||
app.dependency_overrides[gateway_endpoints.user_api_key_auth] = _fake_auth
|
||||
with TestClient(app) as client:
|
||||
yield client, cache
|
||||
yield client, session_cache
|
||||
|
||||
|
||||
def _complete_flow(cache: DualCache, device_code: str) -> None:
|
||||
key = _get_cli_sso_flow_cache_key(device_code)
|
||||
flow = cache.get_cache(key=key)
|
||||
assert isinstance(flow, dict)
|
||||
flow["sso_complete"] = True
|
||||
flow["user_code_verified"] = True
|
||||
flow["session_data"] = {
|
||||
"user_id": "user-123",
|
||||
"user_role": "internal_user",
|
||||
"models": ["claude-sonnet-4-5"],
|
||||
"teams": ["team-a"],
|
||||
def _start_device_flow(client: TestClient) -> str:
|
||||
return client.post("/claude_code_gateway/oauth/device_authorization").json()["device_code"]
|
||||
|
||||
|
||||
def _request_token(client: TestClient, device_code: str) -> httpx.Response:
|
||||
return client.post(
|
||||
"/claude_code_gateway/oauth/token",
|
||||
data={"grant_type": _DEVICE_CODE_GRANT, "device_code": device_code},
|
||||
)
|
||||
|
||||
|
||||
def _completed_flow(session_data: Mapping[str, object] = _COMPLETED_SESSION) -> dict[str, object]:
|
||||
return {
|
||||
"poll_secret_hash": "unused",
|
||||
"user_code_hash": "unused",
|
||||
"sso_complete": True,
|
||||
"user_code_verified": True,
|
||||
"session_data": dict(session_data),
|
||||
}
|
||||
cache.set_cache(key=key, value=flow, ttl=600)
|
||||
|
||||
|
||||
def _complete_flow(
|
||||
cache: DualCache, device_code: str, session_data: Mapping[str, object] = _COMPLETED_SESSION
|
||||
) -> None:
|
||||
key: Final = _get_cli_sso_flow_cache_key(device_code)
|
||||
flow: Final = cache.get_cache(key=key)
|
||||
assert isinstance(flow, dict)
|
||||
cache.set_cache(key=key, value={**flow, **_completed_flow(session_data)}, ttl=600)
|
||||
|
||||
|
||||
def test_discovery_shape():
|
||||
|
|
@ -105,28 +208,18 @@ def test_device_authorization_returns_rfc8628_shape_and_persists_flow():
|
|||
|
||||
def test_token_authorization_pending_before_browser_completes():
|
||||
with _gateway_env() as (client, _):
|
||||
device_code = client.post("/claude_code_gateway/oauth/device_authorization").json()["device_code"]
|
||||
resp = client.post(
|
||||
"/claude_code_gateway/oauth/token",
|
||||
data={"grant_type": "urn:ietf:params:oauth:grant-type:device_code", "device_code": device_code},
|
||||
)
|
||||
resp = _request_token(client, _start_device_flow(client))
|
||||
assert resp.status_code == 400
|
||||
assert resp.json()["error"] == "authorization_pending"
|
||||
|
||||
|
||||
def test_token_success_mints_bearer_and_is_single_use():
|
||||
with _gateway_env() as (client, cache):
|
||||
device_code = client.post("/claude_code_gateway/oauth/device_authorization").json()["device_code"]
|
||||
device_code = _start_device_flow(client)
|
||||
_complete_flow(cache, device_code)
|
||||
|
||||
with patch(
|
||||
"litellm.proxy.auth.auth_checks.ExperimentalUIJWTToken.get_cli_jwt_auth_token",
|
||||
return_value="sk-litellm-session-token",
|
||||
) as mint:
|
||||
resp = client.post(
|
||||
"/claude_code_gateway/oauth/token",
|
||||
data={"grant_type": "urn:ietf:params:oauth:grant-type:device_code", "device_code": device_code},
|
||||
)
|
||||
with patch(_MINT, return_value="sk-litellm-session-token") as mint:
|
||||
resp = _request_token(client, device_code)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["access_token"] == "sk-litellm-session-token"
|
||||
|
|
@ -136,22 +229,77 @@ def test_token_success_mints_bearer_and_is_single_use():
|
|||
called_user = mint.call_args.kwargs["user_info"]
|
||||
assert called_user.user_id == "user-123"
|
||||
assert mint.call_args.kwargs["team_id"] == "team-a"
|
||||
assert mint.call_args.kwargs["team_alias"] == "Team A"
|
||||
assert mint.call_args.kwargs["team_models"] == ("claude-sonnet-4-5",)
|
||||
|
||||
# Single-use: the flow is deleted, so a replay returns expired_token.
|
||||
replay = client.post(
|
||||
"/claude_code_gateway/oauth/token",
|
||||
data={"grant_type": "urn:ietf:params:oauth:grant-type:device_code", "device_code": device_code},
|
||||
)
|
||||
replay = _request_token(client, device_code)
|
||||
assert replay.status_code == 400
|
||||
assert replay.json()["error"] == "expired_token"
|
||||
|
||||
|
||||
def test_token_teamless_user_mints_without_a_team():
|
||||
with _gateway_env() as (client, cache):
|
||||
device_code = _start_device_flow(client)
|
||||
_complete_flow(cache, device_code, session_data={**_COMPLETED_SESSION, "teams": [], "team_details": []})
|
||||
with patch(_MINT, return_value="sk-litellm-session-token") as mint:
|
||||
resp = _request_token(client, device_code)
|
||||
assert resp.status_code == 200
|
||||
assert mint.call_args.kwargs["team_id"] is None
|
||||
assert mint.call_args.kwargs["team_models"] == ()
|
||||
|
||||
|
||||
def test_token_malformed_session_is_invalid_grant():
|
||||
with _gateway_env() as (client, cache):
|
||||
device_code = _start_device_flow(client)
|
||||
_complete_flow(cache, device_code, session_data={"user_role": "internal_user"})
|
||||
with patch(_MINT) as mint:
|
||||
resp = _request_token(client, device_code)
|
||||
assert resp.status_code == 400
|
||||
assert resp.json()["error"] == "invalid_grant"
|
||||
mint.assert_not_called()
|
||||
|
||||
|
||||
def test_token_unknown_team_grants_is_invalid_grant():
|
||||
with _gateway_env() as (client, cache):
|
||||
device_code = _start_device_flow(client)
|
||||
_complete_flow(cache, device_code, session_data={**_COMPLETED_SESSION, "team_details": []})
|
||||
with patch(_MINT) as mint:
|
||||
resp = _request_token(client, device_code)
|
||||
assert resp.status_code == 400
|
||||
assert resp.json()["error"] == "invalid_grant"
|
||||
mint.assert_not_called()
|
||||
|
||||
|
||||
def test_token_mints_on_a_replica_that_did_not_start_the_login():
|
||||
redis: Final = _SharedRedisFake()
|
||||
device_code: Final = "cli-shared-login-code"
|
||||
_set_cli_sso_flow(login_id=device_code, cache=_replica(redis), flow=_completed_flow())
|
||||
|
||||
with _gateway_env(cache=_replica(redis)) as (client, _), patch(_MINT, return_value="sk-session") as mint:
|
||||
resp = _request_token(client, device_code)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["access_token"] == "sk-session"
|
||||
assert mint.call_args.kwargs["team_id"] == "team-a"
|
||||
|
||||
|
||||
def test_token_refuses_a_device_code_another_replica_already_claimed():
|
||||
redis: Final = _SharedRedisFake()
|
||||
replica_a: Final = _replica(redis)
|
||||
device_code: Final = "cli-shared-login-code"
|
||||
_set_cli_sso_flow(login_id=device_code, cache=replica_a, flow=_completed_flow())
|
||||
assert asyncio.run(gateway_endpoints._claim_device_code(device_code, replica_a)) is True
|
||||
|
||||
with _gateway_env(cache=_replica(redis)) as (client, _), patch(_MINT) as mint:
|
||||
resp = _request_token(client, device_code)
|
||||
assert resp.status_code == 400
|
||||
assert resp.json()["error"] == "expired_token"
|
||||
mint.assert_not_called()
|
||||
|
||||
|
||||
def test_token_unknown_device_code_is_expired_token():
|
||||
with _gateway_env() as (client, _):
|
||||
resp = client.post(
|
||||
"/claude_code_gateway/oauth/token",
|
||||
data={"grant_type": "urn:ietf:params:oauth:grant-type:device_code", "device_code": "cli-does-not-exist"},
|
||||
)
|
||||
resp = _request_token(client, "cli-does-not-exist")
|
||||
assert resp.status_code == 400
|
||||
assert resp.json()["error"] == "expired_token"
|
||||
|
||||
|
|
@ -213,6 +361,26 @@ def test_otlp_endpoints_404_when_disabled(signal: str):
|
|||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_otlp_protobuf_body_is_accepted_through_real_auth():
|
||||
with _gateway_env(real_auth=True) as (client, _):
|
||||
resp = client.post(
|
||||
"/claude_code_gateway/v1/metrics",
|
||||
content=_PROTOBUF_BODY,
|
||||
headers={"Authorization": f"Bearer {_MASTER_KEY}", "Content-Type": "application/x-protobuf"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
def test_otlp_without_a_bearer_is_rejected_by_real_auth():
|
||||
with _gateway_env(real_auth=True) as (client, _), pytest.raises(ProxyException) as exc_info:
|
||||
client.post(
|
||||
"/claude_code_gateway/v1/metrics",
|
||||
content=_PROTOBUF_BODY,
|
||||
headers={"Content-Type": "application/x-protobuf"},
|
||||
)
|
||||
assert exc_info.value.code == "401"
|
||||
|
||||
|
||||
def test_messages_gated_by_enable_flag():
|
||||
with _gateway_env(enabled=False) as (client, _):
|
||||
resp = client.post("/claude_code_gateway/v1/messages", json={"model": "claude-sonnet-4-5", "messages": []})
|
||||
|
|
|
|||
|
|
@ -910,6 +910,36 @@ def test_anthropic_count_tokens_route_accessible_to_internal_users():
|
|||
assert RouteChecks.is_llm_api_route("/v1/messages") is True
|
||||
|
||||
|
||||
_CLAUDE_CODE_GATEWAY_ROUTES: Final = (
|
||||
"/claude_code_gateway/v1/messages",
|
||||
"/claude_code_gateway/v1/messages/count_tokens",
|
||||
"/claude_code_gateway/managed/settings",
|
||||
"/claude_code_gateway/v1/metrics",
|
||||
"/claude_code_gateway/v1/logs",
|
||||
"/claude_code_gateway/v1/traces",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("route", _CLAUDE_CODE_GATEWAY_ROUTES)
|
||||
@pytest.mark.parametrize(
|
||||
"role", [LitellmUserRoles.INTERNAL_USER.value, LitellmUserRoles.INTERNAL_USER_VIEW_ONLY.value]
|
||||
)
|
||||
def test_claude_code_gateway_routes_open_to_signed_in_cli_users(role: str, route: str):
|
||||
user_obj: Final = LiteLLM_UserTable(user_id="test_user", user_email="test@example.com", user_role=role)
|
||||
valid_token: Final = UserAPIKeyAuth(user_id="test_user", user_role=role)
|
||||
request: Final = MagicMock(spec=Request)
|
||||
request.query_params = {}
|
||||
|
||||
RouteChecks.non_proxy_admin_allowed_routes_check(
|
||||
user_obj=user_obj,
|
||||
_user_role=role,
|
||||
route=route,
|
||||
request=request,
|
||||
valid_token=valid_token,
|
||||
request_data={},
|
||||
)
|
||||
|
||||
|
||||
def test_virtual_key_llm_api_routes_allows_registered_pass_through_endpoints():
|
||||
"""
|
||||
Virtual keys with llm_api_routes can access auth=true pass-through endpoints only when
|
||||
|
|
|
|||
|
|
@ -573,6 +573,13 @@ async def test_lone_surrogate_escape_is_rejected_with_400(content: bytes):
|
|||
assert parsed["messages"][0]["content"] == "say ok \U0001F600"
|
||||
|
||||
|
||||
@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.asyncio
|
||||
async def test_get_form_data():
|
||||
"""
|
||||
|
|
|
|||
12
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
12
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -26606,6 +26606,13 @@ export interface components {
|
|||
* @description cancel the in-flight upstream LLM request (non-streaming) when the client disconnects, freeing backend capacity (e.g. a vLLM GPU slot); the request is logged as a 499 failure
|
||||
*/
|
||||
cancel_on_disconnect?: boolean | null;
|
||||
/**
|
||||
* Claude Code Gateway Managed Settings
|
||||
* @description Claude Code managed-settings.json served verbatim at the gateway's /claude_code_gateway/managed/settings endpoint. When unset the endpoint returns 404 (no managed policy)
|
||||
*/
|
||||
claude_code_gateway_managed_settings?: {
|
||||
[key: string]: unknown;
|
||||
} | null;
|
||||
/**
|
||||
* Completion Model
|
||||
* @description proxy level default model for all chat completion calls
|
||||
|
|
@ -26700,6 +26707,11 @@ export interface components {
|
|||
* @description If True, disables ownership enforcement on Responses API ids. Keys may then retrieve, cancel, delete, and chain from any response id, including ids belonging to another user or team and ids this proxy never issued. WARNING: this removes tenant isolation on /v1/responses
|
||||
*/
|
||||
disable_responses_id_security?: boolean | null;
|
||||
/**
|
||||
* Enable Claude Code Gateway
|
||||
* @description serve the Claude Code gateway protocol (https://code.claude.com/docs/en/claude-apps-gateway) under /claude_code_gateway: OAuth device-flow sign-in reusing proxy SSO, plus managed settings and OTLP telemetry ingestion. Off by default
|
||||
*/
|
||||
enable_claude_code_gateway?: boolean | null;
|
||||
/**
|
||||
* Enable Openai Websocket Passthrough
|
||||
* @description Serve the OpenAI pass-through WebSocket route, which relays frames to OpenAI under the proxy's own provider credential without reading them. Off by default.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue