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

1"""Model reference parsing and option translation. 

2 

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""" 

7 

8from __future__ import annotations 

9 

10from dataclasses import dataclass 

11from pathlib import Path, PureWindowsPath 

12from typing import Any 

13 

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) 

28 

29_API_PROVIDERS = frozenset( 

30 { 

31 "openrouter", 

32 "gemini", 

33 "anthropic", 

34 "openai", 

35 "mistral", 

36 "deepseek", 

37 } 

38) 

39 

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) 

43 

44# Provider value for refs served from the local registry (native GGUF). 

45LOCAL_PROVIDER = "local" 

46OLLAMA_NO_THINKING = "none" 

47 

48 

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. 

51 

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. 

56 

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 ) 

64 

65 

66def is_loose_model_file(raw: str) -> bool: 

67 """True when *raw* is an absolute path to an existing file, loadable without a manifest. 

68 

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() 

74 

75 

76def routes_to_native_gguf(raw: str) -> bool: 

77 """True when *raw* is a native GGUF shape not claimed by a local-server prefix. 

78 

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) 

85 

86 

87@dataclass(frozen=True) 

88class ProviderModelRef: 

89 """Parsed model reference with provider routing information.""" 

90 

91 raw: str 

92 provider: str # LOCAL_PROVIDER or any value in PROVIDER_PREFIXES 

93 name: str # provider-specific name with tag normalization applied 

94 

95 @property 

96 def is_api(self) -> bool: 

97 return self.provider in _API_PROVIDERS 

98 

99 @property 

100 def is_local(self) -> bool: 

101 return self.provider == LOCAL_PROVIDER 

102 

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 

107 

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 

116 

117 def for_display(self) -> str: 

118 """Human-readable name for UI.""" 

119 return self.raw 

120 

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 

125 

126 

127def format_remote_ref(name: str, provider: str) -> str: 

128 """Render a remote model as a canonical ``provider/name`` ref. 

129 

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() 

138 

139 

140def parse_model_ref(raw: str) -> ProviderModelRef: 

141 """Classify a model string and return the routing ref, native shape first. 

142 

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) 

166 

167 

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)] 

173 

174 

175# Samplers the Anthropic API has no field for. 

176_ANTHROPIC_UNSUPPORTED = ("seed", "frequency_penalty", "presence_penalty") 

177 

178 

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 ) 

191 

192 

193def translate_options(options: dict[str, Any], ref: ProviderModelRef) -> dict[str, Any]: 

194 """Translate generation options for the target provider. 

195 

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