This commit is contained in:
mika-lt 2026-10-30 23:17:23 -07:00 • committed by GitHub
commit 278670f656
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 221 additions and 73 deletions

View file

@ -1188,22 +1188,18 @@ async def make_agent_public(
if agent is None:
raise HTTPException(status_code=404, detail=f"Agent with ID {agent_id} not found")
config: Final = await proxy_config.get_config()
current_public_agent_groups: Final = list(litellm.public_agent_groups or [])
if not AGENT_REGISTRY.ids_for_agent(agent.agent_id).isdisjoint(current_public_agent_groups):
stored_public_agent_groups: Final = await proxy_config.append_to_list_setting(
section_name="litellm_settings",
key="public_agent_groups",
item=agent.agent_id,
listed_as=AGENT_REGISTRY.ids_for_agent(agent.agent_id),
)
if stored_public_agent_groups is None:
raise HTTPException(
status_code=400,
detail=f"Agent with name {agent.agent_name} already in public agent groups",
)
updated_public_agent_groups: Final = [*current_public_agent_groups, agent.agent_id]
if "litellm_settings" not in config or config["litellm_settings"] is None:
config["litellm_settings"] = {}
config["litellm_settings"]["public_agent_groups"] = updated_public_agent_groups
await proxy_config.save_config(new_config=config)
updated_public_agent_groups: Final = list(stored_public_agent_groups)
litellm.public_agent_groups = updated_public_agent_groups

View file

@ -5485,6 +5485,17 @@ _CONFIG_PERSISTED_SECTIONS: Final = ("general_settings", "router_settings", "lit
_CONFIG_UNMANAGED_EXCLUSIONS: Final = frozenset(("environment_variables", "model_list"))
_CONFIG_SECTION_VALUES: Final = TypeAdapter(Mapping[str, JsonValue])
_CONFIG_SECTION_LOCK_SQL: Final = "SELECT 1 AS locked FROM pg_advisory_xact_lock(hashtext($1))"
_STRING_LIST: Final = TypeAdapter(list[str])
_CONFIG_MAPPING: Final = TypeAdapter(Mapping[str, object])
def _with_list_item(
section: Mapping[str, JsonValue], *, key: str, item: str, listed_as: frozenset[str]
) -> Mapping[str, JsonValue]:
current: Final = _STRING_LIST.validate_python(section.get(key) or [])
if not listed_as.isdisjoint(current):
return section
return MappingProxyType({**section, key: [*current, item]})
class _ConfigParamWhere(TypedDict):
@ -6016,6 +6027,63 @@ class ProxyConfig:
changed_keys: Mapping[str, JsonValue],
removed_keys: frozenset[str],
prisma_client: PrismaClient,
) -> Mapping[str, JsonValue] | None:
return await self._rewrite_config_section(
section_name=section_name,
prisma_client=prisma_client,
rewrite=lambda existing_section: MappingProxyType(
{
key: value
for key, value in chain(
((key, value) for key, value in existing_section.items() if key not in removed_keys),
changed_keys.items(),
)
}
),
)
async def append_to_list_setting(
self, *, section_name: Section, key: str, item: str, listed_as: frozenset[str]
) -> tuple[str, ...] | None:
if prisma_client is None or not (
general_settings_view().get("store_model_in_db", False) is True or store_model_in_db
):
return await self._append_to_file_list_setting(
section_name=section_name, key=key, item=item, listed_as=listed_as
)
store: Final = self._settings_stores[section_name]
if store.owned_by_config(key):
if not listed_as.isdisjoint(_STRING_LIST.validate_python(store.get(key) or [])):
return None
self._raise_config_owned(section_name=section_name, rejected=(key,), store=store)
written_section: Final = await self._rewrite_config_section(
section_name=section_name,
prisma_client=prisma_client,
rewrite=lambda existing_section: _with_list_item(existing_section, key=key, item=item, listed_as=listed_as),
)
if written_section is None:
return None
store.apply_db_row(section_name, written_section)
await invalidate_config_param(section_name)
return tuple(_STRING_LIST.validate_python(written_section[key]))
async def _append_to_file_list_setting(
self, *, section_name: Section, key: str, item: str, listed_as: frozenset[str]
) -> tuple[str, ...] | None:
config: Final = _CONFIG_MAPPING.validate_python(await self.get_config())
section: Final = _CONFIG_SECTION_VALUES.validate_python(config.get(section_name) or {})
updated_section: Final = _with_list_item(section, key=key, item=item, listed_as=listed_as)
if updated_section == section:
return None
await self.save_config(new_config={**config, section_name: dict(updated_section)})
return tuple(_STRING_LIST.validate_python(updated_section[key]))
async def _rewrite_config_section(
self,
*,
section_name: str,
prisma_client: PrismaClient,
rewrite: Callable[[Mapping[str, JsonValue]], Mapping[str, JsonValue]],
) -> Mapping[str, JsonValue] | None:
async with prisma_client.tx() as tx:
await tx.query_raw(_CONFIG_SECTION_LOCK_SQL, section_name)
@ -6030,15 +6098,7 @@ class ProxyConfig:
if isinstance(existing_value, Mapping)
else MappingProxyType({})
)
merged_section: Final[Mapping[str, JsonValue]] = MappingProxyType(
{
key: value
for key, value in chain(
((key, value) for key, value in existing_section.items() if key not in removed_keys),
changed_keys.items(),
)
}
)
merged_section: Final = rewrite(existing_section)
if merged_section == existing_section:
return None
serialized_section: Final = json.dumps(dict(merged_section))

View file

@ -1,6 +1,9 @@
import asyncio
import json
from collections.abc import Mapping
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from types import SimpleNamespace
from typing import Final
@ -8,6 +11,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest
import yaml
from fastapi import FastAPI, HTTPException
from fastapi.testclient import TestClient
from prisma.models import LiteLLM_AgentsTable
@ -15,6 +19,7 @@ from prisma.models import LiteLLM_AgentsTable
from litellm.constants import REDACTED_BY_LITELM_STRING
from litellm.proxy._types import LiteLLM_AuditLogs, LitellmTableNames, LitellmUserRoles, UserAPIKeyAuth
from litellm.proxy.agent_endpoints import endpoints as agent_endpoints
from litellm.proxy.agent_endpoints.agent_registry import AgentRegistry
from litellm.proxy.agent_endpoints.auth.agent_permission_handler import (
RestrictedAgentAccess,
)
@ -1143,77 +1148,164 @@ def test_merged_agent_card_url_has_no_double_slash_without_proxy_base_url(
assert "//a2a" not in interface_url
class _DbBackedProxyConfig:
"""Round-trips `litellm_settings` through the DB overlay the proxy applies on every
`get_config()`, which is what re-assigns the `litellm.public_*` globals in production.
Storage goes through JSON the way the `litellm_config` row does, so every read hands back
freshly built values instead of the objects the endpoint still holds a reference to."""
def __init__(self, stored_litellm_settings: dict[str, object] | None = None) -> None:
self.stored_litellm_settings_json: str = json.dumps(stored_litellm_settings or {})
async def get_config(self) -> dict[str, dict[str, object]]:
from litellm.proxy.proxy_server import ProxyConfig
config: Final[dict[str, dict[str, object]]] = {"litellm_settings": {}}
db_param_value: Final[dict[str, object]] = json.loads(self.stored_litellm_settings_json)
if not db_param_value:
return config
proxy_config: Final = ProxyConfig()
db_values: Final = proxy_config._prepared_db_settings_values("litellm_settings", db_param_value)
proxy_config._apply_litellm_settings_db_values(db_values)
return {"litellm_settings": dict(proxy_config.litellm_settings.resolved())}
async def save_config(self, new_config: dict[str, dict[str, object]]) -> None:
self.stored_litellm_settings_json = json.dumps(new_config.get("litellm_settings") or {})
@dataclass(frozen=True, slots=True)
class _StoredConfigRow:
param_name: str
param_value: dict[str, object]
def test_make_agent_public_twice_keeps_both_agents_public(monkeypatch: pytest.MonkeyPatch) -> None:
"""A second /make_public call must not drop the agent published by the first one."""
class _ConfigRows:
"""`LiteLLM_Config` as a section-locked transaction reads it, shared by every replica."""
def __init__(self, rows: Mapping[str, Mapping[str, object]]) -> None:
self.serialized_rows: dict[str, str] = {name: json.dumps(dict(value)) for name, value in rows.items()}
self.section_lock: Final = asyncio.Lock()
async def find_first(self, *, where: Mapping[str, str]) -> _StoredConfigRow | None:
name: Final = where["param_name"]
serialized: Final = self.serialized_rows.get(name)
await asyncio.sleep(0)
return None if serialized is None else _StoredConfigRow(param_name=name, param_value=json.loads(serialized))
async def upsert(self, *, where: Mapping[str, str], data: Mapping[str, Mapping[str, str]]) -> None:
self.serialized_rows[where["param_name"]] = data["update"]["param_value"]
def stored(self, name: str) -> dict[str, object]:
return json.loads(self.serialized_rows[name])
class _ConfigTransaction:
def __init__(self, rows: _ConfigRows) -> None:
self.litellm_config: Final = rows
self.holds_section_lock = False
async def __aenter__(self) -> "_ConfigTransaction":
return self
async def __aexit__(self, *_: object) -> None:
if self.holds_section_lock:
self.litellm_config.section_lock.release()
async def query_raw(self, query: str, section_name: str) -> None:
await self.litellm_config.section_lock.acquire()
self.holds_section_lock = True
class _StaleReplicaPrisma:
"""A replica whose config reads still return what it cached before another replica wrote."""
def __init__(self, rows: _ConfigRows, cached_rows: Mapping[str, Mapping[str, object]]) -> None:
self.rows: Final = rows
self._cached_rows: Final = cached_rows
async def get_generic_data(self, key: str, value: str, table_name: str) -> SimpleNamespace | None:
cached: Final = self._cached_rows.get(value)
return None if cached is None else SimpleNamespace(param_name=value, param_value=dict(cached))
def tx(self) -> _ConfigTransaction:
return _ConfigTransaction(self.rows)
_PROXY_ADMIN: Final = UserAPIKeyAuth(user_id="test-user", user_role=LitellmUserRoles.PROXY_ADMIN)
def _replica_that_missed_a_publish(
monkeypatch: pytest.MonkeyPatch, *, stored_public_agent_groups: list[str], store_model_in_db: bool = True
) -> tuple[_ConfigRows, AgentRegistry]:
import litellm
from litellm.proxy.agent_endpoints import agent_registry as agent_registry_module
from litellm.proxy.agent_endpoints.agent_registry import AgentRegistry
from litellm.proxy.proxy_server import ProxyConfig
registry: Final = AgentRegistry()
registry.register_agent(_sample_agent_response(agent_id="agent-1", agent_name="Agent One"))
registry.register_agent(_sample_agent_response(agent_id="agent-2", agent_name="Agent Two"))
rows: Final = _ConfigRows({"litellm_settings": {"public_agent_groups": stored_public_agent_groups}})
cached_rows: Final = {"litellm_settings": {"public_agent_groups": []}}
monkeypatch.setattr(agent_registry_module, "global_agent_registry", registry)
monkeypatch.setattr(litellm, "public_agent_groups", None)
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", MagicMock())
monkeypatch.setattr("litellm.proxy.proxy_server.proxy_config", _DbBackedProxyConfig())
monkeypatch.setattr(litellm, "public_agent_groups", [])
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", _StaleReplicaPrisma(rows, cached_rows))
monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", store_model_in_db)
monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", {"store_model_in_db": store_model_in_db})
monkeypatch.setattr("litellm.proxy.proxy_server.proxy_config", ProxyConfig())
return rows, registry
first: Final = client.post("/v1/agents/agent-1/make_public", headers={"Authorization": "Bearer test-key"})
second: Final = client.post("/v1/agents/agent-2/make_public", headers={"Authorization": "Bearer test-key"})
assert first.status_code == 200
assert second.status_code == 200
assert second.json()["public_agent_groups"] == ["agent-1", "agent-2"]
@pytest.mark.asyncio
async def test_make_agent_public_keeps_an_agent_another_replica_published(monkeypatch: pytest.MonkeyPatch) -> None:
rows, registry = _replica_that_missed_a_publish(monkeypatch, stored_public_agent_groups=["agent-1"])
response: Final = await agent_endpoints.make_agent_public(agent_id="agent-2", user_api_key_dict=_PROXY_ADMIN)
assert response["public_agent_groups"] == ["agent-1", "agent-2"]
assert rows.stored("litellm_settings")["public_agent_groups"] == ["agent-1", "agent-2"]
assert [agent.agent_id for agent in registry.get_public_agent_list()] == ["agent-1", "agent-2"]
def test_make_agent_public_rejects_an_agent_published_only_in_the_db(monkeypatch: pytest.MonkeyPatch) -> None:
"""The duplicate guard must fire off the stored list, not just what this process published."""
import litellm
from litellm.proxy.agent_endpoints import agent_registry as agent_registry_module
from litellm.proxy.agent_endpoints.agent_registry import AgentRegistry
@pytest.mark.asyncio
async def test_make_agent_public_concurrent_publishes_all_stay_public(monkeypatch: pytest.MonkeyPatch) -> None:
rows, _ = _replica_that_missed_a_publish(monkeypatch, stored_public_agent_groups=[])
registry: Final = AgentRegistry()
registry.register_agent(_sample_agent_response(agent_id="agent-1", agent_name="Agent One"))
monkeypatch.setattr(agent_registry_module, "global_agent_registry", registry)
monkeypatch.setattr(litellm, "public_agent_groups", None)
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", MagicMock())
monkeypatch.setattr(
"litellm.proxy.proxy_server.proxy_config",
_DbBackedProxyConfig({"public_agent_groups": ["agent-1"]}),
await asyncio.gather(
agent_endpoints.make_agent_public(agent_id="agent-1", user_api_key_dict=_PROXY_ADMIN),
agent_endpoints.make_agent_public(agent_id="agent-2", user_api_key_dict=_PROXY_ADMIN),
)
duplicate: Final = client.post("/v1/agents/agent-1/make_public", headers={"Authorization": "Bearer test-key"})
assert sorted(rows.stored("litellm_settings")["public_agent_groups"]) == ["agent-1", "agent-2"]
assert duplicate.status_code == 400
assert "already in public agent groups" in duplicate.json()["detail"]
@pytest.mark.asyncio
async def test_make_agent_public_rejects_an_agent_another_replica_published(monkeypatch: pytest.MonkeyPatch) -> None:
rows, _ = _replica_that_missed_a_publish(monkeypatch, stored_public_agent_groups=["agent-1"])
with pytest.raises(HTTPException) as duplicate:
await agent_endpoints.make_agent_public(agent_id="agent-1", user_api_key_dict=_PROXY_ADMIN)
assert duplicate.value.status_code == 400
assert "already in public agent groups" in duplicate.value.detail
assert rows.stored("litellm_settings")["public_agent_groups"] == ["agent-1"]
@pytest.mark.asyncio
async def test_make_agent_public_refuses_a_list_the_config_file_owns(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
from litellm.proxy import proxy_server
rows, _ = _replica_that_missed_a_publish(monkeypatch, stored_public_agent_groups=[])
config_file: Final = tmp_path / "config.yaml"
config_file.write_text("litellm_settings:\n public_agent_groups: [agent-1]\n")
monkeypatch.setattr("litellm.proxy.proxy_server.user_config_file_path", str(config_file))
await proxy_server.proxy_config.get_config()
with pytest.raises(HTTPException) as already_public:
await agent_endpoints.make_agent_public(agent_id="agent-1", user_api_key_dict=_PROXY_ADMIN)
with pytest.raises(HTTPException) as refused:
await agent_endpoints.make_agent_public(agent_id="agent-2", user_api_key_dict=_PROXY_ADMIN)
assert already_public.value.status_code == 400
assert "already in public agent groups" in already_public.value.detail
assert refused.value.status_code == 400
assert refused.value.detail["keys"] == ["public_agent_groups"]
assert rows.stored("litellm_settings")["public_agent_groups"] == []
@pytest.mark.asyncio
async def test_make_agent_public_appends_to_the_config_file_without_db_settings(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
_replica_that_missed_a_publish(monkeypatch, stored_public_agent_groups=[], store_model_in_db=False)
config_file: Final = tmp_path / "config.yaml"
config_file.write_text("litellm_settings:\n public_agent_groups: [agent-1]\n")
monkeypatch.setattr("litellm.proxy.proxy_server.user_config_file_path", str(config_file))
response: Final = await agent_endpoints.make_agent_public(agent_id="agent-2", user_api_key_dict=_PROXY_ADMIN)
with pytest.raises(HTTPException) as duplicate:
await agent_endpoints.make_agent_public(agent_id="agent-1", user_api_key_dict=_PROXY_ADMIN)
assert response["public_agent_groups"] == ["agent-1", "agent-2"]
assert yaml.safe_load(config_file.read_text())["litellm_settings"]["public_agent_groups"] == ["agent-1", "agent-2"]
assert duplicate.value.status_code == 400
@pytest.mark.parametrize("enabled, claim_field, expected", [(True, "azp", True), (False, "azp", False), (True, None, False)])