Coverage for src/lilbee/providers/model_ref.py: 100%
78 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-08 09:20 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-08 09: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 typing import Any
13from lilbee.catalog.refs import GGUF_SUFFIX, NATIVE_GGUF_REF_MIN_SLASHES
14from lilbee.catalog.types import ModelSource
15from lilbee.providers.base import filter_options, normalize_generation_options
16from lilbee.providers.local_servers import (
17 LOCAL_SERVER_KEYS,
18 local_server_for_key,
19 local_server_for_label,
20)
22_API_PROVIDERS = frozenset(
23 {
24 "openrouter",
25 "gemini",
26 "anthropic",
27 "openai",
28 "mistral",
29 "deepseek",
30 }
31)
33# All provider prefixes that route a ref away from the local registry:
34# API providers plus the local OpenAI-compatible servers (ollama, lm_studio).
35PROVIDER_PREFIXES: frozenset[str] = frozenset(_API_PROVIDERS | LOCAL_SERVER_KEYS)
37# Provider value for refs served from the local registry (native GGUF).
38LOCAL_PROVIDER = "local"
39OLLAMA_NO_THINKING = "none"
42def is_native_gguf_ref(raw: str) -> bool:
43 """True when *raw* has the native HuggingFace GGUF shape ``<org>/<repo>/<file>.gguf``.
45 The suffix check is case-sensitive on purpose: repo extraction
46 (:func:`lilbee.catalog.refs.hf_repo_from_ref`) only recognises the
47 lowercase ``.gguf`` suffix, and classification must agree with it.
48 """
49 return raw.endswith(GGUF_SUFFIX) and raw.count("/") >= NATIVE_GGUF_REF_MIN_SLASHES
52def routes_to_native_gguf(raw: str) -> bool:
53 """True when *raw* is a native GGUF shape not claimed by a local-server prefix.
55 Local-server prefixes (``ollama/``, ``lm_studio/``) are exempt from the
56 shape rule: those servers report model ids that can themselves look like
57 GGUF paths, so the prefix wins over the shape.
58 """
59 first_segment = raw.split("/", 1)[0]
60 return first_segment not in LOCAL_SERVER_KEYS and is_native_gguf_ref(raw)
63@dataclass(frozen=True)
64class ProviderModelRef:
65 """Parsed model reference with provider routing information."""
67 raw: str
68 provider: str # LOCAL_PROVIDER or any value in PROVIDER_PREFIXES
69 name: str # provider-specific name with tag normalization applied
71 @property
72 def is_api(self) -> bool:
73 return self.provider in _API_PROVIDERS
75 @property
76 def is_local(self) -> bool:
77 return self.provider == LOCAL_PROVIDER
79 @property
80 def is_remote(self) -> bool:
81 """True if this model routes through a remote SDK (any non-``local`` provider)."""
82 return self.provider != LOCAL_PROVIDER
84 def for_openai_prefix(self) -> str:
85 """Name with its canonical ``provider/model`` prefix (``ollama/llama3.2:1b``)."""
86 spec = local_server_for_key(self.provider)
87 if spec is not None:
88 return spec.qualify(self.name)
89 if self.is_api:
90 return f"{self.provider}/{self.name}"
91 return self.name
93 def for_display(self) -> str:
94 """Human-readable name for UI."""
95 return self.raw
97 @property
98 def needs_api_base(self) -> bool:
99 """True if the SDK needs an explicit api_base (Ollama/local)."""
100 return not self.is_api
103def format_remote_ref(name: str, provider: str) -> str:
104 """Render a remote model as a canonical ``provider/name`` ref.
106 *provider* may be a routing key (``"ollama"``) or a backend display
107 name (``"LM Studio"``); local-server labels are normalised to the
108 routing key so the prefix survives. API providers fall through to
109 their lowercase key unchanged.
110 """
111 spec = local_server_for_label(provider)
112 key = spec.key if spec is not None else provider.lower()
113 return ProviderModelRef(raw=name, provider=key, name=name).for_openai_prefix()
116def parse_model_ref(raw: str) -> ProviderModelRef:
117 """Classify a model string and return the routing ref, native shape first.
119 Native HuggingFace refs are ``<org>/<repo>/<file>.gguf``; that shape
120 routes locally even when the org collides with an API provider prefix
121 (``openai/``, ``mistral/``, ``deepseek/`` are real HF orgs). Local-server
122 prefixes (``ollama/``, ``lm_studio/``) are exempt from the shape rule:
123 those servers report model ids that can themselves look like GGUF paths
124 (LM Studio 0.2.x uses full relative GGUF paths), so the prefix wins there.
125 Remote providers use prefixes from :data:`PROVIDER_PREFIXES`.
126 """
127 if routes_to_native_gguf(raw):
128 return ProviderModelRef(raw=raw, provider=LOCAL_PROVIDER, name=raw)
129 if "/" not in raw:
130 known = ", ".join(f"{p}/" for p in sorted(PROVIDER_PREFIXES))
131 raise ValueError(
132 f"Model ref {raw!r} must be a HuggingFace ref "
133 f"('<org>/<repo>/<filename>.gguf') or carry a known provider prefix ({known})."
134 )
135 prefix, rest = raw.split("/", 1)
136 if prefix in _API_PROVIDERS:
137 return ProviderModelRef(raw=raw, provider=prefix, name=rest)
138 spec = local_server_for_key(prefix)
139 if spec is not None:
140 return ProviderModelRef(raw=raw, provider=spec.key, name=spec.normalize_name(rest))
141 return ProviderModelRef(raw=raw, provider=LOCAL_PROVIDER, name=raw)
144def default_first(refs: list[str], default_ref: str) -> list[str]:
145 """Order so *default_ref* leads, leaving the rest in their existing order."""
146 if default_ref not in refs:
147 return list(refs)
148 return [default_ref, *(ref for ref in refs if ref != default_ref)]
151def with_configured_remote_chat(refs: list[str], configured: str) -> list[str]:
152 """Return *refs* with *configured* prepended when it is a remote ref not already listed.
154 A remote-configured chat model (``ollama/...``, ``openai/...``) is served
155 through known-model resolution without appearing in the native registry;
156 prepending it keeps a model listing truthful and puts the model lilbee
157 actually serves first. A non-empty *configured* must parse; ``cfg.chat_model``
158 is validated and canonicalized at the write boundary, and empty means the
159 role is unconfigured.
160 """
161 if not configured or configured in refs or not parse_model_ref(configured).is_remote:
162 return list(refs)
163 return [configured, *refs]
166def translate_options(options: dict[str, Any], ref: ProviderModelRef) -> dict[str, Any]:
167 """Translate generation options for the target provider.
169 A local ref forced through the SDK keeps the raw filtered options; an API ref
170 gets the shared per-call mapping (``num_predict`` -> ``max_tokens``, drop
171 ``num_ctx``) plus a ``top_k`` drop: litellm would forward ``top_k`` (into
172 ``extra_body`` for OpenAI-compatible) without erroring, but hosted APIs ignore
173 it, so dropping it keeps the wire request clean.
174 """
175 if not ref.is_api:
176 filtered = filter_options(options)
177 think = filtered.pop("think", None)
178 if think is False and ref.provider == ModelSource.OLLAMA:
179 # litellm maps reasoning_effort onto Ollama's think field; any value
180 # outside low/medium/high turns thinking off.
181 filtered["reasoning_effort"] = OLLAMA_NO_THINKING
182 return filtered
183 api_options = normalize_generation_options(options)
184 api_options.pop("top_k", None)
185 api_options.pop("think", None)
186 return api_options