mirror of
https://github.com/Nighthawk42/MiraiAssist.git
synced 2026-08-30 08:32:26 +00:00
520 lines
30 KiB
Python
520 lines
30 KiB
Python
# modules/llm_manager.py
|
|
|
|
import logging
|
|
import os
|
|
import queue
|
|
import threading
|
|
import asyncio
|
|
import re
|
|
import time
|
|
from typing import Optional, List, Dict, Any, Union
|
|
|
|
# --- Dependency Imports with Checks ---
|
|
try:
|
|
from openai import (
|
|
AsyncOpenAI, APIError, APIConnectionError, APITimeoutError,
|
|
RateLimitError, InternalServerError, AuthenticationError, BadRequestError
|
|
)
|
|
OPENAI_AVAILABLE = True
|
|
except ImportError:
|
|
OPENAI_AVAILABLE = False
|
|
# Dummy classes for type hints if library not installed
|
|
class AsyncOpenAI:
|
|
def __init__(self, *args, **kwargs): pass
|
|
class chat:
|
|
class completions:
|
|
@staticmethod
|
|
async def create(*args, **kwargs):
|
|
if False: yield # Make it an async generator type
|
|
return
|
|
class APIError(Exception): status_code: Optional[int] = None; message: str = "OpenAI API Error"
|
|
class APIConnectionError(APIError): pass
|
|
class APITimeoutError(APIConnectionError): pass
|
|
class RateLimitError(APIError): status_code = 429; message = "Rate limit exceeded."
|
|
class InternalServerError(APIError): status_code = 500; message = "Internal server error."
|
|
class AuthenticationError(APIError): status_code = 401; message = "Authentication error."
|
|
class BadRequestError(APIError): status_code = 400; message = "Bad request."
|
|
|
|
try:
|
|
import tiktoken
|
|
TIKTOKEN_AVAILABLE = True
|
|
# from tiktoken import Encoding as TiktokenEncoding # More specific type
|
|
TiktokenEncoding = Any
|
|
except ImportError:
|
|
TIKTOKEN_AVAILABLE = False
|
|
class TiktokenEncoding: pass
|
|
class tiktoken:
|
|
@staticmethod
|
|
def encoding_for_model(model: str) -> Optional[TiktokenEncoding]: return None
|
|
@staticmethod
|
|
def get_encoding(encoding: str) -> Optional[TiktokenEncoding]: return None
|
|
|
|
try:
|
|
from transformers import AutoTokenizer, PreTrainedTokenizerBase, AutoConfig
|
|
TRANSFORMERS_AVAILABLE = True
|
|
except ImportError:
|
|
TRANSFORMERS_AVAILABLE = False
|
|
class PreTrainedTokenizerBase: pass # Dummy for type hints
|
|
class AutoTokenizer:
|
|
@staticmethod
|
|
def from_pretrained(model_name: str, **kwargs) -> Optional[PreTrainedTokenizerBase]: return None
|
|
class AutoConfig:
|
|
@staticmethod
|
|
def from_pretrained(model_name: str, **kwargs) -> Any: return None
|
|
|
|
|
|
# Local Imports
|
|
from .config_manager import ConfigManager
|
|
from .memory_manager import MemoryManager # Changed from ContextManager
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
class LLMManagerError(Exception):
|
|
"""Custom exception for LLMManager operational errors."""
|
|
pass
|
|
|
|
class LLMManager:
|
|
"""
|
|
Manages asynchronous communication with an OpenAI-compatible LLM API,
|
|
integrating context from MemoryManager and appropriate tokenization.
|
|
"""
|
|
DEFAULT_SYSTEM_PROMPT = "You are Mirei, a helpful and concise AI assistant. Respond clearly and directly using markdown. Use provided context when relevant."
|
|
DEFAULT_TEMPERATURE = 0.7
|
|
DEFAULT_MAX_TOKENS = 1536
|
|
DEFAULT_TIMEOUT = 120.0
|
|
DEFAULT_RETRIES = 1
|
|
DEFAULT_CONTEXT_WINDOW = 0 # 0 means disable token checks / truncation
|
|
DEFAULT_TOKENIZER_PREFERENCE = "auto" # "auto", "tiktoken", "transformers", "heuristic"
|
|
DEFAULT_CHARS_PER_TOKEN = 4 # For heuristic estimation
|
|
PROMPT_TRUNCATION_BUFFER = 100 # Tokens reserved (max_tokens for response + buffer)
|
|
|
|
def __init__(self, config: ConfigManager, gui_queue: queue.Queue):
|
|
logger.info("Initializing LLMManager (with MemoryManager & Tokenizer Logic)...")
|
|
|
|
if not OPENAI_AVAILABLE:
|
|
raise LLMManagerError("Required 'openai' library not installed. Run: uv add openai")
|
|
|
|
self.config = config
|
|
self.gui_queue = gui_queue
|
|
self.llm_config = config.get_llm_config()
|
|
self.memory_manager: Optional[MemoryManager] = None # Will be linked via set_memory_manager
|
|
|
|
self._load_config_values()
|
|
self._validate_config()
|
|
|
|
self.api_key = self._load_api_key()
|
|
self.tokenizer: Optional[Union[TiktokenEncoding, PreTrainedTokenizerBase]] = None
|
|
self.tokenizer_type: Optional[str] = None
|
|
self._initialize_tokenizer() # Now uses refined logic
|
|
self._log_tokenizer_status()
|
|
self._initialize_openai_client()
|
|
|
|
self._is_processing_lock = threading.Lock()
|
|
self._is_processing = False
|
|
logger.info("LLMManager initialized successfully.")
|
|
|
|
def _load_config_values(self) -> None:
|
|
self.api_base_url = self.llm_config.get("api_base_url")
|
|
self.api_key_env_var = self.llm_config.get("api_key_env_var")
|
|
self.model_name = self.llm_config.get("model_name") # Used for API call
|
|
# For tokenizer loading, we might use a different identifier if specified
|
|
self.tokenizer_source_identifier = self.llm_config.get("tokenizer_source_for_estimation", self.model_name)
|
|
|
|
self.system_prompt = self.llm_config.get("system_prompt", self.DEFAULT_SYSTEM_PROMPT)
|
|
self.temperature = float(self.llm_config.get("temperature", self.DEFAULT_TEMPERATURE))
|
|
self.max_tokens = int(self.llm_config.get("max_tokens", self.DEFAULT_MAX_TOKENS))
|
|
self.timeout = float(self.llm_config.get("timeout_seconds", self.DEFAULT_TIMEOUT))
|
|
self.max_retries = int(self.llm_config.get("max_retries", self.DEFAULT_RETRIES))
|
|
self.model_context_window = int(self.llm_config.get("model_context_window", self.DEFAULT_CONTEXT_WINDOW))
|
|
self.tokenizer_preference = self.llm_config.get("tokenizer_preference", self.DEFAULT_TOKENIZER_PREFERENCE).lower()
|
|
self.chars_per_token_estimate = int(self.llm_config.get("chars_per_token_estimate", self.DEFAULT_CHARS_PER_TOKEN))
|
|
if self.chars_per_token_estimate <= 0: self.chars_per_token_estimate = self.DEFAULT_CHARS_PER_TOKEN
|
|
|
|
def _validate_config(self) -> None:
|
|
if not self.api_base_url: raise LLMManagerError("LLM 'api_base_url' missing.")
|
|
if not self.model_name: raise LLMManagerError("LLM 'model_name' missing.")
|
|
if self.model_context_window > 0 and self.max_tokens >= self.model_context_window:
|
|
logger.warning(
|
|
f"Configured 'max_tokens' ({self.max_tokens}) is >= 'model_context_window' ({self.model_context_window}). "
|
|
"This leaves no room for the prompt. LLM calls may fail. Adjust config."
|
|
)
|
|
|
|
def _load_api_key(self) -> Optional[str]:
|
|
key, env_var = None, self.api_key_env_var
|
|
if env_var and env_var.upper() != "NONE":
|
|
key = os.environ.get(env_var)
|
|
if not key: logger.warning(f"LLM API key env var '{env_var}' set but not found.")
|
|
else: logger.debug("LLM API key loaded from environment.")
|
|
else: logger.info("No LLM API key env var (or set to NONE).")
|
|
return key
|
|
|
|
def _initialize_tokenizer(self) -> None:
|
|
if self.model_context_window <= 0:
|
|
logger.warning("model_context_window <= 0. Token checking/specific tokenizer loading disabled. Using heuristic.")
|
|
self.tokenizer_type = 'heuristic'; self.tokenizer = None; return
|
|
|
|
pref = self.tokenizer_preference
|
|
# Use tokenizer_source_identifier for loading the tokenizer
|
|
identifier_for_tokenizer = self.tokenizer_source_identifier
|
|
logger.info(f"Initializing tokenizer (Preference: '{pref}', Source for Tokenizer: '{identifier_for_tokenizer}')...")
|
|
|
|
load_successful = False
|
|
if pref == "tiktoken": load_successful = self._try_load_tiktoken(identifier_for_tokenizer)
|
|
elif pref == "transformers": load_successful = self._try_load_transformers(identifier_for_tokenizer)
|
|
elif pref == "auto": load_successful = self._try_auto_load_tokenizer(identifier_for_tokenizer)
|
|
elif pref == "heuristic": self.tokenizer_type = 'heuristic'; load_successful = True
|
|
else:
|
|
logger.error(f"Invalid 'tokenizer_preference': '{pref}'. Defaulting to heuristic.")
|
|
self.tokenizer_type = 'heuristic'; load_successful = True
|
|
|
|
if not load_successful:
|
|
logger.warning(f"Tokenizer init failed for '{identifier_for_tokenizer}' (pref: '{pref}'). Falling back to heuristic.")
|
|
self.tokenizer_type = 'heuristic'; self.tokenizer = None
|
|
|
|
def _try_auto_load_tokenizer(self, model_identifier: str) -> bool:
|
|
logger.debug(f"Auto-detecting tokenizer for: '{model_identifier}'")
|
|
model_lower = model_identifier.lower()
|
|
if model_lower.startswith("gpt-") or "ada" in model_lower or "babbage" in model_lower or "curie" in model_lower or "davinci" in model_lower or "text-embedding-" in model_lower :
|
|
logger.debug(f"Auto-detect: '{model_identifier}' suggests OpenAI model. Trying Tiktoken first.")
|
|
if self._try_load_tiktoken(model_identifier): return True
|
|
logger.debug(f"Tiktoken failed for '{model_identifier}'. Trying Transformers as broader attempt.")
|
|
if self._try_load_transformers(model_identifier): return True
|
|
return False
|
|
logger.debug(f"Auto-detect: '{model_identifier}' not an explicit OpenAI pattern. Trying Transformers first.")
|
|
if self._try_load_transformers(model_identifier): return True
|
|
logger.debug(f"Transformers failed for '{model_identifier}'. Trying Tiktoken as general fallback.")
|
|
if self._try_load_tiktoken(model_identifier): return True # Tiktoken tries cl100k_base
|
|
logger.warning(f"Auto-detection failed for '{model_identifier}'. No suitable tokenizer found by auto logic."); return False
|
|
|
|
def _try_load_tiktoken(self, model_identifier: str) -> bool:
|
|
if not TIKTOKEN_AVAILABLE: logger.warning("Tiktoken library not available."); return False
|
|
try:
|
|
self.tokenizer = tiktoken.encoding_for_model(model_identifier)
|
|
self.tokenizer_type = 'tiktoken'
|
|
logger.info(f"Successfully loaded Tiktoken for model: '{model_identifier}'")
|
|
return True
|
|
except KeyError:
|
|
logger.debug(f"Tiktoken: No direct encoding for '{model_identifier}'. Trying 'cl100k_base'.")
|
|
try:
|
|
self.tokenizer = tiktoken.get_encoding("cl100k_base")
|
|
self.tokenizer_type = 'tiktoken'
|
|
logger.info("Successfully loaded Tiktoken with 'cl100k_base' fallback.")
|
|
return True
|
|
except Exception as e_fallback: logger.warning(f"Tiktoken fallback load failed: {e_fallback}"); return False
|
|
except Exception as e: logger.error(f"Tiktoken init error for '{model_identifier}': {e}", exc_info=True); return False
|
|
|
|
def _try_load_transformers(self, model_identifier: str) -> bool:
|
|
if not TRANSFORMERS_AVAILABLE: logger.warning("Transformers library not available."); return False
|
|
try:
|
|
# AutoConfig.from_pretrained(model_identifier, trust_remote_code=True) # Optional pre-check
|
|
self.tokenizer = AutoTokenizer.from_pretrained(model_identifier, trust_remote_code=True, use_fast=True)
|
|
self.tokenizer_type = 'transformers'
|
|
logger.info(f"Successfully loaded Transformers tokenizer for: '{model_identifier}'")
|
|
return True
|
|
except OSError as e:
|
|
logger.warning(f"Transformers: Failed to load tokenizer for '{model_identifier}'. If local, ensure tokenizer files (tokenizer.model, etc.) are present or provide Hub ID. Error: {e}")
|
|
return False
|
|
except Exception as e:
|
|
logger.error(f"Transformers: Unexpected error for '{model_identifier}': {e}", exc_info=True)
|
|
return False
|
|
|
|
def _log_tokenizer_status(self) -> None:
|
|
if self.model_context_window <= 0:
|
|
logger.warning("LLM 'model_context_window' <= 0. Token length checking disabled.")
|
|
self.tokenizer_type = 'heuristic' # Ensure type reflects disabled checks
|
|
elif self.tokenizer_type == 'heuristic':
|
|
logger.warning(f"Using heuristic token counting (1 token ≈ {self.chars_per_token_estimate} chars).")
|
|
elif self.tokenizer:
|
|
logger.info(f"Initialized '{self.tokenizer_type}' tokenizer for '{self.tokenizer_source_identifier}'.")
|
|
else: # Should be covered by heuristic fallback, but as a safeguard
|
|
logger.error("Tokenizer initialization failed. Using heuristic token counting.")
|
|
self.tokenizer_type = 'heuristic'
|
|
|
|
def _initialize_openai_client(self) -> None:
|
|
try:
|
|
client_api_key = self.api_key if self.api_key else "placeholder_if_not_needed"
|
|
self.client = AsyncOpenAI(
|
|
base_url=self.api_base_url, api_key=client_api_key,
|
|
timeout=self.timeout, max_retries=self.max_retries
|
|
)
|
|
logger.info(f"AsyncOpenAI client initialized. Target: {self.api_base_url}")
|
|
except Exception as e:
|
|
logger.critical(f"Failed to initialize AsyncOpenAI client: {e}", exc_info=True)
|
|
raise LLMManagerError(f"OpenAI client initialization failed: {e}") from e
|
|
|
|
def set_memory_manager(self, memory_manager: MemoryManager) -> None: # Changed type hint
|
|
if not isinstance(memory_manager, MemoryManager):
|
|
raise TypeError("Invalid MemoryManager provided to LLMManager.")
|
|
self.memory_manager = memory_manager
|
|
logger.info("MemoryManager linked successfully to LLMManager.")
|
|
|
|
def _get_current_system_prompt(self) -> Dict[str, str]:
|
|
return {"role": "system", "content": self.system_prompt}
|
|
|
|
def _filter_think_tags(self, text: str) -> str:
|
|
if not text or "<think>" not in text: return text
|
|
try:
|
|
filtered_text = re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL).strip()
|
|
if len(text) != len(filtered_text): logger.debug("Filtered <think> blocks.")
|
|
if not filtered_text and text: logger.warning("LLM response was only <think> blocks.")
|
|
return filtered_text
|
|
except Exception as e: logger.error(f"Error filtering <think> tags: {e}"); return text
|
|
|
|
def _estimate_prompt_tokens(self, messages: List[Dict[str, Any]]) -> int:
|
|
if self.tokenizer_type == 'heuristic' or self.tokenizer is None or self.model_context_window <= 0:
|
|
char_count = sum(len(str(msg.get("content", ""))) for msg in messages)
|
|
estimated = char_count // self.chars_per_token_estimate
|
|
overhead = len(messages) * 4 # Rough overhead per message
|
|
final_estimate = estimated + overhead
|
|
logger.debug(f"Token estimation (heuristic): ~{final_estimate} tokens for {len(messages)} messages.")
|
|
return final_estimate
|
|
|
|
num_tokens = 0
|
|
try:
|
|
if self.tokenizer_type == 'tiktoken':
|
|
for message in messages:
|
|
num_tokens += 4
|
|
for key, value in message.items():
|
|
if value: num_tokens += len(self.tokenizer.encode(str(value)))
|
|
if message.get("role") == "assistant": num_tokens += 1
|
|
num_tokens += 3
|
|
elif self.tokenizer_type == 'transformers' and isinstance(self.tokenizer, PreTrainedTokenizerBase):
|
|
# Try to apply chat template for more accuracy if available, else sum parts.
|
|
try:
|
|
# This is the ideal way IF the tokenizer has a well-defined chat template
|
|
# and the messages are in the format it expects.
|
|
# We might need to convert our messages list to what tokenizer.apply_chat_template expects
|
|
# For now, using a simpler sum as a robust estimation.
|
|
# chat_prompt = self.tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=False)
|
|
# num_tokens = len(self.tokenizer.encode(chat_prompt))
|
|
|
|
# Simpler sum-of-parts approach (fallback if apply_chat_template is tricky)
|
|
current_tokens = 0
|
|
for message in messages:
|
|
role_tokens = len(self.tokenizer.encode(str(message.get("role", "")), add_special_tokens=False))
|
|
content_tokens = len(self.tokenizer.encode(str(message.get("content", "")), add_special_tokens=False))
|
|
current_tokens += role_tokens + content_tokens + 4 # Rough overhead
|
|
if self.tokenizer.bos_token_id is not None: current_tokens +=1
|
|
if self.tokenizer.eos_token_id is not None: current_tokens +=1
|
|
num_tokens = current_tokens
|
|
|
|
except Exception as template_e:
|
|
logger.warning(f"Failed to apply chat template for Transformers token estimation: {template_e}. Summing parts.")
|
|
current_tokens = 0
|
|
for message in messages:
|
|
role_tokens = len(self.tokenizer.encode(str(message.get("role", "")), add_special_tokens=False))
|
|
content_tokens = len(self.tokenizer.encode(str(message.get("content", "")), add_special_tokens=False))
|
|
current_tokens += role_tokens + content_tokens + 4
|
|
if self.tokenizer.bos_token_id is not None: current_tokens +=1
|
|
if self.tokenizer.eos_token_id is not None: current_tokens +=1
|
|
num_tokens = current_tokens
|
|
else:
|
|
logger.error(f"Unknown tokenizer type '{self.tokenizer_type}' during estimation. Using heuristic.")
|
|
return self._fallback_to_heuristic_estimation(messages)
|
|
|
|
logger.debug(f"Token estimation ({self.tokenizer_type}): {num_tokens} tokens for {len(messages)} messages.")
|
|
return num_tokens
|
|
except Exception as e:
|
|
logger.error(f"{self.tokenizer_type} estimation error: {e}. Falling back to heuristic.", exc_info=True)
|
|
return self._fallback_to_heuristic_estimation(messages)
|
|
|
|
def _fallback_to_heuristic_estimation(self, messages: List[Dict[str, Any]]) -> int:
|
|
"""Utility to force heuristic estimation and log it."""
|
|
original_tokenizer_type = self.tokenizer_type
|
|
self.tokenizer_type = 'heuristic' # Force heuristic for this call
|
|
self.tokenizer = None # Clear potentially problematic tokenizer for safety
|
|
estimated = self._estimate_prompt_tokens(messages) # Recursive call hits heuristic branch
|
|
# Don't restore tokenizer_type here; if we fell back, we stay heuristic until next re-init or successful load.
|
|
logger.debug(f"Fell back to heuristic, estimated ~{estimated} tokens for {len(messages)} messages.")
|
|
return estimated
|
|
|
|
def _truncate_prompt(self, messages: List[Dict[str, Any]], max_prompt_tokens: int) -> List[Dict[str, Any]]:
|
|
estimated_tokens = self._estimate_prompt_tokens(messages)
|
|
logger.debug(f"Truncating prompt. Current estimated tokens: {estimated_tokens}, Target: <= {max_prompt_tokens}")
|
|
|
|
if len(messages) <= 1: # Should at least have system or user
|
|
if estimated_tokens > max_prompt_tokens:
|
|
raise LLMManagerError(f"Cannot truncate: Single message prompt ({estimated_tokens} tokens) exceeds limit ({max_prompt_tokens}).")
|
|
return messages
|
|
|
|
# Identify system prompt (if any) and last user message to preserve them
|
|
system_prompt_msg: Optional[Dict[str, Any]] = None
|
|
last_user_msg_idx = -1
|
|
|
|
if messages[0].get("role") == "system":
|
|
system_prompt_msg = messages[0]
|
|
core_messages_start_idx = 1
|
|
else:
|
|
core_messages_start_idx = 0
|
|
|
|
# Find the last user message
|
|
for i in range(len(messages) - 1, core_messages_start_idx -1, -1):
|
|
if messages[i].get("role") == "user":
|
|
last_user_msg_idx = i
|
|
break
|
|
|
|
if last_user_msg_idx == -1 and messages[-1].get("role") != "system": # No user message, but not just system
|
|
last_user_msg_idx = len(messages) -1 # Treat the last message as immutable if no explicit user message
|
|
|
|
# Messages that can be removed (between system prompt and last user message, or all but last if no system/user)
|
|
mutable_history: List[Dict[str, Any]] = []
|
|
final_fixed_messages: List[Dict[str, Any]] = []
|
|
|
|
if system_prompt_msg:
|
|
mutable_history = messages[core_messages_start_idx : last_user_msg_idx if last_user_msg_idx != -1 else len(messages)]
|
|
final_fixed_messages.append(system_prompt_msg)
|
|
else:
|
|
mutable_history = messages[core_messages_start_idx : last_user_msg_idx if last_user_msg_idx != -1 else len(messages)]
|
|
|
|
if last_user_msg_idx != -1 and last_user_msg_idx < len(messages): # Ensure last_user_msg_idx is valid
|
|
# Add messages before last user message to mutable
|
|
if last_user_msg_idx > core_messages_start_idx :
|
|
mutable_history = messages[core_messages_start_idx : last_user_msg_idx]
|
|
else: # last_user_msg_idx is the first core message or doesn't exist
|
|
mutable_history = [] # No history to remove before last user message
|
|
|
|
if last_user_msg_idx < len(messages): # If there IS a last user message (not just system)
|
|
final_fixed_messages.append(messages[last_user_msg_idx])
|
|
elif not final_fixed_messages: # No system and no identified last user, keep last message
|
|
if messages:
|
|
mutable_history = messages[:-1]
|
|
final_fixed_messages.append(messages[-1])
|
|
|
|
|
|
current_messages_for_truncation = (([system_prompt_msg] if system_prompt_msg else []) +
|
|
mutable_history +
|
|
([messages[last_user_msg_idx]] if last_user_msg_idx != -1 and last_user_msg_idx < len(messages) else []))
|
|
|
|
|
|
while self._estimate_prompt_tokens(current_messages_for_truncation) > max_prompt_tokens and mutable_history:
|
|
removed = mutable_history.pop(0) # Remove oldest from the mutable part
|
|
logger.debug(f"Truncating message: Role={removed.get('role')}, Content='{str(removed.get('content'))[:30]}...'")
|
|
current_messages_for_truncation = (([system_prompt_msg] if system_prompt_msg else []) +
|
|
mutable_history +
|
|
([messages[last_user_msg_idx]] if last_user_msg_idx != -1 and last_user_msg_idx < len(messages) else []))
|
|
logger.debug(f" > New estimated tokens: {self._estimate_prompt_tokens(current_messages_for_truncation)}")
|
|
|
|
final_prompt_construct = current_messages_for_truncation
|
|
final_tokens = self._estimate_prompt_tokens(final_prompt_construct)
|
|
|
|
if final_tokens > max_prompt_tokens:
|
|
raise LLMManagerError(f"Prompt too long ({final_tokens} > {max_prompt_tokens}) after trying to truncate. Critical messages might be too large.")
|
|
|
|
logger.info(f"Prompt truncated to approx {final_tokens} tokens.")
|
|
return final_prompt_construct
|
|
|
|
async def _stream_llm_response(self, user_input: str) -> None:
|
|
if not self.client: self._signal_error("LLM Client Error"); return
|
|
if not self.memory_manager: self._signal_error("MemoryManager Link Error"); return # Use MemoryManager
|
|
|
|
logger.info("Starting LLM stream (using MemoryManager).")
|
|
self.gui_queue.put({"type": "status", "payload": "Constructing Context..."})
|
|
|
|
full_response_text = ""; error_occurred = False; error_message = ""; status_code = None
|
|
final_messages_sent: List[Dict[str, Any]] = []
|
|
|
|
try:
|
|
context_messages = self.memory_manager.construct_prompt_context(user_input)
|
|
messages = [self._get_current_system_prompt()] + context_messages + [{"role": "user", "content": user_input}]
|
|
final_messages_sent = messages
|
|
|
|
if self.model_context_window > 0:
|
|
max_prompt_tokens = self.model_context_window - self.max_tokens - self.PROMPT_TRUNCATION_BUFFER
|
|
if max_prompt_tokens <= 50: max_prompt_tokens = 50 # Min sensible limit
|
|
estimated_tokens = self._estimate_prompt_tokens(messages)
|
|
if estimated_tokens > max_prompt_tokens:
|
|
logger.warning(f"Prompt tokens ({estimated_tokens}) > limit ({max_prompt_tokens}). Truncating...")
|
|
final_messages_sent = self._truncate_prompt(messages, max_prompt_tokens)
|
|
# ... (Rest of API call and streaming logic remains the same as your provided `_stream_llm_response`) ...
|
|
# Ensure to use `final_messages_sent` for the API call.
|
|
# When assistant response is received, add it using `self.memory_manager.add_message("assistant", ...)`.
|
|
|
|
# (Beginning of existing API call block)
|
|
logger.info(f"Sending {len(final_messages_sent)} messages to LLM API.")
|
|
self.gui_queue.put({"type": "status", "payload": "Thinking..."})
|
|
stream = await self.client.chat.completions.create(
|
|
model=self.model_name,
|
|
messages=final_messages_sent, # Use the potentially truncated list
|
|
temperature=self.temperature,
|
|
max_tokens=self.max_tokens,
|
|
stream=True
|
|
)
|
|
|
|
logger.debug("LLM response stream opened.")
|
|
async for chunk in stream:
|
|
delta_content = chunk.choices[0].delta.content if chunk.choices and chunk.choices[0].delta else None
|
|
finish_reason = chunk.choices[0].finish_reason if chunk.choices and chunk.choices[0].finish_reason else None
|
|
if delta_content:
|
|
full_response_text += delta_content
|
|
self.gui_queue.put({"type": "llm_chunk", "payload": {"delta": delta_content}})
|
|
if finish_reason:
|
|
logger.info(f"LLM stream ended. Finish reason: '{finish_reason}'")
|
|
if finish_reason == "length":
|
|
logger.warning("LLM response potentially truncated due to 'max_tokens' limit.")
|
|
self.gui_queue.put({"type": "log", "payload": "Assistant response may be incomplete (max tokens).", "tag": "warning"})
|
|
break
|
|
|
|
if not full_response_text.strip():
|
|
logger.warning("LLM stream completed but yielded empty text content.")
|
|
# (End of existing API call block - ensure error handling follows)
|
|
|
|
except LLMManagerError as prep_err:
|
|
error_occurred = True; error_message = str(prep_err); logger.error(error_message, exc_info=False)
|
|
except AuthenticationError as e: error_occurred=True; status_code=getattr(e,'status_code',401); error_message=f"Auth Error ({status_code}): Check API key. {getattr(e,'message',str(e))}"
|
|
except BadRequestError as e: error_occurred=True; status_code=getattr(e,'status_code',400); error_message=f"Bad Request ({status_code}): Invalid prompt/model? {getattr(e,'message',str(e))}"
|
|
except APIConnectionError as e: error_occurred=True; error_message=f"Network Error connecting to {self.api_base_url}. {e}"
|
|
except APITimeoutError as e: error_occurred=True; error_message=f"LLM request timed out ({self.timeout}s). {e}"
|
|
except RateLimitError as e: error_occurred=True; status_code=getattr(e,'status_code',429); error_message=f"Rate Limit Error ({status_code}). {getattr(e,'message',str(e))}"
|
|
except InternalServerError as e: error_occurred=True; status_code=getattr(e,'status_code',500); error_message=f"LLM Server Error ({status_code}): {getattr(e,'message',str(e))}"
|
|
except APIError as e: error_occurred=True; status_code=getattr(e,'status_code','N/A'); error_message=f"Generic LLM API Error ({status_code}): {getattr(e,'message',str(e))}"
|
|
except Exception as e: # Catch other prep or API errors
|
|
error_occurred = True; error_message = f"Unexpected error during LLM processing: {e}"; logger.error(error_message, exc_info=True)
|
|
|
|
filtered_response_text = self._filter_think_tags(full_response_text)
|
|
|
|
if not error_occurred and filtered_response_text.strip():
|
|
try:
|
|
if self.memory_manager: # Use MemoryManager
|
|
self.memory_manager.add_message("assistant", filtered_response_text)
|
|
logger.debug("Assistant response added via MemoryManager.")
|
|
else: logger.error("MemoryManager reference lost post-processing.")
|
|
except Exception as ctx_e: logger.error(f"Failed to add assistant response via MemoryManager: {ctx_e}", exc_info=True)
|
|
|
|
final_payload = {
|
|
"text": filtered_response_text if not error_occurred else None,
|
|
"error": error_occurred, "error_message": error_message if error_occurred else None,
|
|
"status_code": status_code if error_occurred and status_code else (200 if not error_occurred else None)
|
|
}
|
|
self.gui_queue.put({"type": "llm_result", "payload": final_payload})
|
|
|
|
if error_occurred: self._signal_error(f"LLM Failed ({status_code or 'N/A'})", error_message)
|
|
logger.info("LLM stream processing method finished.")
|
|
|
|
|
|
def run_llm_in_background(self, user_input: str) -> None:
|
|
if not self._is_processing_lock.acquire(blocking=False):
|
|
logger.warning("LLM busy. Request ignored.")
|
|
self.gui_queue.put({"type": "log", "payload": "Assistant is busy.", "tag": "warning"})
|
|
return
|
|
self._is_processing = True
|
|
logger.info(f"Starting LLM background thread for: '{user_input[:50]}...'")
|
|
thread = threading.Thread(target=self._run_llm_thread_target, args=(user_input,), daemon=True, name="LLMStreamThread")
|
|
thread.start()
|
|
|
|
def _run_llm_thread_target(self, user_input: str) -> None:
|
|
loop = None
|
|
try:
|
|
try: loop = asyncio.get_running_loop()
|
|
except RuntimeError: loop = asyncio.new_event_loop(); asyncio.set_event_loop(loop)
|
|
loop.run_until_complete(self._stream_llm_response(user_input))
|
|
except Exception as e:
|
|
logger.error(f"Critical error in LLM background thread: {e}", exc_info=True)
|
|
self._signal_error("LLM Task Failed Unexpectedly", f"Error: {e}")
|
|
finally:
|
|
self._is_processing = False
|
|
self._is_processing_lock.release()
|
|
logger.debug("LLM processing lock released.")
|
|
|
|
def _signal_error(self, status_message: str, log_message: Optional[str] = None) -> None:
|
|
self.gui_queue.put({"type": "status", "payload": f"ERROR: {status_message}"})
|
|
self.gui_queue.put({"type": "log", "payload": log_message or status_message, "tag": "error"}) |