Repository navigation
Expand file tree
/
Copy pathserver.py
More file actions
1922 lines (1700 loc) · 86.2 KB
/
Copy pathserver.py
File metadata and controls
1922 lines (1700 loc) · 86.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
"""
MLX Native Anthropic Server — Claude Code on Apple Silicon.
Single-file server: MLX inference + Anthropic Messages API + tool use support.
Converts Anthropic tool format <-> the model's native function calling format
(Gemma 4's `<|tool_call>call:Name{...}<tool_call|>`, Llama 3.3's raw-JSON
`{"type":"function",...}`, and the common HuggingFace `<tool_call>` JSON form
used by Qwen and others).
Pick a model from the lineup with the MLX_MODEL env var:
MLX_MODEL=divinetribe/gemma-4-31b-it-abliterated-4bit-mlx (THE QUICK ONE — default, our own MLX upload)
MLX_MODEL=divinetribe/Llama-3.3-70B-Instruct-abliterated-8bit-mlx (THE WISE ONE — our own MLX upload)
MLX_MODEL=mlx-community/Qwen3.5-122B-A10B-4bit (THE BEAST)
NOTE FOR CONTRIBUTORS: this file is the source of truth. `setup.sh` installs it
at `~/.local/mlx-native-server/server.py` via a symlink, so edits here take
effect on the running server after a restart — no re-copying needed.
"""
import json
import os
import re
import subprocess
import sys
import threading
import time
import uuid
from http.server import HTTPServer, BaseHTTPRequestHandler
from urllib.parse import urlparse
import mlx.core as mx
import mlx.nn as nn
from mlx_lm.utils import load
from mlx_lm.generate import stream_generate
from mlx_lm.sample_utils import make_sampler
from mlx_lm.models.cache import make_prompt_cache
# ─── Configuration ───────────────────────────────────────────────────────────
def env_int(name, default):
"""int() an env var, treating unset *and empty* as "use the default".
os.environ.get(name, default) only falls back when the key is absent, not
when it is present-but-empty. The launchers pass values through
ensure_mlx_server() as MLX_KV_BITS="${MLX_KV_BITS:-}", which exports an
empty string when the caller hasn't set one — so int("") raised ValueError
and the server died before it ever bound the port.
"""
raw = os.environ.get(name, "")
return int(raw) if raw.strip() else default
MODEL_PATH = os.environ.get("MLX_MODEL", "divinetribe/gemma-4-31b-it-abliterated-4bit-mlx")
PORT = env_int("MLX_PORT", 4000)
KV_BITS = env_int("MLX_KV_BITS", 0) # Gemma 4 RotatingKVCache doesn't support quantization
# This used to default to 8192, which quietly TRIPLES peak memory on a long
# prompt and is the reason a big Claude Code request could take the machine into
# swap (or straight into the OOM killer on a 32GB Mac) while the model itself
# fits fine.
#
# mlx_lm's prefill loop runs the WHOLE model on each chunk, lm_head included, so
# a single 8192-token chunk allocates an (8192 x vocab) bf16 logits tensor it
# immediately throws away — 4.3GB at Gemma's 262144 vocab. On top of that the
# full attention layers score the entire chunk against every key so far. None of
# it is reused, all of it is live at once.
#
# Measured on gemma-4-31b-it-abliterated-4bit-mlx (16.7GB of weights resident),
# same prompt each time, via mx.get_peak_memory():
#
# prompt prefill 8192 prefill 1024 prefill 512
# 21.4k 34.2GB / 49.3s 20.9GB / 37.7s - / 39.9s
# 38.5k - / - 23.5GB / 83.1s 21.9GB / 71.0s
#
# 17.5GB of transients on a 21k prompt, against 4.2GB for the same work at 512.
# Small chunks are not slower either — the GPU saturates far below 8192 tokens —
# so there was never anything being traded away for that memory.
PREFILL_SIZE = env_int("MLX_PREFILL_SIZE", 512)
# Ceiling on MLX's buffer recycle pool, which counts toward this process's
# memory. Measured at 0.0GB on the model above either way, so treat it as a
# backstop for larger ones (Llama 70B 8-bit) rather than a fix on its own.
# MLX_CACHE_LIMIT_GB=0 disables the cap.
MEM_CACHE_LIMIT_GB = float(os.environ.get("MLX_CACHE_LIMIT_GB") or 3)
# Pre-fill an empty thinking block to skip Gemma 4 reasoning chains entirely.
# Set MLX_SUPPRESS_THINKING=0 to disable (e.g. when you want reasoning output).
SUPPRESS_THINKING = os.environ.get("MLX_SUPPRESS_THINKING", "1") == "1"
DEFAULT_MAX_TOKENS = env_int("MLX_MAX_TOKENS", 8192)
KV_QUANT_START = env_int("MLX_KV_QUANT_START", 256)
MAX_TOOL_RETRIES = env_int("MLX_TOOL_RETRIES", 2)
# Browser mode: strip Claude Code bloat, keep only MCP tools
BROWSER_MODE = os.environ.get("MLX_BROWSER_MODE", "0") == "1"
# Code mode: auto-detect Claude Code coding sessions and replace the huge harness
# system prompt with a Llama-tuned one. Set MLX_CODE_MODE=0 to disable.
CODE_MODE_ENABLED = os.environ.get("MLX_CODE_MODE", "1") != "0"
# ─── Globals ─────────────────────────────────────────────────────────────────
model = None
tokenizer = None
generate_lock = threading.Lock()
# Prompt cache: reuse KV state across requests to avoid re-prefilling system+tools
_prompt_cache = None
_cached_token_prefix = None # token IDs we've already prefilled
# ─── Logging ─────────────────────────────────────────────────────────────────
def log(msg):
print(f"[{time.strftime('%H:%M:%S')}] {msg}", file=sys.stderr, flush=True)
# ─── Model Loading ───────────────────────────────────────────────────────────
GEMMA4_CHAT_TEMPLATE = (
"{{ bos_token }}"
"{% set ns = namespace(system='') %}"
"{% for message in messages %}{% if message['role'] == 'system' %}{% set ns.system = message['content'] %}{% endif %}{% endfor %}"
"{% for message in messages %}"
"{% if message['role'] == 'user' %}"
"<|turn>user\n{% if ns.system and loop.first %}{{ ns.system }}\n\n{% endif %}{{ message['content'] }}<turn|>"
"{% elif message['role'] == 'assistant' %}"
"<|turn>model\n{{ message['content'] }}<turn|>"
"{% elif message['role'] == 'tool' %}"
"<|turn>tool_response\n{{ message['content'] }}<turn|>"
"{% endif %}"
"{% endfor %}"
"{% if add_generation_prompt %}<|turn>model\n{% endif %}"
)
GB = 1024 ** 3
def mem_snapshot():
"""What this process is actually holding, in GB.
Watch mx_peak, not rss. `ps rss` cannot see Metal buffers — it reported a
flat 16.7GB straight through a prefill that MLX itself measured at 34GB, so
a server can be moments from swapping while every RSS reading looks calm.
Activity Monitor's "Memory" column (phys_footprint) does see them.
"""
try:
rss = psutil_rss_gb()
except Exception:
rss = 0.0
return {
"rss_gb": round(rss, 1),
"mx_active_gb": round(mx.get_active_memory() / GB, 1),
"mx_cache_gb": round(mx.get_cache_memory() / GB, 1),
"mx_peak_gb": round(mx.get_peak_memory() / GB, 1),
}
def psutil_rss_gb():
"""RSS without a psutil dependency — the mlx venv doesn't ship one."""
out = subprocess.run(["ps", "-o", "rss=", "-p", str(os.getpid())],
capture_output=True, text=True).stdout.strip()
return int(out) / 1024 / 1024 if out else 0.0
def release_transients(tag=""):
"""Hand MLX's recycle pool back after every generation, and log the peak.
The log line is half the point. When a memory death happens there is no
traceback and usually no crash report, because the process is killed from
outside — so a per-request peak, written down while the server was still
alive, is the only thing that distinguishes "that prompt was too big" from
any other silent exit. On a 31B 4-bit the pool itself measures 0.0GB, so the
clear_cache is insurance for models that do accumulate one.
"""
before = mx.get_cache_memory() / GB
peak = mx.get_peak_memory() / GB
mx.clear_cache()
log(f" Memory{tag}: peak {peak:.1f}GB this request, "
f"released {before:.1f}GB of buffer cache, rss now {psutil_rss_gb():.1f}GB")
mx.reset_peak_memory()
def load_model():
global model, tokenizer, KV_BITS
if MEM_CACHE_LIMIT_GB > 0:
mx.set_cache_limit(int(MEM_CACHE_LIMIT_GB * GB))
log(f"MLX buffer cache capped at {MEM_CACHE_LIMIT_GB:g}GB")
log(f"Loading model: {MODEL_PATH}")
t0 = time.time()
model, tokenizer = load(MODEL_PATH)
mx.eval(model.parameters())
# Fallback chat template if model doesn't provide one (Llama 3.3 has its own)
if not getattr(tokenizer, 'chat_template', None):
tokenizer.chat_template = GEMMA4_CHAT_TEMPLATE
log("Injected Gemma 4 chat template")
# Some quant repos ship a generation_config.json whose eos_token_id
# disagrees with tokenizer_config.json's eos_token. mlx-lm seeds its stop
# set (tokenizer.eos_token_ids) from generation_config, so when the chat
# template actually terminates turns with the *other* token, generation
# never stops: the real stop token is decoded as ordinary text and every
# request runs to max_tokens.
#
# Seen on divinetribe/Hermes-4-14B-abliterated-4bit-mlx, whose
# generation_config says 151643 (<|endoftext|>) while its template emits
# 151645 (<|im_end|>) — 503s and 4096 tokens for what should be a 20-token
# tool call. Unioning the two is safe: a token that is EOS in either config
# is never a legitimate mid-sequence token.
_tok_eos = getattr(tokenizer, "eos_token_id", None)
if _tok_eos is not None:
_stop_set = set(getattr(tokenizer, "eos_token_ids", None) or set())
if _tok_eos not in _stop_set:
tokenizer.eos_token_ids = _stop_set | {_tok_eos}
log(f"Added eos_token_id {_tok_eos} to stop set (was {_stop_set or set()})")
elapsed = time.time() - t0
log(f"Model loaded in {elapsed:.1f}s")
# Safety net: Gemma uses sliding-window attention → RotatingKVCache, which
# mlx-lm can't quantize yet ("RotatingKVCache Quantization NYI"). The
# default for MLX_KV_BITS is already 0, but if a user explicitly sets it to
# 8 and happens to be running Gemma, auto-disable it so inference doesn't
# 500 on the first call. (Credit: asdmoment, PR #7.)
if KV_BITS and "gemma" in MODEL_PATH.lower():
log("Gemma detected: disabling KV cache quantization (RotatingKVCache NYI)")
KV_BITS = 0
log(f"KV cache quantization: {KV_BITS}-bit" if KV_BITS else "KV cache: full precision")
# ─── Think Tag Stripping ────────────────────────────────────────────────────
def strip_think_tags(text):
"""Remove thinking blocks from model reasoning output."""
# Standard <think>...</think>
cleaned = re.sub(r'<think>.*?</think>', '', text, flags=re.DOTALL).strip()
cleaned = re.sub(r'</think>', '', cleaned).strip()
# Gemma 4 thinking: <|channel>thought\n...<channel|>
cleaned = re.sub(r'<\|channel>thought\n.*?<channel\|>', '', cleaned, flags=re.DOTALL).strip()
# Empty tool_call blocks
cleaned = re.sub(r'<tool_call>\s*</tool_call>', '', cleaned).strip()
return cleaned if cleaned else text
class ThinkingFilter:
"""Real-time filter that removes Gemma 4 thinking blocks from the mlx_lm token stream.
Applied token-by-token inside the stream_generate loop so thinking content is
discarded as it arrives rather than accumulated and regex-stripped after the fact.
Works independently of HTTP SSE — the SSE layer (send_anthropic_stream) operates
on the already-cleaned result returned by generate_response.
"""
THINK_START = "<|channel>thought\n"
THINK_END = "<channel|>"
def __init__(self):
self.in_thinking = False
self.buf = ""
def feed(self, chunk):
self.buf += chunk
output = ""
while True:
if self.in_thinking:
idx = self.buf.find(self.THINK_END)
if idx == -1:
safe = max(0, len(self.buf) - len(self.THINK_END) + 1)
self.buf = self.buf[safe:]
break
self.in_thinking = False
self.buf = self.buf[idx + len(self.THINK_END):]
else:
idx = self.buf.find(self.THINK_START)
if idx == -1:
safe = max(0, len(self.buf) - len(self.THINK_START) + 1)
output += self.buf[:safe]
self.buf = self.buf[safe:]
break
output += self.buf[:idx]
self.in_thinking = True
self.buf = self.buf[idx + len(self.THINK_START):]
return output
def flush(self):
return "" if self.in_thinking else self.buf
def clean_response(text):
"""Strip think tags, stop tokens, and reasoning artifacts (but preserve tool_call tags)."""
text = strip_think_tags(text)
# Llama 3.x: strip function-call prefix token
text = text.replace('<|python_tag|>', '').strip()
# Gemma 4 + Qwen + ChatML: truncate at end-of-turn or start of a new turn,
# whichever marker comes first (the live stream cuts the same way).
cuts = [text.index(m) for m in ['<turn|>', '<|turn>', '<|im_end|>', '<|endoftext|>',
'<|im_start|>', '<|end_of_text|>', '<|eot_id|>'] if m in text]
if cuts:
text = text[:min(cuts)].strip()
# Remove reasoning preamble if present
if text.lstrip().startswith("Thinking"):
lines = text.split('\n')
for i, line in enumerate(lines):
s = line.strip()
if any(s.startswith(p) for p in ['```', 'def ', 'class ', 'function ', 'import ', '#', '//', '<tool_call>']):
return '\n'.join(lines[i:])
return text
# ─── Tool Conversion ────────────────────────────────────────────────────────
def convert_tools_for_llm(anthropic_tools):
"""Convert Anthropic tool definitions to HuggingFace/Llama format."""
if not anthropic_tools:
return None
llm_tools = []
for tool in anthropic_tools:
llm_tools.append({
"type": "function",
"function": {
"name": tool.get("name", ""),
"description": tool.get("description", ""),
"parameters": tool.get("input_schema", {"type": "object", "properties": {}}),
}
})
return llm_tools
def format_tools_as_text(tools):
"""Format tools as text for system prompt (fallback if chat template doesn't support tools param)."""
lines = ["# Available Tools\n"]
lines.append("CRITICAL: You MUST call tools using EXACTLY this JSON format inside <tool_call> tags:")
lines.append("")
lines.append('<tool_call>')
lines.append('{"name": "Bash", "arguments": {"command": "ls -la"}}')
lines.append('</tool_call>')
lines.append("")
lines.append("RULES:")
lines.append('- The content inside <tool_call> MUST be valid JSON with "name" and "arguments" keys')
lines.append('- Do NOT use <parameter=...> tags inside <tool_call> — use the "arguments" JSON object')
lines.append('- Do NOT mix XML and JSON — use ONLY pure JSON inside the tags')
lines.append("- You may call multiple tools by using multiple <tool_call> blocks")
lines.append("- Output any reasoning text BEFORE the tool calls, not inside them")
lines.append("")
for tool in tools:
func = tool.get("function", tool)
name = func.get("name", "")
desc = func.get("description", "")
params = func.get("parameters", {})
lines.append(f"## {name}")
if desc:
lines.append(f"{desc}")
props = params.get("properties", {})
required = params.get("required", [])
if props:
for pname, pdef in props.items():
req = " (required)" if pname in required else ""
ptype = pdef.get("type", "any")
pdesc = pdef.get("description", "")
lines.append(f" - {pname}: {ptype}{req} — {pdesc}")
lines.append("")
return "\n".join(lines)
def recover_garbled_tool_json(content, original_text=""):
"""Attempt to recover tool name and arguments from garbled JSON inside <tool_call> tags.
Models sometimes produce hybrid XML/JSON like:
{"name": "Bash", "parameter=command>cd ~/Desktop && rm -rf ...
{"name": "Bash", "<parameter_commands>["rm -rf ...
{"name": "Edit", "parameter=file_path>/some/path</parameter...
"""
# Extract tool name
name_match = re.search(r'"name"\s*:\s*"([^"]+)"', content)
if not name_match:
return None
tool_name = name_match.group(1)
arguments = {}
# Pattern A: "parameter=key>value" (most common garble)
# Matches: "parameter=command>cd ~/Desktop..." or parameter=command>value</parameter>
param_a = re.finditer(r'["\s,]?parameter=(\w+)>\s*(.*?)(?:</parameter>|$)', content, re.DOTALL)
for m in param_a:
key = m.group(1)
val = m.group(2).strip().rstrip('"}\n')
if val:
arguments[key] = val
# Pattern B: "<parameter_key>value" or "<parameter_key>["value"]"
if not arguments:
param_b = re.finditer(r'<parameter[_=](\w+)>\s*(.*?)(?:</parameter|<|$)', content, re.DOTALL)
for m in param_b:
key = m.group(1)
val = m.group(2).strip().strip('[]"')
if val:
arguments[key] = val
# Pattern C: "arguments" key exists but is malformed — try to extract the value after it
if not arguments:
args_match = re.search(r'"arguments"\s*:\s*\{(.*)', content, re.DOTALL)
if args_match:
raw = args_match.group(1)
# Try to find key-value pairs
kv_matches = re.finditer(r'"(\w+)"\s*:\s*"((?:[^"\\]|\\.)*)"', raw)
for m in kv_matches:
arguments[m.group(1)] = m.group(2)
# Pattern D: single-argument tools — if we have a tool name and leftover text, use it
# Common for Bash (command), Read (file_path), etc.
if not arguments:
single_arg_tools = {
"Bash": "command", "Read": "file_path", "Write": "file_path",
"Glob": "pattern", "Grep": "pattern",
}
if tool_name in single_arg_tools:
# Everything after the tool name declaration is likely the argument value
after_name = content[name_match.end():]
# Strip JSON noise
val = re.sub(r'^[\s,":{}]+', '', after_name)
val = re.sub(r'[\s"}]+$', '', val)
# Remove parameter= prefix if present
val = re.sub(r'^parameter=\w+>\s*', '', val)
val = re.sub(r'^<parameter[_=]\w+>\s*', '', val)
if val and len(val) > 2:
arguments[single_arg_tools[tool_name]] = val
if arguments:
log(f" Recovered garbled tool call: {tool_name} with {list(arguments.keys())}")
return {"name": tool_name, "arguments": arguments}
return None
def _decode_string_args(args):
"""Some models send arguments as a JSON-encoded string, OpenAI style:
"arguments": "{\"command\": \"ls\"}". Decode it so the call doesn't go
out with empty input."""
if isinstance(args, str):
try:
decoded = json.loads(args)
except ValueError:
return args
if isinstance(decoded, dict):
return decoded
return args
def _gemma4_args(args_str):
"""Parse Gemma 4's argument list (the part inside call:Name{...}) as JSON.
Gemma 4 writes strings as <|"|>text<|"|>, everything else as bare JSON
(true, 50, [...], {...}), and object keys unquoted, sometimes with a dash
(Grep's -i). Swap the strings out, quote the keys, swap the strings back as
JSON strings, and parse. Returns None when that is not valid JSON, so the
caller can fall back to the older regex matching.
"""
strings = []
def stash(m):
strings.append(m.group(1))
return f"\x00{len(strings) - 1}\x00"
s = re.sub(r'<\|"\|>(.*?)<\|"\|>', stash, args_str, flags=re.DOTALL)
s = re.sub(r'(^|[{,])\s*([\w-]+)\s*:', r'\1"\2":', s)
s = re.sub(r'\x00(\d+)\x00', lambda m: json.dumps(strings[int(m.group(1))]), s)
try:
parsed = json.loads("{" + s + "}")
except ValueError:
return None
return parsed if isinstance(parsed, dict) else None
def parse_tool_calls(text):
"""Parse tool calls from generated text. Handles multiple formats including
Gemma 4 native format. Returns (list of tool calls, remaining text).
"""
tool_calls = []
# Format 0: Gemma 4 native — <|tool_call>call:Name{key:<|"|>val<|"|>}<tool_call|>
# Parse BEFORE replacing escape tokens — use <|"|> as reliable value delimiters
#
# Three spellings of the opener turn up in practice. The documented one is
# <|tool_call>call:Name{...}. Some models drop the call: prefix. And
# abliterated Gemma 4 31B collapses the whole marker to <tool_call:Name{...},
# which is the SAME grammar — same <|"|> value delimiters, same <tool_call|>
# closer, only the opening marker differs — so it is one alternation here
# rather than a new format further down.
#
# That third spelling cost 6 of 14 on scripts/test_mlx_server.py with
# divinetribe/gemma-4-31b-it-abliterated-4bit-mlx. Every call was well
# formed, every call was invisible, and the retry path can't rescue it
# because it asks for <tool_call> tags the model has already decided against.
# Some also emit a stray > after the closing brace; the body regex tolerates
# it because re.match doesn't have to consume the whole string.
gemma4_pattern = r'(?:<\|tool_call>|<tool_call:)(.*?)<tool_call\|>'
gemma4_matches = list(re.finditer(gemma4_pattern, text, re.DOTALL))
if gemma4_matches:
remaining = text
for match in gemma4_matches:
remaining = remaining.replace(match.group(0), "", 1)
content = match.group(1).strip()
# [\w.-]: MCP tool names carry the server name, which often has a
# dash (mcp__chrome-devtools__click), same as Formats 1 and 2 allow.
name_m = re.match(r'(?:call:)?([\w.-]+)\s*\{(.*)\}', content, re.DOTALL)
if not name_m:
log(f" Gemma4 no name match: {content[:80]}")
continue
name = name_m.group(1)
args_str = name_m.group(2)
# Primary: read the whole argument list as Gemma's JSON dialect, which
# covers nested arrays/objects and keys like -i that the regexes miss.
arguments = _gemma4_args(args_str)
# call:list_pages{} parses to {}: a real call with no arguments,
# not a failed parse, so it has to go out as one.
no_args_call = arguments == {}
if arguments is None:
arguments = {}
# Fallback: extract key:<|"|>value<|"|> pairs (handles embedded quotes)
for km in re.finditer(r'(\w+):<\|"\|>(.*?)<\|"\|>', args_str, re.DOTALL):
arguments[km.group(1)] = km.group(2)
# Gemma 4 writes non-string values bare (replace_all:true, limit:50)
# in the same call as <|"|> strings, and they were dropped. Pick them
# up from what is left once the strings are removed, so a colon
# inside a string value can't be read as a key.
if arguments:
bare = re.sub(r'\w+:<\|"\|>.*?<\|"\|>', '', args_str, flags=re.DOTALL)
for km in re.finditer(r'(\w+):([^,}]+)', bare):
try:
arguments.setdefault(km.group(1), json.loads(km.group(2).strip()))
except ValueError:
pass
# Fallback: unquoted values (numbers, simple strings)
if not arguments:
for km in re.finditer(r'(\w+):([^,}]+)', args_str):
val = km.group(2).strip().strip('"\'')
arguments[km.group(1)] = val
if arguments or no_args_call:
tool_calls.append({"name": name, "arguments": arguments})
log(f" Gemma4 tool call: {name}({list(arguments.keys())})")
else:
log(f" Gemma4 no args parsed: {content[:80]}")
if tool_calls:
return tool_calls, remaining.strip()
remaining = text
# Format 0.5: Llama 3.3 raw JSON — {"type":"function","name":"...","parameters":{...}}
# Use json.JSONDecoder.raw_decode for robust nested JSON parsing
_decoder = json.JSONDecoder()
_pos = 0
while _pos < len(text):
idx = text.find('{"type"', _pos)
if idx == -1:
break
try:
obj, end_pos = _decoder.raw_decode(text, idx)
if obj.get("type") == "function" and "name" in obj:
name = obj["name"]
arguments = _decode_string_args(obj.get("parameters", {}))
tool_calls.append({"name": name, "arguments": arguments})
remaining = remaining.replace(text[idx:end_pos], "", 1)
log(f" Llama tool call: {name}({list(arguments.keys())})")
_pos = end_pos
except json.JSONDecodeError:
_pos = idx + 1
if tool_calls:
return tool_calls, remaining.strip()
# Format 1: <tool_call>{"name": "x", "arguments": {...}}</tool_call>
pattern1 = r'<tool_call>\s*(.*?)\s*</tool_call>'
for match in re.finditer(pattern1, text, re.DOTALL):
content = match.group(1).strip()
remaining = remaining.replace(match.group(0), "", 1)
if not content:
continue
try:
call_data = json.loads(content)
# Some models wrap several calls in one tag as a JSON array; a
# list (or any non-object) here used to raise AttributeError.
for cd in call_data if isinstance(call_data, list) else [call_data]:
if isinstance(cd, dict):
tool_calls.append({
"name": cd.get("name", ""),
"arguments": cd.get("arguments", {}),
})
except json.JSONDecodeError:
# The model often puts Format 2 (<function=X><parameter=Y>...</parameter></function>)
# inside <tool_call> tags. Handle that first.
func_in_tag = re.search(r'<function=([\w.-]+)>(.*)', content, re.DOTALL)
if func_in_tag:
fname = func_in_tag.group(1)
params_text = func_in_tag.group(2)
arguments = {}
for pmatch in re.finditer(r'<parameter=(\w+)>\s*(.*?)\s*(?:</parameter>|$)', params_text, re.DOTALL):
arguments[pmatch.group(1)] = pmatch.group(2).strip()
if arguments:
tool_calls.append({"name": fname, "arguments": arguments})
log(f" Recovered function-in-tag: {fname}")
else:
log(f" Warning: function-in-tag but no params: {content[:100]}")
else:
# Try general garbled recovery
recovered = recover_garbled_tool_json(content, text)
if recovered:
tool_calls.append(recovered)
else:
log(f" Warning: unrecoverable tool_call JSON: {content[:100]}")
# Format 2: <function=name><parameter=key>value</parameter>...</function>
if not tool_calls:
pattern2 = r'<function=([\w.-]+)>(.*?)</function>'
for match in re.finditer(pattern2, text, re.DOTALL):
func_name = match.group(1)
params_text = match.group(2)
arguments = {}
for pmatch in re.finditer(r'<parameter=(\w+)>\s*(.*?)\s*</parameter>', params_text, re.DOTALL):
arguments[pmatch.group(1)] = pmatch.group(2)
tool_calls.append({"name": func_name, "arguments": arguments})
remaining = remaining.replace(match.group(0), "", 1)
# Format 3: <|tool_call|>...<|/tool_call|> (some model versions)
if not tool_calls:
pattern3 = r'<\|tool_call\|>\s*(.*?)\s*<\|/tool_call\|>'
for match in re.finditer(pattern3, text, re.DOTALL):
remaining = remaining.replace(match.group(0), "", 1)
try:
call_data = json.loads(match.group(1))
for cd in call_data if isinstance(call_data, list) else [call_data]:
if isinstance(cd, dict):
tool_calls.append({
"name": cd.get("name", ""),
"arguments": cd.get("arguments", {}),
})
except json.JSONDecodeError:
recovered = recover_garbled_tool_json(match.group(1))
if recovered:
tool_calls.append(recovered)
# Format 3.5: Qwen 2.5 — <tools>{"name": "x", "arguments": {...}}</tools>
# Some Qwen 2.5 fine-tunes emit a <tools> wrapper instead of <tool_call>.
if not tool_calls:
pattern_qwen25 = r'<tools>\s*(.*?)\s*</tools>'
for match in re.finditer(pattern_qwen25, text, re.DOTALL):
content = match.group(1).strip()
remaining = remaining.replace(match.group(0), "", 1)
if not content:
continue
try:
call_data = json.loads(content)
if isinstance(call_data, list):
for cd in call_data:
if isinstance(cd, dict) and "name" in cd:
tool_calls.append({
"name": cd.get("name", ""),
"arguments": cd.get("arguments", cd.get("parameters", {})),
})
elif isinstance(call_data, dict) and "name" in call_data:
tool_calls.append({
"name": call_data.get("name", ""),
"arguments": call_data.get("arguments", call_data.get("parameters", {})),
})
except json.JSONDecodeError:
recovered = recover_garbled_tool_json(content, text)
if recovered:
tool_calls.append(recovered)
else:
log(f" Warning: unrecoverable <tools> JSON: {content[:100]}")
# Format 3.6: JSON inside markdown code block — ```json\n{"name":..,"arguments":..}\n```
# Some Qwen 2.5 fine-tunes emit tool calls as a fenced code block when the
# system prompt / chat template doesn't explicitly tell them to use the
# native <tool_call> wrapper. Three sub-cases supported:
# (a) single JSON object inside the fence
# (b) multiple JSON objects back-to-back inside the same fence (no
# array, no commas) — the model expressing several sequential
# tool calls in one go. Use raw_decode in a loop to extract them.
# (c) a single JSON array of call objects — Hermes 4 14B emits this
# shape instead of (b); without this branch the whole fence was
# skipped (array doesn't start with "{") and fell through to the
# garbled-tool-call retry path even though the JSON was valid.
def _as_call(obj):
if not isinstance(obj, dict):
return None
name = obj.get("name") or obj.get("tool")
args = (
obj.get("arguments")
or obj.get("parameters")
or obj.get("args")
or obj.get("input")
)
return {"name": name, "arguments": args} if name and isinstance(args, dict) else None
if not tool_calls:
pattern_md = r'```(?:json|tool[_ ]?call)?\s*\n?(.*?)\s*\n?```'
decoder_md = json.JSONDecoder()
for match in re.finditer(pattern_md, text, re.DOTALL):
content = match.group(1).strip()
stripped = content.lstrip()
if not stripped.startswith("{") and not stripped.startswith("["):
continue
extracted_in_block = []
if stripped.startswith("["):
try:
items = json.loads(content)
except json.JSONDecodeError:
items = []
for obj in items if isinstance(items, list) else []:
call = _as_call(obj)
if call:
extracted_in_block.append(call)
else:
pos = 0
while pos < len(content):
# Skip whitespace and stray commas between objects
while pos < len(content) and content[pos] in " \t\n\r,":
pos += 1
if pos >= len(content):
break
if content[pos] != "{":
break
try:
obj, end_pos = decoder_md.raw_decode(content, pos)
except json.JSONDecodeError:
break
pos = end_pos
call = _as_call(obj)
if call:
extracted_in_block.append(call)
if extracted_in_block:
tool_calls.extend(extracted_in_block)
remaining = remaining.replace(match.group(0), "", 1)
log(
f" Markdown-fenced tool calls: "
f"{', '.join(t['name'] for t in extracted_in_block)}"
)
# Format 3.7: bare top-level JSON with no wrapper at all —
# {"name": "Bash", "arguments": {"command": "ls /tmp"}}
# Qwen 2.5 Coder emits exactly this: no <tool_call> tags, no <tools>, no
# markdown fence, just the object. Formats 3.5/3.6 already cover the
# wrapped and fenced variants of the same shape, so this is the last one.
# Without it Qwen 2.5 Coder scores 0/12 on scripts/test_mlx_server.py —
# every call is well-formed and every call is invisible to the parser.
# Re-prompting doesn't rescue it either: the retry explicitly asks for
# <tool_call> tags and the model declines, and recover_garbled_tool_json
# never fires because the JSON was never garbled.
#
# Same back-to-back handling as 3.6 (raw_decode in a loop) for models that
# emit several sequential calls without an array. Runs only after every
# tagged format has failed, so an explicit wrapper always takes priority.
if not tool_calls:
stripped = text.strip()
if stripped.startswith("{"):
decoder_bare = json.JSONDecoder()
extracted_bare = []
pos = 0
while pos < len(stripped):
while pos < len(stripped) and stripped[pos] in " \t\n\r,":
pos += 1
if pos >= len(stripped) or stripped[pos] != "{":
break
try:
obj, end_pos = decoder_bare.raw_decode(stripped, pos)
except json.JSONDecodeError:
break
pos = end_pos
if not isinstance(obj, dict):
continue
name = obj.get("name") or obj.get("tool")
args = (
obj.get("arguments")
or obj.get("parameters")
or obj.get("args")
or obj.get("input")
)
if name and isinstance(args, dict):
extracted_bare.append({"name": name, "arguments": args})
if extracted_bare:
tool_calls.extend(extracted_bare)
# The whole message was the tool call; nothing survives as prose.
remaining = stripped[pos:].strip()
log(
f" Bare JSON tool calls: "
f"{', '.join(t['name'] for t in extracted_bare)}"
)
# Format 4: Garbled — no tags at all, but parameter= patterns in raw text
if not tool_calls:
# Look for any tool name followed by parameter patterns
tool_names_pattern = r'(?:mcp__[\w.-]+|Bash|Read|Write|Edit|Glob|Grep)'
name_match = re.search(rf'"?name"?\s*[:=]\s*"?({tool_names_pattern})"?', text)
param_matches = list(re.finditer(r'<parameter=(\w+)>\s*(.*?)\s*</parameter>', text, re.DOTALL))
if name_match and param_matches:
arguments = {}
for pm in param_matches:
arguments[pm.group(1)] = pm.group(2)
tool_calls.append({"name": name_match.group(1), "arguments": arguments})
remaining = text[:name_match.start()].strip()
log(f" Recovered tagless tool call: {name_match.group(1)}")
elif param_matches:
# We have parameters but no name — try to infer from param keys
arguments = {}
for pm in param_matches:
arguments[pm.group(1)] = pm.group(2)
if "command" in arguments:
tool_calls.append({"name": "Bash", "arguments": arguments})
log(f" Inferred Bash tool call from 'command' parameter")
elif "file_path" in arguments:
tool_calls.append({"name": "Read", "arguments": arguments})
log(f" Inferred Read tool call from 'file_path' parameter")
elif "pattern" in arguments:
tool_calls.append({"name": "Glob", "arguments": arguments})
log(f" Inferred Glob tool call from 'pattern' parameter")
if tool_calls:
remaining = text[:param_matches[0].start()].strip()
# Deduplicate tool calls (model sometimes emits same call in multiple formats).
# Key on name AND arguments: keying on name alone dropped every parallel
# call after the first one to the same tool (Read /a, Read /b -> Read /a).
seen = set()
deduped = []
for tc in tool_calls:
tc["arguments"] = _decode_string_args(tc["arguments"])
key = (tc["name"], json.dumps(tc["arguments"], sort_keys=True, default=str))
if key not in seen:
seen.add(key)
deduped.append(tc)
else:
log(f" Deduped: {tc['name']}")
tool_calls = deduped
# Clean remaining text: strip any leftover <function=...> or <tool_call> fragments
remaining = re.sub(r'<function=[\w.-]+>.*?</function>', '', remaining, flags=re.DOTALL)
remaining = re.sub(r'</?tool_call>', '', remaining)
remaining = remaining.strip()
return tool_calls, remaining
# ─── Anthropic Message Conversion ───────────────────────────────────────────
def convert_messages(body):
"""Convert Anthropic Messages format to MLX chat messages.
Handles:
- Text messages (passthrough)
- Assistant messages with tool_use blocks → <tool_call> format
- User messages with tool_result blocks → role="tool" messages
"""
messages = []
# System prompt
if body.get("system"):
sys_text = body["system"]
if isinstance(sys_text, list):
sys_text = "\n".join(b.get("text", "") for b in sys_text if b.get("type") == "text")
messages.append({"role": "system", "content": sys_text})
# Conversation messages
for msg in body.get("messages", []):
role = msg.get("role", "user")
content = msg.get("content", "")
# Simple string content
if isinstance(content, str):
messages.append({"role": role, "content": content})
continue
# List of content blocks
if isinstance(content, list):
text_parts = []
tool_use_parts = []
tool_result_parts = []
for block in content:
btype = block.get("type", "")
if btype == "text":
text_parts.append(block.get("text", ""))
elif btype == "tool_use":
tool_use_parts.append(block)
elif btype == "tool_result":
tool_result_parts.append(block)
# Assistant message with tool_use blocks → convert to LLM format
if role == "assistant" and tool_use_parts:
content_str = ""
if text_parts:
content_str = "\n".join(p for p in text_parts if p)
for tu in tool_use_parts:
call_json = json.dumps({
"name": tu.get("name", ""),
"arguments": tu.get("input", {})
}, ensure_ascii=False)
content_str += f"\n<tool_call>\n{call_json}\n</tool_call>"
messages.append({"role": "assistant", "content": content_str.strip()})
# User message with tool_result blocks → split into tool messages
elif tool_result_parts:
# Add any text from the user first
if text_parts:
text = "\n".join(p for p in text_parts if p)
if text.strip():
messages.append({"role": "user", "content": text})
# Each tool_result becomes a "tool" role message
for tr in tool_result_parts:
result_content = tr.get("content", "")
if isinstance(result_content, list):
# An image (e.g. Read on a .png) used to fall through to
# str(b) and land in the prompt as base64 text, which can
# be hundreds of thousands of tokens. Leave a marker.
result_content = "\n".join(
f"[{b.get('type')} omitted]" if b.get("type") in ("image", "document")
else b.get("text", str(b))
for b in result_content
)
elif not isinstance(result_content, str):
result_content = str(result_content)
if tr.get("is_error"):
result_content = "Error: " + result_content
# Include tool name context if we can find it
messages.append({"role": "tool", "content": result_content})
# Regular message with just text
else:
text = "\n".join(p for p in text_parts if p)
if text.strip():
messages.append({"role": role, "content": text})
return messages
def tokenize_messages(messages, tools=None):
"""Apply chat template and tokenize, with optional tool definitions."""
kwargs = {
"add_generation_prompt": True,
"tokenize": True,
}
if tools:
kwargs["tools"] = tools
try:
token_ids = tokenizer.apply_chat_template(messages, **kwargs)
if tools:
log(f" Tools: {len(tools)} tools passed via chat template")
return token_ids
except (TypeError, Exception) as e:
# If tools param failed, try injecting into system prompt instead
if tools:
log(f" Chat template tools param failed ({e}), injecting into system prompt")
tool_text = format_tools_as_text(tools)
msg_copy = [m.copy() for m in messages]
if msg_copy and msg_copy[0]["role"] == "system":
msg_copy[0]["content"] = msg_copy[0]["content"] + "\n\n" + tool_text
else:
msg_copy.insert(0, {"role": "system", "content": tool_text})
try:
return tokenizer.apply_chat_template(
msg_copy, add_generation_prompt=True, tokenize=True
)
except Exception:
pass
# Final fallback: plain text
log(" Warning: using plain text fallback for tokenization")
text = "\n".join(f"{m['role']}: {m['content']}" for m in messages)
text += "\nassistant: "
return tokenizer.encode(text)
# ─── Browser Mode Optimization ───────────────────────────────────────────────
BROWSER_SYSTEM_PROMPT = """You are a fast browser agent. You control a web browser via tools.
CORE RULES:
- ONLY use take_snapshot to see the page. NEVER use take_screenshot.
- take_snapshot returns a text DOM tree with uid attributes. Use these uids to click/fill.
- Chain actions quickly. Don't explain, just act.
- Navigate directly to URLs. Don't go to homepages first.
COMMENTING ON ARTICLES (Yahoo, news sites, etc.):
Comment boxes on most news sites are inside iframes that take_snapshot CANNOT see.
You MUST use evaluate_script to interact with comment boxes. Here is the exact process:
Step 1: Click the "Comments" button using its uid from the snapshot to open comments.
Step 2: Use evaluate_script to find and click the comment input inside the iframe:
function: "() => { const frames = document.querySelectorAll('iframe'); for (const f of frames) { try { const doc = f.contentDocument || f.contentWindow.document; const el = doc.querySelector('[contenteditable=true], textarea, [role=textbox], .ow-comment-textarea, [data-spot-im-class=spcv_editor]'); if (el) { el.click(); el.focus(); return 'Found comment input in iframe'; } } catch(e) {} } return 'No comment input found'; }"
Step 3: Use evaluate_script to type your comment text into the focused element:
function: "() => { const frames = document.querySelectorAll('iframe'); for (const f of frames) { try { const doc = f.contentDocument || f.contentWindow.document; const el = doc.querySelector('[contenteditable=true], textarea, [role=textbox], .ow-comment-textarea, [data-spot-im-class=spcv_editor]'); if (el) { el.focus(); el.innerText = 'YOUR COMMENT TEXT HERE'; el.dispatchEvent(new Event('input', {bubbles: true})); return 'Comment typed'; } } catch(e) {} } return 'Failed to type'; }"
Step 4: Do NOT click any Send/Post button. Leave the comment as a draft for the user to review.
IMPORTANT: Replace 'YOUR COMMENT TEXT HERE' with an actual thoughtful comment about the article.
The comment should be 2-3 sentences, relevant to the article content you read in the snapshot."""
# Only these tools are needed for browser control
BROWSER_TOOLS_ALLOW = {
"mcp__chrome-devtools__navigate_page",
"mcp__chrome-devtools__take_snapshot",
"mcp__chrome-devtools__click",