aris-server 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,3 @@
1
+ """Aris Server: open-weight models behind the Anthropic Messages API."""
2
+
3
+ __version__ = "0.1.0"
aris_server/budget.py ADDED
@@ -0,0 +1,82 @@
1
+ """Think budget: close a model's reasoning on a complete thought once it has used its allowance."""
2
+
3
+ import mlx.core as mx
4
+
5
+
6
+ class ThinkBudget:
7
+ """Logits processor. Counts generated tokens while thinking. Past `budget`, outside a code fence, a paragraph
8
+ break becomes a single newline and the end-of-thinking token is forced after the next newline; `overrun` tokens
9
+ later it closes regardless (an open fence, or a line that never ends: models can loop in place). Inert once
10
+ thinking has ended.
11
+
12
+ Ending on a single newline matters: chat templates re-render past reasoning as `text\\n</think>`, so a turn that
13
+ closed after a paragraph break (`text\\n\\n</think>`) no longer matches its own prompt cache, and the next turn
14
+ re-reads everything from that point."""
15
+
16
+ def __init__(self, budget: int, end_id: int, newline_id: int, paragraph_ids: frozenset[int],
17
+ fence_counts: dict[int, int], overrun: int = 1024):
18
+ self.budget, self.end_id, self.newline_id, self.overrun = budget, end_id, newline_id, overrun
19
+ self.paragraph_ids, self.fence_counts = paragraph_ids, fence_counts
20
+ self.n = self.fences = 0
21
+ self.done = self.forced = False
22
+ self._paragraphs = None
23
+
24
+ def _only(self, token_id, logits):
25
+ mask = mx.arange(logits.shape[-1]) == token_id
26
+ return mx.broadcast_to(mx.where(mask, mx.array(0.0, logits.dtype), mx.array(-mx.inf, logits.dtype)),
27
+ logits.shape)
28
+
29
+ def __call__(self, tokens, logits):
30
+ if self.done or tokens is None or len(tokens) == 0:
31
+ return logits
32
+ last = int(tokens[-1].item())
33
+ if last == self.end_id:
34
+ self.done = True
35
+ return logits
36
+ self.n += 1
37
+ self.fences += self.fence_counts.get(last, 0)
38
+ if self.n < self.budget:
39
+ return logits
40
+ hard = self.n >= self.budget + self.overrun
41
+ if self.fences % 2 and not hard:
42
+ return logits
43
+ if last == self.newline_id:
44
+ self.done = self.forced = True
45
+ return self._only(self.end_id, logits)
46
+ if hard:
47
+ return self._only(self.newline_id, logits)
48
+ # A paragraph break the model would take becomes a single newline, which then closes thinking.
49
+ if self._paragraphs is None:
50
+ ids = sorted(self.paragraph_ids)
51
+ self._paragraphs = mx.array(ids) if ids else None
52
+ if self._paragraphs is None:
53
+ return logits
54
+ flat = logits.reshape(-1, logits.shape[-1])
55
+ merged = mx.logaddexp(flat[:, self.newline_id], mx.logsumexp(flat[:, self._paragraphs], axis=-1))
56
+ flat[:, self._paragraphs] = -mx.inf
57
+ flat[:, self.newline_id] = merged
58
+ return flat.reshape(logits.shape)
59
+
60
+
61
+ class BudgetTokens:
62
+ """Per-tokenizer lookups the processor needs, computed once per model."""
63
+
64
+ def __init__(self, tokenizer):
65
+ end = tokenizer.think_end_tokens if hasattr(tokenizer, "think_end_tokens") else None
66
+ self.end_id = end[0] if end and len(end) == 1 else None
67
+ newline = tokenizer.encode("\n", add_special_tokens=False) if hasattr(tokenizer, "encode") else []
68
+ self.newline_id = newline[0] if len(newline) == 1 else None
69
+ self.paragraph_ids, self.fence_counts = set(), {}
70
+ decode = tokenizer.decode
71
+ for i in range(len(tokenizer.vocab) if hasattr(tokenizer, "vocab") else tokenizer.vocab_size):
72
+ text = decode([i])
73
+ if text.endswith("\n\n"):
74
+ self.paragraph_ids.add(i)
75
+ if "```" in text:
76
+ self.fence_counts[i] = text.count("```")
77
+ self.paragraph_ids = frozenset(self.paragraph_ids)
78
+
79
+ def make(self, budget: int) -> ThinkBudget | None:
80
+ if budget <= 0 or self.end_id is None or self.newline_id is None:
81
+ return None
82
+ return ThinkBudget(budget, self.end_id, self.newline_id, self.paragraph_ids, self.fence_counts)
aris_server/cli.py ADDED
@@ -0,0 +1,141 @@
1
+ """aris-server command line."""
2
+
3
+ import argparse
4
+ import dataclasses
5
+ import logging
6
+ import signal
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from . import __version__
11
+ from .config import builtin_models, home, load_models, resolve
12
+
13
+
14
+ def _wired_limit():
15
+ from mlx_lm.utils import maybe_set_recommended_wired_limit
16
+
17
+ maybe_set_recommended_wired_limit()
18
+
19
+
20
+ def _quiet_dependencies(level):
21
+ """mlx-lm logs through the root logger and httpx logs every Hugging Face request; keep only their warnings."""
22
+ for name in ("httpx", "huggingface_hub"):
23
+ logging.getLogger(name).setLevel(level)
24
+
25
+ def keep(record):
26
+ noisy = "mlx_lm" in record.pathname or record.name.startswith(("httpx", "huggingface_hub"))
27
+ return record.levelno >= level or not noisy
28
+
29
+ for handler in logging.getLogger().handlers:
30
+ handler.addFilter(keep)
31
+
32
+
33
+ def main(argv=None):
34
+ ap = argparse.ArgumentParser(prog="aris-server", description="Open-weight models behind the Anthropic Messages API.")
35
+ ap.add_argument("--version", action="version", version=f"aris-server {__version__}")
36
+ sub = ap.add_subparsers(dest="cmd", required=True)
37
+ s = sub.add_parser("serve", help="load a model and serve it")
38
+ s.add_argument("--model", help="configured model name, local MLX folder, or Hugging Face repo id")
39
+ s.add_argument("--adapter", help="LoRA adapter folder to apply on top of the model")
40
+ s.add_argument("--kv-bits", type=int, choices=[4, 8, 16],
41
+ help="bits per KV cache value (16 = full precision); overrides the model's kv_bits")
42
+ s.add_argument("--host", default="127.0.0.1")
43
+ s.add_argument("--port", type=int, default=8484)
44
+ s.add_argument("--api-key", help="require this key in x-api-key")
45
+ s.add_argument("--concurrency", type=int, default=4, help="sequences decoded at once")
46
+ s.add_argument("--prompt-cache-gb", type=float, help="memory for saved prompt caches (default: fit to the GPU)")
47
+ s.add_argument("--disk-cache-gb", type=float, default=20.0,
48
+ help="disk for saved prompt caches, all models together (0 turns it off)")
49
+ s.add_argument("--reserve-gb", type=float, default=6.0, help="GPU memory kept free for active sequences")
50
+ s.add_argument("--log-level", default="INFO")
51
+ sub.add_parser("models", help="list configured and built-in models")
52
+ p = sub.add_parser("pull", help="download the weights a model would load on this Mac, ahead of serving it")
53
+ p.add_argument("model", help="configured or built-in model name, or Hugging Face repo id")
54
+ p.add_argument("--json", action="store_true", help="report each step as a JSON line on stdout, for apps")
55
+ svc = sub.add_parser("service", help="run as a login service that restarts itself (macOS launchd)")
56
+ svc.add_argument("action", choices=["install", "uninstall", "status"])
57
+ svc.add_argument("serve_args", nargs=argparse.REMAINDER, help="arguments for serve, after --")
58
+ a = ap.parse_args(argv)
59
+
60
+ if a.cmd == "service":
61
+ from . import service
62
+
63
+ if a.action == "install":
64
+ return service.install([x for x in a.serve_args if x != "--"])
65
+ return service.uninstall() if a.action == "uninstall" else service.status()
66
+
67
+ models = load_models()
68
+ if a.cmd == "models":
69
+ if not models:
70
+ print(f"no models configured in {home() / 'models.toml'}")
71
+ for name, spec in models.items():
72
+ print(f"{name:24} {' or '.join(spec.choices) or spec.path}")
73
+ for name, spec in builtin_models().items():
74
+ if name not in models:
75
+ print(f"{name:24} {' or '.join(spec.choices) or spec.path} (built in)")
76
+ return 0
77
+ if a.cmd == "pull":
78
+ logging.basicConfig(level=logging.WARNING, format="%(levelname)s %(message)s") # stdout is the report
79
+ _quiet_dependencies(logging.WARNING)
80
+ import mlx.core as mx
81
+
82
+ from .pull import pull
83
+
84
+ return pull(resolve(a.model, models), mx.device_info()["max_recommended_working_set_size"], as_json=a.json)
85
+
86
+ logging.basicConfig(level=getattr(logging, a.log_level.upper(), logging.INFO),
87
+ format="%(asctime)s %(levelname)s %(message)s")
88
+ _quiet_dependencies(logging.DEBUG if a.log_level.upper() == "DEBUG" else logging.WARNING)
89
+ name = a.model or next(iter(models), None)
90
+ if not name:
91
+ print("no model: pass --model or configure one in models.toml", file=sys.stderr)
92
+ return 2
93
+ if a.host not in ("127.0.0.1", "localhost", "::1") and not a.api_key:
94
+ logging.warning("serving on %s without --api-key: anyone who can reach this port can use the model", a.host)
95
+
96
+ import mlx.core as mx
97
+
98
+ from .engine import Engine, NotEnoughMemory, check_memory, choose_weights, weights_and_config
99
+ from .http import Server
100
+
101
+ _wired_limit()
102
+ spec = resolve(name, models)
103
+ if a.adapter:
104
+ spec = dataclasses.replace(spec, adapter=str(Path(a.adapter).expanduser().resolve()))
105
+ if a.kv_bits:
106
+ spec = dataclasses.replace(spec, kv_bits=None if a.kv_bits == 16 else a.kv_bits)
107
+ if spec.adapter and not (Path(spec.adapter) / "adapter_config.json").is_file():
108
+ print(f"no adapter_config.json in {spec.adapter}", file=sys.stderr)
109
+ return 2
110
+ try:
111
+ # Before loading, and for a Hugging Face repo before downloading: a model this Mac can't hold would swap it to
112
+ # a standstill, and under the service, again after every restart.
113
+ limit = mx.device_info()["max_recommended_working_set_size"]
114
+ spec = choose_weights(spec, limit)
115
+ if (sized := weights_and_config(spec.path, spec.adapter)) is not None:
116
+ check_memory(name, limit, sized[0])
117
+ engine = Engine(spec, decode_concurrency=a.concurrency, reserve_gb=a.reserve_gb,
118
+ disk_cache_gb=a.disk_cache_gb,
119
+ prompt_cache_bytes=int(a.prompt_cache_gb * (1 << 30)) if a.prompt_cache_gb else None)
120
+ except NotEnoughMemory as e:
121
+ logging.error("%s", e)
122
+ return 2
123
+ server = Server((a.host, a.port), engine, models, a.api_key)
124
+ logging.info("Aris Server %s serving %s on http://%s:%d", __version__, name, a.host, a.port)
125
+
126
+ def stop(*_): # a service manager stops with SIGTERM: finish the current step (and any cache write) first
127
+ raise KeyboardInterrupt
128
+
129
+ signal.signal(signal.SIGTERM, stop)
130
+ try:
131
+ server.serve_forever()
132
+ except KeyboardInterrupt:
133
+ pass
134
+ finally:
135
+ server.server_close()
136
+ engine.generator.stop_and_join() # the generation thread writes the prompt caches as it exits
137
+ return 0
138
+
139
+
140
+ if __name__ == "__main__":
141
+ sys.exit(main())
aris_server/config.py ADDED
@@ -0,0 +1,119 @@
1
+ """Model settings: which weights a model name means and how effort and thinking map onto it."""
2
+
3
+ import os
4
+ import tomllib
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+
8
+ EFFORTS = ("low", "medium", "high", "xhigh", "max")
9
+ KV_BITS_CHOICES = (4, 8)
10
+ DEFAULT_THINK_BUDGET = {"low": 1024, "medium": 4096, "high": 8192, "xhigh": 16384, "max": 0}
11
+
12
+
13
+ def home() -> Path:
14
+ return Path(os.environ.get("ARIS_SERVER_HOME", Path.home() / ".aris-server"))
15
+
16
+
17
+ # A model given several weights (`path` as a list, best first) loads the first that leaves this much context on the
18
+ # Mac it runs on: a coding agent's conversation outgrows less, and compacting it costs a long re-read each time.
19
+ DEFAULT_MIN_CONTEXT = 65536
20
+
21
+
22
+ @dataclass
23
+ class ModelSpec:
24
+ name: str
25
+ path: str
26
+ context: int | None = None
27
+ # Weights to choose from by memory, best first (`path` is the first): for example the same model at 8 and 4 bits.
28
+ choices: tuple[str, ...] = ()
29
+ min_context: int = DEFAULT_MIN_CONTEXT
30
+ adapter: str | None = None # a LoRA adapter folder (adapter_config.json + adapters.safetensors) on top of path
31
+ # Bits per value of the KV cache (8 or 4), None for full precision: 8 bits holds about twice the context in the
32
+ # same memory.
33
+ kv_bits: int | None = None
34
+ # Template variable that carries effort (e.g. "reasoning_effort"), and how request efforts map to its values.
35
+ effort_kwarg: str | None = None
36
+ effort_map: dict[str, str] = field(default_factory=dict)
37
+ # Thinking tokens allowed per effort before thinking is closed at the next paragraph break; 0 = unlimited.
38
+ think_budget: dict[str, int] = field(default_factory=lambda: dict(DEFAULT_THINK_BUDGET))
39
+ template_kwargs: dict = field(default_factory=dict)
40
+ # Share of the think budget for turns that continue the model's own tool loop: the plan exists by then, and
41
+ # overthinking models re-plan from scratch on every tool result.
42
+ followup_budget: float = 0.5
43
+ # Sampling when the request doesn't say (Claude Code sends none with thinking on). Qwen-family thinking defaults.
44
+ temperature: float = 0.6
45
+ top_p: float = 0.95
46
+ top_k: int = 20
47
+
48
+ def budget_for(self, effort: str) -> int:
49
+ return int(self.think_budget.get(effort, DEFAULT_THINK_BUDGET.get(effort, 0)))
50
+
51
+
52
+ # Models the server knows by name with no models.toml entry; an entry there with the same name replaces one. Their
53
+ # weights are on the Hugging Face Hub, best first: the server picks what each Mac holds before downloading anything.
54
+ BUILTIN_MODELS = {
55
+ # Aris 3.8-27B: Arete-27B (Nous Research, on Qwen3.8-27B) trained on its own passing agent runs. The settings are
56
+ # the ones its evals ran with: Claude Code's effort onto Arete's trained effort levels, and a think budget each.
57
+ "aris-3.8-27b": {
58
+ "path": ["YourAris/Aris-3.8-27B-MLX-8bit", "YourAris/Aris-3.8-27B-MLX-4bit"],
59
+ "kv_bits": 8,
60
+ "effort_kwarg": "reasoning_effort",
61
+ "effort_map": {"low": "low", "medium": "medium", "high": "medium", "xhigh": "xhigh", "max": "xhigh"},
62
+ "think_budget": {"low": 1024, "medium": 3072, "high": 4096, "xhigh": 12288, "max": 0},
63
+ },
64
+ }
65
+
66
+
67
+ def load_models(path: Path | None = None) -> dict[str, ModelSpec]:
68
+ """The models configured in models.toml (not the built-in ones: see builtin_models)."""
69
+ path = path or home() / "models.toml"
70
+ if not path.is_file():
71
+ return {}
72
+ data = tomllib.loads(path.read_text())
73
+ return {name: _spec(name, raw, f"model {name!r} in {path}") for name, raw in (data.get("models") or {}).items()}
74
+
75
+
76
+ def builtin_models() -> dict[str, ModelSpec]:
77
+ return {name: _spec(name, raw, f"built-in model {name!r}") for name, raw in BUILTIN_MODELS.items()}
78
+
79
+
80
+ def _spec(name: str, raw: dict, where: str) -> ModelSpec:
81
+ if "path" not in raw:
82
+ raise ValueError(f"{where} has no path")
83
+ paths = raw["path"] if isinstance(raw["path"], list) else [raw["path"]]
84
+ if not paths or not all(isinstance(p, str) and p for p in paths):
85
+ raise ValueError(f"{where}: path must be a folder or repo id, or a list of them")
86
+ paths = [_weights_path(p) for p in paths]
87
+ budget = dict(DEFAULT_THINK_BUDGET)
88
+ budget.update(raw.get("think_budget") or {})
89
+ if raw.get("kv_bits") not in (None, *KV_BITS_CHOICES):
90
+ raise ValueError(f"{where}: kv_bits must be one of {KV_BITS_CHOICES}")
91
+ return ModelSpec(
92
+ name=name,
93
+ path=paths[0],
94
+ choices=tuple(paths) if len(paths) > 1 else (),
95
+ min_context=int(raw.get("min_context", DEFAULT_MIN_CONTEXT)),
96
+ context=raw.get("context"),
97
+ adapter=str(Path(raw["adapter"]).expanduser()) if raw.get("adapter") else None,
98
+ kv_bits=raw.get("kv_bits"),
99
+ effort_kwarg=raw.get("effort_kwarg"),
100
+ effort_map=dict(raw.get("effort_map") or {}),
101
+ think_budget=budget,
102
+ template_kwargs=dict(raw.get("template_kwargs") or {}),
103
+ **{k: raw[k] for k in ("temperature", "top_p", "top_k", "followup_budget") if k in raw},
104
+ )
105
+
106
+
107
+ def _weights_path(p: str) -> str:
108
+ """A local folder with its home expanded, or a Hugging Face repo id as given."""
109
+ return str(Path(p).expanduser()) if p.startswith(("~", "/", ".")) else p
110
+
111
+
112
+ def resolve(name: str, models: dict[str, ModelSpec]) -> ModelSpec:
113
+ """A configured model by name, then a built-in one, or else an unconfigured one by local path or Hugging Face repo
114
+ id."""
115
+ if name in models:
116
+ return models[name]
117
+ if name in BUILTIN_MODELS:
118
+ return builtin_models()[name]
119
+ return ModelSpec(name=name, path=_weights_path(name))
@@ -0,0 +1,245 @@
1
+ """Prompt caches on disk, so a restart, a crash or a reopened thread resumes in seconds instead of re-reading a long
2
+ conversation (minutes for a 27B model at 40k tokens). A conversation evicted from memory is also loaded back from
3
+ here. Each finished turn's cache and the shared system-prompt snapshot
4
+ are written as they enter the in-memory cache; a conversation's older snapshot is deleted when its next turn is saved.
5
+
6
+ Each setup (weights, adapter, KV precision) has a folder of its own, and one cap covers them all. A server reads only
7
+ its own folder, so the caches of setups no running server holds go first, then its own oldest. A running server holds
8
+ a shared lock on its folder; another server sharing the directory clears only folders nobody holds.
9
+ """
10
+
11
+ import fcntl
12
+ import hashlib
13
+ import json
14
+ import logging
15
+ import os
16
+ import re
17
+ import time
18
+ from pathlib import Path
19
+
20
+ import mlx.core as mx
21
+ from mlx_lm.models.cache import load_prompt_cache, save_prompt_cache
22
+
23
+ MIN_TOKENS = 1024 # shorter prompts are quick to re-read
24
+ LOCK = "lock" # shared by each server using the folder, exclusive for one clearing it
25
+
26
+
27
+ def model_id(model_path: str, adapter: str | None = None, kv_bits: int | None = None) -> str:
28
+ """Caches are only valid for the exact weights that made them (the base model and any adapter on it) and for the
29
+ precision of the KV cache they hold."""
30
+ parts = []
31
+ for folder in (model_path, adapter):
32
+ if folder is None:
33
+ continue
34
+ path = Path(folder)
35
+ parts.append(str(path))
36
+ if path.is_dir():
37
+ parts += [f"{p.name}:{p.stat().st_size}:{int(p.stat().st_mtime)}" for p in sorted(path.glob("*.safetensors"))]
38
+ if kv_bits:
39
+ parts.append(f"kv_bits={kv_bits}")
40
+ return hashlib.sha1("|".join(parts).encode()).hexdigest()[:16]
41
+
42
+
43
+ def _stat(data: Path) -> tuple[float, int]:
44
+ """A cache file's (mtime, size), or (0, 0) once it is gone."""
45
+ try:
46
+ s = data.stat()
47
+ except FileNotFoundError:
48
+ return 0.0, 0
49
+ return s.st_mtime, s.st_size
50
+
51
+
52
+ def _same_file(path: Path, f) -> bool:
53
+ """Whether `path` is still the file `f` has open: not once the file, or its folder, is deleted."""
54
+ try:
55
+ return os.path.samestat(os.stat(path), os.fstat(f.fileno()))
56
+ except FileNotFoundError:
57
+ return False
58
+
59
+
60
+ def _claim(folder: Path):
61
+ """The folder's lock, held exclusively, or None while a server uses the folder or another is clearing it."""
62
+ try:
63
+ lock = open(folder / LOCK, "a")
64
+ except OSError:
65
+ return None
66
+ try:
67
+ fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
68
+ except OSError:
69
+ lock.close()
70
+ return None
71
+ return lock
72
+
73
+
74
+ def _remove(data: Path):
75
+ """Delete a cache and its metadata. One that can't be deleted (in a folder made read-only, say) stays, with a
76
+ warning: the disk cache must never stop the server."""
77
+ try:
78
+ data.unlink(missing_ok=True)
79
+ data.with_suffix(".json").unlink(missing_ok=True)
80
+ except OSError as e:
81
+ logging.warning("could not delete prompt cache %s: %s", data.name, e)
82
+
83
+
84
+ class DiskCache:
85
+ def __init__(self, root: Path, model_path: str, max_bytes: int, adapter: str | None = None,
86
+ kv_bits: int | None = None):
87
+ self.root = root
88
+ self.dir = root / model_id(model_path, adapter, kv_bits)
89
+ self.max_bytes = max_bytes
90
+ self._lock = self._hold()
91
+ self.index: dict[Path, tuple[tuple[int, ...], str]] = {}
92
+ for data in self.dir.glob("*.safetensors"):
93
+ if not data.with_suffix(".json").exists(): # a write cut off by a crash or a kill
94
+ _remove(data)
95
+ for f in self.dir.glob("*.json"):
96
+ data = f.with_suffix(".safetensors")
97
+ if not data.exists():
98
+ _remove(data)
99
+ continue
100
+ try:
101
+ meta = json.loads(f.read_text())
102
+ self.index[data] = (tuple(meta["tokens"]), meta["type"])
103
+ except (OSError, ValueError, KeyError, TypeError) as e: # cut off mid-write: no turn can match it
104
+ # Raised here, it stopped the server before it served, and under the service at every restart.
105
+ logging.warning("dropping prompt cache %s: its metadata can't be read: %s", data.name, e)
106
+ _remove(data)
107
+ self._trim() # started on another setup: what the last one left over the cap goes now
108
+
109
+ def _hold(self):
110
+ """A shared lock on this folder for as long as the server runs."""
111
+ while True:
112
+ self.dir.mkdir(parents=True, exist_ok=True)
113
+ try:
114
+ lock = open(self.dir / LOCK, "a")
115
+ except FileNotFoundError: # another server removed the folder just now
116
+ continue
117
+ fcntl.flock(lock, fcntl.LOCK_SH)
118
+ if _same_file(self.dir / LOCK, lock):
119
+ return lock
120
+ lock.close() # the folder was cleared while this waited for it: make it again
121
+
122
+ def __del__(self):
123
+ lock = getattr(self, "_lock", None)
124
+ if lock is not None:
125
+ lock.close()
126
+
127
+ def has(self, tokens) -> bool:
128
+ """Whether these tokens' cache is saved. One whose file is gone (deleted by hand, say) isn't: it is saved again."""
129
+ key = tuple(tokens)
130
+ return any(t == key and data.exists() for data, (t, _) in self.index.items())
131
+
132
+ def save(self, tokens, prompt_cache, cache_type: str):
133
+ if len(tokens) < MIN_TOKENS or cache_type not in ("system", "assistant"):
134
+ return
135
+ if not _same_file(self.dir / LOCK, self._lock): # the folder was deleted while serving: make it, hold it again
136
+ logging.warning("prompt cache folder %s was deleted while serving; making it again", self.dir.name)
137
+ lock = self._hold()
138
+ self._lock.close()
139
+ self._lock = lock
140
+ t0 = time.time()
141
+ key = tuple(tokens)
142
+ data = self.dir / f"{hashlib.sha1(json.dumps(key).encode()).hexdigest()[:20]}.safetensors"
143
+ save_prompt_cache(str(data), prompt_cache)
144
+ # The metadata file goes last: a file without it is an interrupted write and is ignored.
145
+ data.with_suffix(".json").write_text(json.dumps({"tokens": list(key), "type": cache_type}))
146
+ # Each turn saves the read prompt (what a retry of the same request needs) and then the finished answer (what
147
+ # the next turn continues). Of the same conversation's earlier snapshots, keep only the longest.
148
+ earlier = sorted((p for p, (t, kind) in self.index.items()
149
+ if kind != "system" and p != data and key[:len(t)] == t), key=lambda p: len(self.index[p][0]))
150
+ for other in earlier[:-1]:
151
+ self._delete(other)
152
+ self.index[data] = (key, cache_type)
153
+ size = data.stat().st_size
154
+ self._trim()
155
+ logging.info("saved %d-token %s cache to disk (%.0f MB, %.1fs)", len(key), cache_type, size / 1e6,
156
+ time.time() - t0)
157
+
158
+ def _delete(self, data: Path):
159
+ self.index.pop(data, None)
160
+ _remove(data)
161
+
162
+ def _trim(self):
163
+ """Keep this setup's caches, with those of setups no running server holds, under the cap: the others' go first,
164
+ oldest first, since this server can't read them; then this setup's oldest."""
165
+ claimed = [(folder, lock) for folder in self.root.iterdir()
166
+ if folder != self.dir and folder.is_dir() and re.fullmatch(r"[0-9a-f]{16}", folder.name)
167
+ and (lock := _claim(folder)) is not None]
168
+ try:
169
+ spare = sorted((p for folder, _ in claimed for p in folder.glob("*.safetensors")), key=_stat)
170
+ own = sorted(self.index, key=_stat)
171
+ total = sum(_stat(p)[1] for p in spare + own)
172
+ freed = removed = 0
173
+ for data in spare:
174
+ if total <= self.max_bytes:
175
+ break
176
+ size = _stat(data)[1]
177
+ _remove(data)
178
+ total -= size
179
+ freed += size
180
+ removed += 1
181
+ for data in own:
182
+ if total <= self.max_bytes:
183
+ break
184
+ total -= _stat(data)[1]
185
+ self._delete(data)
186
+ if removed:
187
+ logging.info("disk cache: removed %d caches (%.1f GB) that other models or settings left", removed,
188
+ freed / (1 << 30))
189
+ for folder, _ in claimed:
190
+ if any(folder.glob("*.safetensors")):
191
+ continue
192
+ try:
193
+ for leftover in [*folder.glob("*.json"), folder / LOCK]:
194
+ leftover.unlink(missing_ok=True)
195
+ folder.rmdir()
196
+ except OSError: # something else is in it, or it can't be changed: leave the folder
197
+ pass
198
+ finally:
199
+ for _, lock in claimed:
200
+ lock.close()
201
+
202
+ def nearest(self, tokens, longer_than: int = 0) -> tuple[Path, int] | None:
203
+ """The saved cache covering the longest proper prefix of `tokens`, if it covers more than `longer_than`. One
204
+ whose file is gone from disk (deleted by hand, say) is forgotten here, before the caller lets go of what memory
205
+ holds for the turn."""
206
+ key = tuple(tokens)
207
+ while True:
208
+ best = None
209
+ for data, (t, _) in self.index.items():
210
+ if longer_than < len(t) < len(key) and key[:len(t)] == t and (best is None or len(t) > best[1]):
211
+ best = (data, len(t))
212
+ if best is None or best[0].exists():
213
+ return best
214
+ logging.warning("prompt cache %s is gone from disk; forgetting it", best[0].name)
215
+ self._delete(best[0])
216
+
217
+ def load(self, data: Path):
218
+ """The cache saved in `data`, or None if it can't be read (damaged, or gone): then it is dropped, so no later
219
+ turn tries it again."""
220
+ try:
221
+ cache = load_prompt_cache(str(data))
222
+ # Loading is lazy and bound to the loading thread's stream; generation may run on another thread.
223
+ mx.eval([c.state for c in cache])
224
+ except Exception as e: # noqa: BLE001 MLX raises RuntimeError for a damaged or missing file
225
+ logging.warning("dropping unreadable prompt cache %s: %s", data.name, e)
226
+ self._delete(data)
227
+ return None
228
+ return cache
229
+
230
+ def restore(self, insert, budget: int) -> int:
231
+ """Load the most recent caches that fit in `budget` bytes through insert(tokens, cache, type)."""
232
+ loaded = 0
233
+ newest = sorted(self.index, key=lambda p: p.stat().st_mtime, reverse=True)
234
+ systems = [p for p in newest if self.index[p][1] == "system"][:1]
235
+ for data in systems + [p for p in newest if self.index[p][1] != "system"]:
236
+ size = data.stat().st_size
237
+ if loaded + size > budget:
238
+ continue
239
+ cache = self.load(data)
240
+ if cache is None: # damaged: dropped
241
+ continue
242
+ tokens, cache_type = self.index[data]
243
+ insert(list(tokens), cache, cache_type)
244
+ loaded += size
245
+ return loaded