From 8e829a92c59aaaa8c6b14c3d52169ca0719209ab Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Sun, 9 Aug 2026 14:19:05 +0200 Subject: 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 --- llamachat/providers.py | 40 ++++++++++++++++++++++++++++-- test_llamachat.py | 67 ++++++++++++++++++++++++++++++++++++++++++++++++++ 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() -- cgit v1.2.3