File size: 11,483 Bytes
67ebcef
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""LFM3-1.7B with its inference modules behind one OpenAI-compatible endpoint: for a client it is just a model.

Every request goes through the modules measured in REPORT.md, chosen by the kind of request:
  * code (asks for a program / function): deliberation + test-and-repair on the examples in the statement;
  * visual (draw / build an SVG, a web page, a canvas animation, a three.js scene): deliberation + render in headless
    Chrome + repair of what the browser reports (needs playwright and Chrome);
  * multiple choice (>= 3 lettered options): deliberation with temporal consensus;
  * math / short-answer question: deliberation (answer read-out at the budget);
  * anything else (writing, explanations): deliberation with the free-form budget fallback.
Model "lfm3-deliberate-x4" (or "parallel": k in the request) adds parallel thinking: k independent reasonings and the
weighted vote (math / choice only; AIME test 52.5 -> 66.7 at 4x tokens).

The backend is a llama-server with the model; each request takes 2 of its slots (2k with parallel thinking), so
-np sets how many requests run at once.
usage:
  python lfm3_server.py --start-backend gguf/lfm3-orig-q5km.gguf --slots 16      # starts llama-server too
  python lfm3_server.py --backend http://localhost:8080                           # uses a running one
  then POST http://localhost:8090/v1/chat/completions {"model": "lfm3-deliberate", "messages": [...]}
"""
import argparse
import json
import os
import re
import subprocess
import threading
import time
import urllib.request
import uuid
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

from lfm3_deliberate import Deliberator

HERE = os.path.dirname(os.path.abspath(__file__))

CODE_STRONG = re.compile(r"```python|\bdef \w+\(|standard input|\bstdin\b|>>>|\bassert \w", re.I)
CODE_ASK = re.compile(r"\b(write|implement|complete|fix|create)\b[^\n]{0,80}\b(python|program|function)\b|"
                      r"(напиши|напишите|реализуй|реализуйте|исправь|создай)[^\n]{0,80}(python|программ|функци|скрипт|код)",
                      re.I)
CODE_HINT = re.compile(r"\b(python|input|output|returns?|integers?|array|list|string)\b|"
                       r"(python|возвращ|список|массив|строк|ввод|вывод|чисел|число)", re.I)
MATH_TEX = re.compile(r"\\boxed|\\frac|\$[^$\n]+\$|\d\s*[+*/^×÷]\s*\(?\d")
MATH_WORDS = re.compile(r"\b(compute|calculate|evaluate|simplify|solve|how many|how much|find the (value|number|sum|"
                        r"remainder|product|smallest|largest|least|greatest))\b|(вычисли|найди|найдите|сколько|реши|"
                        r"решите|докажи)", re.I)


VISUAL = re.compile(r"\b(svg|html|css|canvas|three\.js|web ?page|landing page|website)\b|(сайт|страниц|верстк)", re.I)
VISUAL_ASK = re.compile(r"\b(draw|build|make|create|design|render|animate)\b|(нарисуй|сделай|создай|сверстай|построй)",
                        re.I)


def route(text):
    """Picks the module configuration that was measured for this kind of request (REPORT.md)."""
    if len(re.findall(r"^\s*\(?[A-J][.)]\s+\S", text, re.M)) >= 3:
        return "choice"
    if VISUAL.search(text) and VISUAL_ASK.search(text) and not re.search(r"\bpython\b", text, re.I):
        return "visual"
    if CODE_STRONG.search(text) or (CODE_ASK.search(text) and CODE_HINT.search(text)):
        return "code"
    if re.search(r"answer is:?\s*<", text):  # "end your response with 'The answer is: <answer>'"
        return "phrase"
    if len(text) < 4000 and (MATH_TEX.search(text) or (MATH_WORDS.search(text) and re.search(r"\d", text))):
        return "math"
    return "free"


class SlotPool:
    def __init__(self, n):
        self.free, self.cv = list(range(n)), threading.Condition()

    def take(self, k):
        with self.cv:
            self.cv.wait_for(lambda: len(self.free) >= k)
            got, self.free = self.free[:k], self.free[k:]
            return got

    def give(self, slots):
        with self.cv:
            self.free += slots
            self.cv.notify_all()


def start_backend(gguf, slots, ctx, bin_dir, port):
    # llama-server scans its working directory for backends; the project path is not ASCII, so run it from its own
    cmd = [os.path.join(bin_dir, "llama-server.exe" if os.name == "nt" else "llama-server"), "-m", os.path.abspath(gguf),
           "-ngl", "99", "-np", str(slots), "-c", str(slots * ctx), "-fa", "on", "--port", str(port), "--no-webui"]
    os.makedirs(os.path.join(HERE, "results"), exist_ok=True)
    log = open(os.path.join(HERE, "results", "server_lfm3.log"), "w")
    proc = subprocess.Popen(cmd, cwd=bin_dir, stdout=log, stderr=subprocess.STDOUT)
    for _ in range(300):
        try:
            if b'"ok"' in urllib.request.urlopen(f"http://localhost:{port}/health", timeout=2).read():
                return proc
        except Exception:
            pass
        if proc.poll() is not None:
            raise SystemExit(f"llama-server exited, see results/server_lfm3.log")
        time.sleep(1)
    raise SystemExit("llama-server did not become ready")


def make_handler(delib, pool, args):
    class H(BaseHTTPRequestHandler):
        def _send(self, code, obj):
            data = json.dumps(obj, ensure_ascii=False).encode()
            self.send_response(code)
            self.send_header("Content-Type", "application/json")
            self.send_header("Content-Length", str(len(data)))
            self.end_headers()
            self.wfile.write(data)

        def log_message(self, fmt, *a):
            pass

        def do_GET(self):
            if self.path.rstrip("/") in ("/v1/models", "/models"):
                return self._send(200, {"object": "list", "data": [
                    {"id": m, "object": "model", "owned_by": "local"} for m in ("lfm3-deliberate", "lfm3-deliberate-x4")]})
            if self.path.rstrip("/") == "/health":
                return self._send(200, {"status": "ok"})
            self._send(404, {"error": "not found"})

        def do_POST(self):
            if self.path.rstrip("/") not in ("/v1/chat/completions", "/chat/completions"):
                return self._send(404, {"error": "not found"})
            try:
                body = json.loads(self.rfile.read(int(self.headers.get("Content-Length", 0))))
                msgs = [{"role": m["role"], "content": m["content"] if isinstance(m["content"], str) else
                         "".join(p.get("text", "") for p in m["content"])} for m in body["messages"]]
            except Exception as e:
                return self._send(400, {"error": f"bad request: {e}"})
            last = next((m["content"] for m in reversed(msgs) if m["role"] == "user"), "")
            kind = body.get("lfm3_kind") or route(last)
            k = int(body.get("parallel") or (4 if str(body.get("model", "")).endswith("-x4") else 1))
            k = k if kind in ("math", "choice") else 1
            budget = int(body.get("max_tokens") or body.get("max_completion_tokens") or args.budget)
            slots = pool.take(2 * k)
            t0 = time.time()
            try:
                import copy
                d = copy.copy(delib)
                d.budget = max(2048, min(budget, args.budget))
                d.reserve = min(d.reserve, d.budget // 4)
                d.main_slot, d.probe_slot = slots[0], slots[1]
                seed = int(body.get("seed", 0))
                if kind == "code":
                    r = d.solve_code(msgs, seed=seed)
                    info = {"mode": r["mode"], "public_tests": r["public"], "repair_rounds": r["rounds"]}
                elif kind == "visual":
                    r = d.solve_visual(msgs, seed=seed)
                    info = {"mode": r["mode"], "render_problems": r["problems"], "repair_rounds": r["rounds"]}
                elif k > 1:
                    r = d.ask_parallel(msgs, kind, k=k, seed=seed, slots=slots)
                    info = {"votes": r["votes"], "modes": [o["mode"] for o in r["outputs"]],
                            "thinking_tokens": sum(o["thinking_tokens"] for o in r["outputs"])}
                else:
                    r = d.ask(msgs, kind, seed=seed)
                    info = {"mode": r["mode"], "thinking_tokens": r["thinking_tokens"]}
            except Exception as e:
                return self._send(500, {"error": f"{type(e).__name__}: {e}"})
            finally:
                pool.give(slots)
            info.update(kind=kind, parallel=k, seconds=round(time.time() - t0, 1))
            answer = r["answer"]
            rid, created = "chatcmpl-" + uuid.uuid4().hex[:24], int(time.time())
            model = body.get("model", "lfm3-deliberate")
            if body.get("stream"):
                self.send_response(200)
                self.send_header("Content-Type", "text/event-stream")
                self.end_headers()
                for delta, fin in (({"role": "assistant", "content": answer}, None), ({}, "stop")):
                    chunk = {"id": rid, "object": "chat.completion.chunk", "created": created, "model": model,
                             "choices": [{"index": 0, "delta": delta, "finish_reason": fin}]}
                    self.wfile.write(b"data: " + json.dumps(chunk, ensure_ascii=False).encode() + b"\n\n")
                self.wfile.write(b"data: [DONE]\n\n")
                return
            self._send(200, {"id": rid, "object": "chat.completion", "created": created, "model": model,
                             "choices": [{"index": 0, "message": {"role": "assistant", "content": answer},
                                          "finish_reason": "stop"}],
                             "usage": {"completion_tokens": info.get("thinking_tokens", 0)}, "lfm3": info})
    return H


if __name__ == "__main__":
    ap = argparse.ArgumentParser()
    ap.add_argument("--backend", default="http://localhost:8080")
    ap.add_argument("--start-backend", metavar="GGUF", help="start llama-server with this model first")
    import shutil
    found = shutil.which("llama-server")
    ap.add_argument("--llama-bin", default=os.environ.get("LLAMA_BIN") or (os.path.dirname(found) if found else "."),
                    help="folder with llama-server (default: $LLAMA_BIN or the one on PATH)")
    ap.add_argument("--slots", type=int, default=16, help="llama-server slots (2 per request, 2k with parallel k)")
    ap.add_argument("--slot-ctx", type=int, default=20480)
    ap.add_argument("--budget", type=int, default=16384, help="max thinking tokens per reasoning")
    ap.add_argument("--port", type=int, default=8090)
    ap.add_argument("--tokenizer", default=os.path.join(HERE, "tokenizer"))
    a = ap.parse_args()
    if a.start_backend:
        start_backend(a.start_backend, a.slots, a.slot_ctx, a.llama_bin, int(a.backend.rsplit(":", 1)[1]))
    props = json.loads(urllib.request.urlopen(a.backend + "/props", timeout=10).read())
    n = int(props.get("total_slots", a.slots))
    print(f"backend {a.backend}: {n} slots -> up to {n // 2} requests at once; serving on http://localhost:{a.port}/v1",
          flush=True)
    srv = ThreadingHTTPServer(("127.0.0.1", a.port), make_handler(Deliberator(a.backend, a.tokenizer, budget=a.budget),
                                                                  SlotPool(n), a))
    srv.serve_forever()