J.A.R.V.I.S-py/llm_backend.py

153 lines
4.5 KiB
Python
Raw Normal View History

feat: LLM hot-swap voice command + Ollama backend (148 → 151 commands) Closes the last functional parity gap with rust. Python now has voice-driven Ollama↔Groq switching, persistent across restarts. llm_backend.py (new, ~130 lines) Singleton module: current_client() → active OpenAI client (or None) current_backend() → 'groq' | 'ollama' | 'none' current_model() → active model name swap_to(name) → hot-swap + persist to <here>/llm_backend.txt parse_backend(name) → ru/en alias normaliser ('облако'→groq, 'локальный'→ollama) Backends: - Groq: config.GROQ_TOKEN + GROQ_BASE_URL + GROQ_MODEL (defaults) - Ollama: OLLAMA_BASE_URL (default http://localhost:11434/v1) + OLLAMA_MODEL (default qwen2.5:3b). api_key='ollama' (placeholder, openai lib insists on a non-empty string; Ollama ignores it). Init precedence (idempotent _ensure_init): 1. Persisted choice from llm_backend.txt 2. JARVIS_LLM env var 3. Auto-detect: Groq if token present, else Ollama Voice commands (commands.yaml, +3 entries → 151) llm_switch_local → ollama llm_switch_cloud → groq llm_status → speaks current backend extensions.py + do_llm_switch / do_llm_status handlers + llm_backend import do_interesting_fact migrated off direct OpenAI(...) to use llm_backend.current_* dev_handlers.py + llm_backend import do_codebase_ask + do_github_summarize_pr migrated to llm_backend.current_* vision_handler.py Vision call documented as Groq-specific (Ollama doesn't expose vision via OpenAI-compat in our stack), kept direct config.GROQ_TOKEN reading. Tests: ast.parse passes for all 5 modules. yaml.safe_load = 151 entries. PYTHON PARITY VS RUST IS NOW FUNCTIONALLY COMPLETE. Only remaining rust-only feature: the Tauri GUI (python is console-only).
2026-05-16 00:44:42 +03:00
"""LLM backend singleton — Python port of `crates/jarvis-core/src/llm/mod.rs`.
Supports two backends:
- Groq (cloud) uses config.GROQ_TOKEN
- Ollama (local) connects to OLLAMA_BASE_URL (default localhost:11434/v1)
Active choice persists in `<here>/llm_backend.txt`. Loaded on first call.
Public API:
current_client() -> OpenAI client for the active backend, or None
current_backend() -> 'groq' | 'ollama' | 'none'
current_model() -> model name string
swap_to(name) -> 'groq' | 'ollama', persists choice
parse_backend(name) -> normalized 'groq' | 'ollama' | None
"""
import os
import threading
_HERE = os.path.dirname(os.path.abspath(__file__))
_CHOICE_FILE = os.path.join(_HERE, 'llm_backend.txt')
_lock = threading.Lock()
_client = None
_backend = 'none'
_model = ''
_initialized = False
def _read_persisted() -> str:
try:
with open(_CHOICE_FILE, encoding='utf-8') as f:
return f.read().strip().lower()
except OSError:
return ''
def _persist(name: str) -> None:
try:
with open(_CHOICE_FILE, 'w', encoding='utf-8') as f:
f.write(name)
except OSError as exc:
print(f"[llm] persist: {exc}")
def parse_backend(name: str) -> str | None:
"""Normalize a user-facing name to 'groq' / 'ollama' / None."""
n = (name or '').strip().lower()
if n in ('groq', 'cloud', 'облако', 'клауд'):
return 'groq'
if n in ('ollama', 'local', 'локал', 'локальн', 'локальный'):
return 'ollama'
return None
def _build_groq():
"""Try to construct a Groq client. Returns (client, model) or raises."""
try:
import config as cfg
except ImportError:
raise RuntimeError("config.py не найден")
if not getattr(cfg, 'GROQ_TOKEN', None):
raise RuntimeError("GROQ_TOKEN отсутствует в config")
from openai import OpenAI
base = getattr(cfg, 'GROQ_BASE_URL', 'https://api.groq.com/openai/v1')
model = getattr(cfg, 'GROQ_MODEL', 'llama-3.3-70b-versatile')
return OpenAI(api_key=cfg.GROQ_TOKEN, base_url=base), model
def _build_ollama():
"""Construct an Ollama-talking OpenAI-compatible client."""
from openai import OpenAI
base = os.environ.get('OLLAMA_BASE_URL', 'http://localhost:11434/v1')
model = os.environ.get('OLLAMA_MODEL', 'qwen2.5:3b')
# Ollama doesn't need a real key, but openai lib insists on a non-empty string.
return OpenAI(api_key='ollama', base_url=base), model
def _build_for(backend: str):
if backend == 'groq':
return _build_groq()
if backend == 'ollama':
return _build_ollama()
raise ValueError(f"unknown backend: {backend}")
def _auto_detect_backend() -> str:
"""Pick a default backend if none persisted: Groq if token present, else Ollama."""
try:
import config as cfg
if getattr(cfg, 'GROQ_TOKEN', None):
return 'groq'
except ImportError:
pass
return 'ollama'
def _ensure_init():
"""Build the client based on persisted choice, env, or auto-detect."""
global _client, _backend, _model, _initialized
if _initialized:
return
persisted = _read_persisted()
env_choice = os.environ.get('JARVIS_LLM', '').strip().lower()
backend = persisted or env_choice or _auto_detect_backend()
try:
client, model = _build_for(backend)
_client = client
_backend = backend
_model = model
except Exception as exc:
print(f"[llm] init {backend} failed: {exc}")
_client = None
_backend = 'none'
_model = ''
_initialized = True
def current_client():
"""Get the active client (or None if uninitialised). Cheap after first call."""
with _lock:
_ensure_init()
return _client
def current_backend() -> str:
with _lock:
_ensure_init()
return _backend
def current_model() -> str:
with _lock:
_ensure_init()
return _model
def swap_to(name: str) -> str:
"""Hot-swap to a different backend. Persists to disk. Returns new name."""
target = parse_backend(name)
if not target:
raise ValueError(f"unknown backend: {name}")
with _lock:
global _client, _backend, _model, _initialized
client, model = _build_for(target) # may raise
_client = client
_backend = target
_model = model
_initialized = True
_persist(target)
print(f"[llm] swapped → {target} ({model})")
return target