Coverage for src/lilbee/modelhub/model_info.py: 100%

63 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-09-17 10:02 +0000

1"""Public API for reading model architecture metadata from GGUF files.""" 

2 

3from __future__ import annotations 

4 

5import logging 

6from dataclasses import dataclass 

7 

8from lilbee.core.config import cfg 

9 

10log = logging.getLogger(__name__) 

11 

12 

13@dataclass 

14class ModelArchInfo: 

15 """Architecture metadata for installed models.""" 

16 

17 chat_arch: str = "unknown" 

18 embed_arch: str = "unknown" 

19 vision_projector: str = "unknown" 

20 active_handler: str = "not loaded" 

21 

22 

23# Cache: (chat_model_ref, embed_model_ref, vision_model_ref) -> ModelArchInfo. 

24# Reading GGUF headers is hundreds of ms cold (file open + parse); the result 

25# is stable as long as the configured refs 

26# stay the same. Status screen visits, MCP status calls, and any other 

27# read-side caller share this cache. ``invalidate_cache`` lets settings 

28# updates clear it explicitly when a model ref changes. 

29_arch_cache: dict[tuple[str, str, str], ModelArchInfo] = {} 

30 

31 

32def _cache_key() -> tuple[str, str, str]: 

33 return (cfg.chat_model or "", cfg.embedding_model or "", cfg.vision_model or "") 

34 

35 

36def invalidate_cache() -> None: 

37 """Drop the architecture cache. Call when a model ref changes.""" 

38 _arch_cache.clear() 

39 

40 

41def get_model_architecture() -> ModelArchInfo: 

42 """Return architecture metadata for the currently configured models. 

43 

44 Memoized on (chat_model, embed_model, vision_model). First call 

45 reads GGUF headers (binding-free) for each; subsequent calls under 

46 the same refs return the cached result instantly. Each reader 

47 degrades gracefully when its model is unset or unavailable. 

48 """ 

49 key = _cache_key() 

50 cached = _arch_cache.get(key) 

51 if cached is not None: 

52 return cached 

53 info = ModelArchInfo() 

54 info = _read_chat_arch(info) 

55 info = _read_embed_arch(info) 

56 info = _read_vision_arch(info) 

57 _arch_cache[key] = info 

58 return info 

59 

60 

61def _read_chat_arch(info: ModelArchInfo) -> ModelArchInfo: 

62 """Read chat model architecture from GGUF metadata.""" 

63 try: 

64 from lilbee.providers.engine_params import resolve_model_path 

65 from lilbee.providers.gguf_meta import read_gguf_metadata 

66 

67 path = resolve_model_path(cfg.chat_model) 

68 meta = read_gguf_metadata(path) 

69 if meta: 

70 info.chat_arch = meta.get("architecture", "unknown") 

71 info.active_handler = "llama-server" 

72 except Exception: 

73 log.debug("Failed to read chat model architecture", exc_info=True) 

74 return info 

75 

76 

77def _read_embed_arch(info: ModelArchInfo) -> ModelArchInfo: 

78 """Read embedding model architecture from GGUF metadata.""" 

79 try: 

80 from lilbee.providers.engine_params import resolve_model_path 

81 from lilbee.providers.gguf_meta import read_gguf_metadata 

82 

83 path = resolve_model_path(cfg.embedding_model) 

84 meta = read_gguf_metadata(path) 

85 if meta: 

86 info.embed_arch = meta.get("architecture", "unknown") 

87 except Exception: 

88 log.debug("Failed to read embedding model architecture", exc_info=True) 

89 return info 

90 

91 

92def _read_vision_arch(info: ModelArchInfo) -> ModelArchInfo: 

93 """Read vision projector type from GGUF metadata for ``cfg.vision_model``. 

94 

95 Reads the vision model name from the global ``cfg`` singleton (same 

96 pattern as :func:`_read_chat_arch` / :func:`_read_embed_arch`) rather 

97 than taking it as a parameter. The chat model is never inspected for 

98 vision capability here: role separation is explicit. Returns the 

99 input unchanged when no vision model is configured. 

100 """ 

101 if not cfg.vision_model: 

102 return info 

103 try: 

104 from lilbee.providers.engine_params import resolve_model_path 

105 from lilbee.providers.gguf_meta import ( 

106 find_mmproj_for_model, 

107 read_mmproj_projector_type, 

108 ) 

109 

110 path = resolve_model_path(cfg.vision_model) 

111 mmproj = find_mmproj_for_model(path) 

112 proj_type = read_mmproj_projector_type(mmproj) 

113 info.vision_projector = proj_type or "unknown" 

114 except Exception: 

115 log.debug("Failed to read vision projector type", exc_info=True) 

116 return info