mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-07-10 12:17:11 +00:00
Checkpoint Odysseus local update
This commit is contained in:
+248
-5
@@ -8,13 +8,88 @@ import hashlib
|
||||
import threading
|
||||
import re
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import HTTPException
|
||||
from typing import Optional, Dict, List, Tuple
|
||||
from src.model_context import get_context_length, DEFAULT_CONTEXT
|
||||
from src.model_context import get_context_length, DEFAULT_CONTEXT, is_local_endpoint
|
||||
from urllib.parse import urlparse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_LOCAL_MODEL_LOCK = asyncio.Lock()
|
||||
_LOCAL_MODEL_WAITING_FOREGROUND = 0
|
||||
_LOCAL_MODEL_CURRENT: Dict[str, object] = {}
|
||||
|
||||
|
||||
def _local_model_gate_enabled() -> bool:
|
||||
return os.getenv("ODYSSEUS_LOCAL_MODEL_GATE", "true").lower() not in {"0", "false", "no", "off"}
|
||||
|
||||
|
||||
def _gate_workload(workload: Optional[str]) -> str:
|
||||
return "background" if str(workload or "").lower() == "background" else "foreground"
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _local_model_slot(target_url: str, model: str, workload: Optional[str] = None):
|
||||
"""Serialize local model traffic, with foreground chat taking priority.
|
||||
|
||||
Most local servers expose one GPU/CPU generation pipe even when their HTTP
|
||||
API accepts multiple requests. Letting scheduled email/tasks and foreground
|
||||
chat hit that pipe together creates the user-visible "streams crossed" and
|
||||
"prompt waited behind a task" failure mode. Cloud providers are left alone.
|
||||
"""
|
||||
if not _local_model_gate_enabled() or not is_local_endpoint(target_url):
|
||||
yield
|
||||
return
|
||||
|
||||
global _LOCAL_MODEL_WAITING_FOREGROUND
|
||||
kind = _gate_workload(workload)
|
||||
current_task = asyncio.current_task()
|
||||
if kind == "foreground":
|
||||
_LOCAL_MODEL_WAITING_FOREGROUND += 1
|
||||
current = dict(_LOCAL_MODEL_CURRENT)
|
||||
if current.get("workload") == "background":
|
||||
task = current.get("task")
|
||||
if isinstance(task, asyncio.Task) and not task.done():
|
||||
logger.info(
|
||||
"[model-gate] cancelling background local model call for foreground request model=%s",
|
||||
model,
|
||||
)
|
||||
task.cancel()
|
||||
else:
|
||||
# Background work should not jump in while the browser/chat is active
|
||||
# or while a foreground request is waiting to acquire the local model.
|
||||
try:
|
||||
from src.interactive_gate import has_foreground_activity
|
||||
except Exception:
|
||||
has_foreground_activity = lambda: False # type: ignore
|
||||
while _LOCAL_MODEL_WAITING_FOREGROUND > 0 or has_foreground_activity():
|
||||
await asyncio.sleep(0.25)
|
||||
|
||||
acquired = False
|
||||
try:
|
||||
await _LOCAL_MODEL_LOCK.acquire()
|
||||
acquired = True
|
||||
if kind == "foreground":
|
||||
_LOCAL_MODEL_WAITING_FOREGROUND = max(0, _LOCAL_MODEL_WAITING_FOREGROUND - 1)
|
||||
_LOCAL_MODEL_CURRENT.clear()
|
||||
_LOCAL_MODEL_CURRENT.update({
|
||||
"task": current_task,
|
||||
"workload": kind,
|
||||
"url": target_url,
|
||||
"model": model,
|
||||
"started": time.time(),
|
||||
})
|
||||
yield
|
||||
finally:
|
||||
if kind == "foreground":
|
||||
_LOCAL_MODEL_WAITING_FOREGROUND = max(0, _LOCAL_MODEL_WAITING_FOREGROUND - 1)
|
||||
if acquired and _LOCAL_MODEL_LOCK.locked():
|
||||
owner = _LOCAL_MODEL_CURRENT.get("task")
|
||||
if owner is current_task:
|
||||
_LOCAL_MODEL_CURRENT.clear()
|
||||
_LOCAL_MODEL_LOCK.release()
|
||||
|
||||
class LLMConfig:
|
||||
"""Configuration constants for LLM operations."""
|
||||
DEFAULT_TIMEOUT = 30
|
||||
@@ -199,6 +274,75 @@ def _stream_delta_event(text: str, *, thinking: bool = False) -> str:
|
||||
payload["thinking"] = True
|
||||
return f"data: {json.dumps(payload)}\n\n"
|
||||
|
||||
|
||||
_DEGENERATE_WORD_RE = re.compile(r"[A-Za-z0-9_\u0370-\u03ff\u0400-\u04ff]+")
|
||||
|
||||
|
||||
class _DegenerateStreamGuard:
|
||||
"""Detect local-model token collapse before it floods the UI.
|
||||
|
||||
Some self-hosted models fail by repeating one token forever ("Var Var Var",
|
||||
"Summer Summer ..."). This is not a useful response and can burn context,
|
||||
browser memory, and GPU time. Keep the guard conservative: only fire on long
|
||||
same-token runs or a very dominant repeated token in the recent window.
|
||||
"""
|
||||
|
||||
def __init__(self, model: str):
|
||||
self.model = model or "model"
|
||||
self.last_token = ""
|
||||
self.same_run = 0
|
||||
self.recent_tokens: List[str] = []
|
||||
self.total_chars = 0
|
||||
|
||||
def check(self, text: str) -> Optional[str]:
|
||||
if not text:
|
||||
return None
|
||||
self.total_chars += len(text)
|
||||
tokens = [t.lower() for t in _DEGENERATE_WORD_RE.findall(text) if len(t) >= 2]
|
||||
if not tokens:
|
||||
return None
|
||||
for token in tokens:
|
||||
if token == self.last_token:
|
||||
self.same_run += 1
|
||||
else:
|
||||
self.last_token = token
|
||||
self.same_run = 1
|
||||
self.recent_tokens.append(token)
|
||||
if len(self.recent_tokens) > 96:
|
||||
self.recent_tokens = self.recent_tokens[-96:]
|
||||
|
||||
reason = None
|
||||
if self.same_run >= 28 and self.total_chars >= 100:
|
||||
reason = f"repeated '{self.last_token}' {self.same_run} times"
|
||||
elif len(self.recent_tokens) >= 72:
|
||||
top = max(set(self.recent_tokens), key=self.recent_tokens.count)
|
||||
count = self.recent_tokens.count(top)
|
||||
if count >= 60 and count / max(len(self.recent_tokens), 1) >= 0.78:
|
||||
reason = f"repeated '{top}' {count}/{len(self.recent_tokens)} recent tokens"
|
||||
if not reason and len(self.recent_tokens) >= 80:
|
||||
# Phrase loops are common on some local quantized MLX/MoE models:
|
||||
# "Also be a software developer mode?" repeated forever will not
|
||||
# trip the single-token guard above, but it is still a wedged
|
||||
# generation. Require many repeats of the same 4-gram so normal
|
||||
# prose/list formatting is not interrupted.
|
||||
grams = [tuple(self.recent_tokens[i:i + 4]) for i in range(0, len(self.recent_tokens) - 3)]
|
||||
if grams:
|
||||
top_gram = max(set(grams), key=grams.count)
|
||||
gram_count = grams.count(top_gram)
|
||||
if gram_count >= 10:
|
||||
reason = f"repeated phrase '{' '.join(top_gram)}' {gram_count} times"
|
||||
|
||||
if not reason:
|
||||
return None
|
||||
|
||||
logger.warning("[degenerate-stream] aborting model=%s reason=%s", self.model, reason)
|
||||
message = (
|
||||
f"Stopped generation: {self.model} started repeating tokens "
|
||||
f"({reason}). Try a different model or lower temperature."
|
||||
)
|
||||
return f'event: error\ndata: {json.dumps({"status": 502, "text": message, "error": message})}\n\n'
|
||||
|
||||
|
||||
def _model_activity_key(url: str, model: str) -> str:
|
||||
return f"{(url or '').strip()}|{(model or '').strip()}"
|
||||
|
||||
@@ -755,6 +899,52 @@ def _apply_local_cache_affinity(payload: Dict, url: str, session_id: Optional[st
|
||||
payload.setdefault("cache_prompt", True)
|
||||
|
||||
|
||||
def _is_local_minimax_mlx_request(url: str, model: str) -> bool:
|
||||
"""Local MLX MiniMax-family endpoints need conservative sampling defaults.
|
||||
|
||||
The OpenAI-compatible MLX server accepts repetition/frequency penalties.
|
||||
Some large quantized MiniMax/MoE ports otherwise fall into visible reasoning
|
||||
loops ("Also be...", "No.", etc.) even for trivial prompts.
|
||||
"""
|
||||
if not model:
|
||||
return False
|
||||
m = model.lower()
|
||||
if "minimax" not in m and "mini-max" not in m:
|
||||
return False
|
||||
try:
|
||||
from src.model_context import is_local_endpoint
|
||||
return is_local_endpoint(url)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _apply_local_generation_stability(payload: Dict, url: str, model: str) -> None:
|
||||
if not _is_local_minimax_mlx_request(url, model):
|
||||
return
|
||||
if "temperature" in payload:
|
||||
try:
|
||||
# MiniMax MLX quantized ports are very sensitive to chat/agent
|
||||
# harness size. Character presets can ask for a warmer voice, but
|
||||
# local MiniMax needs a final compatibility clamp or trivial
|
||||
# prompts can fall into visible reasoning/repetition loops.
|
||||
payload["temperature"] = min(float(payload.get("temperature") or 0.2), 0.2)
|
||||
except (TypeError, ValueError):
|
||||
payload["temperature"] = 0.2
|
||||
payload.setdefault("top_p", 0.9)
|
||||
payload.setdefault("top_k", 20)
|
||||
payload.setdefault("repetition_penalty", 1.12)
|
||||
payload.setdefault("repetition_context_size", 256)
|
||||
payload.setdefault("frequency_penalty", 0.08)
|
||||
payload.setdefault("frequency_context_size", 256)
|
||||
payload.setdefault("presence_penalty", 0.02)
|
||||
payload.setdefault("presence_context_size", 256)
|
||||
payload.setdefault("stop", ["<|im_end|>", "<|endoftext|>", "</s>"])
|
||||
# A max_tokens of 0 means "server default/unbounded" for many local
|
||||
# endpoints. Keep simple chats from running forever when the model loops.
|
||||
if not payload.get("max_tokens") and not payload.get("max_completion_tokens"):
|
||||
payload["max_tokens"] = 2048
|
||||
|
||||
|
||||
def _provider_headers(provider: str, headers: Optional[Dict] = None) -> Dict[str, str]:
|
||||
h = {"Content-Type": "application/json"}
|
||||
if isinstance(headers, dict):
|
||||
@@ -1602,6 +1792,7 @@ def llm_call(url: str, model: str, messages: List[Dict], temperature: float = LL
|
||||
if max_tokens and max_tokens > 0:
|
||||
tok_key = "max_completion_tokens" if _uses_max_completion_tokens(model) else "max_tokens"
|
||||
payload[tok_key] = max_tokens
|
||||
_apply_local_generation_stability(payload, target_url, model)
|
||||
if provider == "mistral" and _supports_thinking(model):
|
||||
payload["reasoning_effort"] = _MISTRAL_REASONING_EFFORT
|
||||
try:
|
||||
@@ -1711,6 +1902,7 @@ async def llm_call_async(
|
||||
max_retries: int = LLMConfig.MAX_RETRIES,
|
||||
prompt_type: Optional[str] = None,
|
||||
session_id: Optional[str] = None,
|
||||
workload: str = "foreground",
|
||||
) -> str:
|
||||
"""Asynchronous LLM call using httpx with connection pooling, timeout, retry logic, and performance logging."""
|
||||
provider = _detect_provider(url)
|
||||
@@ -1748,6 +1940,7 @@ async def llm_call_async(
|
||||
max_tokens=max_tokens,
|
||||
headers=headers,
|
||||
timeout=timeout,
|
||||
workload=workload,
|
||||
):
|
||||
event_is_error = False
|
||||
for line in str(chunk).splitlines():
|
||||
@@ -1813,6 +2006,7 @@ async def llm_call_async(
|
||||
if provider == "mistral" and _supports_thinking(model):
|
||||
payload["reasoning_effort"] = _MISTRAL_REASONING_EFFORT
|
||||
_apply_local_cache_affinity(payload, url, session_id)
|
||||
_apply_local_generation_stability(payload, target_url, model)
|
||||
|
||||
if _is_host_dead(target_url):
|
||||
raise HTTPException(503, f"Upstream {_host_key(target_url)} marked unreachable (cooldown active)")
|
||||
@@ -1823,9 +2017,10 @@ async def llm_call_async(
|
||||
attempt += 1
|
||||
start = time.time()
|
||||
try:
|
||||
note_model_activity(target_url, model)
|
||||
client = _get_http_client()
|
||||
r = await httpx_post_kimi_aware_async(client, target_url, h, json=payload, timeout=call_timeout)
|
||||
async with _local_model_slot(target_url, model, workload):
|
||||
note_model_activity(target_url, model)
|
||||
client = _get_http_client()
|
||||
r = await httpx_post_kimi_aware_async(client, target_url, h, json=payload, timeout=call_timeout)
|
||||
duration = time.time() - start
|
||||
if not r.is_success:
|
||||
friendly = _format_upstream_error(r.status_code, r.text, target_url)
|
||||
@@ -1867,11 +2062,45 @@ async def llm_call_async(
|
||||
raise HTTPException(502, f"POST {target_url} failed after {max_retries} attempts: {e}")
|
||||
await asyncio.sleep(LLMConfig.RETRY_DELAY)
|
||||
|
||||
def _stream_target_url(url: str) -> str:
|
||||
provider = _detect_provider(url)
|
||||
if provider == "anthropic":
|
||||
return _normalize_anthropic_url(url)
|
||||
if provider == "ollama":
|
||||
return _normalize_ollama_url(url)
|
||||
if provider == "chatgpt-subscription":
|
||||
return _normalize_chatgpt_subscription_url(url)
|
||||
return _normalize_openai_chat_url(url)
|
||||
|
||||
|
||||
async def stream_llm(url: str, model: str, messages: List[Dict], temperature: float = LLMConfig.DEFAULT_TEMPERATURE,
|
||||
max_tokens: int = LLMConfig.DEFAULT_MAX_TOKENS, headers: Optional[Dict] = None,
|
||||
timeout: int = LLMConfig.STREAM_TIMEOUT, prompt_type: Optional[str] = None,
|
||||
tools: Optional[List[Dict]] = None, session_id: Optional[str] = None,
|
||||
tool_choice_none: bool = False):
|
||||
tool_choice_none: bool = False, workload: str = "foreground"):
|
||||
target_url = _stream_target_url(url)
|
||||
async with _local_model_slot(target_url, model, workload):
|
||||
async for chunk in _stream_llm_inner(
|
||||
url,
|
||||
model,
|
||||
messages,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
headers=headers,
|
||||
timeout=timeout,
|
||||
prompt_type=prompt_type,
|
||||
tools=tools,
|
||||
session_id=session_id,
|
||||
tool_choice_none=tool_choice_none,
|
||||
):
|
||||
yield chunk
|
||||
|
||||
|
||||
async def _stream_llm_inner(url: str, model: str, messages: List[Dict], temperature: float = LLMConfig.DEFAULT_TEMPERATURE,
|
||||
max_tokens: int = LLMConfig.DEFAULT_MAX_TOKENS, headers: Optional[Dict] = None,
|
||||
timeout: int = LLMConfig.STREAM_TIMEOUT, prompt_type: Optional[str] = None,
|
||||
tools: Optional[List[Dict]] = None, session_id: Optional[str] = None,
|
||||
tool_choice_none: bool = False):
|
||||
"""Stream LLM responses with improved error handling.
|
||||
|
||||
Yields SSE chunks:
|
||||
@@ -1945,6 +2174,7 @@ async def stream_llm(url: str, model: str, messages: List[Dict], temperature: fl
|
||||
if _is_ollama_openai_compat_url(url) and _supports_thinking(model):
|
||||
payload["think"] = False
|
||||
_apply_local_cache_affinity(payload, url, session_id)
|
||||
_apply_local_generation_stability(payload, target_url, model)
|
||||
h = _provider_headers(provider, headers)
|
||||
if provider == "copilot":
|
||||
from src.copilot import apply_request_headers
|
||||
@@ -1961,6 +2191,7 @@ async def stream_llm(url: str, model: str, messages: List[Dict], temperature: fl
|
||||
yield f'event: error\ndata: {json.dumps({"error": f"Upstream {_host_key(target_url)} unreachable (cooldown active)", "status": 503})}\n\n'
|
||||
return
|
||||
note_model_activity(target_url, model)
|
||||
degenerate_guard = _DegenerateStreamGuard(model)
|
||||
|
||||
# ── ChatGPT Subscription / Codex Responses streaming ──
|
||||
if provider == "chatgpt-subscription":
|
||||
@@ -1995,6 +2226,10 @@ async def stream_llm(url: str, model: str, messages: List[Dict], temperature: fl
|
||||
if evt == "response.output_text.delta":
|
||||
delta = data.get("delta") or ""
|
||||
if delta:
|
||||
_degenerate = degenerate_guard.check(delta)
|
||||
if _degenerate:
|
||||
yield _degenerate
|
||||
return
|
||||
yield f'data: {json.dumps({"delta": delta})}\n\n'
|
||||
elif evt == "response.completed":
|
||||
usage = (data.get("response") or {}).get("usage") or data.get("usage") or {}
|
||||
@@ -2327,11 +2562,19 @@ async def stream_llm(url: str, model: str, messages: List[Dict], temperature: fl
|
||||
reasoning = (reasoning + thinking_part) if reasoning else thinking_part
|
||||
content = text_part
|
||||
if reasoning:
|
||||
_degenerate = degenerate_guard.check(reasoning)
|
||||
if _degenerate:
|
||||
yield _degenerate
|
||||
return
|
||||
yield _stream_delta_event(reasoning, thinking=True)
|
||||
if content:
|
||||
content = _strip_visible_chat_template_artifacts(content)
|
||||
if not content:
|
||||
continue
|
||||
_degenerate = degenerate_guard.check(content)
|
||||
if _degenerate:
|
||||
yield _degenerate
|
||||
return
|
||||
content = re.sub(r"<mm:think(\s+[^>]*)?>", r"<think\1>", content, flags=re.IGNORECASE)
|
||||
content = re.sub(r"</mm:think>", "</think>", content, flags=re.IGNORECASE)
|
||||
stripped = content.lstrip()
|
||||
|
||||
Reference in New Issue
Block a user