Coverage for src/lilbee/providers/model_ref.py: 100%
92 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-11-06 08:17 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-11-06 08:17 +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 pathlib import Path, PureWindowsPath
12from typing import Any
14from lilbee.catalog.refs import GGUF_SUFFIX, NATIVE_GGUF_REF_MIN_SLASHES
15from lilbee.catalog.types import ModelSource
16from lilbee.providers.base import (
17 ChosenSeed,
18 ProviderError,
19 ProviderErrorKind,
20 filter_options,
21 normalize_generation_options,
22)
23from lilbee.providers.local_servers import (
24 LOCAL_SERVER_KEYS,
25 local_server_for_key,
26 local_server_for_label,
27)
29_API_PROVIDERS = frozenset(
30 {
31 "openrouter",
32 "gemini",
33 "anthropic",
34 "openai",
35 "mistral",
36 "deepseek",
37 }
38)
40# All provider prefixes that route a ref away from the local registry:
41# API providers plus the local OpenAI-compatible servers (ollama, lm_studio).
42PROVIDER_PREFIXES: frozenset[str] = frozenset(_API_PROVIDERS | LOCAL_SERVER_KEYS)
44# Provider value for refs served from the local registry (native GGUF).
45LOCAL_PROVIDER = "local"
46OLLAMA_NO_THINKING = "none"
49def is_native_gguf_ref(raw: str) -> bool:
50 """True when *raw* is a GGUF with the ``<org>/<repo>/<file>.gguf`` shape or an absolute path.
52 An absolute Windows path carries no forward slash, so ``C:\\models\\x.gguf``
53 classifies like ``/models/x.gguf``. ``PureWindowsPath`` reads Windows roots
54 on a POSIX host and answers the same on every supported Python, and it
55 keeps a drive-relative ``C:models\\x.gguf`` out: the fleet cannot load it.
57 The suffix check is case-sensitive on purpose: repo extraction
58 (:func:`lilbee.catalog.refs.hf_repo_from_ref`) only recognises the
59 lowercase ``.gguf`` suffix, and classification must agree with it.
60 """
61 return raw.endswith(GGUF_SUFFIX) and (
62 PureWindowsPath(raw).is_absolute() or raw.count("/") >= NATIVE_GGUF_REF_MIN_SLASHES
63 )
66def is_loose_model_file(raw: str) -> bool:
67 """True when *raw* is an absolute path to an existing file, loadable without a manifest.
69 A directory answers False. It exists, but the engine cannot load it, and
70 every caller here treats a True as "this ref serves as it stands".
71 """
72 candidate = Path(raw)
73 return candidate.is_absolute() and candidate.is_file()
76def routes_to_native_gguf(raw: str) -> bool:
77 """True when *raw* is a native GGUF shape not claimed by a local-server prefix.
79 Local-server prefixes (``ollama/``, ``lm_studio/``) are exempt from the
80 shape rule: those servers report model ids that can themselves look like
81 GGUF paths, so the prefix wins over the shape.
82 """
83 first_segment = raw.split("/", 1)[0]
84 return first_segment not in LOCAL_SERVER_KEYS and is_native_gguf_ref(raw)
87@dataclass(frozen=True)
88class ProviderModelRef:
89 """Parsed model reference with provider routing information."""
91 raw: str
92 provider: str # LOCAL_PROVIDER or any value in PROVIDER_PREFIXES
93 name: str # provider-specific name with tag normalization applied
95 @property
96 def is_api(self) -> bool:
97 return self.provider in _API_PROVIDERS
99 @property
100 def is_local(self) -> bool:
101 return self.provider == LOCAL_PROVIDER
103 @property
104 def is_remote(self) -> bool:
105 """True if this model routes through a remote SDK (any non-``local`` provider)."""
106 return self.provider != LOCAL_PROVIDER
108 def for_openai_prefix(self) -> str:
109 """Name with its canonical ``provider/model`` prefix (``ollama/llama3.2:1b``)."""
110 spec = local_server_for_key(self.provider)
111 if spec is not None:
112 return spec.qualify(self.name)
113 if self.is_api:
114 return f"{self.provider}/{self.name}"
115 return self.name
117 def for_display(self) -> str:
118 """Human-readable name for UI."""
119 return self.raw
121 @property
122 def needs_api_base(self) -> bool:
123 """True if the SDK needs an explicit api_base (Ollama/local)."""
124 return not self.is_api
127def format_remote_ref(name: str, provider: str) -> str:
128 """Render a remote model as a canonical ``provider/name`` ref.
130 *provider* may be a routing key (``"ollama"``) or a backend display
131 name (``"LM Studio"``); local-server labels are normalised to the
132 routing key so the prefix survives. API providers fall through to
133 their lowercase key unchanged.
134 """
135 spec = local_server_for_label(provider)
136 key = spec.key if spec is not None else provider.lower()
137 return ProviderModelRef(raw=name, provider=key, name=name).for_openai_prefix()
140def parse_model_ref(raw: str) -> ProviderModelRef:
141 """Classify a model string and return the routing ref, native shape first.
143 Native HuggingFace refs are ``<org>/<repo>/<file>.gguf``; that shape
144 routes locally even when the org collides with an API provider prefix
145 (``openai/``, ``mistral/``, ``deepseek/`` are real HF orgs). Local-server
146 prefixes (``ollama/``, ``lm_studio/``) are exempt from the shape rule:
147 those servers report model ids that can themselves look like GGUF paths
148 (LM Studio 0.2.x uses full relative GGUF paths), so the prefix wins there.
149 Remote providers use prefixes from :data:`PROVIDER_PREFIXES`.
150 """
151 if routes_to_native_gguf(raw):
152 return ProviderModelRef(raw=raw, provider=LOCAL_PROVIDER, name=raw)
153 if "/" not in raw:
154 known = ", ".join(f"{p}/" for p in sorted(PROVIDER_PREFIXES))
155 raise ValueError(
156 f"Model ref {raw!r} must be a HuggingFace ref "
157 f"('<org>/<repo>/<filename>.gguf') or carry a known provider prefix ({known})."
158 )
159 prefix, rest = raw.split("/", 1)
160 if prefix in _API_PROVIDERS:
161 return ProviderModelRef(raw=raw, provider=prefix, name=rest)
162 spec = local_server_for_key(prefix)
163 if spec is not None:
164 return ProviderModelRef(raw=raw, provider=spec.key, name=spec.normalize_name(rest))
165 return ProviderModelRef(raw=raw, provider=LOCAL_PROVIDER, name=raw)
168def default_first(refs: list[str], default_ref: str) -> list[str]:
169 """Order so *default_ref* leads, leaving the rest in their existing order."""
170 if default_ref not in refs:
171 return list(refs)
172 return [default_ref, *(ref for ref in refs if ref != default_ref)]
175# Samplers the Anthropic API has no field for.
176_ANTHROPIC_UNSUPPORTED = ("seed", "frequency_penalty", "presence_penalty")
179def _refuse_unsupported(
180 options: dict[str, Any], ref: ProviderModelRef, unsupported: tuple[str, ...]
181) -> None:
182 """Raise a bad-request error when *options* sets a name in *unsupported* to any value."""
183 refused = [name for name in unsupported if options.get(name) is not None]
184 if refused:
185 raise ProviderError(
186 f"The provider rejected the request for {ref.raw}. "
187 f"It does not support {', '.join(refused)}. Remove the setting and try again.",
188 provider=ModelSource.REMOTE,
189 kind=ProviderErrorKind.BAD_REQUEST,
190 )
193def translate_options(options: dict[str, Any], ref: ProviderModelRef) -> dict[str, Any]:
194 """Translate generation options for the target provider.
196 A local ref forced through the SDK keeps the raw filtered options; an API ref
197 gets the shared per-call mapping (``num_predict`` -> ``max_tokens``, drop
198 ``num_ctx``) plus a ``top_k`` and ``repeat_penalty`` drop: hosted APIs do not
199 take either, so dropping them keeps the wire request clean. An Anthropic ref
200 refuses a ``seed`` or penalty set to any value, zero included, with a bad-request
201 error; when ``response_format`` is set it drops them and sends the request,
202 and it drops a seed that is a :class:`ChosenSeed` instead of refusing it.
203 ``cache_prompt`` is a llama-server field, and no ref here gets it.
204 """
205 if not ref.is_api:
206 filtered = filter_options(options)
207 filtered.pop("cache_prompt", None)
208 think = filtered.pop("think", None)
209 if think is False and ref.provider == ModelSource.OLLAMA:
210 # Ollama maps reasoning_effort onto its think field; any value
211 # outside low/medium/high turns thinking off.
212 filtered["reasoning_effort"] = OLLAMA_NO_THINKING
213 return filtered
214 api_options = normalize_generation_options(options)
215 if ref.provider == "anthropic":
216 # A ``response_format`` marks an internal call, and so does a seed lilbee chose:
217 # the fix the refusal asks for is not the user's to make.
218 if "response_format" not in api_options:
219 chosen = isinstance(options.get("seed"), ChosenSeed)
220 _refuse_unsupported(
221 {k: v for k, v in api_options.items() if not (chosen and k == "seed")},
222 ref,
223 _ANTHROPIC_UNSUPPORTED,
224 )
225 for name in _ANTHROPIC_UNSUPPORTED:
226 api_options.pop(name, None)
227 api_options.pop("cache_prompt", None)
228 api_options.pop("top_k", None)
229 api_options.pop("repeat_penalty", None)
230 api_options.pop("think", None)
231 return api_options