File size: 19,739 Bytes
8c9e2ac
7c0a3b8
 
284955f
7c0a3b8
 
 
284955f
 
 
8c9e2ac
284955f
 
 
 
 
8c9e2ac
 
 
 
 
 
 
 
 
 
 
 
 
 
54ec0c5
 
 
8c9e2ac
7c0a3b8
 
 
 
 
 
8c9e2ac
7c0a3b8
 
 
 
 
8c9e2ac
7c0a3b8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54ec0c5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8c9e2ac
 
 
 
 
 
 
 
 
 
 
284955f
 
 
 
 
 
 
8c9e2ac
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7c0a3b8
 
 
284955f
 
7c0a3b8
 
 
54ec0c5
 
 
 
 
 
 
8c9e2ac
 
 
 
 
284955f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7c0a3b8
 
284955f
 
 
 
 
7c0a3b8
 
 
 
 
 
 
 
 
284955f
 
 
 
 
 
 
 
54ec0c5
 
7c0a3b8
 
 
 
 
 
 
 
 
284955f
 
 
 
7c0a3b8
 
 
 
 
 
 
 
 
 
 
 
 
284955f
 
 
7c0a3b8
54ec0c5
 
 
 
8c9e2ac
 
 
 
 
 
 
284955f
8c9e2ac
54ec0c5
 
 
 
 
 
 
7c0a3b8
 
 
 
 
 
 
 
 
 
 
 
 
8c9e2ac
7c0a3b8
8c9e2ac
7c0a3b8
 
8c9e2ac
54ec0c5
 
7c0a3b8
 
 
8c9e2ac
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7c0a3b8
8c9e2ac
 
54ec0c5
8c9e2ac
 
 
 
 
7c0a3b8
 
 
8c9e2ac
 
 
7c0a3b8
 
 
 
8c9e2ac
7c0a3b8
8c9e2ac
7c0a3b8
284955f
 
 
 
 
54ec0c5
284955f
54ec0c5
 
284955f
7c0a3b8
284955f
 
54ec0c5
8c9e2ac
7c0a3b8
8c9e2ac
284955f
 
 
 
 
 
 
7c0a3b8
 
 
284955f
7c0a3b8
 
 
8c9e2ac
 
7c0a3b8
 
 
 
 
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
"""blink server: a Jev-compatible decision endpoint (the TypeSafe API's wire format).

    pip install "torch==2.13.0" "transformers==5.17.0" "flash-linear-attention==0.5.2" accelerate safetensors huggingface_hub
    pip install "torchvision==0.28.0"  # required when image input is enabled
    hf download thegovind/blink-4b --revision v1.0 --local-dir blink-4b
    python blink-4b/serve.py --model ./blink-4b --port 8000

POST /v1/systemone  {"state": ..., "model": ..., "questions": {...}, "images": [...]}  ->
                    {"model", "answers", "usage"}
GET  /v1/models     ->  {"models": [{"name", "description", "release_date", "accepts_images"}]}
GET  /healthz       ->  {"ok", "model", "revision", "weights_verified", "hub_offline", "warmup", "kernels", "versions",
                         "batching", "api_key_required", "accepts_images"}

Image data URIs in state and the sibling images list are self-host extensions, not fields supported by hosted
TypeSafe models. Image requests alone add image_pixels and visual_tokens (for the shared image prefix) to usage;
answers keep their ordinary noul, choice and score fields. Text-only responses stay unchanged.

A client written for the TypeSafe API works unchanged against this server once its base URL points here (for the
official SDKs: TYPESAFE_BASE_URL=http://127.0.0.1:8000). The request's "model" is accepted and not used: this
server answers with the one model it serves and names it in the response. Any Authorization header is accepted and
ignored unless a key is set with --api-key or BLINK_API_KEY; then /v1/systemone and /v1/models need
"Authorization: Bearer <key>" and answer HTTP 401 without it (/healthz stays open).

Requests are served one at a time. A request blink can't answer (a malformed question, or one over a limit: options
per choice, context length, questions per request) gets HTTP 422 with the reason; nothing is truncated. A body that
isn't a JSON object gets HTTP 400. Error bodies are {"error": reason, "detail": ...}, where detail is a list of
{"loc", "msg", "type"} for a 400 or 422 and the reason again otherwise. Every response carries an
x-typesafe-request-id header. With weights.sha256 beside the weights, every listed file is hashed before serving
(weights_verified). Serving a local folder switches the Hugging Face libraries to offline mode before any of them
loads (hub_offline reports the setting the libraries actually use); the server does not otherwise restrict the network.

Opt-in cross-request batching (--batch-window-ms, default 0 = off): requests that arrive within the window are
decided together, up to --max-batch-requests; at most --max-queued-requests wait, and past that a request gets
HTTP 529 (overloaded) with Retry-After. Each request still gets its own answers or its own error.
"""

from __future__ import annotations

import argparse
import hashlib
import hmac
import json
import os
import socket
import sys
import threading
import uuid
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

WARMUP = ("Order 4471 arrived with a cracked screen. The customer attached photos and wants a replacement.",
          {"route": {"type": "choice", "instructions": "Which team should handle this?",
                     "criteria": {"returns": "Damaged or wrong items", "billing": "Charges and refunds",
                                  "shipping": "Late or lost parcels"}},
           "urgent": {"type": "noul", "instructions": "Does this need a reply today?"}})


def sha256(path: str) -> str:
    h = hashlib.sha256()
    with open(path, "rb") as fh:
        for block in iter(lambda: fh.read(1 << 24), b""):
            h.update(block)
    return h.hexdigest()


def verify(root: str):
    """True/False against weights.sha256 ("<sha256>  <file>" lines) beside the weights; None without one."""
    manifest = os.path.join(root, "weights.sha256")
    if not os.path.exists(manifest):
        return None, []
    bad = []
    with open(manifest, encoding="utf-8") as fh:
        for line in fh:
            if line.strip():
                digest, name = line.split(None, 1)
                name = name.strip()
                path = os.path.join(root, name)
                if not os.path.exists(path) or sha256(path) != digest:
                    bad.append(name)
    return not bad, bad


def versions() -> dict:
    out = {}
    for mod in ("torch", "transformers", "fla"):
        try:
            out[mod] = __import__(mod).__version__
        except Exception:
            out[mod] = None
    return out


def window_ms(value: str) -> float:
    """--batch-window-ms: 0 (off) or a finite window of at most 1000 ms."""
    try:
        ms = float(value)
    except ValueError:
        raise argparse.ArgumentTypeError(f"not a number: {value!r}") from None
    if not 0 <= ms <= 1000:  # also rejects nan and inf
        raise argparse.ArgumentTypeError("must be 0 (off) to 1000 ms")
    return ms


def int_range(lo: int, hi: int):
    def parse(value: str) -> int:
        try:
            n = int(value)
        except ValueError:
            raise argparse.ArgumentTypeError(f"not an integer: {value!r}") from None
        if not lo <= n <= hi:
            raise argparse.ArgumentTypeError(f"must be {lo}-{hi}")
        return n

    return parse


def api_key(value: str):
    """--api-key / BLINK_API_KEY: empty means open; a key must fit in an Authorization header (printable ASCII, no
    whitespace), as TypeSafe's SDKs require of theirs."""
    key = value.strip()
    if not key:
        return None
    if not (key.isascii() and key.isprintable()) or " " in key:
        raise argparse.ArgumentTypeError("must be printable ASCII without whitespace")
    return key


def tower_spec(value: str) -> str:
    repo, separator, revision = value.rpartition("@")
    if not separator or not repo or not revision:
        raise argparse.ArgumentTypeError("must be a repo@revision")
    return value


def bearer(header) -> str | None:
    """The token of an "Authorization: Bearer <token>" header, else None."""
    scheme, _, token = (header or "").strip().partition(" ")
    if scheme.lower() != "bearer":
        return None
    return token.strip() or None


def error_loc(blink, req: dict, message: str) -> list:
    """Where a refused request went wrong, as a FastAPI-style location (TypeSafe's 422 body): the question the reason
    opens with (blink's refusals that name one start "question '<key>' "), else the first question blink can't read,
    else the questions map. Only the opening counts: a key quoted later in a reason, say inside a bad type, isn't it."""
    questions = req.get("questions")
    if isinstance(questions, dict) and 0 < len(questions) <= getattr(blink, "MAX_QUESTIONS", 512):
        for key in questions:
            if message.startswith(f"question {key!r} "):
                return ["body", "questions", key]
        for key, q in questions.items():
            try:
                blink.question_options(q)
            except Exception:  # noqa: BLE001 - only locating the refusal, which is already decided
                return ["body", "questions", key]
    return ["body", "questions"]


def main() -> None:
    ap = argparse.ArgumentParser(description="Serve blink over a Jev-compatible HTTP API.")
    ap.add_argument("--model", default=os.environ.get("BLINK_MODEL", "thegovind/blink-4b"))
    ap.add_argument("--model-name", default=os.environ.get("BLINK_MODEL_NAME") or None,
                    help="logical model family when the checkpoint is stored in a renamed directory (BLINK_MODEL_NAME)")
    ap.add_argument("--revision", default=os.environ.get("BLINK_REVISION"))
    ap.add_argument("--host", default="127.0.0.1")
    ap.add_argument("--port", type=int, default=8000)
    # a string default is parsed only when the flag is absent, so a stray BLINK_BATCH_WINDOW_MS can't block an explicit 0
    ap.add_argument("--batch-window-ms", type=window_ms, default=os.environ.get("BLINK_BATCH_WINDOW_MS", "0"),
                    help="opt-in cross-request batching: requests arriving within this window are decided in one "
                         "GPU call (0 = one request at a time, the evaluated default; at most 1000)")
    ap.add_argument("--max-batch-requests", type=int_range(1, 64), default=16,
                    help="most requests decided together (1-64)")
    ap.add_argument("--max-queued-requests", type=int_range(1, 1024), default=64,
                    help="most requests waiting for a batch; past this a request gets HTTP 529 (1-1024)")
    # a string default goes through api_key() too, so BLINK_API_KEY is checked the same way
    ap.add_argument("--api-key", type=api_key, default=os.environ.get("BLINK_API_KEY", ""),
                    help="require 'Authorization: Bearer <key>' on /v1/systemone and /v1/models (default: open, any "
                         "Authorization header is accepted and ignored); BLINK_API_KEY keeps it out of the process list")
    ap.add_argument("--vision", action="store_true",
                    default=os.environ.get("BLINK_VISION", "0").lower() in ("1", "true", "on", "yes"),
                    help="accept images with this model's own vision tower (BLINK_VISION=1)")
    ap.add_argument("--vision-tower", type=tower_spec, default=os.environ.get("BLINK_VISION_TOWER") or None,
                    help="opt-in a matching text model's base vision tower from repo@revision (BLINK_VISION_TOWER)")
    ap.add_argument("--image-layout", choices=("inline", "first"),
                    default=os.environ.get("BLINK_IMAGE_LAYOUT") or None,
                    help="place image tokens at state placeholders or before the user text (BLINK_IMAGE_LAYOUT)")
    ap.add_argument("--max-images", type=int_range(1, 8), default=os.environ.get("BLINK_MAX_IMAGES", "2"))
    ap.add_argument("--max-image-bytes", type=int_range(1024, 32 * 1024 * 1024),
                    default=os.environ.get("BLINK_MAX_IMAGE_BYTES", str(8 * 1024 * 1024)))
    ap.add_argument("--max-image-pixels", type=int_range(65_536, 16_777_216),
                    default=os.environ.get("BLINK_MAX_IMAGE_PIXELS", str(1920 * 1088)))
    ap.add_argument("--max-image-source-pixels", type=int_range(65_536, 64_000_000),
                    default=os.environ.get("BLINK_MAX_IMAGE_SOURCE_PIXELS", "20000000"))
    a = ap.parse_args()

    model_name = a.model_name or a.model.rstrip("/").split("/")[-1]
    if a.vision and not a.vision_tower and model_name not in (
        "blink-mimo-9b", "Qwen3.5-4B"
    ):
        ap.error("--vision needs a model with its own vision tower or --vision-tower for a text model")
    local = os.path.isdir(a.model)
    if local:
        # the Hub libraries read these once, when they are first imported, so they must be set before that
        for var in ("HF_HUB_OFFLINE", "TRANSFORMERS_OFFLINE", "HF_HUB_DISABLE_TELEMETRY"):
            os.environ.setdefault(var, "1")
    os.environ["BLINK_ENGINE"] = "torch"
    sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
    import blink

    if a.image_layout is not None and a.image_layout not in blink.IMAGE_LAYOUTS:
        ap.error("--image-layout must be inline or first")
    if a.vision_tower:
        try:
            blink.vision_source(model_name, a.vision_tower)
        except blink.BlinkError as exc:
            ap.error(str(exc))
    limits = blink.ImageLimits(a.max_images, a.max_image_bytes, a.max_image_pixels, a.max_image_source_pixels)
    busy = getattr(blink, "BlinkBusy", ())  # an older blink.py has no batching (and no BlinkBusy)

    if local:
        root = a.model
    else:
        from huggingface_hub import snapshot_download

        root = snapshot_download(a.model, revision=a.revision)
    verified, bad = verify(root)
    if verified is False:
        sys.exit(f"weights.sha256 mismatch: {', '.join(bad)}")
    options = ({"vision": a.vision, "vision_tower": a.vision_tower, "limits": limits,
                "model_name": model_name, "image_layout": a.image_layout}
               if a.vision or a.vision_tower else {})
    engine = blink.TorchEngine(root, None, blink.TEMPERATURE, **options)
    blink._ENGINE = engine
    first = blink.decide(*WARMUP)["answers"]
    repeat_identical = blink.decide(*WARMUP)["answers"] == first
    ver = versions()
    kernels = f"flash-linear-attention {ver['fla']}" if ver["fla"] else "reference (much slower; install flash-linear-attention)"
    try:
        from huggingface_hub import constants as hub_constants

        hub_offline = bool(hub_constants.HF_HUB_OFFLINE)
    except Exception:
        hub_offline = None
    health = {"ok": True, "model": a.model, "revision": a.revision, "weights_verified": verified,
              "hub_offline": hub_offline, "warmup": {"repeat_identical": repeat_identical}, "kernels": kernels,
              "versions": ver, "accepts_images": bool(getattr(engine, "accepts_images", False))}
    if health["accepts_images"]:
        health["image_layout"] = getattr(engine, "image_layout", a.image_layout or blink.IMAGE_LAYOUT_DEFAULT)
    lock = threading.Lock()
    batcher = (blink.Batcher(a.batch_window_ms / 1000.0, a.max_batch_requests, max_queued=a.max_queued_requests)
               if a.batch_window_ms > 0 else None)
    health["batching"] = ({"window_ms": a.batch_window_ms, "max_requests": a.max_batch_requests,
                           "max_queued": a.max_queued_requests} if batcher else None)
    health["api_key_required"] = a.api_key is not None
    # TypeSafe's model list, for clients that ask which names the model field takes; any name is accepted here
    listing = {"models": [{
        "name": a.model,
        "description": "blink: typed decisions (noul, choice, score) with option probabilities from one forward pass. "
                       "This server serves one model; a request's model field is accepted and not used.",
        "release_date": "",
        "accepts_images": health["accepts_images"],
    }]}

    def current_health() -> dict:
        if batcher is None:
            return health
        alive = batcher.alive()
        return {**health, "ok": health["ok"] and alive,
                "batching": {**health["batching"], "worker_alive": alive, "queued": batcher.q.qsize()}}

    class Handler(BaseHTTPRequestHandler):
        protocol_version = "HTTP/1.1"

        def log_message(self, fmt, *args):  # quiet by default
            pass

        def setup(self):
            super().setup()
            # a response is two writes (headers, then body); without TCP_NODELAY the body waits
            # on the client's delayed ACK, a flat ~40 ms on every request of a kept-alive connection
            self.connection.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)

        def _send(self, code: int, obj: dict, headers: dict | None = None, reason: str | None = None) -> None:
            body = json.dumps(obj, ensure_ascii=False).encode("utf-8")
            self.send_response(code, reason)
            self.send_header("Content-Type", "application/json")
            self.send_header("Content-Length", str(len(body)))
            self.send_header("x-typesafe-request-id", uuid.uuid4().hex)  # read by TypeSafe's SDKs
            for name, value in (headers or {}).items():
                self.send_header(name, value)
            self.end_headers()
            self.wfile.write(body)

        def _fail(self, code: int, reason: str, detail=None, headers: dict | None = None, *, unread: bool = False,
                  status_text: str | None = None) -> None:
            """An error body: blink's {"error"} plus TypeSafe's (FastAPI's) {"detail"}. With the request body still
            unread, the connection is closed rather than left holding it."""
            if unread:
                self.close_connection = True
                headers = {**(headers or {}), "Connection": "close"}
            self._send(code, {"error": reason, "detail": reason if detail is None else detail}, headers, status_text)

        def _refused(self, code: int, reason: str, loc: list, kind: str) -> None:
            self._fail(code, reason, [{"loc": loc, "msg": reason, "type": kind}])

        def _authorized(self) -> bool:
            if a.api_key is None:
                return True
            token = bearer(self.headers.get("Authorization"))
            return token is not None and hmac.compare_digest(token.encode("utf-8"), a.api_key.encode("utf-8"))

        def _unauthorized(self, unread: bool) -> None:
            self._fail(401, "missing or invalid API key: send Authorization: Bearer <key>",
                       headers={"WWW-Authenticate": "Bearer"}, unread=unread)

        def do_GET(self):
            path = self.path.rstrip("/")
            if path in ("/healthz", "/health"):
                return self._send(200, current_health())
            if path == "/v1/models":
                if not self._authorized():
                    return self._unauthorized(unread=False)
                return self._send(200, listing)
            return self._fail(404, "not found")

        def do_POST(self):
            if self.path.rstrip("/") != "/v1/systemone":
                return self._fail(404, "not found", unread=True)
            if not self._authorized():
                return self._unauthorized(unread=True)
            try:
                size = int(self.headers.get("Content-Length") or 0)
                req = json.loads(self.rfile.read(size) or b"{}")
            except (ValueError, json.JSONDecodeError) as exc:
                return self._refused(400, f"invalid JSON: {exc}", ["body"], "json_invalid")
            if not isinstance(req, dict):
                return self._refused(400, "the body must be a JSON object", ["body"], "value_error")
            try:
                opts = {}
                if "images" in req or blink.contains_image_uri(req.get("state")):
                    if "images" in req:
                        opts["images"] = req["images"]
                    opts["limits"] = limits
                if batcher is not None:
                    out = batcher.submit(req.get("state"), req.get("questions"), **opts)
                else:
                    with lock:
                        out = blink.decide(req.get("state"), req.get("questions"), **opts)
            except blink.BlinkError as exc:
                location = exc.loc if isinstance(exc, blink.ImageError) else error_loc(blink, req, str(exc))
                return self._refused(422, str(exc), location, "value_error")
            except busy as exc:
                return self._fail(529, str(exc), headers={"Retry-After": "1"}, status_text="Overloaded")
            except Exception as exc:  # noqa: BLE001 - report, keep serving
                return self._fail(500, f"{type(exc).__name__}: {exc}")
            usage = {"input_tokens": out["meta"]["input_tokens"], "output_tokens": 0}
            if "image_pixels" in out["meta"]:
                usage["image_pixels"] = out["meta"]["image_pixels"]
                usage["visual_tokens"] = out["meta"]["visual_tokens"]
            for name in ("image_preprocess_ms", "vision_encoder_ms", "lm_prefill_ms"):
                if name in out["meta"]:
                    usage[name] = out["meta"][name]
            return self._send(200, {
                "model": a.model,
                "answers": out["answers"],
                "usage": usage,
            })

    server = ThreadingHTTPServer((a.host, a.port), Handler)
    print(f"blink serving {a.model} on http://{a.host}:{a.port} ({kernels}; weights_verified={verified}; "
          f"api key {'required' if a.api_key else 'not required'})", flush=True)
    server.serve_forever()


if __name__ == "__main__":
    main()