@meridiona/meridian-darwin-arm64 1.65.0 → 1.66.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/VERSION CHANGED
@@ -1 +1 @@
1
- 1.65.0
1
+ 1.66.1
package/bin/meridian CHANGED
Binary file
package/bin/meridian-tray CHANGED
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meridiona/meridian-darwin-arm64",
3
- "version": "1.65.0",
3
+ "version": "1.66.1",
4
4
  "description": "Prebuilt Meridian app for macOS arm64 (daemon binary + dashboard + Python services). Installed via @meridiona/meridian.",
5
5
  "homepage": "https://github.com/Meridiona/meridian",
6
6
  "repository": {
@@ -9,13 +9,22 @@ from typing import Any
9
9
  # and read by every route module.
10
10
  app_state: dict[str, Any] = {}
11
11
 
12
- # Shared prefetch progress, guarded by prefetch_lock. states: idle|downloading|done|error
12
+ # Shared prefetch progress, guarded by prefetch_lock. states: idle|downloading|done|error.
13
+ # `received`/`total` are AGGREGATE byte counts summed across every pipeline model
14
+ # (llm + reranker + embedder), so the wizard's progress bar has a single honest
15
+ # denominator. The wire contract the Rust tray decodes is exactly
16
+ # state/received/total/error/speed (tray/src-tauri/src/mlx_server.rs::PrefetchStatus);
17
+ # `models` is an additive per-model breakdown the tray ignores.
13
18
  prefetch_state: dict[str, Any] = {
14
19
  "state": "idle",
15
- "model_id": None,
16
20
  "received": 0,
17
21
  "total": 0,
22
+ # Live transfer rate in bytes/sec, sourced from HF's own tqdm progress
23
+ # (see routes/prefetch._SpeedTqdm); 0 when not actively transferring.
24
+ "speed": 0.0,
18
25
  "error": None,
26
+ # Per-model rows: {"role", "model_id", "loader", "received", "total", "state"}.
27
+ "models": [],
19
28
  }
20
29
  prefetch_lock = threading.Lock()
21
30
 
@@ -15,13 +15,16 @@ import time
15
15
  from contextlib import contextmanager
16
16
  from typing import Any, Iterator
17
17
 
18
- from agents import observability
18
+ from agents import model_registry, observability
19
19
 
20
20
  log = logging.getLogger("agents.mlx_classifier")
21
21
  tracer = observability.setup("meridian-mlx-classifier")
22
22
 
23
- # Default model — the agent server runs a single hardcoded model.
24
- MODEL_ID = "mlx-community/Qwen3.5-2B-OptiQ-4bit"
23
+ # Generative/classifier checkpointresolved from the model registry (the single
24
+ # source of truth for all three pipeline models), env-overridable via MERIDIAN_LLM_ID.
25
+ # Exposed as a module attribute for external compatibility; the live value is read
26
+ # from the registry per-call inside _get_model() so a runtime env change is honoured.
27
+ MODEL_ID = model_registry.llm_id()
25
28
 
26
29
  _IDLE_EVICT_S = float(os.environ.get("MLX_IDLE_EVICT_S", "120"))
27
30
 
@@ -48,7 +51,7 @@ def _get_model() -> _ModelBundle:
48
51
  Cache-miss load is done under _model_lock (double-checked) so concurrent
49
52
  callers can't double-load and the idle evictor can't race the load.
50
53
  """
51
- model_id = MODEL_ID
54
+ model_id = model_registry.llm_id()
52
55
  cached = _model_cache.get(model_id)
53
56
  if cached is not None:
54
57
  return cached
@@ -65,14 +68,14 @@ def _get_model() -> _ModelBundle:
65
68
  "Install with: pip install 'mlx-lm>=0.22'"
66
69
  ) from exc
67
70
 
68
- log.info("run_task_linker_mlx: loading %s (first call this process)", model_id)
71
+ log.info("mlx_classifier: loading %s (first call this process)", model_id)
69
72
  t0 = time.time()
70
73
  mlx_model, tokenizer = mlx_lm.load(
71
74
  model_id,
72
75
  tokenizer_config={"trust_remote_code": True},
73
76
  )
74
77
  bundle = _ModelBundle(mlx_model, tokenizer)
75
- log.info("run_task_linker_mlx: model loaded in %.1fs", time.time() - t0)
78
+ log.info("mlx_classifier: model loaded in %.1fs", time.time() - t0)
76
79
 
77
80
  _model_cache[model_id] = bundle
78
81
  _tokenizer_cache[model_id] = tokenizer
@@ -81,7 +84,7 @@ def _get_model() -> _ModelBundle:
81
84
 
82
85
  def _get_tokenizer() -> Any:
83
86
  """Return the HF tokenizer for the current model, loading the model if needed."""
84
- model_id = MODEL_ID
87
+ model_id = model_registry.llm_id()
85
88
  tok = _tokenizer_cache.get(model_id)
86
89
  if tok is not None:
87
90
  return tok
@@ -135,7 +138,7 @@ def maybe_evict_idle(idle_s: float | None = None) -> float | None:
135
138
  mx.clear_cache()
136
139
  freed = max(0.0, (before - mx.get_active_memory()) / 1e9)
137
140
  log.info(
138
- "run_task_linker_mlx: evicted idle model (idle ≥ %.0fs), freed ~%.1f GB",
141
+ "mlx_classifier: evicted idle model (idle ≥ %.0fs), freed ~%.1f GB",
139
142
  ttl, freed,
140
143
  )
141
144
  return freed
@@ -169,7 +172,7 @@ def evict_resident_model() -> float | None:
169
172
  if mx is not None:
170
173
  mx.clear_cache()
171
174
  freed = max(0.0, (before - mx.get_active_memory()) / 1e9)
172
- log.info("run_task_linker_mlx: force-evicted resident model, freed ~%.1f GB", freed)
175
+ log.info("mlx_classifier: force-evicted resident model, freed ~%.1f GB", freed)
173
176
  return freed
174
177
  finally:
175
178
  _model_lock.release()
@@ -0,0 +1,108 @@
1
+ """Single source of truth for the on-device models the pipeline needs.
2
+
3
+ The end-to-end worklog pipeline (classification → worklog update) runs three
4
+ distinct models, each in its own single-slot turn (the runtime never keeps two
5
+ resident at once — see ``mlx_classifier.evict_resident_model``):
6
+
7
+ role default checkpoint loader used by
8
+ ──────── ─────────────────────────────────────── ────────────── ──────────────────────────────────
9
+ llm mlx-community/Qwen3.5-2B-OptiQ-4bit mlx_lm classify · match · /summarise · /activity_report · worklog synth · propose
10
+ reranker kerncore/Qwen3-Reranker-0.6B-MLX-4bit mlx_lm ticket↔worklog scoring (/rerank)
11
+ embedder mlx-community/Qwen3-Embedding-0.6B-8bit mlx_embeddings session-distillation SemDeDup (/distill_hour)
12
+
13
+ There is no separate "classifier" model: classification and matching run on the
14
+ ``llm`` checkpoint via the OpenAI-compatible endpoint.
15
+
16
+ Every checkpoint is env-overridable (defaults below) so eval/experiments can
17
+ swap any role without code edits. This module is the ONLY place these ids and
18
+ their HuggingFace download filesets are declared — ``mlx_classifier``,
19
+ ``reranker``, ``session_distiller`` and the ``/prefetch_model`` route all read
20
+ from here, so onboarding can eagerly fetch exactly the set the runtime will load.
21
+
22
+ # Who reads this
23
+ agents.mlx_classifier → MODEL_ID (llm)
24
+ agents.reranker → _RERANKER_ID (reranker)
25
+ agents.session_distiller → embedder load (embedder)
26
+ agents.routes.prefetch → eager multi-model download for the setup wizard
27
+ """
28
+ from __future__ import annotations
29
+
30
+ import os
31
+ from dataclasses import dataclass
32
+
33
+ # mlx_lm.load()'s default fileset — exactly what a generative or reranker MLX
34
+ # repo resolves on load(). The mlx-community embedding repos are plain
35
+ # transformer encoders shipping the same config/safetensors/tokenizer layout,
36
+ # so this pattern set covers all three roles. Stored as a tuple (immutable) so
37
+ # each ModelSpec gets independent identity — list mutation would silently corrupt
38
+ # all three specs' snapshot_download filters simultaneously.
39
+ _MLX_ALLOW_PATTERNS: tuple[str, ...] = (
40
+ "*.json", "model*.safetensors", "*.py", "tokenizer.model",
41
+ "*.tiktoken", "tiktoken.model", "*.txt", "*.jsonl", "*.jinja",
42
+ )
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class ModelSpec:
47
+ """One model role: its env override, default checkpoint, loader, and fileset.
48
+
49
+ ``loader`` selects the runtime entry point — ``"mlx_lm"`` for generative /
50
+ reranker weights (``mlx_lm.load``) and ``"mlx_embeddings"`` for the encoder
51
+ (``mlx_embeddings.load``). ``allow_patterns`` is the HF download filter the
52
+ prefetch route applies so it fetches exactly what ``load()`` will resolve.
53
+ """
54
+
55
+ role: str
56
+ env_var: str
57
+ default_id: str
58
+ loader: str
59
+ allow_patterns: tuple[str, ...]
60
+
61
+ @property
62
+ def model_id(self) -> str:
63
+ """Resolved checkpoint id — the env override if set, else the default."""
64
+ return os.environ.get(self.env_var, self.default_id)
65
+
66
+
67
+ LLM = ModelSpec(
68
+ role="llm",
69
+ env_var="MERIDIAN_LLM_ID",
70
+ default_id="mlx-community/Qwen3.5-2B-OptiQ-4bit",
71
+ loader="mlx_lm",
72
+ allow_patterns=_MLX_ALLOW_PATTERNS,
73
+ )
74
+
75
+ RERANKER = ModelSpec(
76
+ role="reranker",
77
+ env_var="WORKLOG_RERANKER_ID",
78
+ default_id="kerncore/Qwen3-Reranker-0.6B-MLX-4bit",
79
+ loader="mlx_lm",
80
+ allow_patterns=_MLX_ALLOW_PATTERNS,
81
+ )
82
+
83
+ EMBEDDER = ModelSpec(
84
+ role="embedder",
85
+ env_var="MERIDIAN_EMBEDDER_ID",
86
+ default_id="mlx-community/Qwen3-Embedding-0.6B-8bit",
87
+ loader="mlx_embeddings",
88
+ allow_patterns=_MLX_ALLOW_PATTERNS,
89
+ )
90
+
91
+ # Ordered llm → reranker → embedder: the wizard prefetches in this order, and the
92
+ # llm is largest so its progress dominates the bar first.
93
+ ALL_SPECS: tuple[ModelSpec, ...] = (LLM, RERANKER, EMBEDDER)
94
+
95
+
96
+ def llm_id() -> str:
97
+ """Resolved generative/classifier checkpoint id."""
98
+ return LLM.model_id
99
+
100
+
101
+ def reranker_id() -> str:
102
+ """Resolved reranker checkpoint id."""
103
+ return RERANKER.model_id
104
+
105
+
106
+ def embedder_id() -> str:
107
+ """Resolved embedder checkpoint id."""
108
+ return EMBEDDER.model_id
@@ -12,6 +12,8 @@ from __future__ import annotations
12
12
 
13
13
  import os
14
14
 
15
+ from agents import model_registry
16
+
15
17
  # Re-export the shared paths so callers don't have to know about the parent
16
18
  # config module.
17
19
  from agents.config import MERIDIAN_DB, MERIDIAN_HOME # noqa: F401
@@ -27,7 +29,7 @@ PM_WORKLOG_INTERVAL_HOURS = float(os.environ.get("PM_WORKLOG_INTERVAL_HOURS", "1
27
29
  # OpenAILike. The port is the same one the classifier uses.
28
30
  MLX_SERVER_HOST = os.environ.get("MLX_SERVER_HOST", "127.0.0.1")
29
31
  MLX_SERVER_PORT = int(os.environ.get("MLX_SERVER_PORT", "7823"))
30
- MLX_SERVER_MODEL = os.environ.get("MLX_SERVER_MODEL", "mlx-community/Qwen3.5-2B-OptiQ-4bit")
32
+ MLX_SERVER_MODEL = os.environ.get("MLX_SERVER_MODEL") or model_registry.llm_id()
31
33
 
32
34
  # Token caps. The MLX model exposes 128-262K context — a single Synthesise
33
35
  # call comfortably swallows even the heaviest hour of work.
@@ -16,16 +16,17 @@ from __future__ import annotations
16
16
 
17
17
  import gc
18
18
  import logging
19
- import os
20
19
  import threading
21
20
  import time
22
21
  from typing import Any
23
22
 
23
+ from agents import model_registry
24
+
24
25
  log = logging.getLogger("meridian.reranker")
25
26
 
26
- _RERANKER_ID = os.environ.get(
27
- "WORKLOG_RERANKER_ID", "kerncore/Qwen3-Reranker-0.6B-MLX-4bit"
28
- )
27
+ # Reranker checkpoint — resolved from the model registry, still env-overridable
28
+ # via WORKLOG_RERANKER_ID (the registry owns that env var).
29
+ _RERANKER_ID = model_registry.reranker_id()
29
30
 
30
31
  # Reranker prompt scaffold — matches the calibration used in the offline
31
32
  # benchmark (services/tests/evals/cluster/test_matcher_benchmark.py).
@@ -1,25 +1,42 @@
1
1
  """Prefetch routes — /prefetch_model and /prefetch_status.
2
2
 
3
- Eager, spec-aware model download for the onboarding wizard. The wizard's Model
4
- step calls /prefetch_model right after the runtime is chosen, then polls
5
- /prefetch_status for live progress. Downloads run in a background thread so the
6
- event loop is never blocked; progress is shared via prefetch_state in agents._state.
3
+ Eager, registry-driven download of EVERY model the end-to-end pipeline needs
4
+ (llm + reranker + embedder see agents.model_registry), so the onboarding wizard
5
+ can fetch the whole set before the user reaches the dashboard and no first
6
+ worklog run stalls on a silent mid-pipeline download.
7
+
8
+ The wizard's Model step calls /prefetch_model once the runtime is up, then polls
9
+ /prefetch_status for live progress. Downloads run in one background thread
10
+ (sequential, so bandwidth isn't fragmented) and never block the event loop;
11
+ progress is shared via prefetch_state in agents._state. `received`/`total` are
12
+ aggregate byte counts across all models — the single denominator the wizard's
13
+ progress bar renders.
7
14
  """
8
15
  from __future__ import annotations
9
16
 
17
+ import asyncio
10
18
  import logging
11
19
  import threading
20
+ import time
12
21
  from pathlib import Path
13
22
 
14
23
  from fastapi import APIRouter
15
24
  from opentelemetry import trace
16
25
 
17
- from agents._state import app_state, prefetch_state, prefetch_lock
26
+ from agents import model_registry
27
+ from agents._state import app_state, prefetch_lock, prefetch_state
18
28
 
19
29
  log = logging.getLogger("agents.server")
20
30
 
21
31
  router = APIRouter()
22
32
 
33
+ # Speed is derived from the growth of on-disk `received` between /prefetch_status
34
+ # polls — NOT from a tqdm hook. hf_xet (the Xet accelerator these models use) does
35
+ # its download in Rust and bypasses the classic tqdm bar, so a tqdm_class never
36
+ # fires; the byte-delta approach works for any backend. Stored as a dict so both
37
+ # functions can mutate it in-place without `global` declarations.
38
+ _speed_state: dict[str, float | int] = {"recv": 0, "ts": 0.0}
39
+
23
40
 
24
41
  def _hf_cache_dir_for(model_id: str) -> Path:
25
42
  """The HF hub cache directory for `model_id` (where partial + complete blobs land)."""
@@ -28,111 +45,218 @@ def _hf_cache_dir_for(model_id: str) -> Path:
28
45
 
29
46
 
30
47
  def _dir_size_bytes(path: Path) -> int:
31
- """Sum of all file sizes under `path` (includes HF `.incomplete` partials → live progress)."""
48
+ """Bytes actually downloaded under `path` (HF blobs + `.incomplete` partials).
49
+
50
+ Counts only real files, NEVER the ``snapshots/`` symlinks: HF stores each file
51
+ once in ``blobs/`` and symlinks it into ``snapshots/``, so following the links
52
+ would double-count every finished file and push progress past 100%.
53
+ """
32
54
  total = 0
33
55
  if path.exists():
34
56
  for f in path.rglob("*"):
35
57
  try:
36
- if f.is_file():
58
+ if f.is_file() and not f.is_symlink():
37
59
  total += f.stat().st_size
38
60
  except OSError:
39
61
  pass # file may be deleted mid-walk; skip silently
40
62
  return total
41
63
 
42
64
 
43
- def _prefetch_total_bytes(model_id: str) -> int:
44
- """Authoritative download size: sum HF sibling sizes filtered to the load() patterns.
65
+ def _spec_total_bytes(spec: model_registry.ModelSpec) -> int:
66
+ """Authoritative download size for one model: sum HF sibling sizes filtered to
67
+ the spec's load() patterns.
45
68
 
46
69
  Computed upfront so the wizard's progress bar has a stable denominator, instead
47
70
  of summing concurrent per-file tqdm totals (which lurch as new bars spawn).
48
71
  """
49
72
  import fnmatch
50
- from huggingface_hub import HfApi
51
73
 
52
- # Lazy import avoids a circular import: agents.server imports this module at
53
- # load time, so prefetch.py cannot import from agents.server at module scope.
54
- from agents.server import _MODEL_ALLOW_PATTERNS
74
+ from huggingface_hub import HfApi
55
75
 
56
- info = HfApi().model_info(model_id, files_metadata=True)
76
+ info = HfApi().model_info(spec.model_id, files_metadata=True)
57
77
  total = 0
58
78
  for sib in info.siblings or []:
59
- if any(fnmatch.fnmatch(sib.rfilename, pat) for pat in _MODEL_ALLOW_PATTERNS):
79
+ if any(fnmatch.fnmatch(sib.rfilename, pat) for pat in spec.allow_patterns):
60
80
  total += sib.size or 0
61
81
  return total
62
82
 
63
83
 
64
- def _run_prefetch(model_id: str) -> None:
65
- """Background worker: download the model's weights to the HF cache (no load)."""
66
- from agents.server import _MODEL_ALLOW_PATTERNS
84
+ def _download_spec(spec: model_registry.ModelSpec) -> None:
85
+ """Download one model's weights to the HF cache (no load into memory).
86
+
87
+ All specs go through ``snapshot_download`` (filtered to the spec's
88
+ allow_patterns); ``hf-xet`` transparently accelerates the Xet-backed transfers.
89
+ ``mlx_lm.load`` / ``mlx_embeddings.load`` resolve the files straight from the
90
+ cache afterwards. Only disk is touched — the single-slot runtime loads each
91
+ model lazily on first use.
92
+ """
93
+ from huggingface_hub import snapshot_download
94
+ snapshot_download(spec.model_id, allow_patterns=spec.allow_patterns)
67
95
 
96
+
97
+ def _run_prefetch(specs: list[model_registry.ModelSpec]) -> None:
98
+ """Background worker: download every model in `specs` to the HF cache in order."""
68
99
  tracer = trace.get_tracer(__name__)
69
- with tracer.start_as_current_span("model_prefetch") as span:
70
- span.set_attribute("model_id", model_id)
100
+ with tracer.start_as_current_span("model_prefetch_all") as root:
101
+ root.set_attribute("model_count", len(specs))
71
102
  try:
72
- try:
73
- from mlx_lm.utils import _download as _mlx_download
74
- _mlx_download(model_id) # exact fileset load() resolves; download-only
75
- except (ImportError, AttributeError):
76
- # Private primitive unavailable — replicate load()'s default patterns.
77
- from huggingface_hub import snapshot_download
78
- snapshot_download(model_id, allow_patterns=_MODEL_ALLOW_PATTERNS)
79
- received = _dir_size_bytes(_hf_cache_dir_for(model_id))
103
+ for i, spec in enumerate(specs):
104
+ with prefetch_lock:
105
+ prefetch_state["models"][i]["state"] = "downloading"
106
+ with tracer.start_as_current_span("model_prefetch") as span:
107
+ span.set_attribute("role", spec.role)
108
+ span.set_attribute("model_id", spec.model_id)
109
+ try:
110
+ _download_spec(spec)
111
+ received = _dir_size_bytes(_hf_cache_dir_for(spec.model_id))
112
+ span.set_attribute("received_bytes", received)
113
+ except Exception as exc:
114
+ span.set_status(trace.Status(trace.StatusCode.ERROR, str(exc)))
115
+ span.record_exception(exc)
116
+ with prefetch_lock:
117
+ prefetch_state["models"][i]["state"] = "error"
118
+ raise
119
+ with prefetch_lock:
120
+ row = prefetch_state["models"][i]
121
+ row["received"] = received or row["total"]
122
+ row["state"] = "done"
123
+ prefetch_state["received"] = sum(m["received"] for m in prefetch_state["models"])
124
+ log.info(
125
+ "server: model prefetch complete",
126
+ extra={"role": spec.role, "model_id": spec.model_id, "received_bytes": received},
127
+ )
80
128
  with prefetch_lock:
81
- prefetch_state["received"] = received or prefetch_state["total"]
129
+ prefetch_state["received"] = (
130
+ sum(m["received"] for m in prefetch_state["models"]) or prefetch_state["total"]
131
+ )
82
132
  prefetch_state["state"] = "done"
83
- span.set_attribute("received_bytes", received)
84
- log.info("server: model prefetch complete", extra={"model_id": model_id, "received_bytes": received})
133
+ prefetch_state["speed"] = 0.0
134
+ root.set_attribute("received_bytes", prefetch_state["received"])
135
+ log.info("server: all model prefetch complete", extra={"received_bytes": prefetch_state["received"]})
85
136
  except Exception as exc: # noqa: BLE001 — report, never crash the server
86
137
  with prefetch_lock:
87
138
  prefetch_state["state"] = "error"
88
139
  prefetch_state["error"] = str(exc)
89
- span.set_status(trace.Status(trace.StatusCode.ERROR, str(exc)))
90
- log.error("server: model prefetch failed", extra={"model_id": model_id, "error": str(exc)})
140
+ prefetch_state["speed"] = 0.0
141
+ root.set_status(trace.Status(trace.StatusCode.ERROR, str(exc)))
142
+ log.exception("server: model prefetch failed", extra={"error": str(exc)})
91
143
 
92
144
 
93
145
  @router.post("/prefetch_model")
94
146
  async def prefetch_model() -> dict:
95
- """Start the eager, spec-aware model download (idempotent). Returns current status.
147
+ """Start the eager, registry-driven download of all pipeline models (idempotent).
96
148
 
97
- Apple Intelligence backend nothing to download (no-op `done`). Re-POSTing
98
- while `downloading`/`done` returns the live state without spawning a second
149
+ The model set is fixed per run (the registry), so re-POSTing while
150
+ `downloading`/`done` returns the live state without spawning a second
99
151
  download; an earlier `error` is retried.
152
+
153
+ On Apple Intelligence / non-MLX backends, `mlx_module` is None and there
154
+ are no weights to download — return `done` immediately so the wizard
155
+ advances without trying to prefetch.
100
156
  """
157
+ if app_state.get("mlx_module") is None:
158
+ return {"state": "done", "received": 0, "total": 0, "speed": 0.0, "error": None, "models": []}
159
+
101
160
  from fastapi.concurrency import run_in_threadpool
102
161
 
103
- m = app_state.get("mlx_module")
104
- model_id = m.MODEL_ID if m else None
105
- if model_id is None:
106
- return {"state": "done", "model_id": model_id, "received": 0, "total": 0, "error": None}
162
+ specs = list(model_registry.ALL_SPECS)
107
163
 
108
164
  with prefetch_lock:
109
- # Idempotent only for the SAME model: a completed/in-flight prefetch for
110
- # one model must not block starting a different one after the user changes
111
- # their model preference (which changes what MODEL_ID returns).
112
- same_model = prefetch_state.get("model_id") == model_id
113
- if same_model and prefetch_state["state"] in ("downloading", "done"):
165
+ if prefetch_state["state"] in ("downloading", "done"):
114
166
  return dict(prefetch_state) # idempotent — no duplicate downloads
115
- prefetch_state.update(state="downloading", model_id=model_id, received=0, total=0, error=None)
167
+ _speed_state["recv"] = 0
168
+ _speed_state["ts"] = 0.0
169
+ prefetch_state.update(
170
+ state="downloading",
171
+ received=0,
172
+ total=0,
173
+ error=None,
174
+ speed=0.0,
175
+ models=[
176
+ {"role": s.role, "model_id": s.model_id, "loader": s.loader,
177
+ "received": 0, "total": 0, "state": "pending"}
178
+ for s in specs
179
+ ],
180
+ )
116
181
 
117
- try:
118
- total = await run_in_threadpool(_prefetch_total_bytes, model_id)
119
- except Exception as exc: # noqa: BLE001 size probe is best-effort; download still runs
120
- total = 0
121
- log.warning("server: prefetch size-probe failed (bar will be indeterminate)", extra={"error": str(exc)})
182
+ # Per-model size probe (best-effort; a failed probe just leaves that model's
183
+ # denominator at 0 — the download still runs). All three probes fire in
184
+ # parallel so pre-download latency is bounded by the slowest single HF API
185
+ # call rather than tripling it.
186
+ probe_results = await asyncio.gather(
187
+ *[run_in_threadpool(_spec_total_bytes, s) for s in specs],
188
+ return_exceptions=True,
189
+ )
190
+ totals: list[int] = []
191
+ for spec, r in zip(specs, probe_results, strict=True):
192
+ if isinstance(r, int):
193
+ totals.append(r)
194
+ else:
195
+ totals.append(0)
196
+ log.warning(
197
+ "server: prefetch size-probe failed (bar partially indeterminate)",
198
+ extra={"model_id": spec.model_id, "error": str(r)},
199
+ )
122
200
  with prefetch_lock:
123
- prefetch_state["total"] = total
201
+ for i, t in enumerate(totals):
202
+ prefetch_state["models"][i]["total"] = t
203
+ prefetch_state["total"] = sum(totals)
124
204
 
125
- threading.Thread(target=_run_prefetch, args=(model_id,), daemon=True).start()
126
- log.info("server: model prefetch started", extra={"model_id": model_id, "total_bytes": total})
205
+ threading.Thread(target=_run_prefetch, args=(specs,), daemon=True).start()
206
+ log.info(
207
+ "server: model prefetch started",
208
+ extra={"model_count": len(specs), "total_bytes": sum(totals)},
209
+ )
127
210
  with prefetch_lock:
128
211
  return dict(prefetch_state)
129
212
 
130
213
 
131
214
  @router.get("/prefetch_status")
132
215
  async def prefetch_status() -> dict:
133
- """Live prefetch progress. `received` is recomputed from the cache dir while downloading."""
216
+ """Live prefetch progress. While downloading, `received` is recomputed from the
217
+ on-disk cache dirs of every model so the aggregate bar advances smoothly.
218
+
219
+ The disk walk runs in a threadpool so this endpoint never blocks the event
220
+ loop — the wizard polls it ~1 Hz and must stay responsive even while a model
221
+ is downloading or loading.
222
+ """
223
+ from fastapi.concurrency import run_in_threadpool
224
+
134
225
  with prefetch_lock:
135
226
  st = dict(prefetch_state)
136
- if st["state"] == "downloading" and st["model_id"]:
137
- st["received"] = _dir_size_bytes(_hf_cache_dir_for(st["model_id"]))
227
+ models = list(st.get("models", []))
228
+ if st["state"] == "downloading" and models:
229
+ def _recompute() -> int:
230
+ # Clamp each model's on-disk bytes to its probed total: a cache
231
+ # populated by an earlier full-repo pull can hold more than the
232
+ # patterns-filtered total, which would otherwise push the bar past 100%.
233
+ agg = 0
234
+ for mrow in models:
235
+ size = _dir_size_bytes(_hf_cache_dir_for(mrow["model_id"]))
236
+ cap = mrow.get("total") or 0
237
+ agg += min(size, cap) if cap > 0 else size
238
+ return agg
239
+
240
+ agg = await run_in_threadpool(_recompute)
241
+ with prefetch_lock:
242
+ # Re-check state: _run_prefetch may have finished while the disk
243
+ # walk was running. If so, return the authoritative completed state.
244
+ if prefetch_state["state"] != "downloading":
245
+ st = dict(prefetch_state)
246
+ else:
247
+ st["received"] = min(agg, st["total"]) if st["total"] > 0 else agg
248
+
249
+ # Derive speed (bytes/sec) from how much `received` grew since the last poll.
250
+ # EMA-smoothed; decays to 0 when bytes stop flowing (delta 0) or the run ends.
251
+ now = time.monotonic()
252
+ with prefetch_lock:
253
+ if st["state"] == "downloading" and _speed_state["ts"] and now > _speed_state["ts"]:
254
+ inst = max(0, st["received"] - _speed_state["recv"]) / (now - _speed_state["ts"])
255
+ prev = prefetch_state.get("speed", 0.0) or 0.0
256
+ prefetch_state["speed"] = inst if prev == 0.0 else prev * 0.5 + inst * 0.5
257
+ elif st["state"] != "downloading":
258
+ prefetch_state["speed"] = 0.0
259
+ _speed_state["recv"] = st["received"]
260
+ _speed_state["ts"] = now
261
+ st["speed"] = prefetch_state["speed"]
138
262
  return st
@@ -43,15 +43,6 @@ from agents.routes import (
43
43
 
44
44
  log = logging.getLogger("agents.server")
45
45
 
46
- # Download patterns matching mlx_lm.load()'s default fileset — used by the
47
- # prefetch route to size and fetch exactly what the runtime will load. Lives
48
- # here (not in the route module) because it is the server's view of what a
49
- # model "is"; routes/prefetch.py imports it lazily.
50
- _MODEL_ALLOW_PATTERNS = [
51
- "*.json", "model*.safetensors", "*.py", "tokenizer.model",
52
- "*.tiktoken", "tiktoken.model", "*.txt", "*.jsonl", "*.jinja",
53
- ]
54
-
55
46
 
56
47
  # ---------------------------------------------------------------------------
57
48
  # Lifespan — model loaded lazily, evicted when idle