aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--llamachat/providers.py91
-rwxr-xr-xtest_llamachat.py57
2 files changed, 148 insertions, 0 deletions
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. <danix@danix.xyz>
+#
+# 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()