"""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 `/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