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] 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"]