Coverage for src/lilbee/providers/model_ref.py: 100%
74 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-14 11:46 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-14 11:46 +0000
1"""Model reference parsing and option translation.
3Single source of truth for classifying model strings and translating
4generation options per provider type. This module must NOT import from
5lilbee.config or lilbee.models to avoid circular imports.
6"""
8from __future__ import annotations
10from dataclasses import dataclass
11from typing import Any
13from lilbee.catalog.refs import NATIVE_GGUF_REF_MIN_SLASHES
14from lilbee.providers.base import filter_options, normalize_generation_options
15from lilbee.providers.local_servers import (
16 LOCAL_SERVER_KEYS,
17 local_server_for_key,
18 local_server_for_label,
19)
21_API_PROVIDERS = frozenset(
22 {
23 "openrouter",
24 "gemini",
25 "anthropic",
26 "openai",
27 "mistral",
28 "deepseek",
29 }
30)
32# All provider prefixes that route a ref away from the local registry:
33# API providers plus the local OpenAI-compatible servers (ollama, lm_studio).
34PROVIDER_PREFIXES: frozenset[str] = frozenset(_API_PROVIDERS | LOCAL_SERVER_KEYS)
36# Provider value for refs served from the local registry (native GGUF).
37LOCAL_PROVIDER = "local"
40def is_native_gguf_ref(raw: str) -> bool:
41 """True when *raw* has the native HuggingFace GGUF shape ``<org>/<repo>/<file>.gguf``.
43 The suffix check is case-sensitive on purpose: repo extraction
44 (:func:`lilbee.catalog.refs.hf_repo_from_ref`) only recognises the
45 lowercase ``.gguf`` suffix, and classification must agree with it.
46 """
47 return raw.endswith(".gguf") and raw.count("/") >= NATIVE_GGUF_REF_MIN_SLASHES
50def routes_to_native_gguf(raw: str) -> bool:
51 """True when *raw* is a native GGUF shape not claimed by a local-server prefix.
53 Local-server prefixes (``ollama/``, ``lm_studio/``) are exempt from the
54 shape rule: those servers report model ids that can themselves look like
55 GGUF paths, so the prefix wins over the shape.
56 """
57 first_segment = raw.split("/", 1)[0]
58 return first_segment not in LOCAL_SERVER_KEYS and is_native_gguf_ref(raw)
61@dataclass(frozen=True)
62class ProviderModelRef:
63 """Parsed model reference with provider routing information."""
65 raw: str
66 provider: str # LOCAL_PROVIDER or any value in PROVIDER_PREFIXES
67 name: str # provider-specific name with tag normalization applied
69 @property
70 def is_api(self) -> bool:
71 return self.provider in _API_PROVIDERS
73 @property
74 def is_local(self) -> bool:
75 return self.provider == LOCAL_PROVIDER
77 @property
78 def is_remote(self) -> bool:
79 """True if this model routes through a remote SDK (any non-``local`` provider)."""
80 return self.provider != LOCAL_PROVIDER
82 def for_openai_prefix(self) -> str:
83 """Name with its canonical ``provider/model`` prefix (``ollama/llama3.2:1b``)."""
84 spec = local_server_for_key(self.provider)
85 if spec is not None:
86 return spec.qualify(self.name)
87 if self.is_api:
88 return f"{self.provider}/{self.name}"
89 return self.name
91 def for_display(self) -> str:
92 """Human-readable name for UI."""
93 return self.raw
95 @property
96 def needs_api_base(self) -> bool:
97 """True if the SDK needs an explicit api_base (Ollama/local)."""
98 return not self.is_api
101def format_remote_ref(name: str, provider: str) -> str:
102 """Render a remote model as a canonical ``provider/name`` ref.
104 *provider* may be a routing key (``"ollama"``) or a backend display
105 name (``"LM Studio"``); local-server labels are normalised to the
106 routing key so the prefix survives. API providers fall through to
107 their lowercase key unchanged.
108 """
109 spec = local_server_for_label(provider)
110 key = spec.key if spec is not None else provider.lower()
111 return ProviderModelRef(raw=name, provider=key, name=name).for_openai_prefix()
114def parse_model_ref(raw: str) -> ProviderModelRef:
115 """Classify a model string and return the routing ref, native shape first.
117 Native HuggingFace refs are ``<org>/<repo>/<file>.gguf``; that shape
118 routes locally even when the org collides with an API provider prefix
119 (``openai/``, ``mistral/``, ``deepseek/`` are real HF orgs). Local-server
120 prefixes (``ollama/``, ``lm_studio/``) are exempt from the shape rule:
121 those servers report model ids that can themselves look like GGUF paths
122 (LM Studio 0.2.x uses full relative GGUF paths), so the prefix wins there.
123 Remote providers use prefixes from :data:`PROVIDER_PREFIXES`.
124 """
125 if routes_to_native_gguf(raw):
126 return ProviderModelRef(raw=raw, provider=LOCAL_PROVIDER, name=raw)
127 if "/" not in raw:
128 known = ", ".join(f"{p}/" for p in sorted(PROVIDER_PREFIXES))
129 raise ValueError(
130 f"Model ref {raw!r} must be a HuggingFace ref "
131 f"('<org>/<repo>/<filename>.gguf') or carry a known provider prefix ({known})."
132 )
133 prefix, rest = raw.split("/", 1)
134 if prefix in _API_PROVIDERS:
135 return ProviderModelRef(raw=raw, provider=prefix, name=rest)
136 spec = local_server_for_key(prefix)
137 if spec is not None:
138 return ProviderModelRef(raw=raw, provider=spec.key, name=spec.normalize_name(rest))
139 return ProviderModelRef(raw=raw, provider=LOCAL_PROVIDER, name=raw)
142def default_first(refs: list[str], default_ref: str) -> list[str]:
143 """Order so *default_ref* leads, leaving the rest in their existing order."""
144 if default_ref not in refs:
145 return list(refs)
146 return [default_ref, *(ref for ref in refs if ref != default_ref)]
149def with_configured_remote_chat(refs: list[str], configured: str) -> list[str]:
150 """Return *refs* with *configured* prepended when it is a remote ref not already listed.
152 A remote-configured chat model (``ollama/...``, ``openai/...``) is served
153 through known-model resolution without appearing in the native registry;
154 prepending it keeps a model listing truthful and puts the model lilbee
155 actually serves first. A non-empty *configured* must parse; ``cfg.chat_model``
156 is validated and canonicalized at the write boundary, and empty means the
157 role is unconfigured.
158 """
159 if not configured or configured in refs or not parse_model_ref(configured).is_remote:
160 return list(refs)
161 return [configured, *refs]
164def translate_options(options: dict[str, Any], ref: ProviderModelRef) -> dict[str, Any]:
165 """Translate generation options for the target provider.
167 A local ref forced through the SDK keeps the raw filtered options; an API ref
168 gets the shared per-call mapping (``num_predict`` -> ``max_tokens``, drop
169 ``num_ctx``) plus a ``top_k`` drop: litellm would forward ``top_k`` (into
170 ``extra_body`` for OpenAI-compatible) without erroring, but hosted APIs ignore
171 it, so dropping it keeps the wire request clean.
172 """
173 if not ref.is_api:
174 filtered = filter_options(options)
175 # litellm has no chat_template_kwargs passthrough; thinking control
176 # only exists on the native llama-server path.
177 filtered.pop("think", None)
178 return filtered
179 api_options = normalize_generation_options(options)
180 api_options.pop("top_k", None)
181 api_options.pop("think", None)
182 return api_options