@fugood/buttress-server 2.26.0-beta.3 → 2.26.0-beta.4

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,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()