Files
ai-rpg/app/core/llm.py

548 lines
21 KiB
Python
Raw Normal View History

2026-06-20 19:13:05 +03:00
"""LLM client — OpenAI-compatible API wrapper with retry, logging, and streaming.
Usage:
client = LlmClient.from_settings(settings_dict)
resp = await client.complete(
stage="orchestrator_phase1",
messages=[{"role": "system", "content": "..."}, ...],
tools=[...], # optional
temperature=0.7,
max_tokens=2048,
stream=False, # if True, returns an async iterator of deltas
user_id=..., world_id=..., step_id=...,
)
"""
from __future__ import annotations
import asyncio
import json
import time
import uuid
from collections.abc import AsyncIterator
from typing import Any
import httpx
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.logging import get_logger
from app.models import LlmCallLog
_logger = get_logger(__name__)
2026-06-21 04:11:38 +03:00
# Patterns for parsing tool calls emitted as text (some local models don't
# use the OpenAI function-calling format and instead emit calls as text).
import re as _re
_TOOL_CALL_PATTERNS = [
# call:name{json_args} or call:name(json_args)
_re.compile(r"call:(\w+)\s*[\{\(]([^}\)]*)[\}\)]"),
# <tool_call>name{args}</tool_call> or <tool_call>\n{"name": ..., "arguments": ...}\n</tool_call>
_re.compile(r"<tool_call>\s*(\w+)\s*\{([^}]*)\}\s*</tool_call>"),
# name{"key": "value", ...} (function call style)
_re.compile(r"\b(\w+)\s*\(\s*(\{[^}]*\})\s*\)"),
]
def _parse_text_tool_calls(content: str) -> list[dict]:
"""Parse tool calls emitted as text by some local models.
Handles patterns like:
- call:calc{"expression": "2+2"}
- <tool_call>calc{"expression": "2+2"}</tool_call>
- calc({"expression": "2+2"})
Returns a list of OpenAI-format tool_call dicts.
"""
if not content:
return []
calls: list[dict] = []
for pattern in _TOOL_CALL_PATTERNS:
for match in pattern.finditer(content):
name = match.group(1)
args_str = match.group(2).strip()
if not args_str:
args = {}
else:
try:
args = json.loads(args_str)
except json.JSONDecodeError:
# Try fixing common issues: single quotes, unquoted keys
try:
fixed = args_str.replace("'", '"')
# Add quotes around bare keys
fixed = _re.sub(r"(\w+)\s*:", r'"\1":', fixed)
args = json.loads(fixed)
except json.JSONDecodeError:
args = {"_raw": args_str}
calls.append({
"id": f"parsed_{len(calls)}",
"type": "function",
"function": {"name": name, "arguments": json.dumps(args)},
})
return calls
2026-06-20 19:13:05 +03:00
class LLMError(Exception):
"""Base LLM error."""
def __init__(self, code: str, message: str, status: str = "api_error"):
super().__init__(message)
self.code = code
self.status = status
class LLMTimeoutError(LLMError):
def __init__(self, message: str = "LLM call timed out"):
super().__init__("llm_timeout", message, status="timeout")
class LLMUnavailableError(LLMError):
def __init__(self, message: str = "LLM provider unavailable"):
super().__init__("llm_unavailable", message, status="api_error")
class LLMResponseError(LLMError):
def __init__(self, message: str, code: str = "parse_error"):
super().__init__(code, message, status="parse_error")
class LlmClient:
"""OpenAI-compatible LLM client with retry, logging, and streaming."""
def __init__(
self,
api_url: str,
api_key: str,
model: str,
timeout: float = 60.0,
max_retries: int = 3,
):
self._api_url = api_url.rstrip("/")
self._api_key = api_key
self._model = model
self._timeout = timeout
self._max_retries = max_retries
# ------------------------------------------------------------------ #
# Construction
# ------------------------------------------------------------------ #
@classmethod
def from_settings(cls, settings: dict[str, Any]) -> "LlmClient":
return cls(
api_url=settings.get("llm.api_url", "http://localhost:11434/v1"),
api_key=settings.get("llm.api_key", ""),
model=settings.get("llm.model", "qwen2.5-7b-instruct"),
timeout=float(settings.get("llm.timeout_seconds", 60)),
)
# ------------------------------------------------------------------ #
# Non-streaming call
# ------------------------------------------------------------------ #
async def complete(
self,
*,
stage: str,
messages: list[dict[str, Any]],
tools: list[dict] | None = None,
tool_choice: Any = None,
temperature: float = 0.7,
top_p: float = 0.9,
max_tokens: int = 2048,
user_id: uuid.UUID | None = None,
world_id: uuid.UUID | None = None,
step_id: uuid.UUID | None = None,
session: AsyncSession | None = None,
stream: bool = False,
) -> dict[str, Any]:
"""Make a non-streaming chat completion call.
Returns a dict with keys:
- `message`: assistant message (with `content` and optional `tool_calls`)
- `finish_reason`: stop | length | tool_calls
- `prompt_tokens`, `completion_tokens`, `latency_ms`
- `log_id`: id of the LlmCallLog row if `session` provided
"""
if stream:
raise ValueError("Use stream_complete() for streaming calls")
payload: dict[str, Any] = {
"model": self._model,
"messages": messages,
"temperature": temperature,
"top_p": top_p,
"max_tokens": max_tokens,
"stream": False,
}
if tools:
payload["tools"] = tools
payload["tool_choice"] = tool_choice or "auto"
start = time.monotonic()
last_exc: Exception | None = None
for attempt in range(self._max_retries):
try:
async with httpx.AsyncClient(timeout=self._timeout) as client:
resp = await client.post(
f"{self._api_url}/chat/completions",
headers={
"Authorization": f"Bearer {self._api_key}",
"Content-Type": "application/json",
},
json=payload,
)
if resp.status_code >= 500:
raise LLMUnavailableError(
2026-06-21 02:41:48 +03:00
f"LLM provider returned HTTP {resp.status_code}: {resp.text[:300]}"
2026-06-20 19:13:05 +03:00
)
if resp.status_code == 429:
2026-06-21 02:41:48 +03:00
raise LLMUnavailableError("LLM provider rate-limited (HTTP 429)")
2026-06-20 19:13:05 +03:00
if resp.status_code >= 400:
2026-06-21 02:41:48 +03:00
# Try to extract error message from JSON body
err_body = resp.text[:500]
try:
err_json = resp.json()
if "error" in err_json:
err_msg = err_json["error"].get("message", err_body)
else:
err_msg = err_body
except Exception: # noqa: BLE001
err_msg = err_body
2026-06-20 19:13:05 +03:00
raise LLMResponseError(
2026-06-21 02:41:48 +03:00
f"LLM provider returned HTTP {resp.status_code}: {err_msg}",
2026-06-20 19:13:05 +03:00
code="api_error",
)
2026-06-21 02:41:48 +03:00
# Parse JSON response — if this fails, the URL is likely wrong
# (pointing at an HTML page instead of an OpenAI-compatible API)
try:
data = resp.json()
except Exception as e: # noqa: BLE001
raise LLMResponseError(
f"LLM provider returned non-JSON response (check that "
f"api_url points to an OpenAI-compatible endpoint). "
f"First 200 chars: {resp.text[:200]}",
code="parse_error",
) from e
2026-06-20 19:13:05 +03:00
break
except (httpx.TimeoutException, asyncio.TimeoutError) as e:
last_exc = LLMTimeoutError(str(e))
_logger.warning(
"llm_timeout", stage=stage, attempt=attempt + 1, error=str(e)
)
except (httpx.ConnectError, httpx.NetworkError) as e:
last_exc = LLMUnavailableError(str(e))
_logger.warning(
"llm_connection_error", stage=stage, attempt=attempt + 1, error=str(e)
)
except LLMError as e:
last_exc = e
_logger.warning(
"llm_error", stage=stage, attempt=attempt + 1, error=str(e)
)
# exponential backoff
await asyncio.sleep(min(2**attempt, 4))
else:
# All retries exhausted
if session is not None:
await self._write_log_safely(
session=session,
stage=stage,
messages=messages,
tools=tools,
response_message={},
tool_calls=None,
prompt_tokens=None,
completion_tokens=None,
latency_ms=int((time.monotonic() - start) * 1000),
temperature=temperature,
status=last_exc.status if isinstance(last_exc, LLMError) else "api_error",
error_message=str(last_exc) if last_exc else "unknown",
user_id=user_id,
world_id=world_id,
step_id=step_id,
)
assert last_exc is not None
raise last_exc
latency_ms = int((time.monotonic() - start) * 1000)
choice = data["choices"][0]
msg = choice.get("message", {})
finish_reason = choice.get("finish_reason", "stop")
usage = data.get("usage", {})
2026-06-21 04:11:38 +03:00
# If the model didn't return tool_calls in the OpenAI format but DID
# emit them as text (some local models use "call:name{args}" or
# "<tool_call>name{args}</tool_call>"), try to parse them out.
if tools and not msg.get("tool_calls"):
content = msg.get("content", "") or ""
parsed = _parse_text_tool_calls(content)
if parsed:
msg = dict(msg) # don't mutate the original
msg["tool_calls"] = parsed
if finish_reason == "stop":
finish_reason = "tool_calls"
2026-06-20 19:13:05 +03:00
log_id: uuid.UUID | None = None
if session is not None:
log_id = await self._write_log_safely(
session=session,
stage=stage,
messages=messages,
tools=tools,
response_message=msg,
tool_calls=msg.get("tool_calls"),
prompt_tokens=usage.get("prompt_tokens"),
completion_tokens=usage.get("completion_tokens"),
latency_ms=latency_ms,
temperature=temperature,
status="ok",
error_message=None,
user_id=user_id,
world_id=world_id,
step_id=step_id,
)
return {
"message": msg,
"finish_reason": finish_reason,
"prompt_tokens": usage.get("prompt_tokens"),
"completion_tokens": usage.get("completion_tokens"),
"latency_ms": latency_ms,
"log_id": log_id,
}
# ------------------------------------------------------------------ #
# Streaming call
# ------------------------------------------------------------------ #
async def stream_complete(
self,
*,
stage: str,
messages: list[dict[str, Any]],
tools: list[dict] | None = None,
tool_choice: Any = None,
temperature: float = 0.85,
top_p: float = 0.95,
max_tokens: int = 2048,
user_id: uuid.UUID | None = None,
world_id: uuid.UUID | None = None,
step_id: uuid.UUID | None = None,
session: AsyncSession | None = None,
) -> AsyncIterator[dict[str, Any]]:
"""Stream chat completion. Yields dicts with keys:
- `delta`: {content?, tool_calls?}
- `finish_reason`: present only on the final chunk
After the iterator is exhausted, the call is logged to `llm_call_logs`.
"""
payload: dict[str, Any] = {
"model": self._model,
"messages": messages,
"temperature": temperature,
"top_p": top_p,
"max_tokens": max_tokens,
"stream": True,
}
if tools:
payload["tools"] = tools
payload["tool_choice"] = tool_choice or "auto"
start = time.monotonic()
full_content_parts: list[str] = []
full_tool_calls: list[dict] = []
finish_reason: str | None = None
usage: dict[str, Any] = {}
status = "ok"
error_message: str | None = None
try:
async with httpx.AsyncClient(timeout=self._timeout) as client:
async with client.stream(
"POST",
f"{self._api_url}/chat/completions",
headers={
"Authorization": f"Bearer {self._api_key}",
"Content-Type": "application/json",
},
json=payload,
) as resp:
if resp.status_code >= 400:
body = await resp.aread()
raise LLMResponseError(
f"LLM provider returned {resp.status_code}: {body.decode('utf-8', 'ignore')[:500]}",
code="api_error",
)
async for line in resp.aiter_lines():
if not line:
continue
if line.startswith("data: "):
line = line[6:]
if line.strip() == "[DONE]":
break
try:
chunk = json.loads(line)
except json.JSONDecodeError:
continue
if not chunk.get("choices"):
if chunk.get("usage"):
usage = chunk["usage"]
continue
choice = chunk["choices"][0]
delta = choice.get("delta", {})
if delta.get("content"):
full_content_parts.append(delta["content"])
if delta.get("tool_calls"):
full_tool_calls.extend(delta["tool_calls"])
if choice.get("finish_reason"):
finish_reason = choice["finish_reason"]
yield {"delta": delta, "finish_reason": finish_reason}
except Exception as e:
status = "api_error" if not isinstance(e, LLMTimeoutError) else "timeout"
error_message = str(e)
_logger.warning("llm_stream_error", stage=stage, error=error_message)
raise
finally:
latency_ms = int((time.monotonic() - start) * 1000)
if session is not None:
full_content = "".join(full_content_parts)
await self._write_log_safely(
session=session,
stage=stage,
messages=messages,
tools=tools,
response_message={
"role": "assistant",
"content": full_content,
"tool_calls": full_tool_calls or None,
},
tool_calls=full_tool_calls or None,
prompt_tokens=usage.get("prompt_tokens"),
completion_tokens=usage.get("completion_tokens"),
latency_ms=latency_ms,
temperature=temperature,
status=status,
error_message=error_message,
user_id=user_id,
world_id=world_id,
step_id=step_id,
)
# ------------------------------------------------------------------ #
# Safe logging (separate transaction)
# ------------------------------------------------------------------ #
async def _write_log_safely(
self,
*,
session: AsyncSession,
stage: str,
messages: list[dict[str, Any]],
tools: list[dict] | None,
response_message: dict[str, Any],
tool_calls: list | None,
prompt_tokens: int | None,
completion_tokens: int | None,
latency_ms: int,
temperature: float,
status: str,
error_message: str | None,
user_id: uuid.UUID | None,
world_id: uuid.UUID | None,
step_id: uuid.UUID | None,
) -> uuid.UUID | None:
"""Insert an LlmCallLog row in a nested transaction so it survives rollback.
Errors here are logged but never raised logging is best-effort.
"""
try:
async with session.begin_nested():
log = LlmCallLog(
user_id=user_id,
world_id=world_id,
step_id=step_id,
stage=stage,
model=self._model,
request_messages=messages,
request_tools=tools,
response_message=response_message,
tool_calls=tool_calls,
prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens,
latency_ms=latency_ms,
temperature=temperature,
status=status,
error_message=error_message,
)
session.add(log)
await session.flush()
log_id = log.id
await session.commit()
return log_id
except Exception as e: # noqa: BLE001
_logger.error("llm_log_write_failed", stage=stage, error=str(e))
try:
await session.rollback()
except Exception:
pass
return None
# --------------------------------------------------------------------------- #
# Mock LLM client (for tests)
# --------------------------------------------------------------------------- #
class MockLlmClient:
"""Replay-based mock LLM client. Returns pre-recorded responses per stage."""
def __init__(self, replay_data: dict[str, list[dict]] | None = None):
self._replay = replay_data or {}
self._call_counts: dict[str, int] = {}
# Allow recording mode
self.recorded_calls: list[dict[str, Any]] = []
def set_replay(self, stage: str, responses: list[dict]) -> None:
self._replay[stage] = responses
self._call_counts.pop(stage, None)
async def complete(self, *, stage: str, messages=None, tools=None, **kwargs) -> dict[str, Any]:
idx = self._call_counts.get(stage, 0)
responses = self._replay.get(stage, [])
if idx >= len(responses):
raise LLMResponseError(
f"Replay exhausted for stage {stage} (call #{idx + 1})",
code="replay_exhausted",
)
resp = responses[idx]
self._call_counts[stage] = idx + 1
self.recorded_calls.append({"stage": stage, "messages": messages, "tools": tools})
# Mimic the real client's return shape
return {
"message": resp.get("message", {"role": "assistant", "content": resp.get("content", "")}),
"finish_reason": resp.get("finish_reason", "stop"),
"prompt_tokens": resp.get("prompt_tokens", 0),
"completion_tokens": resp.get("completion_tokens", 0),
"latency_ms": 0,
"log_id": None,
}
async def stream_complete(self, *, stage: str, messages=None, tools=None, **kwargs):
idx = self._call_counts.get(stage, 0)
responses = self._replay.get(stage, [])
if idx >= len(responses):
raise LLMResponseError(
f"Replay exhausted for stage {stage} (call #{idx + 1})",
code="replay_exhausted",
)
resp = responses[idx]
self._call_counts[stage] = idx + 1
content = resp.get("message", {}).get("content", resp.get("content", ""))
# Yield content in 3 chunks for streaming tests
chunk_size = max(1, len(content) // 3)
for i in range(0, len(content), chunk_size):
yield {"delta": {"content": content[i : i + chunk_size]}, "finish_reason": None}
yield {"delta": {}, "finish_reason": "stop"}
def get_mock_client() -> MockLlmClient:
"""Convenience factory — used in tests and as a fallback in dev when no LLM configured."""
return MockLlmClient()