From ccbd3e495c203484acd0b96ed296d82377796b7d Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 3 Sep 2026 04:24:09 -0700 Subject: [PATCH 1/4] fix(agents): keep the published agent in public_agent_groups `POST /v1/agents/{id}/make_public` appended the agent id to `litellm.public_agent_groups` and only then called `get_config()`, which re-applies the DB's `litellm_settings` over the module globals and threw the append away. The config it saved was therefore a no-op: the endpoint answered 200 with an empty `public_agent_groups`, the agent never reached `GET /public/agent_hub`, and re-publishing never hit the "already public" 400. Read the config first, derive the new list from the refreshed globals, save it, then update the global Also fixes the e2e model hub spec, which is flaky for a second reason: the "Make Models Public" modal preselects the groups that are already public, so a blind click on "Select All" cleared them and left "Next" disabled for the full 15s action timeout. Check the box instead of toggling it, and wait for "Next" to be enabled before clicking --- litellm/proxy/agent_endpoints/endpoints.py | 25 ++++--- tests/e2e/ui/tests/modelHub/modelHub.spec.ts | 9 ++- .../proxy/agent_endpoints/test_endpoints.py | 69 +++++++++++++++++++ 3 files changed, 87 insertions(+), 16 deletions(-) diff --git a/litellm/proxy/agent_endpoints/endpoints.py b/litellm/proxy/agent_endpoints/endpoints.py index 3b8151d1064..ec50500fdb2 100644 --- a/litellm/proxy/agent_endpoints/endpoints.py +++ b/litellm/proxy/agent_endpoints/endpoints.py @@ -939,35 +939,34 @@ async def make_agent_public( if agent is None: raise HTTPException(status_code=404, detail=f"Agent with ID {agent_id} not found") - if litellm.public_agent_groups is None: - litellm.public_agent_groups = [] - # handle duplicates - if not AGENT_REGISTRY.ids_for_agent(agent.agent_id).isdisjoint(litellm.public_agent_groups): + # get_config() re-applies the DB's litellm_settings over the in-memory + # globals, so read it before deriving the new list and assign the global after + 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): raise HTTPException( status_code=400, detail=f"Agent with name {agent.agent_name} already in public agent groups", ) - litellm.public_agent_groups.append(agent.agent_id) + updated_public_agent_groups: Final = [*current_public_agent_groups, agent.agent_id] - # Load existing config - config: Final = await proxy_config.get_config() - - # Update config with new settings if "litellm_settings" not in config or config["litellm_settings"] is None: config["litellm_settings"] = {} - config["litellm_settings"]["public_agent_groups"] = litellm.public_agent_groups + config["litellm_settings"]["public_agent_groups"] = updated_public_agent_groups - # Save the updated config await proxy_config.save_config(new_config=config) + litellm.public_agent_groups = updated_public_agent_groups + verbose_proxy_logger.debug( - "Updated public agent groups to: %s by user: %s", litellm.public_agent_groups, user_api_key_dict.user_id + "Updated public agent groups to: %s by user: %s", updated_public_agent_groups, user_api_key_dict.user_id ) return { "message": "Successfully updated public agent groups", - "public_agent_groups": litellm.public_agent_groups, + "public_agent_groups": updated_public_agent_groups, "updated_by": user_api_key_dict.user_id, } except HTTPException: diff --git a/tests/e2e/ui/tests/modelHub/modelHub.spec.ts b/tests/e2e/ui/tests/modelHub/modelHub.spec.ts index 6877fc9c48d..7c43b9f761c 100644 --- a/tests/e2e/ui/tests/modelHub/modelHub.spec.ts +++ b/tests/e2e/ui/tests/modelHub/modelHub.spec.ts @@ -22,11 +22,14 @@ test.describe("AI Hub (internal admin view)", () => { // on the disabled-Next button or the success toast. await expect(modal.getByText(/Select All \(\d+\)/)).toBeVisible({ timeout: 5_000 }); - // Step 1: pick the seeded models via "Select All" - await modal.getByText(/Select All/i).click(); + // Step 1: pick the seeded models via "Select All". check() rather than click() because + // the modal preselects groups that are already public, and a click would clear them. + await modal.getByRole("checkbox", { name: /Select All/ }).check(); // Move to confirm step - await modal.getByRole("button", { name: "Next" }).click(); + const next = modal.getByRole("button", { name: "Next" }); + await expect(next).toBeEnabled(); + await next.click(); await expect(modal.getByText("Confirm Making Models Public")).toBeVisible({ timeout: 5_000 }); // Submit diff --git a/tests/test_litellm/proxy/agent_endpoints/test_endpoints.py b/tests/test_litellm/proxy/agent_endpoints/test_endpoints.py index a78b3238a9a..7c1db851a48 100644 --- a/tests/test_litellm/proxy/agent_endpoints/test_endpoints.py +++ b/tests/test_litellm/proxy/agent_endpoints/test_endpoints.py @@ -1062,3 +1062,72 @@ def test_merged_agent_card_url_has_no_double_slash_without_proxy_base_url( interface_url = merged["supportedInterfaces"][0]["url"] assert interface_url == f"{base_url.rstrip('/')}/a2a/agent-xyz" 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.""" + + def __init__(self) -> None: + self.stored_litellm_settings: dict = {} + + async def get_config(self) -> dict: + from litellm.proxy.proxy_server import ProxyConfig + + config: dict = {"litellm_settings": {}} + if not self.stored_litellm_settings: + return config + return ProxyConfig()._update_config_fields( + current_config=config, + param_name="litellm_settings", + db_param_value=dict(self.stored_litellm_settings), + ) + + async def save_config(self, new_config: dict) -> None: + self.stored_litellm_settings = dict(new_config.get("litellm_settings") or {}) + + +def test_make_agent_public_twice_keeps_both_agents_public(monkeypatch): + """A second /make_public call must not drop the agent published by the first one.""" + import litellm + from litellm.proxy.agent_endpoints import agent_registry as agent_registry_module + from litellm.proxy.agent_endpoints.agent_registry import AgentRegistry + + registry = 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")) + + 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()) + + first = client.post("/v1/agents/agent-1/make_public", headers={"Authorization": "Bearer test-key"}) + second = 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"] + assert [agent.agent_id for agent in registry.get_public_agent_list()] == ["agent-1", "agent-2"] + + +def test_make_agent_public_rejects_an_already_public_agent(monkeypatch): + """The duplicate guard must still fire when the published list comes back from the DB.""" + import litellm + from litellm.proxy.agent_endpoints import agent_registry as agent_registry_module + from litellm.proxy.agent_endpoints.agent_registry import AgentRegistry + + registry = 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()) + + first = client.post("/v1/agents/agent-1/make_public", headers={"Authorization": "Bearer test-key"}) + duplicate = client.post("/v1/agents/agent-1/make_public", headers={"Authorization": "Bearer test-key"}) + + assert first.status_code == 200 + assert duplicate.status_code == 400 + assert "already in public agent groups" in duplicate.json()["detail"] From 9d862a65831cf1a7dc8ba6a7e69900d3774dc85e Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 3 Sep 2026 04:27:43 -0700 Subject: [PATCH 2/4] style: drop explanatory comments from the agent publish fix --- litellm/proxy/agent_endpoints/endpoints.py | 2 -- tests/e2e/ui/tests/modelHub/modelHub.spec.ts | 3 +-- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/litellm/proxy/agent_endpoints/endpoints.py b/litellm/proxy/agent_endpoints/endpoints.py index ec50500fdb2..cc17672553b 100644 --- a/litellm/proxy/agent_endpoints/endpoints.py +++ b/litellm/proxy/agent_endpoints/endpoints.py @@ -939,8 +939,6 @@ async def make_agent_public( if agent is None: raise HTTPException(status_code=404, detail=f"Agent with ID {agent_id} not found") - # get_config() re-applies the DB's litellm_settings over the in-memory - # globals, so read it before deriving the new list and assign the global after config: Final = await proxy_config.get_config() current_public_agent_groups: Final = list(litellm.public_agent_groups or []) diff --git a/tests/e2e/ui/tests/modelHub/modelHub.spec.ts b/tests/e2e/ui/tests/modelHub/modelHub.spec.ts index 7c43b9f761c..1fa3e4c530e 100644 --- a/tests/e2e/ui/tests/modelHub/modelHub.spec.ts +++ b/tests/e2e/ui/tests/modelHub/modelHub.spec.ts @@ -22,8 +22,7 @@ test.describe("AI Hub (internal admin view)", () => { // on the disabled-Next button or the success toast. await expect(modal.getByText(/Select All \(\d+\)/)).toBeVisible({ timeout: 5_000 }); - // Step 1: pick the seeded models via "Select All". check() rather than click() because - // the modal preselects groups that are already public, and a click would clear them. + // Step 1: pick the seeded models via "Select All" await modal.getByRole("checkbox", { name: /Select All/ }).check(); // Move to confirm step From b1695e909070562dffb6d01655676a2fa0b8743a Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 3 Sep 2026 04:33:55 -0700 Subject: [PATCH 3/4] test(agents): type the public-agent regression tests fully --- .../proxy/agent_endpoints/test_endpoints.py | 25 ++++++++++--------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/tests/test_litellm/proxy/agent_endpoints/test_endpoints.py b/tests/test_litellm/proxy/agent_endpoints/test_endpoints.py index 7c1db851a48..51797cbb308 100644 --- a/tests/test_litellm/proxy/agent_endpoints/test_endpoints.py +++ b/tests/test_litellm/proxy/agent_endpoints/test_endpoints.py @@ -1,3 +1,4 @@ +from typing import Final from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -1069,12 +1070,12 @@ class _DbBackedProxyConfig: `get_config()`, which is what re-assigns the `litellm.public_*` globals in production.""" def __init__(self) -> None: - self.stored_litellm_settings: dict = {} + self.stored_litellm_settings: dict[str, object] = {} - async def get_config(self) -> dict: + async def get_config(self) -> dict[str, dict[str, object]]: from litellm.proxy.proxy_server import ProxyConfig - config: dict = {"litellm_settings": {}} + config: Final[dict[str, dict[str, object]]] = {"litellm_settings": {}} if not self.stored_litellm_settings: return config return ProxyConfig()._update_config_fields( @@ -1083,17 +1084,17 @@ class _DbBackedProxyConfig: db_param_value=dict(self.stored_litellm_settings), ) - async def save_config(self, new_config: dict) -> None: + async def save_config(self, new_config: dict[str, dict[str, object]]) -> None: self.stored_litellm_settings = dict(new_config.get("litellm_settings") or {}) -def test_make_agent_public_twice_keeps_both_agents_public(monkeypatch): +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.""" import litellm from litellm.proxy.agent_endpoints import agent_registry as agent_registry_module from litellm.proxy.agent_endpoints.agent_registry import AgentRegistry - registry = AgentRegistry() + 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")) @@ -1102,8 +1103,8 @@ def test_make_agent_public_twice_keeps_both_agents_public(monkeypatch): monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", MagicMock()) monkeypatch.setattr("litellm.proxy.proxy_server.proxy_config", _DbBackedProxyConfig()) - first = client.post("/v1/agents/agent-1/make_public", headers={"Authorization": "Bearer test-key"}) - second = client.post("/v1/agents/agent-2/make_public", headers={"Authorization": "Bearer test-key"}) + 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 @@ -1111,13 +1112,13 @@ def test_make_agent_public_twice_keeps_both_agents_public(monkeypatch): assert [agent.agent_id for agent in registry.get_public_agent_list()] == ["agent-1", "agent-2"] -def test_make_agent_public_rejects_an_already_public_agent(monkeypatch): +def test_make_agent_public_rejects_an_already_public_agent(monkeypatch: pytest.MonkeyPatch) -> None: """The duplicate guard must still fire when the published list comes back from the DB.""" import litellm from litellm.proxy.agent_endpoints import agent_registry as agent_registry_module from litellm.proxy.agent_endpoints.agent_registry import AgentRegistry - registry = AgentRegistry() + 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) @@ -1125,8 +1126,8 @@ def test_make_agent_public_rejects_an_already_public_agent(monkeypatch): monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", MagicMock()) monkeypatch.setattr("litellm.proxy.proxy_server.proxy_config", _DbBackedProxyConfig()) - first = client.post("/v1/agents/agent-1/make_public", headers={"Authorization": "Bearer test-key"}) - duplicate = client.post("/v1/agents/agent-1/make_public", headers={"Authorization": "Bearer test-key"}) + first: Final = client.post("/v1/agents/agent-1/make_public", headers={"Authorization": "Bearer test-key"}) + duplicate: Final = client.post("/v1/agents/agent-1/make_public", headers={"Authorization": "Bearer test-key"}) assert first.status_code == 200 assert duplicate.status_code == 400 From 15e956db33829cc82800e36cd89dd23f3d8701b4 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 3 Sep 2026 05:21:42 -0700 Subject: [PATCH 4/4] test(agents): make the make_public regression tests fail without the fix The config stub shared one list object between save_config and get_config, so the DB overlay handed the endpoint back the very list it had just appended to and both tests passed with the product fix reverted. Store the settings as JSON the way the litellm_config row does, and check the duplicate guard against a list that only ever existed in the DB. --- .../proxy/agent_endpoints/test_endpoints.py | 28 +++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/tests/test_litellm/proxy/agent_endpoints/test_endpoints.py b/tests/test_litellm/proxy/agent_endpoints/test_endpoints.py index 51797cbb308..067f5a9f64c 100644 --- a/tests/test_litellm/proxy/agent_endpoints/test_endpoints.py +++ b/tests/test_litellm/proxy/agent_endpoints/test_endpoints.py @@ -1,3 +1,4 @@ +import json from typing import Final from unittest.mock import AsyncMock, MagicMock, patch @@ -1067,25 +1068,29 @@ def test_merged_agent_card_url_has_no_double_slash_without_proxy_base_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.""" + `get_config()`, which is what re-assigns the `litellm.public_*` globals in production. - def __init__(self) -> None: - self.stored_litellm_settings: dict[str, object] = {} + 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": {}} - if not self.stored_litellm_settings: + db_param_value: Final[dict[str, object]] = json.loads(self.stored_litellm_settings_json) + if not db_param_value: return config return ProxyConfig()._update_config_fields( current_config=config, param_name="litellm_settings", - db_param_value=dict(self.stored_litellm_settings), + db_param_value=db_param_value, ) async def save_config(self, new_config: dict[str, dict[str, object]]) -> None: - self.stored_litellm_settings = dict(new_config.get("litellm_settings") or {}) + self.stored_litellm_settings_json = json.dumps(new_config.get("litellm_settings") or {}) def test_make_agent_public_twice_keeps_both_agents_public(monkeypatch: pytest.MonkeyPatch) -> None: @@ -1112,8 +1117,8 @@ def test_make_agent_public_twice_keeps_both_agents_public(monkeypatch: pytest.Mo assert [agent.agent_id for agent in registry.get_public_agent_list()] == ["agent-1", "agent-2"] -def test_make_agent_public_rejects_an_already_public_agent(monkeypatch: pytest.MonkeyPatch) -> None: - """The duplicate guard must still fire when the published list comes back from the DB.""" +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 @@ -1124,11 +1129,12 @@ def test_make_agent_public_rejects_an_already_public_agent(monkeypatch: pytest.M 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.proxy.proxy_server.proxy_config", + _DbBackedProxyConfig({"public_agent_groups": ["agent-1"]}), + ) - first: Final = client.post("/v1/agents/agent-1/make_public", headers={"Authorization": "Bearer test-key"}) duplicate: Final = client.post("/v1/agents/agent-1/make_public", headers={"Authorization": "Bearer test-key"}) - assert first.status_code == 200 assert duplicate.status_code == 400 assert "already in public agent groups" in duplicate.json()["detail"]