@fugood/buttress-server 2.26.0-beta.2 → 2.26.0-beta.21

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.
Files changed (70) hide show
  1. package/README.md +415 -10
  2. package/config/function-samples/README.md +1 -0
  3. package/config/function-samples/classify-ticket.ts +73 -0
  4. package/config/function-samples/run-agent.ts +39 -0
  5. package/config/sample.toml +67 -0
  6. package/lib/agent/cli.d.ts +19 -0
  7. package/lib/agent/client.d.ts +66 -0
  8. package/lib/agent/codemode-worker.d.ts +1 -0
  9. package/lib/agent/codemode.d.ts +7 -0
  10. package/lib/agent/config.d.ts +15 -0
  11. package/lib/agent/context.d.ts +11 -0
  12. package/lib/agent/decisions.check.d.ts +1 -0
  13. package/lib/agent/decisions.d.ts +113 -0
  14. package/lib/agent/device-auth.d.ts +3 -0
  15. package/lib/agent/device-tools.d.ts +5 -0
  16. package/lib/agent/loopback.d.ts +21 -0
  17. package/lib/agent/mcp.d.ts +22 -0
  18. package/lib/agent/models.d.ts +22 -0
  19. package/lib/agent/service.d.ts +16 -0
  20. package/lib/agent/sessions.d.ts +30 -0
  21. package/lib/agent/tools.d.ts +35 -0
  22. package/lib/agent/tui.d.ts +17 -0
  23. package/lib/agent/types.d.ts +155 -0
  24. package/lib/cli-KbjOekMI.mjs +22 -0
  25. package/lib/client-qqI-HCOj.mjs +8 -0
  26. package/lib/codemode-Cva7OeQX.mjs +112 -0
  27. package/lib/codemode-worker.mjs +3 -0
  28. package/lib/command-nXCdWBlc.mjs +38 -0
  29. package/lib/config-DiYweJ2m.mjs +2 -0
  30. package/lib/functions/executor.d.ts +7 -1
  31. package/lib/functions/index.d.ts +3 -1
  32. package/lib/functions/status.d.ts +1 -1
  33. package/lib/functions/templates.d.ts +1 -1
  34. package/lib/functions/types.d.ts +15 -2
  35. package/lib/index.d.ts +12 -5
  36. package/lib/index.mjs +146 -93
  37. package/lib/mcp-DzknURyz.mjs +5 -0
  38. package/lib/mlx-bridge.py +707 -0
  39. package/lib/net-OvedjqrB.mjs +46 -0
  40. package/lib/protocol-WkPByalT.mjs +468 -0
  41. package/lib/quickjs.wasm +0 -0
  42. package/lib/rolldown-runtime-dTnj95Mm.mjs +2 -0
  43. package/lib/routes/agents.d.ts +66 -0
  44. package/lib/routes/decision-shared.d.ts +28 -0
  45. package/lib/routes/generator-cache.d.ts +3 -1
  46. package/lib/routes/index.d.ts +2 -0
  47. package/lib/routes/llm-shared.d.ts +8 -0
  48. package/lib/routes/openai-compat.d.ts +13 -2
  49. package/lib/routes/openai-decisions.check.d.ts +1 -0
  50. package/lib/routes/openai-decisions.d.ts +97 -0
  51. package/lib/routes/status.d.ts +2 -1
  52. package/lib/routes/typesafe-compat.check.d.ts +1 -0
  53. package/lib/routes/typesafe-compat.d.ts +30 -0
  54. package/lib/services/ggml-decision.d.ts +80 -0
  55. package/lib/services/ggml-llm.d.ts +29 -1
  56. package/lib/services/index.d.ts +3 -0
  57. package/lib/tui-ClNupsCH.mjs +2 -0
  58. package/lib/types.d.ts +48 -2
  59. package/lib/ui/command.d.ts +2 -0
  60. package/lib/ui/host.d.ts +1 -0
  61. package/lib/ui/window-module.d.ts +6 -0
  62. package/lib/utils/config.d.ts +3 -3
  63. package/lib/utils/cors.d.ts +1 -0
  64. package/lib/utils/decision-sharing.d.ts +37 -0
  65. package/lib/utils/downloadStatus.d.ts +57 -0
  66. package/lib/utils/hostFacts.d.ts +13 -0
  67. package/lib/utils/workspaceState.d.ts +8 -0
  68. package/lib/wrapper-3PX6qE3t.mjs +7 -0
  69. package/package.json +17 -9
  70. package/public/status.html +219 -1489
@@ -0,0 +1,707 @@
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 _partial_stop_length(text, stop_sequences):
246
+ """Length of the longest end of `text` that a stop sequence starts with."""
247
+ longest = 0
248
+ for seq in filter(None, stop_sequences):
249
+ for length in range(min(len(seq) - 1, len(text)), longest, -1):
250
+ if text.endswith(seq[:length]):
251
+ longest = length
252
+ break
253
+ return longest
254
+
255
+
256
+ def _get_tokenizer():
257
+ """Return the underlying tokenizer from the processor."""
258
+ if _processor is None:
259
+ return None
260
+ return getattr(_processor, "tokenizer", _processor)
261
+
262
+
263
+ def handle_health(req_id, params):
264
+ versions = {}
265
+ try:
266
+ import mlx_vlm
267
+ versions["mlx_vlm"] = mlx_vlm.__version__
268
+ except ImportError:
269
+ pass
270
+ try:
271
+ import mlx_lm
272
+ versions["mlx_lm"] = mlx_lm.__version__
273
+ except ImportError:
274
+ pass
275
+ if versions:
276
+ send({"id": req_id, "result": {"ok": True, **versions}})
277
+ else:
278
+ send({"id": req_id, "error": {"message": "Neither mlx_vlm nor mlx_lm available"}})
279
+
280
+
281
+ def handle_load(req_id, params):
282
+ global _model, _processor, _config, _model_id, _is_vlm
283
+
284
+ model_id = params.get("model")
285
+ if not model_id:
286
+ send({"id": req_id, "error": {"message": "Missing 'model' parameter"}})
287
+ return
288
+
289
+ # Release previous model if any
290
+ _model = None
291
+ _processor = None
292
+ _config = None
293
+ _model_id = None
294
+ _is_vlm = False
295
+
296
+ # vlm param: "auto" (default) tries vlm then lm, True forces vlm, False forces lm
297
+ vlm_mode = params.get("vlm", "auto")
298
+
299
+ log(f"Loading model: {model_id} (vlm={vlm_mode})")
300
+
301
+ load_kwargs = {}
302
+ if params.get("adapter_path"):
303
+ load_kwargs["adapter_path"] = params["adapter_path"]
304
+ if params.get("revision"):
305
+ load_kwargs["revision"] = params["revision"]
306
+
307
+ def _load_via_vlm():
308
+ global _model, _processor, _is_vlm
309
+ from mlx_vlm import load as vlm_load
310
+ _model, _processor = vlm_load(model_id, **load_kwargs)
311
+ _is_vlm = True
312
+ log(f"Model loaded via mlx-vlm: {model_id}")
313
+
314
+ def _load_via_lm():
315
+ global _model, _processor, _is_vlm
316
+ from mlx_lm import load as lm_load
317
+
318
+ lm_kwargs = dict(load_kwargs)
319
+ tokenizer_config = params.get("tokenizer_config") or {}
320
+ model_config = params.get("model_config") or {}
321
+ if tokenizer_config:
322
+ lm_kwargs["tokenizer_config"] = tokenizer_config
323
+ if model_config:
324
+ lm_kwargs["model_config"] = model_config
325
+
326
+ model, tokenizer = lm_load(model_id, **lm_kwargs)
327
+ _model = model
328
+ _processor = tokenizer
329
+ _is_vlm = False
330
+ log(f"Model loaded via mlx-lm: {model_id}")
331
+
332
+ if vlm_mode is True or vlm_mode == "true":
333
+ _load_via_vlm()
334
+ elif vlm_mode is False or vlm_mode == "false":
335
+ _load_via_lm()
336
+ else:
337
+ # auto: try vlm first, fall back to lm
338
+ try:
339
+ _load_via_vlm()
340
+ except Exception as vlm_err:
341
+ log(f"mlx-vlm load failed ({vlm_err}), falling back to mlx-lm")
342
+ _load_via_lm()
343
+
344
+ _config = getattr(_model, "config", {})
345
+ _model_id = model_id
346
+
347
+ send({"id": req_id, "result": {"loaded": True, "model": model_id, "vlm": _is_vlm}})
348
+
349
+
350
+ def handle_generate(req_id, params):
351
+ if _model is None or _processor is None:
352
+ send({"id": req_id, "error": {"message": "No model loaded"}})
353
+ return
354
+
355
+ from mlx_lm.cache_prompt import make_prompt_cache
356
+
357
+ prompt = params.get("prompt", "")
358
+ max_tokens = params.get("max_tokens", 256)
359
+ images = params.get("image") or None
360
+
361
+ # Sampling parameters
362
+ kwargs = {}
363
+ if _is_vlm:
364
+ # mlx-vlm uses "temperature" directly
365
+ if "temperature" in params:
366
+ kwargs["temperature"] = params["temperature"]
367
+ if "top_p" in params:
368
+ kwargs["top_p"] = params["top_p"]
369
+ if "top_k" in params:
370
+ kwargs["top_k"] = params["top_k"]
371
+ if "min_p" in params:
372
+ kwargs["min_p"] = params["min_p"]
373
+ if "repetition_penalty" in params:
374
+ kwargs["repetition_penalty"] = params["repetition_penalty"]
375
+ if "repetition_context_size" in params:
376
+ kwargs["repetition_context_size"] = params["repetition_context_size"]
377
+ else:
378
+ # mlx-lm uses sampler + logits_processors (not raw kwargs)
379
+ from mlx_lm.sample_utils import make_sampler, make_logits_processors
380
+ sampler_kwargs = {}
381
+ if "temperature" in params:
382
+ sampler_kwargs["temp"] = params["temperature"]
383
+ if "top_p" in params:
384
+ sampler_kwargs["top_p"] = params["top_p"]
385
+ if "top_k" in params:
386
+ sampler_kwargs["top_k"] = params["top_k"]
387
+ if "min_p" in params:
388
+ sampler_kwargs["min_p"] = params["min_p"]
389
+ if sampler_kwargs:
390
+ kwargs["sampler"] = make_sampler(**sampler_kwargs)
391
+ lp_kwargs = {}
392
+ if "repetition_penalty" in params:
393
+ lp_kwargs["repetition_penalty"] = params["repetition_penalty"]
394
+ if "repetition_context_size" in params:
395
+ lp_kwargs["repetition_context_size"] = params["repetition_context_size"]
396
+ if lp_kwargs:
397
+ kwargs["logits_processors"] = make_logits_processors(**lp_kwargs)
398
+ if "seed" in params:
399
+ kwargs["seed"] = params["seed"]
400
+ if "max_kv_size" in params:
401
+ kwargs["max_kv_size"] = params["max_kv_size"]
402
+
403
+ stop_sequences = params.get("stop") or []
404
+ if isinstance(stop_sequences, str):
405
+ stop_sequences = [stop_sequences]
406
+
407
+ # Session cache: try to reuse a cached prompt prefix (text-only)
408
+ prompt_cache = None
409
+ if _session_cache and _session_cache.cache_map is not None and not images:
410
+ match = _session_cache.find_best_prefix(prompt)
411
+ if match:
412
+ loaded = _session_cache.load_cache_for(match)
413
+ if loaded:
414
+ prompt_cache = loaded
415
+
416
+ if prompt_cache is None:
417
+ prompt_cache = make_prompt_cache(_model)
418
+
419
+ full_text = ""
420
+ last_response = None
421
+ # Token events carry full_text[:sent_end]. The client builds its output
422
+ # from them, so they must never contain stop sequence text.
423
+ sent_end = 0
424
+
425
+ def send_text_until(end, token_id):
426
+ nonlocal sent_end
427
+ if end > sent_end:
428
+ send({
429
+ "id": req_id,
430
+ "event": "token",
431
+ "data": {"token": full_text[sent_end:end], "token_id": token_id},
432
+ })
433
+ sent_end = end
434
+
435
+ if _is_vlm:
436
+ from mlx_vlm import stream_generate
437
+ gen_iter = stream_generate(
438
+ model=_model,
439
+ processor=_processor,
440
+ prompt=prompt,
441
+ image=images,
442
+ max_tokens=max_tokens,
443
+ prompt_cache=prompt_cache,
444
+ **kwargs,
445
+ )
446
+ else:
447
+ from mlx_lm import stream_generate
448
+ gen_iter = stream_generate(
449
+ _model,
450
+ _processor,
451
+ prompt=prompt,
452
+ max_tokens=max_tokens,
453
+ prompt_cache=prompt_cache,
454
+ **kwargs,
455
+ )
456
+
457
+ for response in gen_iter:
458
+ if is_cancelled(req_id):
459
+ clear_cancel(req_id)
460
+ send_text_until(len(full_text), None)
461
+ send({
462
+ "id": req_id,
463
+ "event": "result",
464
+ "data": {
465
+ "text": full_text,
466
+ "interrupted": True,
467
+ "prompt_tokens": getattr(response, "prompt_tokens", 0),
468
+ "prompt_tps": getattr(response, "prompt_tps", 0),
469
+ "generation_tokens": getattr(response, "generation_tokens", 0),
470
+ "generation_tps": getattr(response, "generation_tps", 0),
471
+ "peak_memory": getattr(response, "peak_memory", 0),
472
+ },
473
+ })
474
+ return
475
+
476
+ full_text += response.text
477
+ last_response = response
478
+
479
+ # Check stop sequences
480
+ should_stop = False
481
+ for seq in stop_sequences:
482
+ if seq and seq in full_text:
483
+ should_stop = True
484
+ # Trim text after stop sequence
485
+ idx = full_text.index(seq)
486
+ full_text = full_text[:idx]
487
+ break
488
+
489
+ # Like llama.node, hold back an end that may still become a stop
490
+ # sequence until a later token completes or breaks it.
491
+ safe_end = len(full_text)
492
+ if not should_stop:
493
+ safe_end -= _partial_stop_length(full_text, stop_sequences)
494
+ send_text_until(safe_end, getattr(response, "token", None))
495
+ if should_stop:
496
+ break
497
+
498
+ # Generation ended without completing the held-back stop sequence
499
+ send_text_until(len(full_text), None)
500
+
501
+ # Send final result
502
+ result_data = {
503
+ "text": full_text,
504
+ "interrupted": False,
505
+ }
506
+ if last_response:
507
+ result_data.update({
508
+ "prompt_tokens": getattr(last_response, "prompt_tokens", 0),
509
+ "prompt_tps": getattr(last_response, "prompt_tps", 0),
510
+ "generation_tokens": getattr(last_response, "generation_tokens", 0),
511
+ "generation_tps": getattr(last_response, "generation_tps", 0),
512
+ "peak_memory": getattr(last_response, "peak_memory", 0),
513
+ "finish_reason": getattr(last_response, "finish_reason", None),
514
+ })
515
+
516
+ send({"id": req_id, "event": "result", "data": result_data})
517
+
518
+ # Session cache: save prompt-only KV state for future reuse (text-only)
519
+ if _session_cache and last_response and not images and not result_data.get("interrupted"):
520
+ try:
521
+ prompt_tokens = getattr(last_response, "prompt_tokens", 0) or 0
522
+ gen_tokens = getattr(last_response, "generation_tokens", 0) or 0
523
+ _session_cache.save_after_generation(
524
+ prompt, prompt_tokens, prompt_cache, gen_tokens,
525
+ )
526
+ except Exception as e:
527
+ log(f"Session cache save error: {e}")
528
+
529
+
530
+ def handle_tokenize(req_id, params):
531
+ tokenizer = _get_tokenizer()
532
+ if tokenizer is None:
533
+ send({"id": req_id, "error": {"message": "No model loaded"}})
534
+ return
535
+
536
+ text = params.get("text", "")
537
+ tokens = tokenizer.encode(text)
538
+ # Ensure tokens are plain Python ints
539
+ send({"id": req_id, "result": {"tokens": [int(t) for t in tokens]}})
540
+
541
+
542
+ def handle_detokenize(req_id, params):
543
+ tokenizer = _get_tokenizer()
544
+ if tokenizer is None:
545
+ send({"id": req_id, "error": {"message": "No model loaded"}})
546
+ return
547
+
548
+ tokens = params.get("tokens", [])
549
+ text = tokenizer.decode(tokens)
550
+ send({"id": req_id, "result": {"text": text}})
551
+
552
+
553
+ def handle_apply_chat_template(req_id, params):
554
+ if _processor is None:
555
+ send({"id": req_id, "error": {"message": "No model loaded"}})
556
+ return
557
+
558
+ messages = params.get("messages", [])
559
+ add_generation_prompt = params.get("add_generation_prompt", True)
560
+
561
+ # Reserved keys that are not chat template kwargs
562
+ reserved_keys = {"messages", "add_generation_prompt"}
563
+ template_kwargs = {k: v for k, v in params.items() if k not in reserved_keys}
564
+
565
+ if _is_vlm:
566
+ # VLM: prefer processor.apply_chat_template (handles image tokens)
567
+ apply_fn = getattr(_processor, "apply_chat_template", None)
568
+ if apply_fn is None:
569
+ apply_fn = _get_tokenizer().apply_chat_template
570
+ else:
571
+ # Text-only: use tokenizer directly
572
+ apply_fn = _get_tokenizer().apply_chat_template
573
+
574
+ text = apply_fn(
575
+ messages,
576
+ tokenize=False,
577
+ add_generation_prompt=add_generation_prompt,
578
+ **template_kwargs,
579
+ )
580
+ send({"id": req_id, "result": {"text": text}})
581
+
582
+
583
+ def handle_configure_cache(req_id, params):
584
+ global _session_cache
585
+
586
+ enabled = params.get("enabled", True)
587
+ if not enabled:
588
+ _session_cache = None
589
+ send({"id": req_id, "result": {"configured": True, "enabled": False}})
590
+ return
591
+
592
+ cache_dir = os.path.abspath(params.get("cache_dir", os.path.join(
593
+ os.path.expanduser("~"), ".buttress", "mlx-session-cache",
594
+ )))
595
+ max_entries = params.get("max_entries", 100)
596
+ max_size_bytes = params.get("max_size_bytes", 5 * 1024 * 1024 * 1024)
597
+
598
+ _session_cache = SessionCacheManager(cache_dir, max_entries, max_size_bytes)
599
+ _session_cache.initialize()
600
+ send({"id": req_id, "result": {
601
+ "configured": True,
602
+ "enabled": True,
603
+ "entries": len(_session_cache.cache_map.get("entries", {})),
604
+ }})
605
+
606
+
607
+ def handle_release(req_id, params):
608
+ global _model, _processor, _config, _model_id, _is_vlm
609
+ _model = None
610
+ _processor = None
611
+ _config = None
612
+ _model_id = None
613
+ _is_vlm = False
614
+ if _session_cache:
615
+ _session_cache.clear_memory()
616
+ log("Model released")
617
+ send({"id": req_id, "result": {"released": True}})
618
+
619
+
620
+ def handle_get_info(req_id, params):
621
+ info = {"model": _model_id, "loaded": _model is not None}
622
+ if _model is not None:
623
+ try:
624
+ import mlx.core as mx
625
+ info["peak_memory"] = mx.metal.get_peak_memory() / (1024 * 1024) # MB
626
+ info["active_memory"] = mx.metal.get_active_memory() / (1024 * 1024) # MB
627
+ except Exception:
628
+ pass
629
+ send({"id": req_id, "result": info})
630
+
631
+
632
+ # Method dispatch table
633
+ SYNC_METHODS = {
634
+ "health": handle_health,
635
+ "load": handle_load,
636
+ "tokenize": handle_tokenize,
637
+ "detokenize": handle_detokenize,
638
+ "apply_chat_template": handle_apply_chat_template,
639
+ "configure_cache": handle_configure_cache,
640
+ "release": handle_release,
641
+ "get_info": handle_get_info,
642
+ }
643
+
644
+
645
+ def handle_request(req):
646
+ req_id = req.get("id")
647
+ method = req.get("method")
648
+ params = req.get("params", {})
649
+
650
+ if method == "cancel":
651
+ target_id = params.get("request_id")
652
+ if target_id:
653
+ with _cancel_lock:
654
+ _cancel_flags.add(target_id)
655
+ return
656
+
657
+ handler = SYNC_METHODS.get(method)
658
+ if handler:
659
+ try:
660
+ handler(req_id, params)
661
+ except Exception as e:
662
+ log(f"Error in {method}: {traceback.format_exc()}")
663
+ send({"id": req_id, "error": {"message": str(e)}})
664
+ return
665
+
666
+ if method == "generate":
667
+ # Run generation in a thread so stdin remains readable for cancel
668
+ thread = threading.Thread(
669
+ target=_run_generate_safe, args=(req_id, params), daemon=True,
670
+ )
671
+ thread.start()
672
+ return
673
+
674
+ send({"id": req_id, "error": {"message": f"Unknown method: {method}"}})
675
+
676
+
677
+ def _run_generate_safe(req_id, params):
678
+ try:
679
+ handle_generate(req_id, params)
680
+ except Exception as e:
681
+ log(f"Error in generate: {traceback.format_exc()}")
682
+ send({"id": req_id, "error": {"message": str(e)}})
683
+ finally:
684
+ clear_cancel(req_id)
685
+
686
+
687
+ def main():
688
+ log("Bridge started")
689
+ send({"id": "__init__", "result": {"ready": True, "pid": os.getpid()}})
690
+
691
+ for line in sys.stdin:
692
+ line = line.strip()
693
+ if not line:
694
+ continue
695
+ try:
696
+ req = json.loads(line)
697
+ except json.JSONDecodeError as e:
698
+ send({"error": {"message": f"Invalid JSON: {e}"}})
699
+ continue
700
+
701
+ handle_request(req)
702
+
703
+ log("Bridge stdin closed, exiting")
704
+
705
+
706
+ if __name__ == "__main__":
707
+ main()