- llm.py: log each Ollama call's caller + total/load/prompt/gen durations so a slow voice turn is attributable to a specific internal call (router/enrichment/digest/main); a RELOAD marker flags cold reloads. - voice.ts: track in-flight Opus captures and abort them on session destroy(); drop any utterance that finishes after the user left, so no trailing post-leave VAD turns are reported. - userbot.ts: show the speaker's Discord user ID on each transcript line (answered and dropped) so it's clear whose audio produced the turn. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
303 lines
12 KiB
Python
303 lines
12 KiB
Python
"""Direct LLM interaction utilities without extra features like temporal context."""
|
|
|
|
from __future__ import annotations
|
|
from typing import Optional, Any, Dict, List, Generator, Callable
|
|
import os
|
|
import sys
|
|
import requests
|
|
import json
|
|
|
|
|
|
def _caller_name() -> str:
|
|
"""Best-effort name of the function that invoked the LLM wrapper, used to
|
|
label per-call timing (router / enrichment / digest / main)."""
|
|
try:
|
|
return sys._getframe(2).f_code.co_name
|
|
except Exception:
|
|
return "?"
|
|
|
|
from .debug import debug_log
|
|
|
|
|
|
# Single context-window size shared by EVERY Ollama call (chat, router,
|
|
# enrichment, digests, streaming). Ollama keeps a SEPARATE loaded model
|
|
# instance per (model, num_ctx): mixing 4096 and 8192 in one voice turn made
|
|
# it evict and cold-reload the model (~3.4s each) on every context switch —
|
|
# the dominant per-turn latency. Keeping one value collapses this to a single
|
|
# resident instance, so only the very first call of a cold model pays a load.
|
|
# 8192 is the floor the main agentic chat needs (system prompt + tool schema +
|
|
# memory) without silent truncation. Tunable via env for tight-VRAM hosts.
|
|
OLLAMA_NUM_CTX = int(os.environ.get("OLLAMA_NUM_CTX", "8192"))
|
|
|
|
|
|
class ToolsNotSupportedError(Exception):
|
|
"""Raised when the model returns HTTP 400 because native tool calling is not supported."""
|
|
pass
|
|
|
|
|
|
def _log_ollama_timing(data: Dict[str, Any], num_ctx: int, caller: str) -> None:
|
|
"""Emit a one-line per-call latency breakdown so a slow voice turn can be
|
|
attributed to a specific internal LLM call (router / enrichment / digest /
|
|
main) instead of just a total. ``load_duration`` > 0 means the model was
|
|
cold-reloaded for this call — the single most expensive thing to avoid.
|
|
"""
|
|
if not isinstance(data, dict):
|
|
return
|
|
try:
|
|
ns = 1e9
|
|
total = data.get("total_duration", 0) / ns
|
|
load = data.get("load_duration", 0) / ns
|
|
peval = data.get("prompt_eval_duration", 0) / ns
|
|
pcount = data.get("prompt_eval_count")
|
|
gen = data.get("eval_duration", 0) / ns
|
|
gcount = data.get("eval_count")
|
|
reload_flag = " RELOAD" if load > 0.5 else ""
|
|
print(
|
|
f" ⏱️ llm[{caller}] ctx={num_ctx} total={total:.2f}s "
|
|
f"load={load:.2f}s{reload_flag} prompt={peval:.2f}s({pcount}t) "
|
|
f"gen={gen:.2f}s({gcount}t)",
|
|
flush=True,
|
|
)
|
|
except Exception: # pragma: no cover - logging must never break a reply
|
|
pass
|
|
|
|
|
|
def call_llm_direct(base_url: str, chat_model: str, system_prompt: str, user_content: str, timeout_sec: float = 10.0, thinking: bool = False, num_ctx: int = OLLAMA_NUM_CTX, temperature: Optional[float] = None) -> Optional[str]:
|
|
"""Direct LLM call without temporal context, location, or other ask_coach features.
|
|
|
|
``num_ctx`` controls Ollama's context window for this call. It defaults to
|
|
the shared ``OLLAMA_NUM_CTX`` so small classification-shaped passes load the
|
|
SAME Ollama instance as the main chat loop (no cold reload on context
|
|
switch). Callers may still override it, but diverging from the shared value
|
|
reintroduces a per-turn model reload.
|
|
|
|
``temperature`` is forwarded to Ollama when set. Pass ``0.0`` for
|
|
classification / extraction calls where determinism beats creativity —
|
|
Ollama defaults to ~0.8 otherwise, which can flake small models on
|
|
rule-following tasks (e.g. the knowledge extractor's banned-form list).
|
|
"""
|
|
messages = [
|
|
{"role": "system", "content": system_prompt},
|
|
{"role": "user", "content": user_content}
|
|
]
|
|
|
|
options: Dict[str, Any] = {"num_ctx": num_ctx}
|
|
if temperature is not None:
|
|
options["temperature"] = temperature
|
|
|
|
payload: Dict[str, Any] = {
|
|
"model": chat_model,
|
|
"messages": messages,
|
|
"stream": False,
|
|
"options": options,
|
|
"think": thinking,
|
|
# Keep the chat model resident between calls. Without an explicit
|
|
# keep_alive Ollama evicts it after its default idle window and the
|
|
# next turn pays a cold reload. We pin the chat model only (embeddings
|
|
# pass keep_alive=0 so they unload after use) instead of a global
|
|
# OLLAMA_KEEP_ALIVE=-1, which would keep every model resident forever.
|
|
"keep_alive": "30m",
|
|
}
|
|
|
|
caller = _caller_name()
|
|
try:
|
|
with requests.post(f"{base_url.rstrip('/')}/api/chat", json=payload, timeout=timeout_sec) as resp:
|
|
resp.raise_for_status()
|
|
data = resp.json()
|
|
_log_ollama_timing(data, num_ctx, caller)
|
|
|
|
if isinstance(data, dict):
|
|
content = extract_text_from_response(data)
|
|
if isinstance(content, str) and content.strip():
|
|
return content
|
|
debug_log(f"call_llm_direct: empty content from response keys={list(data.keys())}", "llm")
|
|
except requests.exceptions.Timeout:
|
|
debug_log(f"call_llm_direct: timeout after {timeout_sec}s", "llm")
|
|
return None
|
|
except Exception as e:
|
|
debug_log(f"call_llm_direct: request failed — {e}", "llm")
|
|
return None
|
|
|
|
return None
|
|
|
|
|
|
def call_llm_streaming(
|
|
base_url: str,
|
|
chat_model: str,
|
|
system_prompt: str,
|
|
user_content: str,
|
|
on_token: Optional[Callable[[str], None]] = None,
|
|
timeout_sec: float = 30.0,
|
|
thinking: bool = False,
|
|
) -> Optional[str]:
|
|
"""
|
|
Streaming LLM call that invokes on_token callback for each token received.
|
|
|
|
Args:
|
|
base_url: Ollama base URL
|
|
chat_model: Model name
|
|
system_prompt: System prompt
|
|
user_content: User message
|
|
on_token: Callback invoked with each token as it arrives
|
|
timeout_sec: Request timeout
|
|
thinking: Enable thinking/reasoning mode
|
|
|
|
Returns:
|
|
Complete response text, or None on error
|
|
"""
|
|
messages = [
|
|
{"role": "system", "content": system_prompt},
|
|
{"role": "user", "content": user_content}
|
|
]
|
|
|
|
payload: Dict[str, Any] = {
|
|
"model": chat_model,
|
|
"messages": messages,
|
|
"stream": True,
|
|
"options": {"num_ctx": OLLAMA_NUM_CTX},
|
|
"think": thinking,
|
|
# Keep the chat model resident between calls (see call_llm_direct).
|
|
"keep_alive": "30m",
|
|
}
|
|
|
|
# Use ``with`` so the streaming response (and the underlying TCP
|
|
# connection) is released even if iter_lines exits early via an
|
|
# exception or the caller stops consuming. Without this an aborted
|
|
# stream pinned the connection until GC, which could happen many
|
|
# turns later under sustained reply load.
|
|
try:
|
|
with requests.post(
|
|
f"{base_url.rstrip('/')}/api/chat",
|
|
json=payload,
|
|
timeout=timeout_sec,
|
|
stream=True,
|
|
) as resp:
|
|
resp.raise_for_status()
|
|
|
|
full_response = []
|
|
for line in resp.iter_lines():
|
|
if line:
|
|
try:
|
|
data = json.loads(line)
|
|
if "message" in data and isinstance(data["message"], dict):
|
|
content = data["message"].get("content", "")
|
|
if content:
|
|
full_response.append(content)
|
|
if on_token:
|
|
on_token(content)
|
|
except json.JSONDecodeError:
|
|
continue
|
|
|
|
result = "".join(full_response)
|
|
return result if result.strip() else None
|
|
|
|
except requests.exceptions.Timeout:
|
|
return None
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def extract_text_from_response(data: Dict[str, Any]) -> Optional[str]:
|
|
"""Extract text from LLM response - supports multiple response formats."""
|
|
# Preferred: Ollama chat non-stream format
|
|
if "message" in data and isinstance(data["message"], dict):
|
|
content = data["message"].get("content")
|
|
if isinstance(content, str):
|
|
return content
|
|
|
|
# Fallback: OpenAI-style format
|
|
if "choices" in data and isinstance(data["choices"], list) and len(data["choices"]) > 0:
|
|
choice = data["choices"][0]
|
|
if isinstance(choice, dict):
|
|
if "message" in choice and isinstance(choice["message"], dict):
|
|
content = choice["message"].get("content")
|
|
if isinstance(content, str):
|
|
return content
|
|
elif "text" in choice:
|
|
content = choice["text"]
|
|
if isinstance(content, str):
|
|
return content
|
|
|
|
# Another fallback: direct "content" field
|
|
if "content" in data:
|
|
content = data["content"]
|
|
if isinstance(content, str):
|
|
return content
|
|
|
|
return None
|
|
|
|
|
|
def chat_with_messages(
|
|
base_url: str,
|
|
chat_model: str,
|
|
messages: List[Dict[str, str]],
|
|
timeout_sec: float = 30.0,
|
|
extra_options: Optional[Dict[str, Any]] = None,
|
|
tools: Optional[List[Dict[str, Any]]] = None,
|
|
thinking: bool = False,
|
|
) -> Optional[Dict[str, Any]]:
|
|
"""
|
|
Send an arbitrary messages array to the LLM and return the raw response JSON.
|
|
Caller is responsible for interpreting assistant content (including JSON/tool calls).
|
|
|
|
Args:
|
|
base_url: Ollama base URL
|
|
chat_model: Model name
|
|
messages: Conversation messages
|
|
timeout_sec: Request timeout
|
|
extra_options: Additional model options
|
|
tools: Optional list of tools in OpenAI-compatible JSON schema format for native tool calling
|
|
thinking: Enable thinking/reasoning mode
|
|
|
|
Returns the parsed JSON response dict on success, or None on error/timeout.
|
|
"""
|
|
# Main agentic chat uses 8192 so the system prompt (tool list + protocol
|
|
# guidance + memory context) doesn't overflow and force ollama to truncate
|
|
# — which previously dropped the tool schema on smaller models like
|
|
# gemma4:e2b, tipping them into their pre-trained tool_code scaffolding.
|
|
payload: Dict[str, Any] = {
|
|
"model": chat_model,
|
|
"messages": messages,
|
|
"stream": False,
|
|
"options": {"num_ctx": OLLAMA_NUM_CTX},
|
|
"think": thinking,
|
|
# Keep the chat model resident between turns (see call_llm_direct).
|
|
"keep_alive": "30m",
|
|
}
|
|
if extra_options and isinstance(extra_options, dict):
|
|
# Merge shallowly into options
|
|
payload["options"].update(extra_options)
|
|
|
|
# Add tools for native tool calling support (Ollama 0.4+)
|
|
if tools and isinstance(tools, list) and len(tools) > 0:
|
|
payload["tools"] = tools
|
|
|
|
caller = _caller_name()
|
|
try:
|
|
with requests.post(f"{base_url.rstrip('/')}/api/chat", json=payload, timeout=timeout_sec) as resp:
|
|
resp.raise_for_status()
|
|
data = resp.json()
|
|
_log_ollama_timing(data, OLLAMA_NUM_CTX, caller)
|
|
if isinstance(data, dict):
|
|
return data
|
|
except requests.exceptions.Timeout:
|
|
print(" ⏱️ LLM request timed out", flush=True)
|
|
return None
|
|
except requests.exceptions.ConnectionError as e:
|
|
print(f" ❌ LLM connection error: {e}", flush=True)
|
|
return None
|
|
except requests.exceptions.HTTPError as e:
|
|
# Raise a specific error when the model rejects the tools parameter (HTTP 400).
|
|
# This lets the caller fall back to text-based tool calling automatically.
|
|
if e.response is not None and e.response.status_code == 400 and tools:
|
|
raise ToolsNotSupportedError(
|
|
f"Model {chat_model!r} returned HTTP 400 — native tools API not supported"
|
|
)
|
|
print(f" ❌ LLM HTTP error: {e}", flush=True)
|
|
return None
|
|
except Exception as e:
|
|
print(f" ❌ LLM error: {e}", flush=True)
|
|
return None
|
|
|
|
return None
|