File size: 9,821 Bytes
41aaade
 
 
 
 
cdeabf7
88cf3a0
 
 
 
41aaade
 
 
88cf3a0
41aaade
88cf3a0
 
 
41aaade
88cf3a0
 
 
41aaade
88cf3a0
 
 
 
41aaade
 
 
 
 
 
 
 
 
 
 
3aea476
41aaade
3aea476
41aaade
 
 
 
 
 
 
 
 
88cf3a0
 
41aaade
 
88cf3a0
41aaade
88cf3a0
 
33a41ff
 
 
 
3aea476
 
33a41ff
 
cdeabf7
 
 
 
 
33a41ff
 
 
cdeabf7
33a41ff
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
437757b
 
41aaade
437757b
33a41ff
41aaade
88cf3a0
41aaade
 
8802270
 
 
 
41aaade
 
88cf3a0
41aaade
88cf3a0
41aaade
 
 
437757b
33a41ff
 
437757b
41aaade
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
437757b
 
33a41ff
 
 
 
 
 
 
 
 
41aaade
33a41ff
 
 
 
41aaade
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
88cf3a0
 
 
41aaade
 
 
 
 
88cf3a0
41aaade
88cf3a0
 
 
 
bfaaaa0
 
3aea476
 
bfaaaa0
3aea476
 
 
 
 
 
 
 
8802270
 
 
bfaaaa0
 
 
 
 
 
 
 
 
88cf3a0
 
 
 
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
# -*- coding: utf-8 -*-
"""POCKET-26B (Gemma4-based) CPU chat + live A/B vs Bonsai.
FastAPI frontend proxying to two local llama.cpp servers (upstream prebuilt release).
POCKET-26B speaks the Gemma-4 chat format; Bonsai speaks ChatML. Native /completion,
manual prompt build (no --jinja). No GPU — CPU + RAM only."""
import os, json, time, re
import httpx
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse, StreamingResponse, JSONResponse

BACKEND = os.environ.get("BACKEND", "http://127.0.0.1:8080")               # POCKET-26B (Gemma4)
BONSAI_BACKEND = os.environ.get("BONSAI_BACKEND", "http://127.0.0.1:8081")  # Bonsai (ChatML)
HERE = os.path.dirname(__file__)

app = FastAPI(title="POCKET-26B CPU chat")


def build_gemma(messages):
    """Gemma-4 chat prompt. llama.cpp adds <bos> from the GGUF metadata."""
    parts = []
    for m in messages:
        role = "user" if m.get("role") == "user" else "model"
        parts.append("<start_of_turn>%s\n%s<end_of_turn>\n" % (role, m.get("content", "").strip()))
    parts.append("<start_of_turn>model\n")
    return "".join(parts)


def build_chatml(messages):
    """Qwen-family ChatML prompt (Bonsai)."""
    parts = []
    if not any(m.get("role") == "system" for m in messages):
        parts.append("<|im_start|>system\nYou are a helpful assistant. Answer concisely.<|im_end|>\n")
    for m in messages:
        parts.append("<|im_start|>%s\n%s<|im_end|>\n" % (m.get("role", "user"), m.get("content", "")))
    parts.append("<|im_start|>assistant\n")
    return "".join(parts)


GEMMA_STOPS = ["<end_of_turn>", "<start_of_turn>", "<end_turn>", "<start_turn>"]
TEMPLATES = {
    "gemma":  (build_gemma,  GEMMA_STOPS),
    "chatml": (build_chatml, ["<|im_end|>", "<|im_start|>"]),
}


def _sse(obj):
    return "data: " + json.dumps(obj) + "\n\n"


async def _health(backend):
    try:
        async with httpx.AsyncClient(timeout=3.0) as c:
            r = await c.get(backend + "/health")
        return r.status_code == 200
    except Exception:
        return False


# Gemma4 wraps answers in a reasoning channel whose markers vary in this GGUF
# (sometimes <|channel>thought…<channel|>, sometimes <thought…\n). We extract the
# answer that follows the reasoning preamble so the UI shows clean text, never raw markers.
_JUNK = ["<|channel>", "<channel|>", "<|message>", "<tool_call|>", "<|start|>",
         "<|end|>", "<end_of_turn>", "<start_of_turn>", "<end_turn>", "<start_turn>",
         "<thought", "thought>"]


# catches partial/hallucinated turn & channel fragments the Q2 model sometimes emits
# (e.g. a stray "of_turn>" from a split <end_of_turn>), while leaving normal words alone.
_FRAG_RE = re.compile(r'[<|/a-z_]*_turn[|>]+|<\|?channel[|>]*|<thought')


def _scrub(s):
    for j in _JUNK:
        s = s.replace(j, "")
    return _FRAG_RE.sub("", s)


def _extract_gemma_answer(raw):
    """Return the answer portion of a (possibly partial) Gemma4 generation."""
    for close in ("<channel|>", "<|message>"):
        i = raw.rfind(close)
        if i != -1:
            return _scrub(raw[i + len(close):]).strip("\n ")
    head = raw.lstrip()
    looks_reasoning = ("<|channel>" in raw) or ("<thought" in raw) \
        or head[:10].lower().startswith(("channel", "thought"))
    if looks_reasoning:
        nl = raw.find("\n")            # answer begins after the thought label's newline
        if nl != -1:
            return _scrub(raw[nl + 1:]).strip("\n ")
        return ""                      # still inside the preamble
    return _scrub(raw).strip("\n ")    # plain answer, no reasoning wrapper


async def run_one(backend, tag, messages, max_tokens, template, temperature=0.7):
    """Stream one model's /completion output as SSE, tagged with `tag` (None = untagged).
    For Gemma (POCKET-26B) the reasoning wrapper is stripped so the UI shows a clean answer."""
    build, stops = TEMPLATES[template]
    payload = {
        "prompt": build(messages),
        "temperature": float(temperature),
        "top_p": 0.92,
        "min_p": 0.05,
        "repeat_penalty": 1.18,   # Q2 free-form generation falls into repetition loops without this
        "repeat_last_n": 256,
        "n_predict": int(max_tokens),
        "stream": True,
        "cache_prompt": True,
        "stop": stops,
    }
    n_tok = 0
    t_first = None
    tagd = {"m": tag} if tag else {}
    strip_channel = (template == "gemma")
    raw = ""
    sent_len = 0
    emitted = 0
    async with httpx.AsyncClient(timeout=None) as c:
        async with c.stream("POST", backend + "/completion", json=payload) as r:
            if r.status_code != 200:
                detail = (await r.aread()).decode("utf-8", "ignore")[:200]
                yield _sse(dict(tagd, error="backend %s: %s" % (r.status_code, detail)))
                return
            async for line in r.aiter_lines():
                if not line or not line.startswith("data:"):
                    continue
                try:
                    obj = json.loads(line[5:].strip())
                except Exception:
                    continue
                delta = obj.get("content")
                if delta:
                    if t_first is None:
                        t_first = time.monotonic()
                    n_tok += 1  # count every generated token (true generation speed)
                    if strip_channel:
                        raw += delta
                        clean = _extract_gemma_answer(raw)
                        if len(clean) > sent_len:
                            yield _sse(dict(tagd, t=clean[sent_len:]))
                            sent_len = len(clean)
                            emitted += 1
                    else:
                        emitted += 1
                        yield _sse(dict(tagd, t=delta))
                if obj.get("stop"):
                    if strip_channel and emitted == 0 and raw.strip():
                        fb = _scrub(raw).strip()   # never blank
                        if fb:
                            yield _sse(dict(tagd, t=fb))
                    tim = obj.get("timings") or {}
                    tok_s = tim.get("predicted_per_second")
                    if not tok_s and t_first is not None and n_tok > 0:
                        dt = time.monotonic() - t_first
                        tok_s = (n_tok / dt) if dt > 0 else 0.0
                    yield _sse(dict(tagd, done=True, tok_s=round(tok_s or 0.0, 1),
                                    n=int(tim.get("predicted_n") or n_tok)))
                    return
    yield _sse(dict(tagd, done=True, tok_s=0.0, n=n_tok))


@app.get("/api/status")
async def status():
    pk = await _health(BACKEND)
    bn = await _health(BONSAI_BACKEND)
    if pk:
        detail = "POCKET-26B on CPU · ready" if bn else "POCKET ready · loading Bonsai for the A/B…"
    else:
        detail = "downloading & loading models on CPU… (first boot can take a few minutes)"
    return JSONResponse({"state": "ready" if pk else "loading", "detail": detail,
                         "pocket": pk, "bonsai": bn})


@app.post("/api/chat")
async def chat(req: Request):
    body = await req.json()
    messages = body.get("messages") or [{"role": "user", "content": body.get("prompt", "Hi")}]
    mt = int(body.get("max_tokens", 512))

    async def gen():
        try:
            async for chunk in run_one(BACKEND, None, messages, mt, "gemma", body.get("temperature", 0.7)):
                yield chunk
        except Exception as e:
            yield _sse({"error": str(e)[:200]})

    return StreamingResponse(gen(), media_type="text/event-stream")


@app.post("/api/chat_ab")
async def chat_ab(req: Request):
    """Sequential race: Bonsai runs first, then POCKET-26B (each gets the full CPU)."""
    body = await req.json()
    messages = body.get("messages") or [{"role": "user", "content": body.get("prompt", "Hi")}]
    mt = int(body.get("max_tokens", 512))

    async def gen():
        try:
            async for chunk in run_one(BONSAI_BACKEND, "bonsai", messages, mt, "chatml"):
                yield chunk
            async for chunk in run_one(BACKEND, "pocket", messages, mt, "gemma"):
                yield chunk
            yield _sse({"ab_done": True})
        except Exception as e:
            yield _sse({"error": str(e)[:200]})

    return StreamingResponse(gen(), media_type="text/event-stream")


@app.post("/api/raw")
async def raw(req: Request):
    """DEBUG: return the model's UNstripped output. Accepts `raw_prompt` to bypass the
    template builder so prompt/prefill experiments need no rebuild."""
    body = await req.json()
    if body.get("raw_prompt") is not None:
        prompt = body["raw_prompt"]
        stops = body.get("stop", GEMMA_STOPS)
    else:
        messages = body.get("messages") or [{"role": "user", "content": body.get("prompt", "Hi")}]
        build, stops = TEMPLATES[body.get("template", "gemma")]
        prompt = build(messages)
    payload = {"prompt": prompt, "temperature": float(body.get("temperature", 0.7)),
               "top_p": 0.92, "min_p": 0.05,
               "repeat_penalty": float(body.get("repeat_penalty", 1.18)), "repeat_last_n": 256,
               "n_predict": int(body.get("max_tokens", 60)), "stream": False,
               "cache_prompt": True, "stop": stops}
    async with httpx.AsyncClient(timeout=None) as c:
        r = await c.post(BACKEND + "/completion", json=payload)
    j = r.json()
    return JSONResponse({"raw": j.get("content", ""),
                         "extracted": _extract_gemma_answer(j.get("content", "")),
                         "stop": j.get("stopping_word", "")})


@app.get("/", response_class=HTMLResponse)
def index():
    with open(os.path.join(HERE, "index.html"), encoding="utf-8") as f:
        return f.read()