feat(proxy): serve Relay managed config from a dedicated YAML file

Add GET /relay/managed-config so Relay devices can pull an admin-owned
Claude Code policy (pinned version, model, managed settings) from the
Gateway. The policy lives in a standalone YAML file located by
LITELLM_RELAY_SETTINGS_PATH (default relay_settings.yaml), separate from
the proxy config.yaml, and is re-read on each request so edits apply on
the next sync. The endpoint requires existing LiteLLM auth.

Co-Authored-By: Ishaan Jaffer <155045088+ishaan-berri@users.noreply.github.com>
This commit is contained in:
Devin AI 2026-07-14 04:00:22 +00:00
parent 6a213de9f4
commit 383b12d0cf
8 changed files with 366 additions and 0 deletions

View file

@ -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",

View file

@ -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": {

View file

@ -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())

View file

@ -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)

View file

@ -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"
}

View file

@ -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;