"""
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 <warithmetics@deraison.ai>
"""
from __future__ import annotations
import json
import math
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")
# `pull.write_env` persists four values, not two. The other two resolve here
# for the same reason the model tags do: the transport used to read only the
# legacy `SPREZZATURE_*` spelling, so a `BEST_LLM_BACKEND` written by `pull`,
# documented in the README's export block and sourced by the user was read by
# nobody. Half the block worked, which is what made it hard to notice.
DEFAULT_BACKEND = "ollama"
DEFAULT_BASE_URL = "http://localhost:11434"
_BACKEND_KEYS = ("BEST_LLM_BACKEND", "SPREZZATURE_LLM_BACKEND")
_BASE_URL_KEYS = ("BEST_LLM_BASE_URL", "SPREZZATURE_LLM_BASE_URL")
[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 setting 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"Resolved 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"Resolved 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 backend() -> str:
"""Return the serving backend for the legacy, no-engine transport path.
Precedence: ``BEST_LLM_BACKEND`` env (or legacy
``SPREZZATURE_LLM_BACKEND``) -> persisted ``config.json`` ->
:data:`DEFAULT_BACKEND`. Lower-cased, because the transport dispatches on
an exact string and a sourced env file is hand-editable.
Does not apply to the ``engine=`` path, where the backend comes from the
resolved engine descriptor.
"""
return _resolve(_BACKEND_KEYS, DEFAULT_BACKEND, None).lower()
[docs]
def base_url() -> str:
"""Return the server base URL for the legacy, no-engine transport path.
Same precedence as :func:`backend`, with any trailing slash removed so
callers can join paths without doubling it.
"""
return _resolve(_BASE_URL_KEYS, DEFAULT_BASE_URL, None).rstrip("/")
[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),
}
# ---------------------------------------------------------------------------
# User preferences (written by the GUI, read by anything that ranks)
# ---------------------------------------------------------------------------
#: Where preferences live. Deliberately **not** ``settings.yaml``.
#:
#: That file holds API keys, and a browser form is the last thing that should
#: be able to rewrite it: one naive dump and a user's keys are gone. Secrets
#: and preferences also have different lifetimes, different blast radii, and
#: different people editing them. Keeping them apart means the GUI can save a
#: slider position without ever holding a file that contains a secret.
#:
#: The YAML stays the source of truth either way — the GUI writes this file and
#: then reads it back like any other client, rather than keeping its own state.
_PREFERENCES_YAML = _USER_DIR / "preferences.yaml"
#: Preferences this module will persist, with the default each falls back to.
#: An unknown key sent by a client is ignored rather than stored: a settings
#: file that accumulates whatever anyone posted stops being readable.
_PREFERENCE_DEFAULTS: dict[str, Any] = {
# 0 = rank purely on price, 1 = purely on quality.
"quality_vs_cost": 0.7,
# Fraction of the usable memory pool a model may occupy.
"headroom": 0.5,
# Throughput floor in tokens/s, below which a model is not comfortable.
"min_tps": 15.0,
# Never suggest a paid model, whatever keys are present.
"local_only": False,
# Also weigh what the machine is doing right now, not just its capacity.
"live_load": False,
}
[docs]
def load_preferences() -> dict[str, Any]:
"""
Read the saved preferences, falling back to the defaults for anything unset.
A missing or unreadable file is not an error: no preferences yet is the
normal state, and every value has a usable default. Returns a fresh dict,
so a caller mutating the result cannot corrupt the next read.
Returns
-------
dict
Every key in :data:`_PREFERENCE_DEFAULTS`, saved value or default.
"""
import yaml
prefs = dict(_PREFERENCE_DEFAULTS)
if not _PREFERENCES_YAML.exists():
return prefs
try:
saved = yaml.safe_load(_PREFERENCES_YAML.read_text(encoding="utf-8"))
except Exception as exc: # noqa: BLE001 — a corrupt file must not break ranking
osh.warning(f"Could not read preferences, using defaults:\n\t{_PREFERENCES_YAML}\n\t{exc}")
return prefs
if isinstance(saved, dict):
for key in prefs:
value = saved.get(key)
if value is None:
continue
# A file written before 1.11.2 can hold `.nan`: the default is the
# only usable reading of it, and serving it would hand GET a null.
if isinstance(value, float) and not math.isfinite(value):
osh.warning(f"Preference {key!r} is not a finite number, using the default")
continue
prefs[key] = value
return prefs
[docs]
def save_preferences(values: dict[str, Any]) -> dict[str, Any]:
"""
Merge `values` into the saved preferences and write them back.
Only known keys are kept, and each is coerced to the type its default
implies — a form posts strings, and a ``headroom`` of ``"0.4"`` that stayed
a string would compare wrongly against a float for the rest of its life.
A value that cannot be coerced is skipped with a warning rather than stored
broken.
Parameters
----------
values : dict
Preferences to set. Unknown keys are ignored.
Returns
-------
dict
The full set of preferences as now saved.
"""
import yaml
prefs = load_preferences()
for key, raw in (values or {}).items():
if key not in _PREFERENCE_DEFAULTS:
osh.debug(f"Ignoring unknown preference {key!r}")
continue
default = _PREFERENCE_DEFAULTS[key]
try:
value = bool(raw) if isinstance(default, bool) else type(default)(raw)
# float("nan") and float("inf") succeed, and a non-finite number
# is exactly the "stored broken" this function promises to skip:
# it lands in the YAML as `.nan` and every later read serves it.
if isinstance(value, float) and not math.isfinite(value):
raise ValueError("not a finite number")
except (TypeError, ValueError):
osh.warning(
f"Preference {key!r} could not be read as {type(default).__name__}: {raw!r}"
)
continue
prefs[key] = value
_PREFERENCES_YAML.parent.mkdir(parents=True, exist_ok=True)
_PREFERENCES_YAML.write_text(
yaml.safe_dump(prefs, sort_keys=True, allow_unicode=True), encoding="utf-8"
)
osh.info(f"Preferences saved:\n\t{_PREFERENCES_YAML}")
return prefs