hack-house/cmd_chat/agent/profiles.py
leetcrypt 65df12de9e feat(ai): model profiles, capability discovery, and agentless /ai list|models
Make connecting any model a config step, not a code change:
- models.toml named profiles (api_key_env names an env var, never the key)
- providers gain available_models(); add preflight + --list-models/--check
- /ai list and /ai models in-room; client probes local Ollama for
  /ai models when no agent is running, and /ai list hints to summon one
- docs/providers.md provider guide + examples/echo_provider.py
- README: command table, AI section, layout updated

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-01 15:25:07 -07:00

103 lines
3.5 KiB
Python

"""Named model profiles for the hack-house AI agent.
A *profile* maps a friendly name (``groq-llama``, ``local``, ``claude``) to a
provider + model + endpoint, so operators type ``--profile groq-llama`` instead
of remembering ``--provider openai --base-url … --model …``. This mirrors the
``models:`` list in Continue.dev and the ``model_list`` in a LiteLLM proxy:
each entry is ``{provider, model, base_url, api_key_env}``.
Secrets are **never** stored here — ``api_key_env`` names an environment
variable to read the key from, keeping the file safe to commit and share.
Lookup order (first hit wins):
1. ``$HH_MODELS_FILE``
2. ``./models.toml`` (cwd)
3. ``~/.config/hh/models.toml``
"""
from __future__ import annotations
import os
from pathlib import Path
try: # stdlib on 3.11+, falls back to the `tomli` backport on 3.10
import tomllib
except ModuleNotFoundError: # pragma: no cover
import tomli as tomllib # type: ignore[no-redef]
from .providers import Provider, make_provider
_RECOGNIZED = {"provider", "model", "base_url", "host", "api_key_env",
"system", "context_window"}
def _candidate_paths(explicit: str | None) -> list[Path]:
if explicit:
return [Path(explicit).expanduser()]
paths = []
env = os.environ.get("HH_MODELS_FILE")
if env:
paths.append(Path(env).expanduser())
paths.append(Path.cwd() / "models.toml")
paths.append(Path.home() / ".config" / "hh" / "models.toml")
return paths
def find_profiles_file(explicit: str | None = None) -> Path | None:
for p in _candidate_paths(explicit):
if p.is_file():
return p
return None
def load_profiles(explicit: str | None = None) -> dict[str, dict]:
"""Return ``{name: profile_dict}`` from the first models.toml found."""
path = find_profiles_file(explicit)
if path is None:
return {}
with path.open("rb") as fh:
data = tomllib.load(fh)
profiles: dict[str, dict] = {}
for name, body in data.items():
if not isinstance(body, dict) or "provider" not in body:
continue # skip non-profile tables / malformed entries
unknown = set(body) - _RECOGNIZED
if unknown:
raise ValueError(
f"profile '{name}': unknown key(s) {', '.join(sorted(unknown))}"
)
profiles[name] = body
return profiles
def provider_from_profile(prof: dict, *, name: str = "?",
model: str | None = None,
base_url: str | None = None) -> Provider:
"""Build a :class:`Provider` from a profile dict.
``model`` / ``base_url`` (CLI flags) override the profile when given. The
api key is read from ``$<api_key_env>`` and passed only to providers that
accept one, so an Ollama profile never sees a stray ``api_key`` kwarg.
"""
spec = prof["provider"]
custom = ":" in spec
opts: dict = {}
mdl = model or prof.get("model")
bu = base_url or prof.get("base_url")
if bu and (spec == "openai" or custom):
opts["base_url"] = bu
if spec == "ollama" and prof.get("host"):
opts["host"] = prof["host"]
key_env = prof.get("api_key_env")
if key_env and (spec in ("openai", "anthropic") or custom):
key = os.environ.get(key_env)
if not key:
raise SystemExit(
f"profile '{name}': ${key_env} is not set — export it first"
)
opts["api_key"] = key
return make_provider(spec, model=mdl, **opts)