Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 20 additions & 2 deletions python/packages/devui/agent_framework_devui/_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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:
Expand Down
26 changes: 26 additions & 0 deletions python/packages/devui/tests/devui/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
Loading