aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-09 14:19:05 +0200
committerDanilo M. <danix@danix.xyz>2026-08-09 14:19:05 +0200
commit8e829a92c59aaaa8c6b14c3d52169ca0719209ab (patch)
tree7ca87cdfbaf8088b9a32fe7d30eb575c6318f663
parent67e1badee2e087bc673f252a5463c55e4ff4f9b1 (diff)
downloadllamachat-8e829a92c59aaaa8c6b14c3d52169ca0719209ab.tar.gz
llamachat-8e829a92c59aaaa8c6b14c3d52169ca0719209ab.zip
feat: namespace model ids by provider and filter model lists
Cloud models are addressed as provider:model; local ones stay bare so existing sessions keep resolving. An unknown prefix is treated as part of the model name rather than a provider. Filters are case-insensitive substrings because provider ids capitalise inconsistently. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
-rw-r--r--llamachat/providers.py40
-rwxr-xr-xtest_llamachat.py67
2 files changed, 105 insertions, 2 deletions
diff --git a/llamachat/providers.py b/llamachat/providers.py
index d955436..5874b32 100644
--- a/llamachat/providers.py
+++ b/llamachat/providers.py
@@ -83,9 +83,12 @@ def parse(values: dict) -> dict[str, Provider]:
continue
# filter = "qwen" is an easy TOML slip for filter = ["qwen"], and
# iterating the string would turn it into four single-character
- # needles that match nearly every model id.
+ # needles that match nearly every model id. The same goes for every
+ # other non-sequence shape: a number would raise on iteration and a
+ # dict would quietly degrade into its keys, so wrap anything that is
+ # not a list as a single needle and let it simply match nothing.
needles = entry.get("filter") or []
- if isinstance(needles, str):
+ if not isinstance(needles, (list, tuple)):
needles = [needles]
vision = entry.get("vision")
out[str(name)] = Provider(
@@ -99,3 +102,36 @@ def parse(values: dict) -> dict[str, Provider]:
price_out=_number(entry.get("price_out"), float),
)
return out
+
+
+def qualify(provider: str, model: str) -> str:
+ """The stored, displayed id for one model. Local models stay bare."""
+ if provider == LOCAL:
+ return model
+ return f"{provider}:{model}"
+
+
+def split(model_id: str, table: dict[str, Provider]) -> tuple[str, str]:
+ """Inverse of qualify, resolved against the configured providers.
+
+ A prefix that is not a configured provider is treated as part of the
+ model name, which keeps a bare local model containing a colon working.
+ """
+ prefix, sep, rest = model_id.partition(":")
+ if sep and prefix in table and prefix != LOCAL:
+ return prefix, rest
+ return LOCAL, model_id
+
+
+def apply_filter(provider: Provider, listed: list[str]) -> list[str]:
+ """Keep models matching any of the provider's substrings.
+
+ Case-insensitive, because provider ids capitalise inconsistently:
+ "qwen" has to match "Qwen/Qwen2.5-72B-Instruct-Turbo".
+ """
+ if provider.is_local or not provider.filter:
+ return list(listed)
+ needles = [f.lower() for f in provider.filter if f]
+ if not needles:
+ return list(listed)
+ return [m for m in listed if any(n in m.lower() for n in needles)]
diff --git a/test_llamachat.py b/test_llamachat.py
index 09d6203..5f6bad6 100755
--- a/test_llamachat.py
+++ b/test_llamachat.py
@@ -978,9 +978,75 @@ def test_provider_parsing():
"filter": "qwen"}}}
)
assert stringy["p"].filter == ["qwen"]
+
+ # Any other non-list shape is wrapped too, rather than iterated: a number
+ # would raise, and a dict would silently degrade into its keys.
+ odd = providers.parse(
+ {"providers": {"n": {"base_url": "http://x.example.org", "filter": 5},
+ "d": {"base_url": "http://y.example.org",
+ "filter": {"a": 1}}}}
+ )
+ assert odd["n"].filter == ["5"]
+ assert odd["d"].filter == ["{'a': 1}"]
print("ok provider config parsing")
+def test_model_ids_and_filtering():
+ """Ids are provider:model, local stays bare, filters are substrings."""
+ from llamachat import providers
+
+ table = providers.parse(
+ {
+ "providers": {
+ "local": {"base_url": "http://localhost:8181"},
+ "together": {
+ "base_url": "https://api.example.org",
+ "filter": ["qwen", "deepseek"],
+ },
+ "unfiltered": {"base_url": "https://api2.example.org"},
+ }
+ }
+ )
+
+ # Local models carry no prefix, in the dropdown and in the database.
+ assert providers.qualify("local", "gemma4") == "gemma4"
+ assert providers.qualify("together", "Qwen/Qwen2.5") == "together:Qwen/Qwen2.5"
+
+ # Splitting is the inverse, and only for providers that exist.
+ assert providers.split("gemma4", table) == ("local", "gemma4")
+ assert providers.split("together:Qwen/Qwen2.5", table) == (
+ "together",
+ "Qwen/Qwen2.5",
+ )
+ # An unknown prefix is part of the model name, not a provider. This is
+ # what keeps a local model whose name contains a colon working.
+ assert providers.split("weird:name", table) == ("local", "weird:name")
+ # Only the first colon splits.
+ assert providers.split("together:a:b", table) == ("together", "a:b")
+
+ # Filtering is case-insensitive substring, any match wins.
+ listed = [
+ "Qwen/Qwen2.5-72B-Instruct-Turbo",
+ "deepseek-ai/DeepSeek-V3",
+ "meta-llama/Llama-3.3-70B",
+ ]
+ kept = providers.apply_filter(table["together"], listed)
+ assert kept == [
+ "Qwen/Qwen2.5-72B-Instruct-Turbo",
+ "deepseek-ai/DeepSeek-V3",
+ ]
+
+ # No filter means everything.
+ assert providers.apply_filter(table["unfiltered"], listed) == listed
+ # The local provider is never filtered even if one is configured.
+ table["local"].filter = ["nothing-matches-this"]
+ assert providers.apply_filter(table["local"], listed) == listed
+ # A filter matching nothing yields nothing, it does not fall back to all.
+ table["together"].filter = ["zzz"]
+ assert providers.apply_filter(table["together"], listed) == []
+ print("ok model ids and filtering")
+
+
class _FakeResponse:
"""Enough of an http.client response for urlopen's context manager."""
@@ -1719,6 +1785,7 @@ if __name__ == "__main__":
test_venv_discovery()
test_config_defaults()
test_provider_parsing()
+ test_model_ids_and_filtering()
test_search_tool_schema()
test_search_results_sanitising()
test_tool_call_accumulation()