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

111 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-11-06 08:17 +0000

1"""Engine-neutral parameter helpers: model-path resolution, context and GPU-layer 

2sizing, and chat-option translation. 

3 

4These derive launch/generation parameters from cfg + a model's GGUF metadata 

5without loading the model, so both the local llama-server engine and any other 

6provider compute them identically. No native binding. 

7""" 

8 

9from __future__ import annotations 

10 

11import logging 

12from pathlib import Path 

13from typing import TYPE_CHECKING, Any 

14 

15if TYPE_CHECKING: 

16 from lilbee.modelhub.registry import ModelRegistry 

17from lilbee.core.config import DEFAULT_NUM_CTX, cfg 

18from lilbee.core.config.enums import KV_CACHE_TYPE_BYTES, KvCacheType 

19from lilbee.core.health_warnings import HealthWarning, WarningCode 

20from lilbee.providers.base import ( 

21 CONTEXT_WINDOW_MARGIN_TOKENS, 

22 GENERATION_RESERVE_TOKENS, 

23 ProviderError, 

24 ProviderErrorKind, 

25 estimate_budget_tokens, 

26 normalize_generation_options, 

27) 

28from lilbee.providers.gguf_meta import read_gguf_metadata, train_ctx_from_meta 

29from lilbee.providers.model_cache import ( 

30 compute_dynamic_ctx, 

31 get_available_memory, 

32 kv_bytes_per_token, 

33) 

34from lilbee.providers.model_ref import is_loose_model_file 

35 

36log = logging.getLogger(__name__) 

37 

38EMBED_FALLBACK_CTX = 2048 

39"""Context used for embed/rerank when a GGUF reports junk (e.g. context_length=0).""" 

40 

41# Sized above chunk_size so BOS re-added on re-tokenization doesn't overflow a full-chunk input. 

42_EMBED_CTX_MARGIN = 8 

43 

44 

45def resolve_embed_ctx(meta: dict[str, str] | None, model_path: Path) -> int: 

46 """Embed/rerank context: worst-case chunk tokenization, capped by trained context. 

47 

48 ``chunk_size`` is token-denominated but the chunker enforces a CHARACTER 

49 budget (``chunk_size * CHARS_PER_TOKEN``). A BPE token is at least one 

50 character, so that char budget is also the provable token ceiling for any 

51 chunk the chunker can emit: size the context to it and embed-time 

52 truncation becomes impossible. Token-dense text (numeric tables, dense 

53 identifiers) otherwise reaches ~2x chunk_size tokens against a 1x cap and 

54 silently loses its tail at embed time. A trained context below that budget 

55 caps the window instead, and the chunker then sizes chunks in the embedder's 

56 own tokens at the served cap (see :func:`embed_token_cap`).""" 

57 from lilbee.data.extract.chunk import CHARS_PER_TOKEN 

58 

59 train_ctx = train_ctx_from_meta(meta, fallback=EMBED_FALLBACK_CTX, model_path=model_path) 

60 return min(train_ctx, cfg.chunk_size * CHARS_PER_TOKEN + _EMBED_CTX_MARGIN) 

61 

62 

63def embed_token_cap(ctx: int) -> int: 

64 """Tokens an embed server truncates one input to, given its per-slot context.""" 

65 return max(1, ctx - _EMBED_CTX_MARGIN) 

66 

67 

68def embed_window_warning(cap: int) -> HealthWarning | None: 

69 """The embed-window warning for a real, actionable gap, else None. 

70 

71 Plain chunking self-corrects to token sizing when *cap* binds, so it loses 

72 no text and needs no warning. Semantic chunking ignores token sizing and 

73 stays character-sized, so a bound *cap* loses text there regardless of the 

74 ``token_sizing`` setting. 

75 """ 

76 from lilbee.data.extract.chunk import CHARS_PER_TOKEN 

77 

78 if cfg.semantic_chunking: 

79 chars = cfg.chunk_size * CHARS_PER_TOKEN 

80 if cap >= chars: 

81 return None 

82 return HealthWarning( 

83 code=WarningCode.EMBED_WINDOW_BELOW_CHUNK, 

84 message=( 

85 f"The embedding model accepts {cap} tokens per input, below the {chars} " 

86 f"characters that chunk_size {cfg.chunk_size} allows. Semantic chunking " 

87 f"sizes chunks by characters and ignores token sizing, so a chunk over " 

88 f"{cap} tokens loses text at embedding time." 

89 ), 

90 remedy="Turn off semantic_chunking or lower chunk_size.", 

91 ) 

92 if cfg.token_sizing and cap < cfg.chunk_size: 

93 return HealthWarning( 

94 code=WarningCode.EMBED_WINDOW_BELOW_CHUNK, 

95 message=( 

96 f"The embedding model accepts {cap} tokens per input, below the configured " 

97 f"chunk_size {cfg.chunk_size} in tokens. Chunks are capped to {cap} tokens " 

98 f"instead." 

99 ), 

100 remedy=f"Set chunk_size to {cap} to match the window.", 

101 ) 

102 return None 

103 

104 

105_LLM_RERANK_HEADROOM = 512 

106"""Tokens reserved above chunk_size for an LLM reranker's query, prompt, and 1-token answer.""" 

107 

108 

109def resolve_llm_rerank_ctx(meta: dict[str, str] | None, model_path: Path) -> int: 

110 """LLM-reranker context: a query+candidate pair, capped by the model's trained context.""" 

111 train_ctx = train_ctx_from_meta(meta, fallback=EMBED_FALLBACK_CTX, model_path=model_path) 

112 return min(train_ctx, cfg.chunk_size + _LLM_RERANK_HEADROOM) 

113 

114 

115_VISION_FALLBACK_N_CTX = 4096 

116"""Context for a vision load when the GGUF reports no usable context_length.""" 

117 

118_VISION_PAGE_CTX_CAP = 32768 

119"""Per-page ceiling on a vision OCR server's context: covers a single high-res page's 

120image tokens plus prompt, while keeping a long-context VLM placeable beside a chat giant.""" 

121 

122N_GPU_LAYERS_AUTO = -1 

123"""llama.cpp's "fit as many layers as the device holds" value for n_gpu_layers. 

124 

125The engine measures free VRAM at load and picks the count itself, spilling the 

126rest to system memory. That is a better answer than any number lilbee can 

127compute ahead of time, because it is taken on the real device after every other 

128tenant, so the planner passes this rather than a layer count of its own. 

129""" 

130# llama.cpp's "offload nothing"; the user's CPU-only opt-out rather than a budget. 

131_N_GPU_LAYERS_NONE = 0 

132 

133 

134def chat_options_to_kwargs(options: dict[str, Any] | None) -> dict[str, Any]: 

135 """Translate user-facing chat options into generation kwargs. 

136 

137 The output keys (``temperature``/``top_p``/``top_k``/``seed``/``max_tokens``/ 

138 ``repeat_penalty``) are accepted by llama-server's OpenAI body. ``top_k`` is 

139 kept (local llama.cpp honors it), unlike the SDK/API translator which drops it. 

140 ``think`` becomes ``chat_template_kwargs.enable_thinking``, which thinking 

141 templates honor and others ignore. 

142 """ 

143 kwargs = normalize_generation_options(options) 

144 think = kwargs.pop("think", None) 

145 if think is not None: 

146 kwargs["chat_template_kwargs"] = {"enable_thinking": think} 

147 return kwargs 

148 

149 

150def resolve_model_path(model: str, registry: ModelRegistry | None = None) -> Path: 

151 """Resolve a model name to a .gguf file path. 

152 

153 Resolution order: (1) registry (canonical source for installed models), 

154 (2) an absolute path to an existing file. Pass *registry* to resolve without 

155 reaching for ``get_services()`` (callers running inside its construction). 

156 """ 

157 if not model: 

158 raise ProviderError( 

159 "No model is configured for this role. Pick one from the catalog " 

160 "or run 'lilbee model pull <model>'.", 

161 provider="llama-server", 

162 kind=ProviderErrorKind.NOT_FOUND, 

163 ) 

164 if registry is None: 

165 # call-time import: keeps the app-layer container off this module's import graph 

166 from lilbee.app.services import get_services 

167 

168 registry = get_services().registry 

169 try: 

170 return registry.resolve(model) 

171 except (KeyError, ValueError): 

172 pass 

173 

174 if is_loose_model_file(model): 

175 return Path(model) 

176 if Path(model).is_absolute(): 

177 raise ProviderError( 

178 f"Model file not found: {model}", 

179 provider="llama-server", 

180 kind=ProviderErrorKind.NOT_FOUND, 

181 ) 

182 

183 raise ProviderError( 

184 f"Model {model!r} is not installed. Run 'lilbee model pull {model}' to download it.", 

185 provider="llama-server", 

186 kind=ProviderErrorKind.NOT_FOUND, 

187 ) 

188 

189 

190def chat_kv_elem_bytes() -> float: 

191 """Per-element byte cost of the K and V cache a chat launch allocates. 

192 

193 Reads the flag the launch passes for both K and V 

194 (:func:`lilbee.providers.fleet.planning.chat_cache_type_flag`), so the 

195 granted window stays in step with the cache the engine allocates. 

196 """ 

197 # call-time import: planning imports this module at load 

198 from lilbee.providers.fleet.planning import chat_cache_type_flag 

199 

200 flag = chat_cache_type_flag() 

201 return KV_CACHE_TYPE_BYTES[KvCacheType(flag) if flag else KvCacheType.F16] 

202 

203 

204def chat_ctx_ceiling(meta: dict[str, str] | None, model_path: Path) -> int: 

205 """Hard upper bound on a chat per-slot n_ctx: trained context, capped by ``cfg.num_ctx_max``.""" 

206 training_ctx = train_ctx_from_meta(meta, fallback=DEFAULT_NUM_CTX, model_path=model_path) 

207 if cfg.num_ctx_max is not None: 

208 return min(training_ctx, cfg.num_ctx_max) 

209 return training_ctx 

210 

211 

212def resolve_chat_ctx( 

213 model_path: Path, meta: dict[str, str] | None, *, available_bytes: int | None = None 

214) -> int: 

215 """Pick a single-GPU chat n_ctx aiming for ``cfg.chat_n_ctx_target``, clamped to model + host. 

216 

217 A gguf-parser fit answers first 

218 (:func:`lilbee.providers.fleet.planning.fit_chat_ctx`), because it prices the 

219 cache each layer of this architecture holds. Header math takes over when the 

220 estimator cannot answer; it charges every layer as dense attention over the 

221 whole window, which under-grants linear-attention, sliding-window and MLA 

222 models. Either way the window stops at the smallest of the trained context, 

223 ``cfg.num_ctx_max`` and the target. 

224 

225 A multi-GPU tensor-split chat is sized separately by the fleet against its 

226 per-device headroom (see :func:`lilbee.providers.fleet.ctx.fit_split_ctx`). 

227 ``available_bytes`` overrides the live host-memory read, and every caller 

228 that is sizing a real launch passes it: the fleet and the surfaces that 

229 mirror it hand over 

230 :func:`lilbee.providers.fleet.planning.plan_sizing_budget`, which reports the 

231 memory of the GPU that will run the model and holds a clean-box snapshot so 

232 a reload sizes ctx like the boot did. 

233 """ 

234 # call-time import: planning imports this module at load 

235 from lilbee.providers.fleet.planning import fit_chat_ctx 

236 

237 training_ctx = train_ctx_from_meta(meta, fallback=DEFAULT_NUM_CTX, model_path=model_path) 

238 ceiling = cfg.num_ctx_max if cfg.num_ctx_max is not None else training_ctx 

239 if available_bytes is None: 

240 available_bytes = get_available_memory(cfg.gpu_memory_fraction) 

241 upper = min(training_ctx, ceiling, cfg.chat_n_ctx_target) 

242 

243 try: 

244 return fit_chat_ctx(model_path, meta, available_bytes=available_bytes, ctx_ceiling=upper) 

245 except (ProviderError, OSError, ValueError): 

246 log.debug("gguf-parser ctx fit failed for %s, using header math", model_path, exc_info=True) 

247 return _header_math_chat_ctx( 

248 model_path, 

249 meta, 

250 available_bytes=available_bytes, 

251 training_ctx=training_ctx, 

252 ceiling=ceiling, 

253 ) 

254 

255 

256def _header_math_chat_ctx( 

257 model_path: Path, 

258 meta: dict[str, str] | None, 

259 *, 

260 available_bytes: int, 

261 training_ctx: int, 

262 ceiling: int, 

263) -> int: 

264 """Window from GGUF header arithmetic: weights plus a dense-attention cache.""" 

265 try: 

266 model_bytes = model_path.stat().st_size 

267 elem_bytes = chat_kv_elem_bytes() 

268 kv_per_tok = kv_bytes_per_token(meta, elem_bytes, elem_bytes) 

269 return compute_dynamic_ctx( 

270 model_bytes=model_bytes, 

271 available_bytes=available_bytes, 

272 training_ctx=training_ctx, 

273 kv_bytes_per_tok=kv_per_tok, 

274 ceiling=ceiling, 

275 target=cfg.chat_n_ctx_target, 

276 ) 

277 except (OSError, ValueError): 

278 log.debug("dynamic ctx sizing failed for %s, using static cap", model_path, exc_info=True) 

279 return min(training_ctx, cfg.chat_n_ctx_target) 

280 

281 

282# Tokens the minimum grounded prompt allows for the question plus the context 

283# template's framing, beyond the system prompt and one retrieved source. 

284_GROUNDED_QUESTION_TOKENS = 128 

285 

286 

287def min_usable_chat_ctx() -> int: 

288 """Smallest chat window that serves one grounded answer: the system prompt, 

289 one retrieved source, the question, and the generation reserve plus margin.""" 

290 return ( 

291 estimate_budget_tokens(cfg.rag_system_prompt) 

292 + cfg.chunk_size 

293 + _GROUNDED_QUESTION_TOKENS 

294 + GENERATION_RESERVE_TOKENS 

295 + CONTEXT_WINDOW_MARGIN_TOKENS 

296 ) 

297 

298 

299def resolve_n_gpu_layers(*, embedding: bool) -> int: 

300 """Resolve ``cfg.n_gpu_layers`` (None=all) to llama.cpp's offload integer. 

301 

302 Zero is honoured for every role. It is not a layer budget but the way a user 

303 says "run this on the CPU", and the search roles used to take the 

304 full-offload sentinel before the setting was read, so embed, rerank and 

305 vision kept loading onto the GPU that had just been excluded. 

306 

307 Any other value is a chat-shaped budget and says nothing useful about a small 

308 embedding model, which still offloads fully. 

309 """ 

310 if cfg.n_gpu_layers == _N_GPU_LAYERS_NONE: 

311 return _N_GPU_LAYERS_NONE 

312 if embedding or cfg.n_gpu_layers is None: 

313 return N_GPU_LAYERS_AUTO 

314 return cfg.n_gpu_layers 

315 

316 

317def resolve_vision_ctx(model_path: Path) -> int: 

318 """Pick n_ctx for a vision OCR load: the model's training context, capped per page. 

319 

320 Uses the model's ``<arch>.context_length`` (not the chat-tuned ``cfg.num_ctx``: a 

321 vision pass packs image-token embeddings plus the prompt, and a small chat ctx 

322 truncates OCR output) but caps it at ``_VISION_PAGE_CTX_CAP``. OCR processes one page 

323 per request, so a single page never exceeds the cap, yet a long-context VLM's full 

324 context would otherwise estimate too large to place alongside a chat giant. 

325 """ 

326 try: 

327 meta = read_gguf_metadata(model_path) 

328 except Exception: 

329 log.debug("read_gguf_metadata failed for vision %s", model_path, exc_info=True) 

330 meta = None 

331 train_ctx = train_ctx_from_meta(meta, fallback=_VISION_FALLBACK_N_CTX, model_path=model_path) 

332 return min(train_ctx, _VISION_PAGE_CTX_CAP)