diff --git a/python/packages/devui/agent_framework_devui/_server.py b/python/packages/devui/agent_framework_devui/_server.py index 30d8ab4831..7f76aca6ba 100644 --- a/python/packages/devui/agent_framework_devui/_server.py +++ b/python/packages/devui/agent_framework_devui/_server.py @@ -55,6 +55,24 @@ def _extract_error_details(body: object) -> tuple[str | None, str | None, str | ) +def _validated_conversation_metadata(request_data: dict[str, Any]) -> dict[str, str]: + """Return conversation metadata that is safe for the store and every reader. + + The Conversations API contract for metadata is a map of string keys to string + values. Values with any other shape are rejected here so the store can never + hold a value that makes the conversation list copy raise for every caller, + and an explicit null is treated as an empty map. + """ + raw_metadata = request_data.get("metadata") + if raw_metadata is None: + return {} + if not isinstance(raw_metadata, dict) or not all( + isinstance(key, str) and isinstance(value, str) for key, value in raw_metadata.items() + ): + raise HTTPException(status_code=400, detail="metadata must be a map of string keys to string values") + return dict(raw_metadata) + + # Get package version try: __version__ = importlib.metadata.version("agent-framework-devui") @@ -979,7 +997,7 @@ async def create_conversation(raw_request: Request) -> dict[str, Any] | JSONResp ) # Local mode - use DevUI conversation store - metadata = request_data.get("metadata") + metadata = _validated_conversation_metadata(request_data) executor = await self._ensure_executor() conversation = executor.conversation_store.create_conversation(metadata=metadata) return conversation.model_dump() @@ -1051,7 +1069,7 @@ async def update_conversation(conversation_id: str, request_data: dict[str, Any] """Update conversation metadata - OpenAI standard.""" try: executor = await self._ensure_executor() - metadata = request_data.get("metadata", {}) + metadata = _validated_conversation_metadata(request_data) conversation = executor.conversation_store.update_conversation(conversation_id, metadata=metadata) return conversation.model_dump() except ValueError as e: diff --git a/python/packages/devui/tests/devui/test_server.py b/python/packages/devui/tests/devui/test_server.py index 3c85085001..6d71806261 100644 --- a/python/packages/devui/tests/devui/test_server.py +++ b/python/packages/devui/tests/devui/test_server.py @@ -524,6 +524,32 @@ async def test_api_restrictions_in_user_mode(): assert user_response.status_code in [200, 400, 404] +def test_conversation_metadata_rejects_non_string_shapes_and_keeps_listing_healthy(): + """Non-map metadata must be rejected at the boundary, not stored until listing crashes.""" + from fastapi.testclient import TestClient + + server = DevServer(mode="developer", auth_enabled=False) + client = TestClient(server.create_app(), base_url="http://127.0.0.1") + + string_metadata = client.post("/v1/conversations", json={"metadata": "oops-not-a-dict"}) + assert string_metadata.status_code == 400 + + created = client.post("/v1/conversations", json={"metadata": {"owner": "alice"}}) + assert created.status_code == 200 + conversation_id = created.json()["id"] + + non_string_value = client.post(f"/v1/conversations/{conversation_id}", json={"metadata": {"count": 5}}) + assert non_string_value.status_code == 400 + + cleared = client.post(f"/v1/conversations/{conversation_id}", json={"metadata": None}) + assert cleared.status_code == 200 + assert cleared.json()["metadata"] == {} + + listing = client.get("/v1/conversations") + assert listing.status_code == 200 + assert any(item["id"] == conversation_id for item in listing.json()["data"]) + + if __name__ == "__main__": # Simple test runner async def run_tests():