abedgemma commited on
Commit
d3faeaf
ยท
verified ยท
1 Parent(s): 0203f84

Create model_manager.py

Browse files
Files changed (1) hide show
  1. model_manager.py +454 -0
model_manager.py ADDED
@@ -0,0 +1,454 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ model_manager.py โ€” ู†ุธุงู… ุฅุฏุงุฑุฉ ุงู„ู†ู…ุงุฐุฌ ุงู„ู†ู‡ุงุฆูŠ ุงู„ู…ุณุชู‚ุฑ
3
+ โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
4
+
5
+ ุงู„ู‚ุฑุงุฑุงุช ุงู„ู…ุจู†ูŠุฉ ุนู„ู‰ debugging ุญู‚ูŠู‚ูŠ:
6
+ โ€ข Qwen3-8B (ู…ุด 3.5) โ€” ูŠุชุฌู†ุจ KV cache reuse bug ููŠ Qwen3.5 variants
7
+ โ€ข bartowski ูู‚ุท โ€” ุฃูุถู„ KLD qualityุŒ ู…ููŠุด Unsloth _XL bugs
8
+ โ€ข Q4_K_M ูู‚ุท โ€” Q8_0 ุนู†ุฏู‡ garbage output bug ููŠ llama.cpp (#21538)
9
+ โ€ข ู„ุง speculative decoding โ€” net-negative ุนู„ู‰ CPU Q4
10
+ โ€ข ู„ุง turbo3/ik quants โ€” ุบูŠุฑ ู…ุณุชู‚ุฑุฉ ููŠ production
11
+ """
12
+
13
+ from __future__ import annotations
14
+ import asyncio, json, logging, os, time
15
+ from dataclasses import asdict, dataclass, field
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ logger = logging.getLogger("mythical.models")
20
+
21
+ MODEL_DIR = Path(os.getenv("MODEL_DIR", "/data/models"))
22
+ CONFIG_PATH = Path(os.getenv("MODEL_CONFIG", "/data/model_config.json"))
23
+ RAM_LIMIT_GB = float(os.getenv("RAM_LIMIT_GB","11.5"))
24
+
25
+
26
+ @dataclass
27
+ class ModelDef:
28
+ id: str
29
+ name: str
30
+ repo: str
31
+ glob_primary: str
32
+ glob_fallback: str
33
+ size_gb: float # ุญุฌู… ุงู„ุฃูˆุฒุงู† ุชู‚ุฑูŠุจุงู‹
34
+ ctx_size: int # ุฃู‚ุตู‰ context ุขู…ู†
35
+ quality: int # ุฌูˆุฏุฉ ู†ุณุจูŠุฉ 0-100
36
+ has_vision: bool
37
+ has_thinking: bool
38
+ mmproj_repo: str = ""
39
+ mmproj_glob: str = "*mmproj*"
40
+ extra_flags: str = ""
41
+ temp: float = 0.6
42
+ top_k: int = 20
43
+ top_p: float = 0.95
44
+ min_p: float = 0.0
45
+ notes: str = ""
46
+
47
+
48
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
49
+ # CATALOG โ€” ู…ูุญุฏูŽู‘ุซ ู…ุงูŠูˆ 2026ุŒ ู…ูุฎุชุจูŽุฑ ุนู„ู‰ CPU production
50
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
51
+ CATALOG: list[ModelDef] = [
52
+
53
+ # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
54
+ # TIER 1 โ€” ุงู„ุฎูŠุงุฑ ุงู„ุงูุชุฑุงุถูŠ: ุฌูˆุฏุฉ ุนุงู„ูŠุฉ + ุงุณุชู‚ุฑุงุฑ ู…ูุซุจูŽุช
55
+ # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
56
+ ModelDef(
57
+ id = "qwen3-8b",
58
+ name = "Qwen3 8B โ€” Thinking (bartowski Q4_K_M)",
59
+ repo = "bartowski/Qwen3-8B-Instruct-GGUF",
60
+ glob_primary = "*Q4_K_M*",
61
+ glob_fallback= "*Q4_K_S*",
62
+ size_gb = 5.2,
63
+ ctx_size = 16384,
64
+ quality = 88,
65
+ has_vision = True,
66
+ has_thinking = True,
67
+ mmproj_repo = "bartowski/Qwen3-8B-Instruct-GGUF",
68
+ mmproj_glob = "*mmproj*",
69
+ extra_flags = "--jinja --reasoning-format deepseek",
70
+ temp=0.6, top_k=20, top_p=0.95, min_p=0.0,
71
+ notes="ุงู„ุฎูŠุงุฑ ุงู„ุงูุชุฑุงุถูŠ. Qwen3 (ู…ุด 3.5) ูŠุชุฌู†ุจ KV cache reuse bug. "
72
+ "bartowski Q4_K_M: KLD=0.0087 โ€” ุฃูุถู„ ุฏู‚ุฉ ููŠ ู‡ุฐุง ุงู„ุญุฌู….",
73
+ ),
74
+
75
+ # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
76
+ # TIER 2 โ€” ุฃุณุฑุน ู„ู„ู…ู‡ุงู… ุงู„ุจุณูŠุทุฉ
77
+ # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
78
+ ModelDef(
79
+ id = "qwen3-4b",
80
+ name = "Qwen3 4B โ€” Fast (bartowski Q4_K_M)",
81
+ repo = "bartowski/Qwen3-4B-Instruct-GGUF",
82
+ glob_primary = "*Q4_K_M*",
83
+ glob_fallback= "*Q4_K_S*",
84
+ size_gb = 2.9,
85
+ ctx_size = 16384,
86
+ quality = 76,
87
+ has_vision = True,
88
+ has_thinking = True,
89
+ mmproj_repo = "bartowski/Qwen3-4B-Instruct-GGUF",
90
+ mmproj_glob = "*mmproj*",
91
+ extra_flags = "--jinja --reasoning-format deepseek",
92
+ temp=0.6, top_k=20, top_p=0.95, min_p=0.0,
93
+ notes="ุฃุณุฑุน 2x ู…ู† 8B. ู„ู„ู…ู‡ุงู… ุงู„ุจุณูŠุทุฉ ูˆุงู„ู€ classification ูˆุงู„ู€ routing.",
94
+ ),
95
+
96
+ # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•๏ฟฝ๏ฟฝ๏ฟฝโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
97
+ # TIER 3 โ€” ุฌูˆุฏุฉ ุฃุนู„ู‰ ู„ู…ุง RAM ูŠุณู…ุญ
98
+ # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
99
+ ModelDef(
100
+ id = "qwen3-14b",
101
+ name = "Qwen3 14B โ€” Premium (bartowski Q4_K_M)",
102
+ repo = "bartowski/Qwen3-14B-Instruct-GGUF",
103
+ glob_primary = "*Q4_K_M*",
104
+ glob_fallback= "*Q4_K_S*",
105
+ size_gb = 9.5,
106
+ ctx_size = 12288, # ุฃุตุบุฑ context ู„ุฃู† ุงู„ุฃูˆุฒุงู† ุฃูƒุจุฑ
107
+ quality = 94,
108
+ has_vision = True,
109
+ has_thinking = True,
110
+ mmproj_repo = "bartowski/Qwen3-14B-Instruct-GGUF",
111
+ mmproj_glob = "*mmproj*",
112
+ extra_flags = "--jinja --reasoning-format deepseek",
113
+ temp=0.6, top_k=20, top_p=0.95, min_p=0.0,
114
+ notes="Premium tier. ูŠุญุชุงุฌ 16GB ุตุงููŠ (HF CPU Pro). ู„ูˆ RAM ู…ุชุงุญ ุงุฎุชุงุฑู‡.",
115
+ ),
116
+
117
+ # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
118
+ # TIER 4 โ€” ุงู„ู…ุณุชู‚ุจู„ (ุชุฑุงู‚ุจู‡ู… model_manager ุฃุณุจูˆุนูŠุงู‹)
119
+ # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
120
+ ModelDef(
121
+ id = "qwen3-32b-q2",
122
+ name = "Qwen3 32B โ€” IQ2_XXS (Future โ€” 24GB+)",
123
+ repo = "bartowski/Qwen3-32B-Instruct-GGUF",
124
+ glob_primary = "*IQ2_XXS*",
125
+ glob_fallback= "*Q2_K*",
126
+ size_gb = 12.0,
127
+ ctx_size = 8192,
128
+ quality = 98,
129
+ has_vision = False,
130
+ has_thinking = True,
131
+ extra_flags = "--jinja --reasoning-format deepseek",
132
+ temp=0.6, top_k=20, top_p=0.95, min_p=0.0,
133
+ notes="ู„ู„ู…ุณุชู‚ุจู„ ู„ูˆ ุชุฑู‚ูŠุช ู„ู€ CPU Pro. ูŠุญุชุงุฌ >14GB RAM ุตุงููŠ.",
134
+ ),
135
+ ]
136
+
137
+ CATALOG_BY_ID = {m.id: m for m in CATALOG}
138
+
139
+
140
+ @dataclass
141
+ class ModelConfig:
142
+ active_id: str
143
+ active_path: str
144
+ mmproj_path: str
145
+ pending_id: str = ""
146
+ pending_path: str = ""
147
+ last_check: float = 0.0
148
+ version: int = 2
149
+
150
+ def save(self):
151
+ CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
152
+ CONFIG_PATH.write_text(json.dumps(asdict(self), indent=2))
153
+
154
+ @classmethod
155
+ def load(cls) -> "ModelConfig":
156
+ if not CONFIG_PATH.exists():
157
+ cfg = cls(active_id="qwen3-8b", active_path="", mmproj_path="")
158
+ cfg.save()
159
+ return cfg
160
+ try:
161
+ d = json.loads(CONFIG_PATH.read_text())
162
+ # Migration: ุชู†ุธูŠู ุญู‚ูˆู„ ู‚ุฏูŠู…ุฉ
163
+ valid = {k for k in cls.__dataclass_fields__}
164
+ return cls(**{k: v for k, v in d.items() if k in valid})
165
+ except Exception as e:
166
+ logger.warning(f"[models] Config load error ({e}), using default.")
167
+ return cls(active_id="qwen3-8b", active_path="", mmproj_path="")
168
+
169
+
170
+ class ModelManager:
171
+
172
+ UPDATE_INTERVAL_H = float(os.getenv("UPDATE_CHECK_HOURS", "168"))
173
+
174
+ def __init__(self):
175
+ self.cfg = ModelConfig.load()
176
+ self._downloading = False
177
+ self._bg_task: asyncio.Task | None = None
178
+
179
+ # โ”€โ”€ Selection โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
180
+ def select_best(self, ram_gb: float | None = None) -> ModelDef:
181
+ limit = ram_gb or RAM_LIMIT_GB
182
+ candidates = sorted(
183
+ [m for m in CATALOG if m.size_gb <= limit],
184
+ key=lambda m: m.quality,
185
+ reverse=True,
186
+ )
187
+ if not candidates:
188
+ return min(CATALOG, key=lambda m: m.size_gb)
189
+ return candidates[0]
190
+
191
+ # โ”€โ”€ Discovery โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
192
+ def find_local(self, m: ModelDef) -> tuple[str, str]:
193
+ """ูŠุฑุฌุน (model_path, mmproj_path) ู…ู† ุงู„ุฏูŠุณูƒ."""
194
+ def _find(glob: str, min_mb: int) -> str:
195
+ for p in sorted(MODEL_DIR.glob(glob),
196
+ key=lambda x: x.stat().st_size, reverse=True):
197
+ if p.stat().st_size > min_mb * 1024 * 1024:
198
+ return str(p)
199
+ return ""
200
+
201
+ model = _find(m.glob_primary, 500) or _find(m.glob_fallback, 500)
202
+ mmproj = _find(m.mmproj_glob, 50) if m.has_vision else ""
203
+ return model, mmproj
204
+
205
+ # โ”€โ”€ Download โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€๏ฟฝ๏ฟฝโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
206
+ async def download(self, m: ModelDef, force=False) -> tuple[str, str]:
207
+ if not force:
208
+ existing, existing_mm = self.find_local(m)
209
+ if existing:
210
+ return existing, existing_mm
211
+
212
+ self._downloading = True
213
+ logger.info(f"[models] Downloading {m.name} from {m.repo}...")
214
+
215
+ loop = asyncio.get_running_loop()
216
+
217
+ def _dl(repo, glob):
218
+ import subprocess, shutil
219
+ for g in [glob, m.glob_fallback if glob == m.glob_primary else ""]:
220
+ if not g:
221
+ continue
222
+ r = subprocess.run(
223
+ ["huggingface-cli", "download", repo,
224
+ "--include", g, "--local-dir", str(MODEL_DIR), "--quiet"],
225
+ env={**os.environ, "HF_HUB_ENABLE_HF_TRANSFER": "1"},
226
+ capture_output=True
227
+ )
228
+ if r.returncode == 0:
229
+ break
230
+ return ""
231
+
232
+ TIMEOUT = float(os.getenv("DOWNLOAD_TIMEOUT", "30.0")) * 20 # max 10 min
233
+ try:
234
+ # [CRIT-09 fix] timeout prevents infinite hang on HF outage
235
+ await asyncio.wait_for(
236
+ loop.run_in_executor(None, _dl, m.repo, m.glob_primary),
237
+ timeout=TIMEOUT)
238
+ if m.has_vision and m.mmproj_repo:
239
+ await asyncio.wait_for(
240
+ loop.run_in_executor(None, _dl, m.mmproj_repo, m.mmproj_glob),
241
+ timeout=TIMEOUT)
242
+ model, mmproj = self.find_local(m)
243
+ logger.info(f"[models] Done: {model}")
244
+ return model, mmproj
245
+ except asyncio.TimeoutError:
246
+ logger.error(f"[models] Download timed out after {TIMEOUT:.0f}s")
247
+ return "", ""
248
+ finally:
249
+ self._downloading = False
250
+
251
+ # โ”€โ”€ Apply pending upgrade โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
252
+ def apply_pending(self) -> bool:
253
+ if not self.cfg.pending_id or not self.cfg.pending_path:
254
+ return False
255
+ if not Path(self.cfg.pending_path).exists():
256
+ self.cfg.pending_id = self.cfg.pending_path = ""
257
+ self.cfg.save()
258
+ return False
259
+ old = self.cfg.active_id
260
+ self.cfg.active_id = self.cfg.pending_id
261
+ self.cfg.active_path = self.cfg.pending_path
262
+ self.cfg.pending_id = self.cfg.pending_path = ""
263
+ self.cfg.save()
264
+ logger.info(f"[models] Hot-swap: {old} โ†’ {self.cfg.active_id}")
265
+ return True
266
+
267
+ # โ”€โ”€ Auto-update background loop โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
268
+ def start_updater(self):
269
+ self._bg_task = asyncio.create_task(self._update_loop(), name="model_updater")
270
+
271
+ async def _update_loop(self):
272
+ await asyncio.sleep(600) # ุงู†ุชุธุฑ 10 ุฏู‚ุงุฆู‚ ุจุนุฏ ุงู„ุจุฏุก
273
+ while True:
274
+ try:
275
+ if (time.time() - self.cfg.last_check) / 3600 >= self.UPDATE_INTERVAL_H:
276
+ await self._smart_upgrade()
277
+ except Exception as e:
278
+ logger.error(f"[models] Update loop error: {e}")
279
+ await asyncio.sleep(3600)
280
+
281
+ async def _smart_upgrade(self):
282
+ current = CATALOG_BY_ID.get(self.cfg.active_id)
283
+ if not current:
284
+ return
285
+ better = [
286
+ m for m in CATALOG
287
+ if m.quality > current.quality
288
+ and m.size_gb <= RAM_LIMIT_GB
289
+ and m.id != current.id
290
+ ]
291
+ if not better:
292
+ self.cfg.last_check = time.time()
293
+ self.cfg.save()
294
+ logger.info(f"[models] Already on best model for this hardware.")
295
+ return
296
+ best = max(better, key=lambda m: m.quality)
297
+ logger.info(f"[models] Better model found: {best.name}. Downloading in background...")
298
+
299
+ async def _bg():
300
+ path, mmproj = await self.download(best)
301
+ if path:
302
+ self.cfg.pending_id = best.id
303
+ self.cfg.pending_path = path
304
+ self.cfg.last_check = time.time()
305
+ self.cfg.save()
306
+ logger.info(f"[models] Upgrade ready: {best.name}. Activates on restart.")
307
+ asyncio.create_task(_bg())
308
+
309
+ def stop(self):
310
+ if self._bg_task:
311
+ self._bg_task.cancel()
312
+
313
+ # โ”€โ”€ Build llama-server command โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
314
+ def build_cmd(self, host="127.0.0.1", port="8080",
315
+ ctx_size: str | None = None) -> list[str]:
316
+ m = CATALOG_BY_ID.get(self.cfg.active_id)
317
+ if not m:
318
+ raise ValueError(f"Unknown model id: {self.cfg.active_id}")
319
+
320
+ ctx = ctx_size or str(m.ctx_size)
321
+
322
+ cmd = [
323
+ "llama-server",
324
+ "--model", self.cfg.active_path,
325
+ "--host", host,
326
+ "--port", port,
327
+ "--ctx-size", ctx,
328
+ "-t", "2",
329
+ "-tb", "2",
330
+ "-np", "2",
331
+ "--cont-batching",
332
+ "--poll", "100",
333
+ "--prio", "3",
334
+ "--prio-batch", "2",
335
+ "--cpu-range", "0-1",
336
+ "--cpu-strict", "1",
337
+ "-b", "512",
338
+ "-ub", "128",
339
+ "-ctk", "q4_0",
340
+ "-ctv", "q4_0",
341
+ "-fa", "on",
342
+ "--slot-save-path", "/data/slot_cache",
343
+ "--mlock",
344
+ "--no-mmap",
345
+ "--timeout", "90",
346
+ "--log-disable",
347
+ # NO speculative decoding โ€” net-negative on CPU Q4 quantized models
348
+ # Qwen3 sampling params (official recommended)
349
+ "--temp", str(m.temp),
350
+ "--top-k", str(m.top_k),
351
+ "--top-p", str(m.top_p),
352
+ "--min-p", str(m.min_p),
353
+ ]
354
+
355
+ # Vision
356
+ if self.cfg.mmproj_path and m.has_vision:
357
+ cmd += ["--mmproj", self.cfg.mmproj_path, "--no-mmproj-offload"]
358
+
359
+ # Model-specific flags (jinja, reasoning-format, etc.)
360
+ if m.extra_flags:
361
+ import shlex
362
+ cmd += shlex.split(m.extra_flags)
363
+
364
+ return cmd
365
+
366
+ # โ”€โ”€ Status & Catalog โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
367
+ def status(self) -> dict:
368
+ m = CATALOG_BY_ID.get(self.cfg.active_id, {})
369
+ pm = CATALOG_BY_ID.get(self.cfg.pending_id, {}) if self.cfg.pending_id else {}
370
+ return {
371
+ "active": {
372
+ "id": self.cfg.active_id,
373
+ "name": m.name if m else "unknown",
374
+ "path": self.cfg.active_path,
375
+ "mmproj": self.cfg.mmproj_path,
376
+ "quality": m.quality if m else 0,
377
+ "thinking": m.has_thinking if m else False,
378
+ "vision": m.has_vision if m else False,
379
+ "ctx_size": m.ctx_size if m else 0,
380
+ "size_gb": m.size_gb if m else 0,
381
+ },
382
+ "pending": {"id": self.cfg.pending_id, "name": pm.name if pm else None} if self.cfg.pending_id else None,
383
+ "is_downloading":self._downloading,
384
+ "last_check_h": round((time.time() - self.cfg.last_check)/3600, 1),
385
+ "update_every_h":self.UPDATE_INTERVAL_H,
386
+ }
387
+
388
+ def catalog(self) -> list[dict]:
389
+ result = []
390
+ for m in CATALOG:
391
+ local, _ = self.find_local(m)
392
+ result.append({
393
+ "id": m.id,
394
+ "name": m.name,
395
+ "quality": m.quality,
396
+ "size_gb": m.size_gb,
397
+ "thinking": m.has_thinking,
398
+ "vision": m.has_vision,
399
+ "on_disk": bool(local),
400
+ "active": m.id == self.cfg.active_id,
401
+ "pending": m.id == self.cfg.pending_id,
402
+ "notes": m.notes,
403
+ })
404
+ return result
405
+
406
+
407
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
408
+ # CLI โ€” ูŠุณุชุฎุฏู…ู‡ startup.sh
409
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
410
+ if __name__ == "__main__":
411
+ import sys
412
+ logging.basicConfig(level=logging.INFO)
413
+ mgr = ModelManager()
414
+
415
+ cmd = sys.argv[1] if len(sys.argv) > 1 else "status"
416
+
417
+ if cmd == "status":
418
+ print(json.dumps(mgr.status(), indent=2))
419
+
420
+ elif cmd == "apply-pending":
421
+ print("applied" if mgr.apply_pending() else "no-pending")
422
+
423
+ elif cmd == "get-cmd":
424
+ try:
425
+ parts = mgr.build_cmd()
426
+ print(" ".join(parts))
427
+ except ValueError as e:
428
+ print(f"ERROR: {e}", file=sys.stderr); sys.exit(1)
429
+
430
+ elif cmd == "ensure-downloaded":
431
+ async def _run():
432
+ mgr.apply_pending()
433
+ m = CATALOG_BY_ID.get(mgr.cfg.active_id) or mgr.select_best()
434
+ mgr.cfg.active_id = m.id
435
+ path, mmproj = await mgr.download(m)
436
+ if not path:
437
+ # fallback to smallest
438
+ fb = min(CATALOG, key=lambda x: x.size_gb)
439
+ path, mmproj = await mgr.download(fb)
440
+ if path:
441
+ mgr.cfg.active_id = fb.id
442
+ mgr.cfg.active_path = path
443
+ mgr.cfg.mmproj_path = mmproj
444
+ mgr.cfg.save()
445
+ print(json.dumps({
446
+ "model_id": mgr.cfg.active_id,
447
+ "model_path": mgr.cfg.active_path,
448
+ "mmproj_path":mgr.cfg.mmproj_path,
449
+ "ok": bool(path),
450
+ }))
451
+ asyncio.run(_run())
452
+
453
+ else:
454
+ print(f"Unknown: {cmd}", file=sys.stderr); sys.exit(1)