@fugood/buttress-server 2.26.0-beta.3 → 2.26.0-beta.5
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/README.md +112 -0
- package/config/function-samples/run-agent.ts +39 -0
- package/config/sample.toml +22 -0
- package/lib/agent/cli.d.ts +19 -0
- package/lib/agent/client.d.ts +66 -0
- package/lib/agent/config.d.ts +15 -0
- package/lib/agent/context.d.ts +11 -0
- package/lib/agent/loopback.d.ts +21 -0
- package/lib/agent/mcp.d.ts +23 -0
- package/lib/agent/models.d.ts +20 -0
- package/lib/agent/service.d.ts +16 -0
- package/lib/agent/session-fs.d.ts +42 -0
- package/lib/agent/sessions.d.ts +15 -0
- package/lib/agent/tools.d.ts +32 -0
- package/lib/agent/tui.d.ts +17 -0
- package/lib/agent/types.d.ts +123 -0
- package/lib/cli-DrbWX4ea.mjs +22 -0
- package/lib/client-BCBBen9i.mjs +8 -0
- package/lib/config-lP89VahD.mjs +2 -0
- package/lib/functions/executor.d.ts +7 -1
- package/lib/functions/index.d.ts +3 -1
- package/lib/functions/status.d.ts +1 -1
- package/lib/functions/types.d.ts +8 -0
- package/lib/index.d.ts +8 -2
- package/lib/index.mjs +59 -56
- package/lib/mlx-bridge.py +681 -0
- package/lib/routes/agents.d.ts +37 -0
- package/lib/routes/index.d.ts +1 -0
- package/lib/tui-7B7x6A08.mjs +2 -0
- package/lib/types.d.ts +9 -0
- package/package.json +7 -4
|
@@ -0,0 +1,681 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""MLX-VLM IPC bridge for buttress-backend-core.
|
|
3
|
+
|
|
4
|
+
Protocol: newline-delimited JSON over stdin/stdout.
|
|
5
|
+
Request: {"id": "...", "method": "...", "params": {...}}
|
|
6
|
+
Response: {"id": "...", "result": {...}} or {"id": "...", "error": {"message": "..."}}
|
|
7
|
+
Stream: {"id": "...", "event": "token"|"progress"|"result", "data": {...}}
|
|
8
|
+
|
|
9
|
+
Logging goes to stderr to avoid mixing with IPC.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import sys
|
|
14
|
+
import os
|
|
15
|
+
import hashlib
|
|
16
|
+
import time
|
|
17
|
+
import threading
|
|
18
|
+
import traceback
|
|
19
|
+
|
|
20
|
+
# Shared state
|
|
21
|
+
_model = None
|
|
22
|
+
_processor = None
|
|
23
|
+
_config = None
|
|
24
|
+
_model_id = None
|
|
25
|
+
_is_vlm = False # True when loaded via mlx-vlm, False when loaded via mlx-lm
|
|
26
|
+
_session_cache = None
|
|
27
|
+
_cancel_flags = set()
|
|
28
|
+
_cancel_lock = threading.Lock()
|
|
29
|
+
_output_lock = threading.Lock()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
# Session Cache Manager
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
class SessionCacheManager:
|
|
37
|
+
"""Manages KV cache persistence for prompt prefix reuse.
|
|
38
|
+
|
|
39
|
+
Saves prompt-only KV state to .safetensors files after each generation.
|
|
40
|
+
On subsequent requests, loads the longest matching prefix cache so the
|
|
41
|
+
model only needs to process new tokens.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(self, cache_dir, max_entries=100, max_size_bytes=5 * 1024 * 1024 * 1024):
|
|
45
|
+
self.cache_dir = cache_dir
|
|
46
|
+
self.max_entries = max_entries
|
|
47
|
+
self.max_size_bytes = max_size_bytes
|
|
48
|
+
self.map_path = os.path.join(cache_dir, "cache-map.json")
|
|
49
|
+
self.states_dir = os.path.join(cache_dir, "states")
|
|
50
|
+
self.cache_map = None
|
|
51
|
+
# Keep the last-used cache in memory to avoid re-reading from disk
|
|
52
|
+
self._mem_cache = None
|
|
53
|
+
self._mem_cache_key = None
|
|
54
|
+
|
|
55
|
+
def initialize(self):
|
|
56
|
+
os.makedirs(self.states_dir, exist_ok=True)
|
|
57
|
+
self._load_map()
|
|
58
|
+
count = len(self.cache_map.get("entries", {}))
|
|
59
|
+
log(f"Session cache initialized: {count} entries in {self.cache_dir}")
|
|
60
|
+
|
|
61
|
+
# -- Map persistence --
|
|
62
|
+
|
|
63
|
+
def _load_map(self):
|
|
64
|
+
try:
|
|
65
|
+
with open(self.map_path, "r") as f:
|
|
66
|
+
self.cache_map = json.load(f)
|
|
67
|
+
except (FileNotFoundError, json.JSONDecodeError):
|
|
68
|
+
self.cache_map = {"entries": {}, "totalSize": 0}
|
|
69
|
+
|
|
70
|
+
def _save_map(self):
|
|
71
|
+
try:
|
|
72
|
+
with open(self.map_path, "w") as f:
|
|
73
|
+
json.dump(self.cache_map, f)
|
|
74
|
+
except Exception as e:
|
|
75
|
+
log(f"Failed to save cache map: {e}")
|
|
76
|
+
|
|
77
|
+
@staticmethod
|
|
78
|
+
def _entry_id(text):
|
|
79
|
+
return hashlib.sha256(text.encode()).hexdigest()[:32]
|
|
80
|
+
|
|
81
|
+
# -- Lookup --
|
|
82
|
+
|
|
83
|
+
def find_best_prefix(self, prompt_text):
|
|
84
|
+
"""Return the entry whose promptText is the longest prefix of *prompt_text*."""
|
|
85
|
+
best = None
|
|
86
|
+
best_len = 0
|
|
87
|
+
for entry in self.cache_map.get("entries", {}).values():
|
|
88
|
+
saved = entry.get("promptText", "")
|
|
89
|
+
if prompt_text.startswith(saved) and len(saved) > best_len:
|
|
90
|
+
best = entry
|
|
91
|
+
best_len = len(saved)
|
|
92
|
+
return best
|
|
93
|
+
|
|
94
|
+
def load_cache_for(self, entry):
|
|
95
|
+
"""Load a saved prompt cache from disk (or memory)."""
|
|
96
|
+
from mlx_lm.models.cache import load_prompt_cache
|
|
97
|
+
|
|
98
|
+
entry_id = entry["id"]
|
|
99
|
+
|
|
100
|
+
# Fast path: still in memory
|
|
101
|
+
if self._mem_cache is not None and self._mem_cache_key == entry_id:
|
|
102
|
+
entry["lastAccessedAt"] = time.time()
|
|
103
|
+
self._save_map()
|
|
104
|
+
log(f"Session cache hit (memory): {entry_id}")
|
|
105
|
+
return self._mem_cache
|
|
106
|
+
|
|
107
|
+
file_path = entry.get("filePath")
|
|
108
|
+
if not file_path or not os.path.exists(file_path):
|
|
109
|
+
self._remove_entry(entry_id)
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
cache = load_prompt_cache(file_path)
|
|
114
|
+
entry["lastAccessedAt"] = time.time()
|
|
115
|
+
self._save_map()
|
|
116
|
+
self._mem_cache = cache
|
|
117
|
+
self._mem_cache_key = entry_id
|
|
118
|
+
return cache
|
|
119
|
+
except Exception as e:
|
|
120
|
+
log(f"Failed to load cache {file_path}: {e}")
|
|
121
|
+
self._remove_entry(entry_id)
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
# -- Save --
|
|
125
|
+
|
|
126
|
+
def save_after_generation(self, prompt_text, prompt_tokens, cache, generated_tokens):
|
|
127
|
+
"""Trim generated tokens from *cache* and persist the prompt-only KV state."""
|
|
128
|
+
from mlx_lm.cache_prompt import save_prompt_cache
|
|
129
|
+
from mlx_lm.models.cache import trim_prompt_cache
|
|
130
|
+
|
|
131
|
+
if generated_tokens > 0:
|
|
132
|
+
trim_prompt_cache(cache, generated_tokens)
|
|
133
|
+
|
|
134
|
+
entry_id = self._entry_id(prompt_text)
|
|
135
|
+
file_path = os.path.join(self.states_dir, f"{entry_id}.safetensors")
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
save_prompt_cache(file_path, cache, metadata={
|
|
139
|
+
"prompt_tokens": str(prompt_tokens),
|
|
140
|
+
"prompt_length": str(len(prompt_text)),
|
|
141
|
+
})
|
|
142
|
+
file_size = os.path.getsize(file_path)
|
|
143
|
+
|
|
144
|
+
# Remove old entry size from total
|
|
145
|
+
old = self.cache_map["entries"].get(entry_id)
|
|
146
|
+
if old:
|
|
147
|
+
self.cache_map["totalSize"] -= old.get("fileSize", 0)
|
|
148
|
+
|
|
149
|
+
self.cache_map["entries"][entry_id] = {
|
|
150
|
+
"id": entry_id,
|
|
151
|
+
"promptText": prompt_text,
|
|
152
|
+
"promptTokens": prompt_tokens,
|
|
153
|
+
"filePath": file_path,
|
|
154
|
+
"fileSize": file_size,
|
|
155
|
+
"createdAt": old["createdAt"] if old else time.time(),
|
|
156
|
+
"lastAccessedAt": time.time(),
|
|
157
|
+
}
|
|
158
|
+
self.cache_map["totalSize"] += file_size
|
|
159
|
+
|
|
160
|
+
# Keep in memory
|
|
161
|
+
self._mem_cache = cache
|
|
162
|
+
self._mem_cache_key = entry_id
|
|
163
|
+
|
|
164
|
+
self._evict_superseded(prompt_text, entry_id)
|
|
165
|
+
self._evict_if_needed()
|
|
166
|
+
self._save_map()
|
|
167
|
+
|
|
168
|
+
log(f"Session cache saved: {entry_id} ({file_size} bytes, {prompt_tokens} tokens)")
|
|
169
|
+
except Exception as e:
|
|
170
|
+
log(f"Failed to save session cache: {e}")
|
|
171
|
+
|
|
172
|
+
# -- Eviction --
|
|
173
|
+
|
|
174
|
+
def _evict_superseded(self, prompt_text, current_id):
|
|
175
|
+
"""Remove entries whose prompt is a strict prefix of *prompt_text*."""
|
|
176
|
+
to_remove = [
|
|
177
|
+
eid for eid, e in self.cache_map["entries"].items()
|
|
178
|
+
if eid != current_id and prompt_text.startswith(e.get("promptText", ""))
|
|
179
|
+
and e.get("promptText", "") != prompt_text
|
|
180
|
+
]
|
|
181
|
+
for eid in to_remove:
|
|
182
|
+
log(f"Session cache evict superseded: {eid}")
|
|
183
|
+
self._remove_entry(eid)
|
|
184
|
+
|
|
185
|
+
def _evict_if_needed(self):
|
|
186
|
+
entries = self.cache_map.get("entries", {})
|
|
187
|
+
sorted_ids = sorted(
|
|
188
|
+
entries.keys(), key=lambda k: entries[k].get("lastAccessedAt", 0),
|
|
189
|
+
)
|
|
190
|
+
while (
|
|
191
|
+
len(entries) > self.max_entries
|
|
192
|
+
or self.cache_map.get("totalSize", 0) > self.max_size_bytes
|
|
193
|
+
):
|
|
194
|
+
if not sorted_ids:
|
|
195
|
+
break
|
|
196
|
+
oldest_id = sorted_ids.pop(0)
|
|
197
|
+
log(f"Session cache evict LRU: {oldest_id}")
|
|
198
|
+
self._remove_entry(oldest_id)
|
|
199
|
+
|
|
200
|
+
def _remove_entry(self, entry_id):
|
|
201
|
+
entry = self.cache_map["entries"].get(entry_id)
|
|
202
|
+
if not entry:
|
|
203
|
+
return
|
|
204
|
+
fp = entry.get("filePath")
|
|
205
|
+
if fp:
|
|
206
|
+
try:
|
|
207
|
+
os.unlink(fp)
|
|
208
|
+
except OSError:
|
|
209
|
+
pass
|
|
210
|
+
self.cache_map["totalSize"] -= entry.get("fileSize", 0)
|
|
211
|
+
del self.cache_map["entries"][entry_id]
|
|
212
|
+
if self._mem_cache_key == entry_id:
|
|
213
|
+
self._mem_cache = None
|
|
214
|
+
self._mem_cache_key = None
|
|
215
|
+
|
|
216
|
+
def clear_memory(self):
|
|
217
|
+
"""Drop the in-memory cache (e.g. on model release)."""
|
|
218
|
+
self._mem_cache = None
|
|
219
|
+
self._mem_cache_key = None
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def send(msg):
|
|
223
|
+
"""Send a JSON message to stdout (thread-safe)."""
|
|
224
|
+
with _output_lock:
|
|
225
|
+
sys.stdout.write(json.dumps(msg, ensure_ascii=False) + "\n")
|
|
226
|
+
sys.stdout.flush()
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def log(msg):
|
|
230
|
+
"""Log to stderr."""
|
|
231
|
+
sys.stderr.write(f"[mlx-bridge] {msg}\n")
|
|
232
|
+
sys.stderr.flush()
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def is_cancelled(req_id):
|
|
236
|
+
with _cancel_lock:
|
|
237
|
+
return req_id in _cancel_flags
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def clear_cancel(req_id):
|
|
241
|
+
with _cancel_lock:
|
|
242
|
+
_cancel_flags.discard(req_id)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _get_tokenizer():
|
|
246
|
+
"""Return the underlying tokenizer from the processor."""
|
|
247
|
+
if _processor is None:
|
|
248
|
+
return None
|
|
249
|
+
return getattr(_processor, "tokenizer", _processor)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def handle_health(req_id, params):
|
|
253
|
+
versions = {}
|
|
254
|
+
try:
|
|
255
|
+
import mlx_vlm
|
|
256
|
+
versions["mlx_vlm"] = mlx_vlm.__version__
|
|
257
|
+
except ImportError:
|
|
258
|
+
pass
|
|
259
|
+
try:
|
|
260
|
+
import mlx_lm
|
|
261
|
+
versions["mlx_lm"] = mlx_lm.__version__
|
|
262
|
+
except ImportError:
|
|
263
|
+
pass
|
|
264
|
+
if versions:
|
|
265
|
+
send({"id": req_id, "result": {"ok": True, **versions}})
|
|
266
|
+
else:
|
|
267
|
+
send({"id": req_id, "error": {"message": "Neither mlx_vlm nor mlx_lm available"}})
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def handle_load(req_id, params):
|
|
271
|
+
global _model, _processor, _config, _model_id, _is_vlm
|
|
272
|
+
|
|
273
|
+
model_id = params.get("model")
|
|
274
|
+
if not model_id:
|
|
275
|
+
send({"id": req_id, "error": {"message": "Missing 'model' parameter"}})
|
|
276
|
+
return
|
|
277
|
+
|
|
278
|
+
# Release previous model if any
|
|
279
|
+
_model = None
|
|
280
|
+
_processor = None
|
|
281
|
+
_config = None
|
|
282
|
+
_model_id = None
|
|
283
|
+
_is_vlm = False
|
|
284
|
+
|
|
285
|
+
# vlm param: "auto" (default) tries vlm then lm, True forces vlm, False forces lm
|
|
286
|
+
vlm_mode = params.get("vlm", "auto")
|
|
287
|
+
|
|
288
|
+
log(f"Loading model: {model_id} (vlm={vlm_mode})")
|
|
289
|
+
|
|
290
|
+
load_kwargs = {}
|
|
291
|
+
if params.get("adapter_path"):
|
|
292
|
+
load_kwargs["adapter_path"] = params["adapter_path"]
|
|
293
|
+
if params.get("revision"):
|
|
294
|
+
load_kwargs["revision"] = params["revision"]
|
|
295
|
+
|
|
296
|
+
def _load_via_vlm():
|
|
297
|
+
global _model, _processor, _is_vlm
|
|
298
|
+
from mlx_vlm import load as vlm_load
|
|
299
|
+
_model, _processor = vlm_load(model_id, **load_kwargs)
|
|
300
|
+
_is_vlm = True
|
|
301
|
+
log(f"Model loaded via mlx-vlm: {model_id}")
|
|
302
|
+
|
|
303
|
+
def _load_via_lm():
|
|
304
|
+
global _model, _processor, _is_vlm
|
|
305
|
+
from mlx_lm import load as lm_load
|
|
306
|
+
|
|
307
|
+
lm_kwargs = dict(load_kwargs)
|
|
308
|
+
tokenizer_config = params.get("tokenizer_config") or {}
|
|
309
|
+
model_config = params.get("model_config") or {}
|
|
310
|
+
if tokenizer_config:
|
|
311
|
+
lm_kwargs["tokenizer_config"] = tokenizer_config
|
|
312
|
+
if model_config:
|
|
313
|
+
lm_kwargs["model_config"] = model_config
|
|
314
|
+
|
|
315
|
+
model, tokenizer = lm_load(model_id, **lm_kwargs)
|
|
316
|
+
_model = model
|
|
317
|
+
_processor = tokenizer
|
|
318
|
+
_is_vlm = False
|
|
319
|
+
log(f"Model loaded via mlx-lm: {model_id}")
|
|
320
|
+
|
|
321
|
+
if vlm_mode is True or vlm_mode == "true":
|
|
322
|
+
_load_via_vlm()
|
|
323
|
+
elif vlm_mode is False or vlm_mode == "false":
|
|
324
|
+
_load_via_lm()
|
|
325
|
+
else:
|
|
326
|
+
# auto: try vlm first, fall back to lm
|
|
327
|
+
try:
|
|
328
|
+
_load_via_vlm()
|
|
329
|
+
except Exception as vlm_err:
|
|
330
|
+
log(f"mlx-vlm load failed ({vlm_err}), falling back to mlx-lm")
|
|
331
|
+
_load_via_lm()
|
|
332
|
+
|
|
333
|
+
_config = getattr(_model, "config", {})
|
|
334
|
+
_model_id = model_id
|
|
335
|
+
|
|
336
|
+
send({"id": req_id, "result": {"loaded": True, "model": model_id, "vlm": _is_vlm}})
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def handle_generate(req_id, params):
|
|
340
|
+
if _model is None or _processor is None:
|
|
341
|
+
send({"id": req_id, "error": {"message": "No model loaded"}})
|
|
342
|
+
return
|
|
343
|
+
|
|
344
|
+
from mlx_lm.cache_prompt import make_prompt_cache
|
|
345
|
+
|
|
346
|
+
prompt = params.get("prompt", "")
|
|
347
|
+
max_tokens = params.get("max_tokens", 256)
|
|
348
|
+
images = params.get("image") or None
|
|
349
|
+
|
|
350
|
+
# Sampling parameters
|
|
351
|
+
kwargs = {}
|
|
352
|
+
if _is_vlm:
|
|
353
|
+
# mlx-vlm uses "temperature" directly
|
|
354
|
+
if "temperature" in params:
|
|
355
|
+
kwargs["temperature"] = params["temperature"]
|
|
356
|
+
if "top_p" in params:
|
|
357
|
+
kwargs["top_p"] = params["top_p"]
|
|
358
|
+
if "top_k" in params:
|
|
359
|
+
kwargs["top_k"] = params["top_k"]
|
|
360
|
+
if "min_p" in params:
|
|
361
|
+
kwargs["min_p"] = params["min_p"]
|
|
362
|
+
if "repetition_penalty" in params:
|
|
363
|
+
kwargs["repetition_penalty"] = params["repetition_penalty"]
|
|
364
|
+
if "repetition_context_size" in params:
|
|
365
|
+
kwargs["repetition_context_size"] = params["repetition_context_size"]
|
|
366
|
+
else:
|
|
367
|
+
# mlx-lm uses sampler + logits_processors (not raw kwargs)
|
|
368
|
+
from mlx_lm.sample_utils import make_sampler, make_logits_processors
|
|
369
|
+
sampler_kwargs = {}
|
|
370
|
+
if "temperature" in params:
|
|
371
|
+
sampler_kwargs["temp"] = params["temperature"]
|
|
372
|
+
if "top_p" in params:
|
|
373
|
+
sampler_kwargs["top_p"] = params["top_p"]
|
|
374
|
+
if "top_k" in params:
|
|
375
|
+
sampler_kwargs["top_k"] = params["top_k"]
|
|
376
|
+
if "min_p" in params:
|
|
377
|
+
sampler_kwargs["min_p"] = params["min_p"]
|
|
378
|
+
if sampler_kwargs:
|
|
379
|
+
kwargs["sampler"] = make_sampler(**sampler_kwargs)
|
|
380
|
+
lp_kwargs = {}
|
|
381
|
+
if "repetition_penalty" in params:
|
|
382
|
+
lp_kwargs["repetition_penalty"] = params["repetition_penalty"]
|
|
383
|
+
if "repetition_context_size" in params:
|
|
384
|
+
lp_kwargs["repetition_context_size"] = params["repetition_context_size"]
|
|
385
|
+
if lp_kwargs:
|
|
386
|
+
kwargs["logits_processors"] = make_logits_processors(**lp_kwargs)
|
|
387
|
+
if "seed" in params:
|
|
388
|
+
kwargs["seed"] = params["seed"]
|
|
389
|
+
if "max_kv_size" in params:
|
|
390
|
+
kwargs["max_kv_size"] = params["max_kv_size"]
|
|
391
|
+
|
|
392
|
+
stop_sequences = params.get("stop") or []
|
|
393
|
+
if isinstance(stop_sequences, str):
|
|
394
|
+
stop_sequences = [stop_sequences]
|
|
395
|
+
|
|
396
|
+
# Session cache: try to reuse a cached prompt prefix (text-only)
|
|
397
|
+
prompt_cache = None
|
|
398
|
+
if _session_cache and _session_cache.cache_map is not None and not images:
|
|
399
|
+
match = _session_cache.find_best_prefix(prompt)
|
|
400
|
+
if match:
|
|
401
|
+
loaded = _session_cache.load_cache_for(match)
|
|
402
|
+
if loaded:
|
|
403
|
+
prompt_cache = loaded
|
|
404
|
+
|
|
405
|
+
if prompt_cache is None:
|
|
406
|
+
prompt_cache = make_prompt_cache(_model)
|
|
407
|
+
|
|
408
|
+
full_text = ""
|
|
409
|
+
last_response = None
|
|
410
|
+
|
|
411
|
+
if _is_vlm:
|
|
412
|
+
from mlx_vlm import stream_generate
|
|
413
|
+
gen_iter = stream_generate(
|
|
414
|
+
model=_model,
|
|
415
|
+
processor=_processor,
|
|
416
|
+
prompt=prompt,
|
|
417
|
+
image=images,
|
|
418
|
+
max_tokens=max_tokens,
|
|
419
|
+
prompt_cache=prompt_cache,
|
|
420
|
+
**kwargs,
|
|
421
|
+
)
|
|
422
|
+
else:
|
|
423
|
+
from mlx_lm import stream_generate
|
|
424
|
+
gen_iter = stream_generate(
|
|
425
|
+
_model,
|
|
426
|
+
_processor,
|
|
427
|
+
prompt=prompt,
|
|
428
|
+
max_tokens=max_tokens,
|
|
429
|
+
prompt_cache=prompt_cache,
|
|
430
|
+
**kwargs,
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
for response in gen_iter:
|
|
434
|
+
if is_cancelled(req_id):
|
|
435
|
+
clear_cancel(req_id)
|
|
436
|
+
send({
|
|
437
|
+
"id": req_id,
|
|
438
|
+
"event": "result",
|
|
439
|
+
"data": {
|
|
440
|
+
"text": full_text,
|
|
441
|
+
"interrupted": True,
|
|
442
|
+
"prompt_tokens": getattr(response, "prompt_tokens", 0),
|
|
443
|
+
"prompt_tps": getattr(response, "prompt_tps", 0),
|
|
444
|
+
"generation_tokens": getattr(response, "generation_tokens", 0),
|
|
445
|
+
"generation_tps": getattr(response, "generation_tps", 0),
|
|
446
|
+
"peak_memory": getattr(response, "peak_memory", 0),
|
|
447
|
+
},
|
|
448
|
+
})
|
|
449
|
+
return
|
|
450
|
+
|
|
451
|
+
full_text += response.text
|
|
452
|
+
last_response = response
|
|
453
|
+
|
|
454
|
+
send({
|
|
455
|
+
"id": req_id,
|
|
456
|
+
"event": "token",
|
|
457
|
+
"data": {
|
|
458
|
+
"token": response.text,
|
|
459
|
+
"token_id": getattr(response, "token", None),
|
|
460
|
+
},
|
|
461
|
+
})
|
|
462
|
+
|
|
463
|
+
# Check stop sequences
|
|
464
|
+
should_stop = False
|
|
465
|
+
for seq in stop_sequences:
|
|
466
|
+
if seq and seq in full_text:
|
|
467
|
+
should_stop = True
|
|
468
|
+
# Trim text after stop sequence
|
|
469
|
+
idx = full_text.index(seq)
|
|
470
|
+
full_text = full_text[:idx]
|
|
471
|
+
break
|
|
472
|
+
if should_stop:
|
|
473
|
+
break
|
|
474
|
+
|
|
475
|
+
# Send final result
|
|
476
|
+
result_data = {
|
|
477
|
+
"text": full_text,
|
|
478
|
+
"interrupted": False,
|
|
479
|
+
}
|
|
480
|
+
if last_response:
|
|
481
|
+
result_data.update({
|
|
482
|
+
"prompt_tokens": getattr(last_response, "prompt_tokens", 0),
|
|
483
|
+
"prompt_tps": getattr(last_response, "prompt_tps", 0),
|
|
484
|
+
"generation_tokens": getattr(last_response, "generation_tokens", 0),
|
|
485
|
+
"generation_tps": getattr(last_response, "generation_tps", 0),
|
|
486
|
+
"peak_memory": getattr(last_response, "peak_memory", 0),
|
|
487
|
+
"finish_reason": getattr(last_response, "finish_reason", None),
|
|
488
|
+
})
|
|
489
|
+
|
|
490
|
+
send({"id": req_id, "event": "result", "data": result_data})
|
|
491
|
+
|
|
492
|
+
# Session cache: save prompt-only KV state for future reuse (text-only)
|
|
493
|
+
if _session_cache and last_response and not images and not result_data.get("interrupted"):
|
|
494
|
+
try:
|
|
495
|
+
prompt_tokens = getattr(last_response, "prompt_tokens", 0) or 0
|
|
496
|
+
gen_tokens = getattr(last_response, "generation_tokens", 0) or 0
|
|
497
|
+
_session_cache.save_after_generation(
|
|
498
|
+
prompt, prompt_tokens, prompt_cache, gen_tokens,
|
|
499
|
+
)
|
|
500
|
+
except Exception as e:
|
|
501
|
+
log(f"Session cache save error: {e}")
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def handle_tokenize(req_id, params):
|
|
505
|
+
tokenizer = _get_tokenizer()
|
|
506
|
+
if tokenizer is None:
|
|
507
|
+
send({"id": req_id, "error": {"message": "No model loaded"}})
|
|
508
|
+
return
|
|
509
|
+
|
|
510
|
+
text = params.get("text", "")
|
|
511
|
+
tokens = tokenizer.encode(text)
|
|
512
|
+
# Ensure tokens are plain Python ints
|
|
513
|
+
send({"id": req_id, "result": {"tokens": [int(t) for t in tokens]}})
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def handle_detokenize(req_id, params):
|
|
517
|
+
tokenizer = _get_tokenizer()
|
|
518
|
+
if tokenizer is None:
|
|
519
|
+
send({"id": req_id, "error": {"message": "No model loaded"}})
|
|
520
|
+
return
|
|
521
|
+
|
|
522
|
+
tokens = params.get("tokens", [])
|
|
523
|
+
text = tokenizer.decode(tokens)
|
|
524
|
+
send({"id": req_id, "result": {"text": text}})
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def handle_apply_chat_template(req_id, params):
|
|
528
|
+
if _processor is None:
|
|
529
|
+
send({"id": req_id, "error": {"message": "No model loaded"}})
|
|
530
|
+
return
|
|
531
|
+
|
|
532
|
+
messages = params.get("messages", [])
|
|
533
|
+
add_generation_prompt = params.get("add_generation_prompt", True)
|
|
534
|
+
|
|
535
|
+
# Reserved keys that are not chat template kwargs
|
|
536
|
+
reserved_keys = {"messages", "add_generation_prompt"}
|
|
537
|
+
template_kwargs = {k: v for k, v in params.items() if k not in reserved_keys}
|
|
538
|
+
|
|
539
|
+
if _is_vlm:
|
|
540
|
+
# VLM: prefer processor.apply_chat_template (handles image tokens)
|
|
541
|
+
apply_fn = getattr(_processor, "apply_chat_template", None)
|
|
542
|
+
if apply_fn is None:
|
|
543
|
+
apply_fn = _get_tokenizer().apply_chat_template
|
|
544
|
+
else:
|
|
545
|
+
# Text-only: use tokenizer directly
|
|
546
|
+
apply_fn = _get_tokenizer().apply_chat_template
|
|
547
|
+
|
|
548
|
+
text = apply_fn(
|
|
549
|
+
messages,
|
|
550
|
+
tokenize=False,
|
|
551
|
+
add_generation_prompt=add_generation_prompt,
|
|
552
|
+
**template_kwargs,
|
|
553
|
+
)
|
|
554
|
+
send({"id": req_id, "result": {"text": text}})
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def handle_configure_cache(req_id, params):
|
|
558
|
+
global _session_cache
|
|
559
|
+
|
|
560
|
+
enabled = params.get("enabled", True)
|
|
561
|
+
if not enabled:
|
|
562
|
+
_session_cache = None
|
|
563
|
+
send({"id": req_id, "result": {"configured": True, "enabled": False}})
|
|
564
|
+
return
|
|
565
|
+
|
|
566
|
+
cache_dir = os.path.abspath(params.get("cache_dir", os.path.join(
|
|
567
|
+
os.path.expanduser("~"), ".buttress", "mlx-session-cache",
|
|
568
|
+
)))
|
|
569
|
+
max_entries = params.get("max_entries", 100)
|
|
570
|
+
max_size_bytes = params.get("max_size_bytes", 5 * 1024 * 1024 * 1024)
|
|
571
|
+
|
|
572
|
+
_session_cache = SessionCacheManager(cache_dir, max_entries, max_size_bytes)
|
|
573
|
+
_session_cache.initialize()
|
|
574
|
+
send({"id": req_id, "result": {
|
|
575
|
+
"configured": True,
|
|
576
|
+
"enabled": True,
|
|
577
|
+
"entries": len(_session_cache.cache_map.get("entries", {})),
|
|
578
|
+
}})
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def handle_release(req_id, params):
|
|
582
|
+
global _model, _processor, _config, _model_id, _is_vlm
|
|
583
|
+
_model = None
|
|
584
|
+
_processor = None
|
|
585
|
+
_config = None
|
|
586
|
+
_model_id = None
|
|
587
|
+
_is_vlm = False
|
|
588
|
+
if _session_cache:
|
|
589
|
+
_session_cache.clear_memory()
|
|
590
|
+
log("Model released")
|
|
591
|
+
send({"id": req_id, "result": {"released": True}})
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def handle_get_info(req_id, params):
|
|
595
|
+
info = {"model": _model_id, "loaded": _model is not None}
|
|
596
|
+
if _model is not None:
|
|
597
|
+
try:
|
|
598
|
+
import mlx.core as mx
|
|
599
|
+
info["peak_memory"] = mx.metal.get_peak_memory() / (1024 * 1024) # MB
|
|
600
|
+
info["active_memory"] = mx.metal.get_active_memory() / (1024 * 1024) # MB
|
|
601
|
+
except Exception:
|
|
602
|
+
pass
|
|
603
|
+
send({"id": req_id, "result": info})
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
# Method dispatch table
|
|
607
|
+
SYNC_METHODS = {
|
|
608
|
+
"health": handle_health,
|
|
609
|
+
"load": handle_load,
|
|
610
|
+
"tokenize": handle_tokenize,
|
|
611
|
+
"detokenize": handle_detokenize,
|
|
612
|
+
"apply_chat_template": handle_apply_chat_template,
|
|
613
|
+
"configure_cache": handle_configure_cache,
|
|
614
|
+
"release": handle_release,
|
|
615
|
+
"get_info": handle_get_info,
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def handle_request(req):
|
|
620
|
+
req_id = req.get("id")
|
|
621
|
+
method = req.get("method")
|
|
622
|
+
params = req.get("params", {})
|
|
623
|
+
|
|
624
|
+
if method == "cancel":
|
|
625
|
+
target_id = params.get("request_id")
|
|
626
|
+
if target_id:
|
|
627
|
+
with _cancel_lock:
|
|
628
|
+
_cancel_flags.add(target_id)
|
|
629
|
+
return
|
|
630
|
+
|
|
631
|
+
handler = SYNC_METHODS.get(method)
|
|
632
|
+
if handler:
|
|
633
|
+
try:
|
|
634
|
+
handler(req_id, params)
|
|
635
|
+
except Exception as e:
|
|
636
|
+
log(f"Error in {method}: {traceback.format_exc()}")
|
|
637
|
+
send({"id": req_id, "error": {"message": str(e)}})
|
|
638
|
+
return
|
|
639
|
+
|
|
640
|
+
if method == "generate":
|
|
641
|
+
# Run generation in a thread so stdin remains readable for cancel
|
|
642
|
+
thread = threading.Thread(
|
|
643
|
+
target=_run_generate_safe, args=(req_id, params), daemon=True,
|
|
644
|
+
)
|
|
645
|
+
thread.start()
|
|
646
|
+
return
|
|
647
|
+
|
|
648
|
+
send({"id": req_id, "error": {"message": f"Unknown method: {method}"}})
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def _run_generate_safe(req_id, params):
|
|
652
|
+
try:
|
|
653
|
+
handle_generate(req_id, params)
|
|
654
|
+
except Exception as e:
|
|
655
|
+
log(f"Error in generate: {traceback.format_exc()}")
|
|
656
|
+
send({"id": req_id, "error": {"message": str(e)}})
|
|
657
|
+
finally:
|
|
658
|
+
clear_cancel(req_id)
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def main():
|
|
662
|
+
log("Bridge started")
|
|
663
|
+
send({"id": "__init__", "result": {"ready": True, "pid": os.getpid()}})
|
|
664
|
+
|
|
665
|
+
for line in sys.stdin:
|
|
666
|
+
line = line.strip()
|
|
667
|
+
if not line:
|
|
668
|
+
continue
|
|
669
|
+
try:
|
|
670
|
+
req = json.loads(line)
|
|
671
|
+
except json.JSONDecodeError as e:
|
|
672
|
+
send({"error": {"message": f"Invalid JSON: {e}"}})
|
|
673
|
+
continue
|
|
674
|
+
|
|
675
|
+
handle_request(req)
|
|
676
|
+
|
|
677
|
+
log("Bridge stdin closed, exiting")
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
if __name__ == "__main__":
|
|
681
|
+
main()
|