diff --git a/litellm/proxy/_lazy_features.py b/litellm/proxy/_lazy_features.py index 1dda1f29fb9..faf2c7543cf 100644 --- a/litellm/proxy/_lazy_features.py +++ b/litellm/proxy/_lazy_features.py @@ -212,6 +212,11 @@ LAZY_FEATURES: Tuple[LazyFeature, ...] = ( path_prefixes=("/claude-code",), register_fn=_include_router("claude_code_marketplace_router"), ), + LazyFeature( + name="relay", + module_path="litellm.proxy.relay_endpoints.endpoints", + path_prefixes=("/relay",), + ), LazyFeature( name="scim", module_path="litellm.proxy.management_endpoints.scim.scim_v2", diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json index 27cdc483d4a..831917ecc9c 100644 --- a/litellm/proxy/_lazy_openapi_snapshot.json +++ b/litellm/proxy/_lazy_openapi_snapshot.json @@ -23826,6 +23826,133 @@ } } }, + "relay": { + "components": { + "schemas": { + "JsonValue": {}, + "RelayClaudeCodeConfig": { + "properties": { + "channel": { + "default": "pinned", + "title": "Channel", + "type": "string" + }, + "managed_settings": { + "additionalProperties": { + "$ref": "#/components/schemas/JsonValue" + }, + "title": "Managed Settings", + "type": "object" + }, + "model": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Model" + }, + "package": { + "default": "@anthropic-ai/claude-code", + "title": "Package", + "type": "string" + }, + "registry": { + "default": "npm", + "title": "Registry", + "type": "string" + }, + "version": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Version" + } + }, + "title": "RelayClaudeCodeConfig", + "type": "object" + }, + "RelayManagedConfigResponse": { + "properties": { + "claude_code": { + "$ref": "#/components/schemas/RelayClaudeCodeConfig" + }, + "policy_version": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Policy Version" + }, + "updated_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Updated At" + }, + "updated_by": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Updated By" + } + }, + "title": "RelayManagedConfigResponse", + "type": "object" + } + } + }, + "paths": { + "/relay/managed-config": { + "get": { + "operationId": "get_relay_managed_config_relay_managed_config_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RelayManagedConfigResponse" + } + } + }, + "description": "Successful Response" + } + }, + "security": [ + { + "APIKeyHeader": [] + } + ], + "summary": "Get Relay Managed Config", + "tags": [ + "relay" + ] + } + } + } + }, "scim": { "components": { "schemas": { diff --git a/litellm/proxy/relay_endpoints/__init__.py b/litellm/proxy/relay_endpoints/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/litellm/proxy/relay_endpoints/endpoints.py b/litellm/proxy/relay_endpoints/endpoints.py new file mode 100644 index 00000000000..1d96ce7ff8b --- /dev/null +++ b/litellm/proxy/relay_endpoints/endpoints.py @@ -0,0 +1,49 @@ +import os +from pathlib import Path + +import yaml +from fastapi import APIRouter, Depends, HTTPException + +from litellm.proxy.auth.user_api_key_auth import user_api_key_auth +from litellm.types.proxy.relay_endpoints import RelayManagedConfigResponse + +router = APIRouter() + +RELAY_SETTINGS_PATH_ENV = "LITELLM_RELAY_SETTINGS_PATH" +DEFAULT_RELAY_SETTINGS_PATH = "relay_settings.yaml" + + +def _relay_settings_path() -> Path: + return Path(os.getenv(RELAY_SETTINGS_PATH_ENV, DEFAULT_RELAY_SETTINGS_PATH)) + + +def _load_managed_config(path: Path) -> RelayManagedConfigResponse: + if not path.exists(): + return RelayManagedConfigResponse() + + try: + parsed = yaml.safe_load(path.read_text()) + except yaml.YAMLError as e: + raise HTTPException( + status_code=500, + detail={"error": f"Failed to parse Relay settings at {path}: {e}"}, + ) + + if parsed is None: + return RelayManagedConfigResponse() + if not isinstance(parsed, dict): + raise HTTPException( + status_code=500, + detail={"error": f"Relay settings at {path} must be a YAML mapping, got {type(parsed).__name__}"}, + ) + return RelayManagedConfigResponse.model_validate(parsed) + + +@router.get( + "/relay/managed-config", + tags=["LiteLLM Relay"], + dependencies=[Depends(user_api_key_auth)], + response_model=RelayManagedConfigResponse, +) +async def get_relay_managed_config() -> RelayManagedConfigResponse: + return _load_managed_config(_relay_settings_path()) diff --git a/litellm/types/proxy/relay_endpoints.py b/litellm/types/proxy/relay_endpoints.py new file mode 100644 index 00000000000..4b70879d0c7 --- /dev/null +++ b/litellm/types/proxy/relay_endpoints.py @@ -0,0 +1,19 @@ +from typing import Dict, Optional + +from pydantic import BaseModel, Field, JsonValue + + +class RelayClaudeCodeConfig(BaseModel): + channel: str = Field(default="pinned") + version: Optional[str] = Field(default=None) + registry: str = Field(default="npm") + package: str = Field(default="@anthropic-ai/claude-code") + model: Optional[str] = Field(default=None) + managed_settings: Dict[str, JsonValue] = Field(default_factory=dict) + + +class RelayManagedConfigResponse(BaseModel): + claude_code: RelayClaudeCodeConfig = Field(default_factory=RelayClaudeCodeConfig) + policy_version: Optional[int] = Field(default=None) + updated_by: Optional[str] = Field(default=None) + updated_at: Optional[str] = Field(default=None) diff --git a/tests/test_litellm/proxy/relay_endpoints/__init__.py b/tests/test_litellm/proxy/relay_endpoints/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/test_litellm/proxy/relay_endpoints/test_endpoints.py b/tests/test_litellm/proxy/relay_endpoints/test_endpoints.py new file mode 100644 index 00000000000..db6a7844eb6 --- /dev/null +++ b/tests/test_litellm/proxy/relay_endpoints/test_endpoints.py @@ -0,0 +1,92 @@ +from pathlib import Path + +import pytest +from fastapi import HTTPException + +from litellm.proxy.relay_endpoints.endpoints import _load_managed_config + + +def test_load_managed_config_parses_pinned_version(tmp_path: Path) -> None: + path = tmp_path / "relay_settings.yaml" + path.write_text( + "\n".join( + [ + "claude_code:", + " channel: pinned", + " version: 1.2.3", + " registry: npm", + " model: claude-sonnet-4-5", + "policy_version: 7", + ] + ) + ) + + config = _load_managed_config(path) + + assert config.claude_code.version == "1.2.3" + assert config.claude_code.channel == "pinned" + assert config.claude_code.registry == "npm" + assert config.claude_code.model == "claude-sonnet-4-5" + assert config.policy_version == 7 + + +def test_load_managed_config_missing_file_returns_empty_policy(tmp_path: Path) -> None: + config = _load_managed_config(tmp_path / "does_not_exist.yaml") + + assert config.claude_code.version is None + assert config.policy_version is None + + +def test_load_managed_config_empty_file_returns_empty_policy(tmp_path: Path) -> None: + path = tmp_path / "relay_settings.yaml" + path.write_text("") + + config = _load_managed_config(path) + + assert config.claude_code.version is None + + +def test_load_managed_config_rejects_non_mapping(tmp_path: Path) -> None: + path = tmp_path / "relay_settings.yaml" + path.write_text("- just\n- a\n- list\n") + + with pytest.raises(HTTPException) as exc_info: + _load_managed_config(path) + + assert exc_info.value.status_code == 500 + + +def test_load_managed_config_rejects_invalid_yaml(tmp_path: Path) -> None: + path = tmp_path / "relay_settings.yaml" + path.write_text("claude_code: {version: '1.2.3'\n") + + with pytest.raises(HTTPException) as exc_info: + _load_managed_config(path) + + assert exc_info.value.status_code == 500 + + +def test_load_managed_config_preserves_nested_managed_settings(tmp_path: Path) -> None: + path = tmp_path / "relay_settings.yaml" + path.write_text( + "\n".join( + [ + "claude_code:", + " version: 2.1.208", + " managed_settings:", + " env:", + " ANTHROPIC_MODEL: claude-sonnet-4-5", + " permissions:", + " defaultMode: acceptEdits", + ] + ) + ) + + config = _load_managed_config(path) + + assert config.claude_code.managed_settings["env"] == { + "ANTHROPIC_MODEL": "claude-sonnet-4-5" + } + assert config.claude_code.managed_settings["permissions"] == { + "defaultMode": "acceptEdits" + } diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 1d620e9d153..02fccdf73b6 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -11071,6 +11071,23 @@ export interface paths { patch?: never; trace?: never; }; + "/relay/managed-config": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Relay Managed Config */ + get: operations["get_relay_managed_config_relay_managed_config_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/reload/anthropic_beta_headers": { parameters: { query?: never; @@ -24541,6 +24558,7 @@ export interface components { /** Updated By */ updated_by?: string | null; }; + JsonValue: unknown; /** KeyHealthResponse */ KeyHealthResponse: { /** @@ -30192,6 +30210,42 @@ export interface components { /** Review Notes */ review_notes?: string | null; }; + /** RelayClaudeCodeConfig */ + RelayClaudeCodeConfig: { + /** + * Channel + * @default pinned + */ + channel: string; + /** Managed Settings */ + managed_settings?: { + [key: string]: components["schemas"]["JsonValue"]; + }; + /** Model */ + model?: string | null; + /** + * Package + * @default @anthropic-ai/claude-code + */ + package: string; + /** + * Registry + * @default npm + */ + registry: string; + /** Version */ + version?: string | null; + }; + /** RelayManagedConfigResponse */ + RelayManagedConfigResponse: { + claude_code?: components["schemas"]["RelayClaudeCodeConfig"]; + /** Policy Version */ + policy_version?: number | null; + /** Updated At */ + updated_at?: string | null; + /** Updated By */ + updated_by?: string | null; + }; /** ResetSpendRequest */ ResetSpendRequest: { /** Reset To */ @@ -47451,6 +47505,26 @@ export interface operations { }; }; }; + get_relay_managed_config_relay_managed_config_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RelayManagedConfigResponse"]; + }; + }; + }; + }; reload_anthropic_beta_headers_reload_anthropic_beta_headers_post: { parameters: { query?: never;