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

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 typing import Any 

12 

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) 

21 

22_API_PROVIDERS = frozenset( 

23 { 

24 "openrouter", 

25 "gemini", 

26 "anthropic", 

27 "openai", 

28 "mistral", 

29 "deepseek", 

30 } 

31) 

32 

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) 

36 

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

38LOCAL_PROVIDER = "local" 

39OLLAMA_NO_THINKING = "none" 

40 

41 

42def is_native_gguf_ref(raw: str) -> bool: 

43 """True when *raw* has the native HuggingFace GGUF shape ``<org>/<repo>/<file>.gguf``. 

44 

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 

50 

51 

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

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

54 

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) 

61 

62 

63@dataclass(frozen=True) 

64class ProviderModelRef: 

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

66 

67 raw: str 

68 provider: str # LOCAL_PROVIDER or any value in PROVIDER_PREFIXES 

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

70 

71 @property 

72 def is_api(self) -> bool: 

73 return self.provider in _API_PROVIDERS 

74 

75 @property 

76 def is_local(self) -> bool: 

77 return self.provider == LOCAL_PROVIDER 

78 

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 

83 

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 

92 

93 def for_display(self) -> str: 

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

95 return self.raw 

96 

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 

101 

102 

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

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

105 

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

114 

115 

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

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

118 

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) 

142 

143 

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

149 

150 

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. 

153 

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] 

164 

165 

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

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

168 

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