Coverage for src/lilbee/providers/model_ref.py: 100%
82 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-28 17:20 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-28 17:20 +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 filter_options, normalize_generation_options
17from lilbee.providers.local_servers import (
18 LOCAL_SERVER_KEYS,
19 local_server_for_key,
20 local_server_for_label,
21)
23_API_PROVIDERS = frozenset(
24 {
25 "openrouter",
26 "gemini",
27 "anthropic",
28 "openai",
29 "mistral",
30 "deepseek",
31 }
32)
34# All provider prefixes that route a ref away from the local registry:
35# API providers plus the local OpenAI-compatible servers (ollama, lm_studio).
36PROVIDER_PREFIXES: frozenset[str] = frozenset(_API_PROVIDERS | LOCAL_SERVER_KEYS)
38# Provider value for refs served from the local registry (native GGUF).
39LOCAL_PROVIDER = "local"
40OLLAMA_NO_THINKING = "none"
43def is_native_gguf_ref(raw: str) -> bool:
44 """True when *raw* is a GGUF with the ``<org>/<repo>/<file>.gguf`` shape or an absolute path.
46 An absolute Windows path carries no forward slash, so ``C:\\models\\x.gguf``
47 classifies like ``/models/x.gguf``. ``PureWindowsPath`` reads Windows roots
48 on a POSIX host and answers the same on every supported Python, and it
49 keeps a drive-relative ``C:models\\x.gguf`` out: the fleet cannot load it.
51 The suffix check is case-sensitive on purpose: repo extraction
52 (:func:`lilbee.catalog.refs.hf_repo_from_ref`) only recognises the
53 lowercase ``.gguf`` suffix, and classification must agree with it.
54 """
55 return raw.endswith(GGUF_SUFFIX) and (
56 PureWindowsPath(raw).is_absolute() or raw.count("/") >= NATIVE_GGUF_REF_MIN_SLASHES
57 )
60def is_loose_model_file(raw: str) -> bool:
61 """True when *raw* is an absolute path to an existing file, loadable without a manifest.
63 A directory answers False. It exists, but the engine cannot load it, and
64 every caller here treats a True as "this ref serves as it stands".
65 """
66 candidate = Path(raw)
67 return candidate.is_absolute() and candidate.is_file()
70def routes_to_native_gguf(raw: str) -> bool:
71 """True when *raw* is a native GGUF shape not claimed by a local-server prefix.
73 Local-server prefixes (``ollama/``, ``lm_studio/``) are exempt from the
74 shape rule: those servers report model ids that can themselves look like
75 GGUF paths, so the prefix wins over the shape.
76 """
77 first_segment = raw.split("/", 1)[0]
78 return first_segment not in LOCAL_SERVER_KEYS and is_native_gguf_ref(raw)
81@dataclass(frozen=True)
82class ProviderModelRef:
83 """Parsed model reference with provider routing information."""
85 raw: str
86 provider: str # LOCAL_PROVIDER or any value in PROVIDER_PREFIXES
87 name: str # provider-specific name with tag normalization applied
89 @property
90 def is_api(self) -> bool:
91 return self.provider in _API_PROVIDERS
93 @property
94 def is_local(self) -> bool:
95 return self.provider == LOCAL_PROVIDER
97 @property
98 def is_remote(self) -> bool:
99 """True if this model routes through a remote SDK (any non-``local`` provider)."""
100 return self.provider != LOCAL_PROVIDER
102 def for_openai_prefix(self) -> str:
103 """Name with its canonical ``provider/model`` prefix (``ollama/llama3.2:1b``)."""
104 spec = local_server_for_key(self.provider)
105 if spec is not None:
106 return spec.qualify(self.name)
107 if self.is_api:
108 return f"{self.provider}/{self.name}"
109 return self.name
111 def for_display(self) -> str:
112 """Human-readable name for UI."""
113 return self.raw
115 @property
116 def needs_api_base(self) -> bool:
117 """True if the SDK needs an explicit api_base (Ollama/local)."""
118 return not self.is_api
121def format_remote_ref(name: str, provider: str) -> str:
122 """Render a remote model as a canonical ``provider/name`` ref.
124 *provider* may be a routing key (``"ollama"``) or a backend display
125 name (``"LM Studio"``); local-server labels are normalised to the
126 routing key so the prefix survives. API providers fall through to
127 their lowercase key unchanged.
128 """
129 spec = local_server_for_label(provider)
130 key = spec.key if spec is not None else provider.lower()
131 return ProviderModelRef(raw=name, provider=key, name=name).for_openai_prefix()
134def parse_model_ref(raw: str) -> ProviderModelRef:
135 """Classify a model string and return the routing ref, native shape first.
137 Native HuggingFace refs are ``<org>/<repo>/<file>.gguf``; that shape
138 routes locally even when the org collides with an API provider prefix
139 (``openai/``, ``mistral/``, ``deepseek/`` are real HF orgs). Local-server
140 prefixes (``ollama/``, ``lm_studio/``) are exempt from the shape rule:
141 those servers report model ids that can themselves look like GGUF paths
142 (LM Studio 0.2.x uses full relative GGUF paths), so the prefix wins there.
143 Remote providers use prefixes from :data:`PROVIDER_PREFIXES`.
144 """
145 if routes_to_native_gguf(raw):
146 return ProviderModelRef(raw=raw, provider=LOCAL_PROVIDER, name=raw)
147 if "/" not in raw:
148 known = ", ".join(f"{p}/" for p in sorted(PROVIDER_PREFIXES))
149 raise ValueError(
150 f"Model ref {raw!r} must be a HuggingFace ref "
151 f"('<org>/<repo>/<filename>.gguf') or carry a known provider prefix ({known})."
152 )
153 prefix, rest = raw.split("/", 1)
154 if prefix in _API_PROVIDERS:
155 return ProviderModelRef(raw=raw, provider=prefix, name=rest)
156 spec = local_server_for_key(prefix)
157 if spec is not None:
158 return ProviderModelRef(raw=raw, provider=spec.key, name=spec.normalize_name(rest))
159 return ProviderModelRef(raw=raw, provider=LOCAL_PROVIDER, name=raw)
162def default_first(refs: list[str], default_ref: str) -> list[str]:
163 """Order so *default_ref* leads, leaving the rest in their existing order."""
164 if default_ref not in refs:
165 return list(refs)
166 return [default_ref, *(ref for ref in refs if ref != default_ref)]
169def with_configured_remote_chat(refs: list[str], configured: str) -> list[str]:
170 """Return *refs* with *configured* prepended when it is a remote ref not already listed.
172 A remote-configured chat model (``ollama/...``, ``openai/...``) is served
173 through known-model resolution without appearing in the native registry;
174 prepending it keeps a model listing truthful and puts the model lilbee
175 actually serves first. A non-empty *configured* must parse; ``cfg.chat_model``
176 is validated and canonicalized at the write boundary, and empty means the
177 role is unconfigured.
178 """
179 if not configured or configured in refs or not parse_model_ref(configured).is_remote:
180 return list(refs)
181 return [configured, *refs]
184def translate_options(options: dict[str, Any], ref: ProviderModelRef) -> dict[str, Any]:
185 """Translate generation options for the target provider.
187 A local ref forced through the SDK keeps the raw filtered options; an API ref
188 gets the shared per-call mapping (``num_predict`` -> ``max_tokens``, drop
189 ``num_ctx``) plus a ``top_k`` drop: litellm would forward ``top_k`` (into
190 ``extra_body`` for OpenAI-compatible) without erroring, but hosted APIs ignore
191 it, so dropping it keeps the wire request clean.
192 """
193 if not ref.is_api:
194 filtered = filter_options(options)
195 think = filtered.pop("think", None)
196 if think is False and ref.provider == ModelSource.OLLAMA:
197 # litellm maps reasoning_effort onto Ollama's think field; any value
198 # outside low/medium/high turns thinking off.
199 filtered["reasoning_effort"] = OLLAMA_NO_THINKING
200 return filtered
201 api_options = normalize_generation_options(options)
202 api_options.pop("top_k", None)
203 api_options.pop("think", None)
204 return api_options