Source code for klea_utils.api.chat_common
#!/usr/bin/env python3
"""
Shared chat request identity and app-state helpers.
These are the pieces every chat endpoint needs regardless of its contract:
resolving the graph/session store from ``app.state``, deriving a chat's
checkpoint thread id, the per-thread single-flight registry, and cancelling
an in-flight run. The endpoint runners themselves live in
:mod:`klea_utils.api.chat_core`.
See ``devdocs/system/api-sse-sequence.md`` and ``devdocs/system/streams.md``.
File: klea_utils/api/chat_common.py
Copyright 2026 Ankur Sinha
Author: Ankur Sinha <sanjay DOT ankur AT gmail dot com>
"""
import logging
from typing import Any
from fastapi import HTTPException, Request
from pydantic import BaseModel, Field
from klea_utils.api.runs import ActiveRunRegistry
from klea_utils.api.sessions_db import SessionStore
logger = logging.getLogger(__name__)
[docs]
class CancelPayload(BaseModel):
"""Identity payload for ``POST /query/cancel`` (shared by both apps).
Cancellation addresses a chat's thread, so it needs only the same
``(user_id, chat_id)`` identity the query payloads carry.
"""
chat_id: str = Field(..., pattern=r"^[^:]+$")
user_id: str = Field(default="", pattern=r"^[^:]*$")
def _graph_and_store(request: Request) -> tuple[Any, SessionStore]:
"""Resolve the graph and session store from ``app.state``.
Raises HTTP 503 when the server has not finished starting up or the
graph is missing (the lifespan built by
:func:`klea_utils.api.app.make_app` populates both).
:param request: The incoming request; ``request.app.state`` carries
``is_ready``, ``graph`` and ``chat_sessions``.
:returns: ``(graph, SessionStore)``
"""
if (
not getattr(request.app.state, "is_ready", False)
or not getattr(request.app.state, "graph", None)
or getattr(request.app.state.graph, "graph", None) is None
):
raise HTTPException(status_code=503, detail="Service not ready")
return request.app.state.graph, request.app.state.chat_sessions
[docs]
def thread_id_for(user_id: str, chat_id: str) -> str:
"""Return the checkpoint thread id for a ``{user_id}:{chat_id}`` pair."""
return f"user_{user_id}:chat_{chat_id}"
def _active_runs(request: Request) -> ActiveRunRegistry:
"""Resolve the in-flight run registry from ``app.state``.
:param request: The incoming request; ``request.app.state.active_runs``
is populated by :func:`klea_utils.api.app.make_app`'s lifespan.
:returns: The process's :class:`~klea_utils.api.runs.ActiveRunRegistry`.
"""
registry = getattr(request.app.state, "active_runs", None)
if registry is None:
# Defensive: a hand-built test app may not run the shared lifespan.
registry = ActiveRunRegistry()
request.app.state.active_runs = registry
return registry
def _ensure_thread_free(registry: ActiveRunRegistry, thread_id: str) -> None:
"""Reject a new run when the thread already has one in flight.
A LangGraph checkpoint is a log, not a mutex: a second same-thread run
would proceed concurrently and its checkpoint writes would race. The
registry makes a thread single-flight.
:param registry: The process's active-run registry.
:param thread_id: The checkpoint thread identifier.
:raises HTTPException: 409 when a run is already active for the thread.
"""
if registry.is_active(thread_id):
logger.warning("Rejecting concurrent run for thread=%s", thread_id)
raise HTTPException(
status_code=409,
detail="A run is already in progress for this chat.",
)
[docs]
def cancel_run(request: Request, user_id: str, chat_id: str) -> bool:
"""Cancel the active run for a chat's thread, if any.
Idempotent: an absent or already-completed run is a no-op. Cancelling
the driving task raises ``CancelledError`` at the node's next ``await``,
leaving the checkpoint resumable at the failed node; no assistant turn
is persisted (only the ``complete`` event writes one).
:param request: The incoming request; carries the active-run registry.
:param user_id: Persistent user identifier.
:param chat_id: Chat conversation identifier.
:returns: True when a live run was found and cancellation requested.
"""
registry = _active_runs(request)
thread_id = thread_id_for(user_id, chat_id)
cancelled = registry.cancel(thread_id)
logger.info(
"cancel_run(user_id=%s chat_id=%s) thread=%s cancelled=%s",
user_id,
chat_id,
thread_id,
cancelled,
)
return cancelled