Coverage for src/lilbee/providers/model_ref.py: 100%

74 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-08-14 11:46 +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 NATIVE_GGUF_REF_MIN_SLASHES 

14from lilbee.providers.base import filter_options, normalize_generation_options 

15from lilbee.providers.local_servers import ( 

16 LOCAL_SERVER_KEYS, 

17 local_server_for_key, 

18 local_server_for_label, 

19) 

20 

21_API_PROVIDERS = frozenset( 

22 { 

23 "openrouter", 

24 "gemini", 

25 "anthropic", 

26 "openai", 

27 "mistral", 

28 "deepseek", 

29 } 

30) 

31 

32# All provider prefixes that route a ref away from the local registry: 

33# API providers plus the local OpenAI-compatible servers (ollama, lm_studio). 

34PROVIDER_PREFIXES: frozenset[str] = frozenset(_API_PROVIDERS | LOCAL_SERVER_KEYS) 

35 

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

37LOCAL_PROVIDER = "local" 

38 

39 

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

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

42 

43 The suffix check is case-sensitive on purpose: repo extraction 

44 (:func:`lilbee.catalog.refs.hf_repo_from_ref`) only recognises the 

45 lowercase ``.gguf`` suffix, and classification must agree with it. 

46 """ 

47 return raw.endswith(".gguf") and raw.count("/") >= NATIVE_GGUF_REF_MIN_SLASHES 

48 

49 

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

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

52 

53 Local-server prefixes (``ollama/``, ``lm_studio/``) are exempt from the 

54 shape rule: those servers report model ids that can themselves look like 

55 GGUF paths, so the prefix wins over the shape. 

56 """ 

57 first_segment = raw.split("/", 1)[0] 

58 return first_segment not in LOCAL_SERVER_KEYS and is_native_gguf_ref(raw) 

59 

60 

61@dataclass(frozen=True) 

62class ProviderModelRef: 

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

64 

65 raw: str 

66 provider: str # LOCAL_PROVIDER or any value in PROVIDER_PREFIXES 

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

68 

69 @property 

70 def is_api(self) -> bool: 

71 return self.provider in _API_PROVIDERS 

72 

73 @property 

74 def is_local(self) -> bool: 

75 return self.provider == LOCAL_PROVIDER 

76 

77 @property 

78 def is_remote(self) -> bool: 

79 """True if this model routes through a remote SDK (any non-``local`` provider).""" 

80 return self.provider != LOCAL_PROVIDER 

81 

82 def for_openai_prefix(self) -> str: 

83 """Name with its canonical ``provider/model`` prefix (``ollama/llama3.2:1b``).""" 

84 spec = local_server_for_key(self.provider) 

85 if spec is not None: 

86 return spec.qualify(self.name) 

87 if self.is_api: 

88 return f"{self.provider}/{self.name}" 

89 return self.name 

90 

91 def for_display(self) -> str: 

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

93 return self.raw 

94 

95 @property 

96 def needs_api_base(self) -> bool: 

97 """True if the SDK needs an explicit api_base (Ollama/local).""" 

98 return not self.is_api 

99 

100 

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

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

103 

104 *provider* may be a routing key (``"ollama"``) or a backend display 

105 name (``"LM Studio"``); local-server labels are normalised to the 

106 routing key so the prefix survives. API providers fall through to 

107 their lowercase key unchanged. 

108 """ 

109 spec = local_server_for_label(provider) 

110 key = spec.key if spec is not None else provider.lower() 

111 return ProviderModelRef(raw=name, provider=key, name=name).for_openai_prefix() 

112 

113 

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

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

116 

117 Native HuggingFace refs are ``<org>/<repo>/<file>.gguf``; that shape 

118 routes locally even when the org collides with an API provider prefix 

119 (``openai/``, ``mistral/``, ``deepseek/`` are real HF orgs). Local-server 

120 prefixes (``ollama/``, ``lm_studio/``) are exempt from the shape rule: 

121 those servers report model ids that can themselves look like GGUF paths 

122 (LM Studio 0.2.x uses full relative GGUF paths), so the prefix wins there. 

123 Remote providers use prefixes from :data:`PROVIDER_PREFIXES`. 

124 """ 

125 if routes_to_native_gguf(raw): 

126 return ProviderModelRef(raw=raw, provider=LOCAL_PROVIDER, name=raw) 

127 if "/" not in raw: 

128 known = ", ".join(f"{p}/" for p in sorted(PROVIDER_PREFIXES)) 

129 raise ValueError( 

130 f"Model ref {raw!r} must be a HuggingFace ref " 

131 f"('<org>/<repo>/<filename>.gguf') or carry a known provider prefix ({known})." 

132 ) 

133 prefix, rest = raw.split("/", 1) 

134 if prefix in _API_PROVIDERS: 

135 return ProviderModelRef(raw=raw, provider=prefix, name=rest) 

136 spec = local_server_for_key(prefix) 

137 if spec is not None: 

138 return ProviderModelRef(raw=raw, provider=spec.key, name=spec.normalize_name(rest)) 

139 return ProviderModelRef(raw=raw, provider=LOCAL_PROVIDER, name=raw) 

140 

141 

142def default_first(refs: list[str], default_ref: str) -> list[str]: 

143 """Order so *default_ref* leads, leaving the rest in their existing order.""" 

144 if default_ref not in refs: 

145 return list(refs) 

146 return [default_ref, *(ref for ref in refs if ref != default_ref)] 

147 

148 

149def with_configured_remote_chat(refs: list[str], configured: str) -> list[str]: 

150 """Return *refs* with *configured* prepended when it is a remote ref not already listed. 

151 

152 A remote-configured chat model (``ollama/...``, ``openai/...``) is served 

153 through known-model resolution without appearing in the native registry; 

154 prepending it keeps a model listing truthful and puts the model lilbee 

155 actually serves first. A non-empty *configured* must parse; ``cfg.chat_model`` 

156 is validated and canonicalized at the write boundary, and empty means the 

157 role is unconfigured. 

158 """ 

159 if not configured or configured in refs or not parse_model_ref(configured).is_remote: 

160 return list(refs) 

161 return [configured, *refs] 

162 

163 

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

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

166 

167 A local ref forced through the SDK keeps the raw filtered options; an API ref 

168 gets the shared per-call mapping (``num_predict`` -> ``max_tokens``, drop 

169 ``num_ctx``) plus a ``top_k`` drop: litellm would forward ``top_k`` (into 

170 ``extra_body`` for OpenAI-compatible) without erroring, but hosted APIs ignore 

171 it, so dropping it keeps the wire request clean. 

172 """ 

173 if not ref.is_api: 

174 filtered = filter_options(options) 

175 # litellm has no chat_template_kwargs passthrough; thinking control 

176 # only exists on the native llama-server path. 

177 filtered.pop("think", None) 

178 return filtered 

179 api_options = normalize_generation_options(options) 

180 api_options.pop("top_k", None) 

181 api_options.pop("think", None) 

182 return api_options