@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 +1 -1
- package/bin/meridian +0 -0
- package/bin/meridian-tray +0 -0
- package/package.json +1 -1
- package/services/agents/_state.py +11 -2
- package/services/agents/mlx_classifier.py +12 -9
- package/services/agents/model_registry.py +108 -0
- package/services/agents/pm_worklog_update/config.py +3 -1
- package/services/agents/reranker.py +5 -4
- package/services/agents/routes/prefetch.py +181 -57
- package/services/agents/server.py +0 -9
- package/services/agents/session_distiller.py +38 -14
- package/services/pyproject.toml +11 -1
- package/services/scripts/com.meridiona.mlx-server.plist +13 -0
- package/services/uv.lock +333 -31
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
1.
|
|
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.
|
|
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
|
-
#
|
|
24
|
-
|
|
23
|
+
# Generative/classifier checkpoint — resolved 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 =
|
|
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("
|
|
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("
|
|
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 =
|
|
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
|
-
"
|
|
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("
|
|
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"
|
|
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
|
-
|
|
27
|
-
|
|
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,
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
|
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
|
-
"""
|
|
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
|
|
44
|
-
"""Authoritative download size: sum HF sibling sizes filtered to
|
|
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
|
-
|
|
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
|
|
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
|
|
65
|
-
"""
|
|
66
|
-
|
|
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("
|
|
70
|
-
|
|
100
|
+
with tracer.start_as_current_span("model_prefetch_all") as root:
|
|
101
|
+
root.set_attribute("model_count", len(specs))
|
|
71
102
|
try:
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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"] =
|
|
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
|
-
|
|
84
|
-
|
|
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
|
-
|
|
90
|
-
|
|
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,
|
|
147
|
+
"""Start the eager, registry-driven download of all pipeline models (idempotent).
|
|
96
148
|
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
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=(
|
|
126
|
-
log.info(
|
|
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
|
|
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
|
-
|
|
137
|
-
|
|
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
|