fix: eliminate event-loop-blocking sync calls in async FastAPI routes (#106)

* feat: add async DB wrapper and eliminate event-loop blocking

* fix: rewrite llm_client.py with native async httpx - removes urlopen and run_in_executor

* fix: rewrite routes/coach.py - replace all sync DB/HTTP with async (aiosqlite + httpx)

* fix: add async DB shutdown hook to lab_api.py

* feat: add aiosqlite>=0.19.0 to requirements
This commit is contained in:
KoalaTrapLord authored and GitHub committed 2026-03-25 19:17:41 -04:00
1 parent cf7c872e12
commit af2a71d1a5
5 files changed
+776 -724

No files matched your search

+127 -130
View File
@@ -1,22 +1,25 @@
"""
Commander AI Lab — Ollama Client
Commander AI Lab — Ollama LLM Client
════════════════════════════════════
Async HTTP client targeting Ollama's OpenAI-compatible
/v1/chat/completions endpoint. Includes retry logic for
non-JSON responses and configurable timeout.
Fully async HTTP client targeting Ollama's OpenAI-compatible
/v1/chat/completions endpoint. Every network call uses
httpx.AsyncClient so it NEVER blocks the FastAPI event loop.
"""
from __future__ import annotations
import json
import logging
import asyncio
from typing import Optional
from urllib.request import urlopen, Request
from urllib.error import URLError
import httpx
from .config import (
LLM_URL, LLM_MODEL,
LLM_TIMEOUT, LLM_MAX_RETRIES,
DEFAULT_TEMPERATURE, DEFAULT_MAX_TOKENS,
LLM_URL,
LLM_MODEL,
LLM_TIMEOUT,
LLM_MAX_RETRIES,
DEFAULT_TEMPERATURE,
DEFAULT_MAX_TOKENS,
)
logger = logging.getLogger("coach.llm")
@@ -24,9 +27,15 @@ logger = logging.getLogger("coach.llm")
class LLMResponse:
"""Parsed LLM response container."""
def __init__(self, content: str, parsed_json: Optional[dict],
prompt_tokens: int, completion_tokens: int,
model: str):
def __init__(
self,
content: str,
parsed_json: Optional[dict],
prompt_tokens: int,
completion_tokens: int,
model: str,
):
self.content = content
self.parsed_json = parsed_json
self.prompt_tokens = prompt_tokens
@@ -36,7 +45,7 @@ class LLMResponse:
class LLMClient:
"""
Client for Ollama's OpenAI-compatible API.
Fully async client for Ollama's OpenAI-compatible API.
Usage:
client = LLMClient()
@@ -45,19 +54,25 @@ class LLMClient:
suggestions = response.parsed_json
"""
def __init__(self, base_url: str = None, model: str = None,
timeout: int = None):
def __init__(
self,
base_url: str = None,
model: str = None,
timeout: int = None,
):
self.base_url = (base_url or LLM_URL).rstrip("/")
self.model = model or LLM_MODEL
self.timeout = timeout or LLM_TIMEOUT
self._resolved_model = None # actual model name from /models endpoint
self._resolved_model: Optional[str] = None
def _resolve_model(self) -> str:
# ── Model resolution ───────────────────────────────────────
async def _resolve_model(self) -> str:
"""Get the actual loaded model name from Ollama /models endpoint."""
if self._resolved_model:
return self._resolved_model
try:
status = self.check_connection()
status = await self.check_connection()
if status.get("connected") and status.get("active_model"):
self._resolved_model = status["active_model"]
logger.info("Resolved Ollama model: %s", self._resolved_model)
@@ -66,11 +81,18 @@ class LLMClient:
pass
return self.model
def _build_request_body(self, system_prompt: str, user_prompt: str,
temperature: float, max_tokens: int,
force_json: bool) -> dict:
model_name = self._resolve_model()
body = {
# ── Request building ──────────────────────────────────────
async def _build_request_body(
self,
system_prompt: str,
user_prompt: str,
temperature: float,
max_tokens: int,
force_json: bool,
) -> dict:
model_name = await self._resolve_model()
body: dict = {
"model": model_name,
"messages": [
{"role": "system", "content": system_prompt},
@@ -79,32 +101,27 @@ class LLMClient:
"temperature": temperature,
"max_tokens": max_tokens,
}
# Only request JSON format if using a model that supports it
# Some models (esp. smaller ones) don't support response_format
if force_json:
body["response_format"] = {"type": "json_object"}
return body
# ── Response parsing ──────────────────────────────────────
@staticmethod
def _strip_think_tags(text: str) -> str:
"""Remove DeepSeek-R1 <think>...</think> reasoning blocks from LLM output."""
"""Remove DeepSeek-R1 <think>...</think> reasoning blocks."""
import re
# Remove all <think>...</think> blocks (greedy, handles multiline)
cleaned = re.sub(r'<think>.*?</think>', '', text, flags=re.DOTALL)
return cleaned.strip()
return re.sub(r'<think>.*?</think>', '', text, flags=re.DOTALL).strip()
@staticmethod
def _extract_json_object(text: str) -> Optional[dict]:
"""Find and parse the first complete JSON object in text.
Uses brace-counting to handle nested objects reliably."""
"""Find and parse the first complete JSON object via brace-counting."""
start = text.find('{')
if start == -1:
return None
depth = 0
in_string = False
escape_next = False
for i in range(start, len(text)):
ch = text[i]
if escape_next:
@@ -124,66 +141,56 @@ class LLMClient:
elif ch == '}':
depth -= 1
if depth == 0:
candidate = text[start:i+1]
candidate = text[start : i + 1]
try:
return json.loads(candidate)
except (json.JSONDecodeError, ValueError):
# Malformed — try next opening brace
next_start = text.find('{', start + 1)
if next_start != -1 and next_start < i:
start = next_start
depth = 1 # we're already past this brace
return None
depth = 1
return None
return None
def _parse_response(self, raw: dict) -> LLMResponse:
"""Extract content and usage from OpenAI-format response.
Handles DeepSeek-R1 <think> tags, markdown fences, and raw JSON."""
"""Extract content and usage from an OpenAI-format response."""
choice = raw.get("choices", [{}])[0]
content = choice.get("message", {}).get("content", "")
usage = raw.get("usage", {})
# Step 1: Strip <think>...</think> blocks (DeepSeek-R1)
cleaned = self._strip_think_tags(content)
# Step 2: Try direct JSON parse on cleaned content
parsed = None
parsed: Optional[dict] = None
# 1. Direct JSON parse
try:
parsed = json.loads(cleaned)
except (json.JSONDecodeError, TypeError):
pass
# Step 3: Try extracting from markdown fences
# 2. Markdown fences
if parsed is None and "```json" in cleaned:
try:
start = cleaned.index("```json") + 7
end = cleaned.index("```", start)
parsed = json.loads(cleaned[start:end].strip())
s = cleaned.index("```json") + 7
e = cleaned.index("```", s)
parsed = json.loads(cleaned[s:e].strip())
except (json.JSONDecodeError, ValueError, IndexError):
pass
if parsed is None and "```" in cleaned:
try:
start = cleaned.index("```") + 3
end = cleaned.index("```", start)
parsed = json.loads(cleaned[start:end].strip())
s = cleaned.index("```") + 3
e = cleaned.index("```", s)
parsed = json.loads(cleaned[s:e].strip())
except (json.JSONDecodeError, ValueError, IndexError):
pass
# Step 4: Brute-force — find the first valid JSON object in the text
# 3. Brace-counting extraction
if parsed is None:
parsed = self._extract_json_object(cleaned)
# Step 5: If still nothing, try on the original content (in case
# think-tag stripping was too aggressive)
# 4. Fallback on original content
if parsed is None and content != cleaned:
parsed = self._extract_json_object(content)
if parsed is not None:
logger.info("Successfully parsed JSON from LLM response")
else:
logger.warning("Could not extract JSON from LLM response (%d chars)",
len(content))
logger.warning(
"Could not extract JSON from LLM response (%d chars)", len(content)
)
return LLMResponse(
content=content,
parsed_json=parsed,
@@ -192,94 +199,84 @@ class LLMClient:
model=raw.get("model", self.model),
)
def chat_sync(self, system_prompt: str, user_prompt: str,
temperature: float = None, max_tokens: int = None,
force_json: bool = True) -> LLMResponse:
# ── Public API ─────────────────────────────────────────────
async def chat(
self,
system_prompt: str,
user_prompt: str,
temperature: float = None,
max_tokens: int = None,
force_json: bool = True,
) -> LLMResponse:
"""
Synchronous chat call to Ollama.
Retries up to LLM_MAX_RETRIES on failure or non-JSON response.
Async chat call to Ollama. Retries up to LLM_MAX_RETRIES.
Never blocks the event loop — uses httpx.AsyncClient throughout.
"""
temp = temperature if temperature is not None else DEFAULT_TEMPERATURE
tokens = max_tokens if max_tokens is not None else DEFAULT_MAX_TOKENS
body = self._build_request_body(system_prompt, user_prompt,
temp, tokens, force_json)
last_error = None
body = await self._build_request_body(
system_prompt, user_prompt, temp, tokens, force_json
)
last_error: Optional[Exception] = None
for attempt in range(1, LLM_MAX_RETRIES + 1):
try:
logger.info("LLM call attempt %d/%d", attempt, LLM_MAX_RETRIES)
req = Request(
f"{self.base_url}/chat/completions",
data=json.dumps(body).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST",
)
with urlopen(req, timeout=self.timeout) as resp:
raw = json.loads(resp.read().decode("utf-8"))
async with httpx.AsyncClient(
timeout=httpx.Timeout(float(self.timeout))
) as client:
resp = await client.post(
f"{self.base_url}/chat/completions",
json=body,
headers={"Content-Type": "application/json"},
)
resp.raise_for_status()
raw = resp.json()
result = self._parse_response(raw)
# If we need JSON and didn't get it, retry
if force_json and result.parsed_json is None:
logger.warning("Attempt %d: LLM returned non-JSON, retrying", attempt)
logger.warning(
"Attempt %d: LLM returned non-JSON, retrying", attempt
)
last_error = ValueError("Non-JSON response from LLM")
continue
return result
except httpx.HTTPStatusError as e:
logger.warning("Attempt %d HTTP error %s", attempt, e.response.status_code)
last_error = e
# 400 with json format → retry without response_format constraint
if e.response.status_code == 400 and force_json:
logger.warning("Got 400 — retrying without response_format")
force_json = False
body = await self._build_request_body(
system_prompt, user_prompt, temp, tokens, False
)
except Exception as e:
logger.warning("Attempt %d failed: %s", attempt, e)
last_error = e
# If 400 and we used json response_format, retry without it
error_str = str(e)
if "400" in error_str and force_json:
logger.warning("Got 400 — retrying without response_format constraint")
force_json = False
body = self._build_request_body(
system_prompt, user_prompt, temp, tokens, False)
if attempt < LLM_MAX_RETRIES:
import time
time.sleep(2 * attempt) # Simple backoff
if attempt < LLM_MAX_RETRIES:
import asyncio
await asyncio.sleep(2 * attempt)
raise ConnectionError(
f"LLM call failed after {LLM_MAX_RETRIES} attempts: {last_error}"
)
async def chat(self, system_prompt: str, user_prompt: str,
temperature: float = None, max_tokens: int = None,
force_json: bool = True) -> LLMResponse:
"""
Async chat call — runs the sync call in a thread executor
so it doesn't block the FastAPI event loop.
"""
loop = asyncio.get_event_loop()
return await loop.run_in_executor(
None,
lambda: self.chat_sync(system_prompt, user_prompt,
temperature, max_tokens, force_json)
)
def check_connection(self) -> dict:
"""
Check if Ollama is reachable. Returns status dict.
"""
async def check_connection(self) -> dict:
"""Check if Ollama is reachable. Returns status dict."""
try:
req = Request(
f"{self.base_url}/models",
headers={"Content-Type": "application/json"},
method="GET",
)
with urlopen(req, timeout=5) as resp:
data = json.loads(resp.read().decode("utf-8"))
models = data.get("data", [])
model_ids = [m.get("id", "unknown") for m in models]
return {
"connected": True,
"models": model_ids,
"active_model": model_ids[0] if model_ids else self.model,
}
async with httpx.AsyncClient(timeout=httpx.Timeout(5.0)) as client:
resp = await client.get(
f"{self.base_url}/models",
headers={"Content-Type": "application/json"},
)
resp.raise_for_status()
data = resp.json()
models = data.get("data", [])
model_ids = [m.get("id", "unknown") for m in models]
return {
"connected": True,
"models": model_ids,
"active_model": model_ids[0] if model_ids else self.model,
}
except Exception as e:
return {
"connected": False,
+7
View File
@@ -84,6 +84,13 @@ async def _on_startup():
if not COMMANDER_META:
load_commander_meta()
@app.on_event("shutdown")
async def _on_shutdown():
"""Close the async SQLite connection pool."""
from services.async_db import async_close_db
await async_close_db()
# ── Static UI ──────────────────────────────────────────────────
_legacy_ui_dir = Path(__file__).parent / "ui"
_spa_dir = Path(__file__).parent / "frontend" / "commander-ai-lab-ui" / "dist"
+1
View File
@@ -8,6 +8,7 @@ pydantic>=2.0.0
httpx>=0.24.0
websockets>=11.0
openai>=1.0.0
aiosqlite>=0.19.0
# Anthropic Claude API
anthropic>=0.40.0
+421 -594
View File
File diff suppressed because it is too large. Load diff
+220
View File
@@ -0,0 +1,220 @@
"""Async SQLite wrapper for FastAPI — eliminates event-loop blocking.
Drop-in async replacements for the sync helpers in services/database.py.
Uses aiosqlite under the hood so every DB call yields back to the
event loop instead of freezing it.
Usage in route handlers:
from services.async_db import async_get_db, async_execute, async_fetchall
rows = await async_fetchall("SELECT * FROM decks")
row = await async_fetchone("SELECT * FROM decks WHERE id = ?", (1,))
await async_execute("INSERT INTO ...", params)
await async_execute_commit("DELETE FROM ...", params)
"""
from __future__ import annotations
import json
import logging
from pathlib import Path
from typing import Any, Optional
import aiosqlite
log = logging.getLogger("commander_ai_lab.async_db")
# Re-use the same DB path as the sync module
COLLECTION_DB_PATH = Path(__file__).parent.parent / "collection.db"
# Module-level connection (lazy-init, one per process)
_aconn: Optional[aiosqlite.Connection] = None
async def async_get_db() -> aiosqlite.Connection:
"""Return (or create) the module-level async SQLite connection."""
global _aconn
if _aconn is None:
_aconn = await aiosqlite.connect(str(COLLECTION_DB_PATH))
_aconn.row_factory = aiosqlite.Row
await _aconn.execute("PRAGMA journal_mode=WAL")
log.info("Async SQLite connection opened: %s", COLLECTION_DB_PATH)
return _aconn
async def async_close_db() -> None:
"""Close the module-level connection (call on shutdown)."""
global _aconn
if _aconn is not None:
await _aconn.close()
_aconn = None
log.info("Async SQLite connection closed.")
# ── Convenience helpers ─────────────────────────────────────────
async def async_fetchall(
sql: str, params: tuple = (), *, conn: aiosqlite.Connection = None
) -> list[dict]:
"""Execute a SELECT and return all rows as dicts."""
db = conn or await async_get_db()
async with db.execute(sql, params) as cursor:
columns = [d[0] for d in cursor.description]
rows = await cursor.fetchall()
return [dict(zip(columns, row)) for row in rows]
async def async_fetchone(
sql: str, params: tuple = (), *, conn: aiosqlite.Connection = None
) -> Optional[dict]:
"""Execute a SELECT and return one row as a dict (or None)."""
db = conn or await async_get_db()
async with db.execute(sql, params) as cursor:
columns = [d[0] for d in cursor.description]
row = await cursor.fetchone()
return dict(zip(columns, row)) if row else None
async def async_execute(
sql: str, params: tuple = (), *, conn: aiosqlite.Connection = None
) -> int:
"""Execute a write statement. Returns lastrowid."""
db = conn or await async_get_db()
cursor = await db.execute(sql, params)
return cursor.lastrowid
async def async_execute_commit(
sql: str, params: tuple = (), *, conn: aiosqlite.Connection = None
) -> int:
"""Execute a write statement and commit. Returns lastrowid."""
db = conn or await async_get_db()
cursor = await db.execute(sql, params)
await db.commit()
return cursor.lastrowid
async def async_executemany_commit(
sql: str, params_seq, *, conn: aiosqlite.Connection = None
) -> None:
"""Execute many and commit."""
db = conn or await async_get_db()
await db.executemany(sql, params_seq)
await db.commit()
async def async_commit(*, conn: aiosqlite.Connection = None) -> None:
"""Commit the current transaction."""
db = conn or await async_get_db()
await db.commit()
# ── JSON field helpers (mirrors database._row_to_dict) ──────────
_JSON_FIELDS = ("category", "color_identity", "subtypes", "keywords")
def row_to_dict_parsed(row: dict) -> dict:
"""Parse JSON string columns into Python lists (same as database._row_to_dict)."""
if row is None:
return None
d = dict(row)
for f in _JSON_FIELDS:
if f in d and isinstance(d[f], str):
try:
d[f] = json.loads(d[f])
except (json.JSONDecodeError, TypeError):
d[f] = []
return d
# ── Coach session async helpers ─────────────────────────────────
async def async_save_coach_session(session_data: dict) -> int:
"""Insert or update a coach session. Returns row id."""
db = await async_get_db()
existing = await async_fetchone(
"SELECT id FROM coach_sessions WHERE session_id = ?",
(session_data["session_id"],),
)
if existing:
await db.execute(
"""UPDATE coach_sessions SET
deck_id=?, timestamp=?, model_used=?, prompt_tokens=?,
completion_tokens=?, summary=?, goals_json=?, cuts_json=?,
adds_json=?, heuristic_hints_json=?, mana_base_advice=?,
raw_text=?
WHERE session_id=?""",
(
session_data.get("deck_id", ""),
session_data.get("timestamp", ""),
session_data.get("model_used", ""),
session_data.get("prompt_tokens", 0),
session_data.get("completion_tokens", 0),
session_data.get("summary", ""),
session_data.get("goals_json", "{}"),
session_data.get("cuts_json", "[]"),
session_data.get("adds_json", "[]"),
session_data.get("heuristic_hints_json", "[]"),
session_data.get("mana_base_advice", ""),
session_data.get("raw_text", ""),
session_data["session_id"],
),
)
await db.commit()
return existing["id"]
else:
cursor = await db.execute(
"""INSERT INTO coach_sessions
(session_id, deck_id, timestamp, model_used, prompt_tokens,
completion_tokens, summary, goals_json, cuts_json, adds_json,
heuristic_hints_json, mana_base_advice, raw_text)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(
session_data["session_id"],
session_data.get("deck_id", ""),
session_data.get("timestamp", ""),
session_data.get("model_used", ""),
session_data.get("prompt_tokens", 0),
session_data.get("completion_tokens", 0),
session_data.get("summary", ""),
session_data.get("goals_json", "{}"),
session_data.get("cuts_json", "[]"),
session_data.get("adds_json", "[]"),
session_data.get("heuristic_hints_json", "[]"),
session_data.get("mana_base_advice", ""),
session_data.get("raw_text", ""),
),
)
await db.commit()
return cursor.lastrowid
async def async_load_coach_session(session_id: str) -> Optional[dict]:
"""Load a single coach session by session_id."""
return await async_fetchone(
"SELECT * FROM coach_sessions WHERE session_id = ?", (session_id,)
)
async def async_list_coach_sessions(deck_id: str = None) -> list[dict]:
"""List coach sessions, optionally filtered by deck_id."""
if deck_id:
return await async_fetchall(
"SELECT session_id, deck_id, timestamp, summary, created_at "
"FROM coach_sessions WHERE deck_id = ? ORDER BY timestamp DESC",
(deck_id,),
)
return await async_fetchall(
"SELECT session_id, deck_id, timestamp, summary, created_at "
"FROM coach_sessions ORDER BY timestamp DESC"
)
async def async_delete_coach_session(session_id: str) -> bool:
"""Delete a coach session. Returns True if a row was deleted."""
db = await async_get_db()
cursor = await db.execute(
"DELETE FROM coach_sessions WHERE session_id = ?", (session_id,)
)
await db.commit()
return cursor.rowcount > 0