Skip to content
Open
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
9 changes: 9 additions & 0 deletions changelog.d/history-detail-performance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
category: Features
pr: 795
---

**History detail performance**: bound memory and make the conversation-history detail view fast for arbitrarily large sessions (fixes admin-dashboard slowness/OOM on large sessions).
- List reads precomputed `session_summaries` (no full-payload scan); one-time preview backfill.
- Detail + markdown/JSONL exports stream bounded-memory; O(turns) single-anchor request reconstruction (the largest cumulative request array is parsed once and per-turn deltas are sliced from it via a raw→parsed prefix map), with a fast path plus a correctness fallback for request-modified / non-monotonic sessions. Output is byte-identical to the per-turn build.
- Conversation-detail turn pagination (`offset`/`limit`; omit `offset` ⇒ newest page): only the requested window's payloads are read, so each request is bounded. Frontend loads the newest page first and loads older pages on scroll-up; whole-session stats stay invariant across pages; rendered conversation unchanged. This is the conversation-page lazy-loading deferred by #752.
5 changes: 5 additions & 0 deletions src/luthien_proxy/history/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ class ConversationTurn(BaseModel):
model: str | None = None
# Messages in this turn (from final request/response)
request_messages: list[ConversationMessage]
request_messages_full: list[ConversationMessage] | None = None
response_messages: list[ConversationMessage]
# Policy annotations for this turn
annotations: list[PolicyAnnotation]
Expand Down Expand Up @@ -167,6 +168,10 @@ class SessionDetail(BaseModel):
turns: list[ConversationTurn]
total_policy_interventions: int
models_used: list[str]
total_turns: int = 0
offset: int = 0
limit: int = 50
has_more: bool = False


__all__ = [
Expand Down
58 changes: 38 additions & 20 deletions src/luthien_proxy/history/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
from datetime import datetime

from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import FileResponse, PlainTextResponse
from fastapi.responses import FileResponse, StreamingResponse
from pydantic import BaseModel, Field

from luthien_proxy.auth import check_auth_or_redirect, verify_admin_token
Expand All @@ -27,7 +27,7 @@

from . import user_labels as user_labels_service
from .models import SessionDetail, SessionListResponse, SessionSearchParams
from .service import export_session_jsonl, export_session_markdown, fetch_session_detail, fetch_session_list
from .service import fetch_session_list, stream_session_detail_json, stream_session_jsonl, stream_session_markdown


class UserLabelRequest(BaseModel):
Expand Down Expand Up @@ -197,48 +197,62 @@ async def delete_user_label(
return {"deleted": True}


@api_router.get("/sessions/{session_id}", response_model=SessionDetail)
@api_router.get("/sessions/{session_id}", responses={200: {"model": SessionDetail}})
async def get_session(
session_id: str,
offset: int | None = Query(default=None, ge=0),
limit: int = Query(default=50, ge=1, le=200),
_: str = Depends(verify_admin_token),
db_pool: DatabasePool = Depends(get_db_pool),
) -> SessionDetail:
"""Get full session detail with conversation turns.
) -> StreamingResponse:
"""Get a window of session turns in chronological display order.

Returns the complete conversation history for a session,
including all messages, tool calls, and policy annotations.
``offset`` is a zero-based chronological turn offset. When omitted, the
newest page is returned. ``limit`` defaults to 50 and is capped at 200.
"""
try:
return await fetch_session_detail(session_id, db_pool)
stream = stream_session_detail_json(session_id, db_pool, offset=offset, limit=limit)
first_chunk = await anext(stream)
except ValueError as e:
logger.warning(f"Session not found: {repr(e)}")
raise HTTPException(status_code=404, detail="Session not found.") from None

async def body():
yield first_chunk
async for chunk in stream:
yield chunk

return StreamingResponse(body(), media_type="application/json")


@api_router.get("/sessions/{session_id}/export")
async def export_session(
session_id: str,
_: str = Depends(verify_admin_token),
db_pool: DatabasePool = Depends(get_db_pool),
) -> PlainTextResponse:
) -> StreamingResponse:
"""Export session as markdown.

Returns the conversation history formatted as a markdown document,
suitable for saving or sharing.
"""
try:
session = await fetch_session_detail(session_id, db_pool)
stream = stream_session_markdown(session_id, db_pool)
first_chunk = await anext(stream)
except ValueError as e:
logger.warning(f"Session not found for export: {repr(e)}")
raise HTTPException(status_code=404, detail="Session not found.") from None

markdown = export_session_markdown(session)

# Sanitize session_id for filename
safe_id = "".join(c if c.isalnum() or c in "-_" else "_" for c in session_id)

return PlainTextResponse(
content=markdown,
async def body():
yield first_chunk
async for chunk in stream:
yield chunk

return StreamingResponse(
body(),
media_type="text/markdown",
headers={"Content-Disposition": f'attachment; filename="conversation_{safe_id}.md"'},
)
Expand All @@ -249,23 +263,27 @@ async def export_session_jsonl_endpoint(
session_id: str,
_: str = Depends(verify_admin_token),
db_pool: DatabasePool = Depends(get_db_pool),
) -> PlainTextResponse:
) -> StreamingResponse:
"""Export session as JSONL (one JSON line per turn).

Returns the conversation history as JSONL, suitable for
programmatic analysis and log ingestion.
"""
try:
session = await fetch_session_detail(session_id, db_pool)
stream = stream_session_jsonl(session_id, db_pool)
first_chunk = await anext(stream)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e)) from None

jsonl = export_session_jsonl(session)

safe_id = "".join(c if c.isalnum() or c in "-_" else "_" for c in session_id)

return PlainTextResponse(
content=jsonl,
async def body():
yield first_chunk
async for chunk in stream:
yield chunk

return StreamingResponse(
body(),
media_type="application/x-ndjson",
headers={"Content-Disposition": f'attachment; filename="conversation_{safe_id}.jsonl"'},
)
Expand Down
Loading