Source code for best_engine_ai_helper.config

"""
config — cheap, deterministic resolution of the chosen model tags.

This module answers a single question for downstream consumers (md2star and the
rest of the AI Helpers suite): *"which local model tag should I use right now?"*
It never probes hardware and never pulls anything — that expensive, machine-
dependent work belongs to the ``recommend`` / ``pull`` flow. Here we only read,
in a fixed precedence, what has already been decided:

    1. an explicit environment override (``BEST_LLM_TEXT`` / ``BEST_LLM_VISION``,
       with the legacy ``SPREZZATURE_LLM_*`` spellings accepted as aliases),
    2. the persisted selection written by ``best-engine-ai-helper pull`` to
       ``~/.best-engine-ai-helper/config.json`` (see :func:`pull.write_env`),
    3. a conservative built-in default (:data:`DEFAULT_TEXT_MODEL` /
       :data:`DEFAULT_VISION_MODEL`) so the call *always* returns a usable tag,
       even on a machine that has never run detection.

Because the resolvers are pure reads with a guaranteed fallback, they are safe
to call at import time, in CI (where no config file and no Ollama exist), and
inside unit tests — the result is deterministic and never raises.

Author
------
Warith Harchaoui <warith.harchaoui@deraison.ai>
"""

from __future__ import annotations

import json
import os
from typing import Any

import os_helper as osh

# Reuse the single source of truth for the runtime directory and the config
# file name, so this resolver and the writer (``pull.write_env``) can never
# disagree about where the selection lives.
from .pull import _CONFIG_JSON, _USER_DIR

# Conservative defaults used when nothing has been selected yet.
#
# Neither default is the multimodal ``qwen3-vl:8b``: that model returns an empty
# response under Ollama's JSON-schema structured output on /api/generate, which
# silently broke every structured call -- text extraction (intent, edit
# proposals) and vision critique alike.
#
# Text  -> ``qwen3:8b``   (catalog's solid text-only 8B; honours ``format=<schema>``).
# Vision-> ``gemma3:12b``  (multimodal; the only catalog VLM that reliably emits
#          grammar-constrained structured JSON for image prompts). It is heavier
#          (~12B) than qwen3-vl:8b, so a memory-constrained machine should pull a
#          smaller working VLM via the recommend/pull flow, which overrides this.
DEFAULT_TEXT_MODEL = "qwen3:8b"
DEFAULT_VISION_MODEL = "gemma3:12b"

# Config keys, canonical spelling first. The canonical names match what
# ``pull.write_env`` persists; the ``SPREZZATURE_*`` names are the legacy
# spelling the transport layer (``llm.py``) shipped with — accepted so a value
# set either way is honoured everywhere.
_TEXT_KEYS = ("BEST_LLM_TEXT", "SPREZZATURE_LLM_TEXT")
_VISION_KEYS = ("BEST_LLM_VISION", "SPREZZATURE_LLM_VISION")


[docs] def load_config() -> dict[str, Any]: """Return the persisted selection dict, or an empty dict if absent/unreadable. Reads ``~/.best-engine-ai-helper/config.json`` (written by :func:`pull.write_env`). A missing file is the normal "never ran detection" case, so it maps to ``{}`` rather than an error; a corrupt file is treated the same way so a bad write can never break a downstream caller. Returns ------- dict The parsed config, or ``{}`` when the file does not exist or cannot be parsed as a JSON object. """ path = _USER_DIR / _CONFIG_JSON # Missing file == "no selection yet": the overwhelmingly common path in CI # and on a fresh machine. Return empty rather than raising. if not osh.file_exists(str(path)): osh.debug(f"No persisted selection yet:\n\t{path}") return {} try: data = json.loads(path.read_text(encoding="utf-8")) except (OSError, ValueError) as exc: # Unreadable or malformed: degrade to "no selection" so the caller # falls through to its default instead of crashing on a bad file. osh.warning(f"Unreadable/malformed config, ignoring:\n\t{path}\n\t{exc}") return {} # Guard the shape: only a JSON object carries the string values we expect. return data if isinstance(data, dict) else {}
def _resolve(keys: tuple[str, ...], default: str, config: dict[str, Any] | None) -> str: """Resolve one model tag by the fixed env -> config -> default precedence. Parameters ---------- keys : tuple of str Candidate keys, most-canonical first, tried in order against both the process environment and the persisted config. default : str The built-in fallback returned when no source supplies a value. config : dict or None A pre-loaded config to consult (avoids re-reading the file when several tags are resolved together). ``None`` triggers a lazy :func:`load_config`. Returns ------- str The resolved, non-empty model tag. """ # 1. Environment override wins — it is the most explicit, per-process signal. for key in keys: value = os.environ.get(key) if value: osh.debug(f"Model tag from env {key}: {value}") return value # 2. Otherwise consult the persisted selection from the last `pull`. cfg = load_config() if config is None else config for key in keys: value = cfg.get(key) if isinstance(value, str) and value: osh.debug(f"Model tag from config {key}: {value}") return value # 3. Nothing decided anywhere: hand back the safe built-in default. osh.debug(f"No selection found, using default: {default}") return default
[docs] def text_model() -> str: """Return the model tag to use for text-only prompts (lint, summaries, …). Precedence: ``BEST_LLM_TEXT`` env (or legacy ``SPREZZATURE_LLM_TEXT``) -> persisted ``config.json`` -> :data:`DEFAULT_TEXT_MODEL`. Never probes hardware, never raises. """ return _resolve(_TEXT_KEYS, DEFAULT_TEXT_MODEL, None)
[docs] def vision_model() -> str: """Return the model tag to use for prompts that include images (alt-text, OCR). Precedence: ``BEST_LLM_VISION`` env (or legacy ``SPREZZATURE_LLM_VISION``) -> persisted ``config.json`` -> :data:`DEFAULT_VISION_MODEL`. Never probes hardware, never raises. """ return _resolve(_VISION_KEYS, DEFAULT_VISION_MODEL, None)
[docs] def resolved_models() -> dict[str, str]: """Return both resolved tags in one call, reading the config file at most once. Returns ------- dict ``{"text": <tag>, "vision": <tag>}`` — the same values :func:`text_model` and :func:`vision_model` would return. """ # Load once and share, so resolving both tags does not stat/parse twice. cfg = load_config() return { "text": _resolve(_TEXT_KEYS, DEFAULT_TEXT_MODEL, cfg), "vision": _resolve(_VISION_KEYS, DEFAULT_VISION_MODEL, cfg), }