From 7246d07dda18c07278cc9df122071f523f697be3 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Sun, 9 Aug 2026 14:06:01 +0200 Subject: feat: parse provider definitions from config Providers come from a [providers.*] table. A bare top-level base_url synthesizes the local provider so existing configs keep working, and an explicit [providers.local] wins over it. Unset numbers stay None so 'unknown' never collapses into zero. Co-Authored-By: Claude Opus 5 --- llamachat/providers.py | 91 ++++++++++++++++++++++++++++++++++++++++++++++++++ test_llamachat.py | 57 +++++++++++++++++++++++++++++++ 2 files changed, 148 insertions(+) create mode 100644 llamachat/providers.py diff --git a/llamachat/providers.py b/llamachat/providers.py new file mode 100644 index 0000000..48b4883 --- /dev/null +++ b/llamachat/providers.py @@ -0,0 +1,91 @@ +# SPDX-License-Identifier: GPL-2.0-only +# +# llamachat - a small native chat client for a local llama.cpp router +# Copyright (C) 2026 Danilo M. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +"""Provider definitions, model-id namespacing and API key resolution.""" + +import os +import subprocess +from dataclasses import dataclass, field + +# The local llama.cpp router. Its models are shown and stored without a +# prefix, so an existing session pointing at a local model still resolves. +LOCAL = "local" + +# A stuck pinentry must not freeze the worker thread forever. +KEY_TIMEOUT = 30 + + +class KeyError_(Exception): + """An api_key that could not be resolved, phrased for the user.""" + + +@dataclass +class Provider: + name: str + base_url: str + api_key: str = "" + filter: list[str] = field(default_factory=list) + # None rather than 0: unset must stay distinguishable from "zero". + ctx_size: int | None = None + vision: bool | None = None + price_in: float | None = None + price_out: float | None = None + + @property + def is_local(self) -> bool: + return self.name == LOCAL + + +def _number(raw, cast): + if raw is None or raw == "": + return None + try: + return cast(raw) + except (TypeError, ValueError): + return None + + +def parse(values: dict) -> dict[str, Provider]: + """Build the provider table from already-loaded config values. + + Takes the raw dict rather than a path so config.py owns file reading and + this stays testable without touching disk. + """ + table = dict(values.get("providers") or {}) + + # An old config has only a bare base_url. Synthesize the local provider + # from it so nothing needs migrating, but never override an explicit one. + bare = values.get("base_url") + if bare and LOCAL not in table: + table[LOCAL] = {"base_url": bare} + + out: dict[str, Provider] = {} + for name, entry in table.items(): + entry = entry or {} + base_url = str(entry.get("base_url") or "").rstrip("/") + if not base_url: + # ponytail: a provider with no URL is misconfigured, not a + # partial one. Skipping beats inventing a default endpoint. + continue + vision = entry.get("vision") + out[str(name)] = Provider( + name=str(name), + base_url=base_url, + api_key=str(entry.get("api_key") or ""), + filter=[str(f) for f in (entry.get("filter") or [])], + ctx_size=_number(entry.get("ctx_size"), int), + vision=None if vision is None else bool(vision), + price_in=_number(entry.get("price_in"), float), + price_out=_number(entry.get("price_out"), float), + ) + return out diff --git a/test_llamachat.py b/test_llamachat.py index a06d737..6a5c0e6 100755 --- a/test_llamachat.py +++ b/test_llamachat.py @@ -905,6 +905,62 @@ def test_config_defaults(): print("ok config defaults") +def test_provider_parsing(): + """Providers come from [providers.*]; a bare base_url synthesizes local.""" + from llamachat import providers + + # A modern config with two providers. + parsed = providers.parse( + { + "providers": { + "local": {"base_url": "http://localhost:8181/"}, + "together": { + "base_url": "https://api.example.org", + "api_key": "env:TEST_KEY_NAME", + "filter": ["qwen", "deepseek"], + "ctx_size": 32768, + "price_in": 0.6, + "price_out": 0.9, + }, + } + } + ) + assert set(parsed) == {"local", "together"} + # Trailing slashes are stripped so URL joining stays predictable. + assert parsed["local"].base_url == "http://localhost:8181" + assert parsed["local"].api_key == "" + assert parsed["together"].filter == ["qwen", "deepseek"] + assert parsed["together"].ctx_size == 32768 + assert parsed["together"].price_in == 0.6 + assert parsed["together"].price_out == 0.9 + + # An old config: bare base_url, no providers table at all. + legacy = providers.parse({"base_url": "http://localhost:8181"}) + assert set(legacy) == {"local"} + assert legacy["local"].base_url == "http://localhost:8181" + + # Both present: the explicit entry wins over the bare key. + both = providers.parse( + { + "base_url": "http://ignored.example.org", + "providers": {"local": {"base_url": "http://explicit.example.org"}}, + } + ) + assert both["local"].base_url == "http://explicit.example.org" + + # A provider with no base_url is skipped rather than half-configured. + broken = providers.parse( + {"providers": {"local": {"base_url": "http://x.example.org"}, + "bad": {"api_key": "literal"}}} + ) + assert set(broken) == {"local"} + + # Unset numbers stay None so "unknown" is distinguishable from zero. + assert parsed["local"].ctx_size is None + assert parsed["local"].price_in is None + print("ok provider config parsing") + + class _FakeResponse: """Enough of an http.client response for urlopen's context manager.""" @@ -1642,6 +1698,7 @@ if __name__ == "__main__": test_version_matches_changelog() test_venv_discovery() test_config_defaults() + test_provider_parsing() test_search_tool_schema() test_search_results_sanitising() test_tool_call_accumulation() -- cgit v1.2.3