File size: 5,696 Bytes
e186935
e77d0bf
 
 
4df8486
b07a776
e186935
 
 
e77d0bf
 
b07a776
e186935
 
 
 
f8feff1
 
d6e52f6
 
 
 
ea6a70b
d6e52f6
f8feff1
d6e52f6
e77d0bf
d6e52f6
 
 
 
ea6a70b
 
 
e77d0bf
 
 
 
 
b07a776
 
e77d0bf
 
b07a776
e77d0bf
b07a776
e186935
f8feff1
e77d0bf
b07a776
f8feff1
e77d0bf
b07a776
e77d0bf
b07a776
ea6a70b
 
b07a776
e77d0bf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e186935
b07a776
e186935
8bfa67a
e77d0bf
8bfa67a
e77d0bf
 
e186935
e77d0bf
e186935
 
 
b07a776
e186935
 
b07a776
e186935
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
# -*- coding: utf-8 -*-
"""POCKET-35B CPU chat + live A/B vs Bonsai.
FastAPI frontend proxying to two local llama.cpp servers (upstream prebuilt release,
supports POCKET's qwen35moe arch). Native /completion + manual ChatML. No GPU."""
import os, json, time
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
BONSAI_BACKEND = os.environ.get("BONSAI_BACKEND", "http://127.0.0.1:8081")  # Bonsai
HERE = os.path.dirname(__file__)

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


def build_chatml(messages, no_think=False):
    """Qwen-family ChatML prompt (works for POCKET and Bonsai).
    When no_think is set, the assistant turn is prefilled with a closed (empty)
    <think></think> block so the model skips reasoning and answers directly — the
    reliable way to get short, fast replies for the A/B race (the /no_think soft
    switch is not honored through a raw prompt)."""
    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", "")))
    if no_think:
        parts.append("<|im_start|>assistant\n<think>\n\n</think>\n\n")
    else:
        parts.append("<|im_start|>assistant\n")
    return "".join(parts)


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


async def run_one(backend, tag, messages, max_tokens, temperature=0.7, no_think=False):
    """Stream one model's /completion output as SSE, tagged with `tag` (None = untagged)."""
    payload = {
        "prompt": build_chatml(messages, no_think=no_think),
        "temperature": float(temperature),
        "top_p": 0.95,
        "n_predict": int(max_tokens),
        "stream": True,
        "cache_prompt": True,
        "stop": ["<|im_end|>", "<|im_start|>"],
    }
    n_tok = 0
    t_first = None
    tagd = {"m": tag} if tag else {}
    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
                    yield _sse(dict(tagd, t=delta))
                if obj.get("stop"):
                    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-35B on CPU · ready" if bn else "POCKET ready · loading Bonsai for the A/B…"
    else:
        detail = "downloading & loading models… (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, 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 (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", 200))

    async def gen():
        try:
            async for chunk in run_one(BONSAI_BACKEND, "bonsai", messages, mt):
                yield chunk
            async for chunk in run_one(BACKEND, "pocket", messages, mt):
                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.get("/", response_class=HTMLResponse)
def index():
    with open(os.path.join(HERE, "index.html"), encoding="utf-8") as f:
        return f.read()