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

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

22 

23_API_PROVIDERS = frozenset( 

24 { 

25 "openrouter", 

26 "gemini", 

27 "anthropic", 

28 "openai", 

29 "mistral", 

30 "deepseek", 

31 } 

32) 

33 

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) 

37 

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

39LOCAL_PROVIDER = "local" 

40OLLAMA_NO_THINKING = "none" 

41 

42 

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. 

45 

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. 

50 

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 ) 

58 

59 

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

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

62 

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

68 

69 

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

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

72 

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) 

79 

80 

81@dataclass(frozen=True) 

82class ProviderModelRef: 

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

84 

85 raw: str 

86 provider: str # LOCAL_PROVIDER or any value in PROVIDER_PREFIXES 

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

88 

89 @property 

90 def is_api(self) -> bool: 

91 return self.provider in _API_PROVIDERS 

92 

93 @property 

94 def is_local(self) -> bool: 

95 return self.provider == LOCAL_PROVIDER 

96 

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 

101 

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 

110 

111 def for_display(self) -> str: 

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

113 return self.raw 

114 

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 

119 

120 

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

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

123 

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

132 

133 

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

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

136 

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) 

160 

161 

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

167 

168 

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. 

171 

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] 

182 

183 

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

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

186 

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