thegovind commited on
Commit
400f1f6
·
verified ·
1 Parent(s): 0314ad9

Code revision v1.3: opt-in screenshot input (self-hosted)

Browse files
Files changed (5) hide show
  1. Dockerfile +1 -1
  2. README.md +35 -6
  3. blink.py +675 -17
  4. graft_keys.py +9 -0
  5. serve.py +71 -9
Dockerfile CHANGED
@@ -4,7 +4,7 @@
4
  # curl -s http://127.0.0.1:8000/healthz # weights_verified, warmup.repeat_identical, kernels
5
  FROM pytorch/pytorch:2.13.0-cuda12.6-cudnn9-runtime
6
  ENV PIP_BREAK_SYSTEM_PACKAGES=1 PIP_NO_CACHE_DIR=1 PIP_DISABLE_PIP_VERSION_CHECK=1
7
- RUN pip install "transformers==5.17.0" "flash-linear-attention==0.5.2" "accelerate>=1.0" "safetensors>=0.4" "huggingface_hub>=1.0"
8
  COPY . /blink
9
  ENV HF_HUB_OFFLINE=1 TRANSFORMERS_OFFLINE=1 HF_HUB_DISABLE_TELEMETRY=1
10
  EXPOSE 8000
 
4
  # curl -s http://127.0.0.1:8000/healthz # weights_verified, warmup.repeat_identical, kernels
5
  FROM pytorch/pytorch:2.13.0-cuda12.6-cudnn9-runtime
6
  ENV PIP_BREAK_SYSTEM_PACKAGES=1 PIP_NO_CACHE_DIR=1 PIP_DISABLE_PIP_VERSION_CHECK=1
7
+ RUN pip install "transformers==5.17.0" "torchvision==0.28.0" "flash-linear-attention==0.5.2" "accelerate>=1.0" "safetensors>=0.4" "huggingface_hub>=1.0"
8
  COPY . /blink
9
  ENV HF_HUB_OFFLINE=1 TRANSFORMERS_OFFLINE=1 HF_HUB_DISABLE_TELEMETRY=1
10
  EXPOSE 8000
README.md CHANGED
@@ -29,7 +29,7 @@ Send a text or JSON `state` and typed questions: `choice` picks from up to 255 o
29
  |---|---|
30
  | Base model | [Qwen/Qwen3.5-4B](https://huggingface.co/Qwen/Qwen3.5-4B) (text model only; vision encoder and MTP head removed) |
31
  | Weights size | 8.4 GB (bf16, 4,205,751,296 parameters) |
32
- | Revision | v1.2 (code revision; weights identical to v1.0) |
33
  | License | Non-commercial research only ([LICENSE.md](LICENSE.md)); base model Apache-2.0 (`LICENSE-Qwen`) |
34
 
35
  ## Results
@@ -153,8 +153,8 @@ import os, sys
153
  from huggingface_hub import hf_hub_download
154
 
155
  os.environ["BLINK_MODEL"] = "thegovind/blink-4b"
156
- os.environ["BLINK_REVISION"] = "v1.2"
157
- sys.path.insert(0, os.path.dirname(hf_hub_download("thegovind/blink-4b", "blink.py", revision="v1.2")))
158
  import blink
159
 
160
  out = blink.decide(
@@ -178,7 +178,7 @@ print(out["answers"]["intent"]["probabilities"])
178
 
179
  ```sh
180
  pip install "torch==2.13.0" "transformers==5.17.0" "flash-linear-attention==0.5.2" "accelerate>=1.1.0" safetensors huggingface_hub
181
- hf download thegovind/blink-4b --revision v1.2 --local-dir blink-4b
182
  python blink-4b/serve.py --model ./blink-4b --port 8000
183
  # TypeSafe SDKs: export TYPESAFE_BASE_URL=http://127.0.0.1:8000 TYPESAFE_API_KEY=any
184
  ```
@@ -195,10 +195,37 @@ docker build -t blink-4b . && docker run --rm --gpus all -p 127.0.0.1:8000:8000
195
  To enable cross-request batching:
196
 
197
  ```sh
198
- hf download thegovind/blink-4b serve.py blink.py --revision v1.2 --local-dir blink-4b
199
  python blink-4b/serve.py --model ./blink-4b --port 8000 --batch-window-ms 5
200
  ```
201
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
202
  ## Details
203
 
204
  <details><summary>Architecture and training progression</summary>
@@ -280,4 +307,6 @@ These are source-repository licences; they do not settle rights in every underly
280
 
281
  ## License
282
 
283
- Code (`blink.py`, `serve.py`, Dockerfile): Apache-2.0. Weights: non-commercial research only; see [LICENSE.md](LICENSE.md). Base model: Apache-2.0 (`LICENSE-Qwen`).
 
 
 
29
  |---|---|
30
  | Base model | [Qwen/Qwen3.5-4B](https://huggingface.co/Qwen/Qwen3.5-4B) (text model only; vision encoder and MTP head removed) |
31
  | Weights size | 8.4 GB (bf16, 4,205,751,296 parameters) |
32
+ | Revision | v1.3 (code revision; weights identical to v1.0) |
33
  | License | Non-commercial research only ([LICENSE.md](LICENSE.md)); base model Apache-2.0 (`LICENSE-Qwen`) |
34
 
35
  ## Results
 
153
  from huggingface_hub import hf_hub_download
154
 
155
  os.environ["BLINK_MODEL"] = "thegovind/blink-4b"
156
+ os.environ["BLINK_REVISION"] = "v1.3"
157
+ sys.path.insert(0, os.path.dirname(hf_hub_download("thegovind/blink-4b", "blink.py", revision="v1.3")))
158
  import blink
159
 
160
  out = blink.decide(
 
178
 
179
  ```sh
180
  pip install "torch==2.13.0" "transformers==5.17.0" "flash-linear-attention==0.5.2" "accelerate>=1.1.0" safetensors huggingface_hub
181
+ hf download thegovind/blink-4b --revision v1.3 --local-dir blink-4b
182
  python blink-4b/serve.py --model ./blink-4b --port 8000
183
  # TypeSafe SDKs: export TYPESAFE_BASE_URL=http://127.0.0.1:8000 TYPESAFE_API_KEY=any
184
  ```
 
195
  To enable cross-request batching:
196
 
197
  ```sh
198
+ hf download thegovind/blink-4b serve.py blink.py --revision v1.3 --local-dir blink-4b
199
  python blink-4b/serve.py --model ./blink-4b --port 8000 --batch-window-ms 5
200
  ```
201
 
202
+ ## Screenshots (opt-in, self-hosted)
203
+
204
+ Image input is off by default. Start `serve.py` with `--vision-tower Qwen/Qwen3.5-4B@851bf6e806efd8d0a36b00ddf55e13ccb7b8cd0a`, or set `BLINK_VISION_TOWER` to that value for direct `blink.py` calls. Cache the matching tower before serving a downloaded folder offline; image mode needs `torchvision==0.28.0`. The checkpoint's weights stay text-only.
205
+
206
+ If loading only `blink.py` via `hf_hub_download`, also download `graft_keys.py` from the same repo and revision beside it.
207
+
208
+ This is a self-hosted blink extension. TypeSafe's hosted Jev is text-only and its published API has no image field. Put a `data:image/png;base64,...` URI (JPEG or WebP also work) in a string in `state`, or send data URIs in a top-level `images` list. Image URLs are not fetched. `--image-layout first|inline` places image tokens; `first` is the default, not a switch that enables images. For renamed folders or Docker's `/blink`, use `--model-name blink-4b` or `BLINK_MODEL_NAME=blink-4b`; matching checks still apply.
209
+
210
+ Defaults: 2 images per request, 8 MiB decoded per image, 20 million source pixels and 2,088,960 pixels after resizing. `--max-images`, `--max-image-bytes`, `--max-image-source-pixels` and `--max-image-pixels` can raise those limits. Invalid images return 422. Text-only requests keep the byte-identical pre-image prompt and numeric path; image requests alone add pixel and visual-token usage.
211
+
212
+ These are **development readouts, not a benchmark result**. The `first` default came from an amended rule on previously seen development data. Earlier text-serving results on this card predate v1.3 image serving. Target accuracy with five offered choices:
213
+
214
+ | Layout | ScreenSpot-v2 K5 target | GUIOdyssey K5 target | Mind2Web K5 given gold |
215
+ |---|---:|---:|---:|
216
+ | first (default) | 94.73% | 79.40% | 61.54% |
217
+ | inline | 90.72% | 73.53% | 68.27% |
218
+
219
+ Mind2Web shortlist recall is only 104/1,000: "given gold" excludes steps with no gold target offered. GUIOdyssey K5 target accuracy covers its labelled subset, not completed tasks.
220
+
221
+ **Screenshot HTTP latency:** Measured on serving revision `f2414d5`, with default `first` layout and the reference PyTorch causal-conv1d fallback observed in retained server logs; 24 fresh requests per condition. These are not remeasurements on the released revision.
222
+
223
+ | Image size | Questions | HTTP p50 | HTTP p95 |
224
+ |---|---:|---:|---:|
225
+ | 1280 x 720 | 1 | 158.23 ms | 169.27 ms |
226
+ | 1920 x 1080 | 1 | 314.79 ms | 327.47 ms |
227
+ | 1920 x 1080 | 4 | 1125.90 ms | 1170.53 ms |
228
+
229
  ## Details
230
 
231
  <details><summary>Architecture and training progression</summary>
 
307
 
308
  ## License
309
 
310
+ Code: Apache-2.0. Weights: non-commercial research and evaluation only; see each model card's license.
311
+
312
+ See [LICENSE.md](LICENSE.md) for the weight terms; the base model is Apache-2.0 (`LICENSE-Qwen`).
blink.py CHANGED
@@ -19,20 +19,31 @@ recording is only used by the model that made it.
19
 
20
  from __future__ import annotations
21
 
 
 
22
  import hashlib
 
23
  import itertools
24
  import json
25
  import math
26
  import os
27
  import re
 
28
  import string
29
  import time
 
 
 
 
 
30
 
31
  # --- constants the deployment sets -------------------------------------------------
32
 
33
  MODEL_ID = os.environ.get("BLINK_MODEL") or "thegovind/blink-4b"
34
  MODEL_ID_27B = "thegovind/blink-27b"
35
  MODEL_REVISION = os.environ.get("BLINK_REVISION") or None
 
 
36
 
37
 
38
  def _model_specs() -> list[tuple[str, str | None]]:
@@ -62,6 +73,10 @@ TOKEN_BUDGET = int(os.environ.get("BLINK_TOKEN_BUDGET", "32768")) # padded toke
62
  # multi-question documents, but on ~18k-token documents blink-4b's bf16 answers drift slightly further from an FP32
63
  # reference than the plain path's do, so it is off by default (experiments/t5/PREREG.md, gate A').
64
  PREFIX_CACHE = os.environ.get("BLINK_PREFIX_CACHE", "0").lower() in ("1", "true", "on", "yes")
 
 
 
 
65
 
66
 
67
  def _prefix_setting(name: str, default: int) -> int:
@@ -113,6 +128,332 @@ class ReplayMiss(BlinkError):
113
  """Replay mode has no recorded output for this request."""
114
 
115
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
116
  # --- rendering (pure python; the model was trained on exactly this) -----------------
117
 
118
 
@@ -375,7 +716,9 @@ class TorchEngine:
375
  name = "torch"
376
 
377
  def __init__(self, model_id: str = MODEL_ID, revision=None, temperature: float = TEMPERATURE,
378
- token_budget: int = TOKEN_BUDGET):
 
 
379
  import torch
380
  from transformers import AutoModelForCausalLM, AutoTokenizer
381
 
@@ -384,19 +727,102 @@ class TorchEngine:
384
  self.token_budget = int(token_budget)
385
  self.tok = AutoTokenizer.from_pretrained(model_id, revision=revision)
386
  load = dict(revision=revision, dtype=torch.bfloat16, attn_implementation="sdpa")
387
- if _on_zero_gpu():
388
- # ZeroGPU: load on the host, then place on cuda at module level (emulated until
389
- # a @spaces.GPU call attaches a real device).
390
- self.model = AutoModelForCausalLM.from_pretrained(model_id, **load).to("cuda")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
391
  else:
392
- device = "cuda" if torch.cuda.is_available() else "cpu"
393
- self.model = AutoModelForCausalLM.from_pretrained(model_id, device_map=device, **load)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
394
  self.model.eval()
395
  self.pad_id = self.tok.pad_token_id if self.tok.pad_token_id is not None else 0
396
  self.labels, self.label_ids = self._verify_labels()
 
 
397
  self.key = id(self)
398
  _LIVE[self.key] = self
399
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
400
  def _wrap(self, user: str) -> str:
401
  return self.tok.apply_chat_template(
402
  [{"role": "system", "content": SYSTEM}, {"role": "user", "content": user}],
@@ -427,6 +853,18 @@ class TorchEngine:
427
  raise BlinkError(f"only {len(labels)} verified single-token labels")
428
  return labels, ids
429
 
 
 
 
 
 
 
 
 
 
 
 
 
430
  def render(self, state, questions: dict):
431
  work = []
432
  for qkey, q in questions.items():
@@ -451,6 +889,88 @@ class TorchEngine:
451
  )
452
  return work
453
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
454
  def logits(self, state, questions: dict, bias: dict | None = None) -> tuple[dict[str, list[float]], int]:
455
  del bias # demo-only shaping; the trained model reads the evidence instead
456
  work = self.render(state, questions)
@@ -583,17 +1103,19 @@ def _padded(seqs, rows, pad_id):
583
  return ids
584
 
585
 
586
- def _forward_shared(model, head, seqs, cands, prefix_len: int, pad_id: int, budget: int, out, device) -> None:
 
587
  """Encode seqs[0][:prefix_len] once, then run every tail against an expanded copy of that cache."""
588
  import torch
589
 
 
590
  prefix = torch.tensor([seqs[0][:prefix_len]], dtype=torch.long, device=device)
591
- cache = model.model(input_ids=prefix, use_cache=True).past_key_values
592
  tails = [s[prefix_len:] for s in seqs]
593
  rows_cap = max(1, PREFIX_KV_TOKENS // (prefix_len + max(len(t) for t in tails)))
594
  for b in _batches([len(t) for t in tails], budget, max_rows=rows_cap):
595
  ids = _padded(tails, b, pad_id).to(device)
596
- h = model.model(input_ids=ids, past_key_values=_expand_cache(cache, len(b)), use_cache=True).last_hidden_state
597
  _read(model, head, h, [len(tails[i]) for i in b], cands, b, out, device)
598
 
599
 
@@ -608,21 +1130,88 @@ def _forward(key: int, seqs: list[list[int]], cands: list[list[int]]):
608
  model = engine.model
609
  device = next(model.parameters()).device
610
  head = model.lm_head.weight
 
611
  budget = getattr(engine, "token_budget", TOKEN_BUDGET)
612
  out: list = [None] * len(seqs)
613
  t0 = time.perf_counter()
614
  with torch.no_grad():
615
  shared = _shared_prefix_len(seqs) if getattr(engine, "prefix_cache", PREFIX_CACHE) else 0
616
  if shared > 0 and shared >= PREFIX_MIN_TOKENS:
617
- _forward_shared(model, head, seqs, cands, shared, engine.pad_id, budget, out, device)
618
  else:
619
  for b in _batches([len(s) for s in seqs], budget):
620
  ids = _padded(seqs, b, engine.pad_id).to(device)
621
- h = model.model(input_ids=ids, use_cache=False).last_hidden_state
622
  _read(model, head, h, [len(seqs[i]) for i in b], cands, b, out, device)
623
  return out, round((time.perf_counter() - t0) * 1000, 1)
624
 
625
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
626
  # --- hybrid engine ------------------------------------------------------------------
627
 
628
 
@@ -636,6 +1225,7 @@ class HybridEngine:
636
  self.replay, self.live = replay, live
637
  self.model_id = live.model_id
638
  self.temperature = live.temperature
 
639
  self.hardware = replay.hardware if replay is not None else ""
640
  self.last_source = None
641
  self.last = None
@@ -700,9 +1290,13 @@ def _build(model_id: str):
700
  return MockEngine(model_id)
701
  if kind == "replay":
702
  return ReplayEngine(replay_path(model_id), TEMPERATURE)
 
 
 
 
703
  if kind == "hybrid":
704
- return HybridEngine(_matching_replay(model_id), TorchEngine(model_id, revision, TEMPERATURE))
705
- return TorchEngine(model_id, revision, TEMPERATURE)
706
 
707
 
708
  def engine(model: str | None = None):
@@ -724,8 +1318,24 @@ def warm() -> list:
724
  return [engine(m) for m in models()]
725
 
726
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
727
  def decide(state, questions: dict, temperature: float | None = None, bias: dict | None = None,
728
- prefer: str = "live", model: str | None = None) -> dict:
 
729
  """{state, questions} -> {answers, meta}. One forward pass, zero generated tokens.
730
 
731
  `bias` shapes the mock engine so the bundled examples read realistically without a
@@ -739,7 +1349,19 @@ def decide(state, questions: dict, temperature: float | None = None, bias: dict
739
  if T <= 0:
740
  raise BlinkError("temperature must be positive")
741
  t0 = time.perf_counter()
742
- if isinstance(eng, HybridEngine):
 
 
 
 
 
 
 
 
 
 
 
 
743
  raw, n_tokens = eng.logits(state, questions, bias, prefer=prefer)
744
  else:
745
  raw, n_tokens = eng.logits(state, questions, bias)
@@ -757,6 +1379,13 @@ def decide(state, questions: dict, temperature: float | None = None, bias: dict
757
  "generated_tokens": 0,
758
  "latency_ms": latency_ms,
759
  }
 
 
 
 
 
 
 
760
  if source == "replay":
761
  meta["latency_ms"] = float(eng.last["latency_ms"])
762
  if eng.hardware:
@@ -781,6 +1410,26 @@ def decide_many(requests: list, temperature: float | None = None, model: str | N
781
  T = float(temperature if temperature is not None else eng.temperature)
782
  if T <= 0:
783
  raise BlinkError("temperature must be positive")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
784
  live = getattr(eng, "live", eng)
785
  packed = hasattr(live, "logits_rendered")
786
  out: list = [None] * len(requests)
@@ -859,11 +1508,20 @@ class Batcher:
859
  def alive(self) -> bool:
860
  return self.worker.is_alive()
861
 
862
- def submit(self, state, questions: dict) -> dict:
863
  import concurrent.futures
864
  import queue
865
 
866
  validate(questions) # a malformed request fails at once and never takes a place in the queue
 
 
 
 
 
 
 
 
 
867
  if not self.worker.is_alive():
868
  raise RuntimeError("the batching worker has stopped")
869
  fut = concurrent.futures.Future()
 
19
 
20
  from __future__ import annotations
21
 
22
+ import base64
23
+ import binascii
24
  import hashlib
25
+ import io
26
  import itertools
27
  import json
28
  import math
29
  import os
30
  import re
31
+ import secrets
32
  import string
33
  import time
34
+ from dataclasses import dataclass, replace
35
+ from typing import TYPE_CHECKING, Iterator
36
+
37
+ if TYPE_CHECKING:
38
+ from PIL import Image
39
 
40
  # --- constants the deployment sets -------------------------------------------------
41
 
42
  MODEL_ID = os.environ.get("BLINK_MODEL") or "thegovind/blink-4b"
43
  MODEL_ID_27B = "thegovind/blink-27b"
44
  MODEL_REVISION = os.environ.get("BLINK_REVISION") or None
45
+ VISION_BASES = {"blink-4b": "Qwen/Qwen3.5-4B", "blink-27b": "Qwen/Qwen3.8-27B"}
46
+ NATIVE_VISION_MODELS = {"blink-mimo-9b", "Qwen3.5-4B"}
47
 
48
 
49
  def _model_specs() -> list[tuple[str, str | None]]:
 
73
  # multi-question documents, but on ~18k-token documents blink-4b's bf16 answers drift slightly further from an FP32
74
  # reference than the plain path's do, so it is off by default (experiments/t5/PREREG.md, gate A').
75
  PREFIX_CACHE = os.environ.get("BLINK_PREFIX_CACHE", "0").lower() in ("1", "true", "on", "yes")
76
+ VISION = os.environ.get("BLINK_VISION", "0").lower() in ("1", "true", "on", "yes")
77
+ VISION_TOWER = os.environ.get("BLINK_VISION_TOWER") or None
78
+ IMAGE_LAYOUTS = ("inline", "first")
79
+ IMAGE_LAYOUT_DEFAULT = "first"
80
 
81
 
82
  def _prefix_setting(name: str, default: int) -> int:
 
128
  """Replay mode has no recorded output for this request."""
129
 
130
 
131
+ class ImageError(BlinkError):
132
+ """Invalid image input, with its location in the request body."""
133
+
134
+ def __init__(self, message: str, loc: list):
135
+ super().__init__(message)
136
+ self.loc = loc
137
+
138
+
139
+ @dataclass(frozen=True)
140
+ class ImageLimits:
141
+ max_images: int = 2
142
+ max_bytes: int = 8 * 1024 * 1024
143
+ max_pixels: int = 1920 * 1088
144
+ max_source_pixels: int = 20_000_000
145
+ min_pixels: int = 65_536
146
+ factor: int = 32
147
+
148
+
149
+ @dataclass(frozen=True)
150
+ class ImageRequest:
151
+ state: object
152
+ marked_state: object
153
+ images: tuple[Image.Image, ...]
154
+ markers: tuple[str, ...]
155
+ limits: ImageLimits
156
+ loc: list
157
+
158
+
159
+ _NO_IMAGES = object()
160
+
161
+
162
+ @dataclass(frozen=True)
163
+ class ImageSubmission:
164
+ state: object
165
+ images: object
166
+ limits: ImageLimits
167
+ loc: list
168
+
169
+
170
+ MAX_HEADER = 256
171
+ _IMAGE_PREFIX = "data:image/"
172
+ _CANONICAL_IMAGE_URI = re.compile(r"data:image/(?:png|jpeg|webp);base64,[A-Za-z0-9+/]*={0,2}",
173
+ re.IGNORECASE)
174
+ _IMAGE_DATA = re.compile(r"data:(image/(?:png|jpeg|webp));base64,([A-Za-z0-9+/]+={0,2})\Z", re.IGNORECASE)
175
+ _IMAGE_FORMATS = {"image/png": "PNG", "image/jpeg": "JPEG", "image/webp": "WEBP"}
176
+ _CANONICAL_IMAGE_HEADERS = {f"data:{mime};base64" for mime in _IMAGE_FORMATS}
177
+
178
+
179
+ def _scan_image_uris(value: str) -> Iterator[tuple[int, int, str | None]]:
180
+ """Yield canonical URI spans or invalid-header errors with bounded header lookahead."""
181
+ low = value.lower()
182
+ if len(low) != len(value):
183
+ # Unicode lowercasing can expand; retain the original offsets for payload extraction.
184
+ low = "".join(char.lower() if len(char.lower()) == 1 else char for char in value)
185
+ pos = 0
186
+ while True:
187
+ start = low.find(_IMAGE_PREFIX, pos)
188
+ if start < 0:
189
+ return
190
+ comma = low.find(",", start, start + MAX_HEADER)
191
+ if comma < 0:
192
+ pos = start + len(_IMAGE_PREFIX)
193
+ continue
194
+ header = low[start:comma]
195
+ compact = "".join(header.split())
196
+ if not compact.endswith(";base64"):
197
+ pos = start + len(_IMAGE_PREFIX)
198
+ continue
199
+ if compact not in _CANONICAL_IMAGE_HEADERS or header != compact:
200
+ error = ("image data URI parameters or whitespace are not supported"
201
+ if header != compact or compact.count(";") > 1
202
+ else "image must be a data:image/png, image/jpeg, or image/webp URI with base64 data")
203
+ yield start, comma + 1, error
204
+ pos = comma + 1
205
+ continue
206
+ match = _CANONICAL_IMAGE_URI.match(value, start)
207
+ if match is None:
208
+ yield start, comma + 1, "image must be a data:image/png, image/jpeg, or image/webp URI with base64 data"
209
+ pos = comma + 1
210
+ continue
211
+ yield start, match.end(), None
212
+ pos = match.end()
213
+
214
+
215
+ def image_limits() -> ImageLimits:
216
+ """Read image-only settings lazily, without changing the text-only path."""
217
+ settings = {
218
+ "MAX_IMAGES": ("max_images", 1, 8),
219
+ "MAX_IMAGE_BYTES": ("max_bytes", 1024, 32 * 1024 * 1024),
220
+ "MAX_IMAGE_PIXELS": ("max_pixels", 65_536, 16_777_216),
221
+ "MAX_IMAGE_SOURCE_PIXELS": ("max_source_pixels", 65_536, 64_000_000),
222
+ }
223
+ defaults = ImageLimits()
224
+ values = {}
225
+ for key, (field, lo, hi) in settings.items():
226
+ name = f"BLINK_{key}"
227
+ try:
228
+ n = int(os.environ.get(name, str(getattr(defaults, field))))
229
+ except ValueError:
230
+ raise BlinkError(f"{name} must be an integer from {lo} to {hi}") from None
231
+ if not lo <= n <= hi:
232
+ raise BlinkError(f"{name} must be from {lo} to {hi}")
233
+ values[field] = n
234
+ return replace(defaults, **values)
235
+
236
+
237
+ def vision_source(model_name: str, spec: str) -> tuple[str, str]:
238
+ """Only graft a text checkpoint with its own base model's vision tower."""
239
+ expected = VISION_BASES.get(model_name)
240
+ if expected is None:
241
+ raise BlinkError("--vision-tower is only supported for blink-4b and blink-27b")
242
+ source, separator, revision = spec.rpartition("@")
243
+ if not separator or not source or not revision:
244
+ raise BlinkError("--vision-tower must be a repo@revision")
245
+ if source != expected and not (
246
+ os.path.isdir(source) and os.path.basename(os.path.normpath(source)) == expected.split("/")[-1]
247
+ ):
248
+ raise BlinkError(f"--vision-tower for {model_name} must use {expected}@revision")
249
+ return source, revision
250
+
251
+
252
+ def _image_size(height: int, width: int, limits: ImageLimits) -> tuple[int, int]:
253
+ """Use the vision processor's factor-aligned min/max pixel resize policy."""
254
+ factor = limits.factor
255
+ if max(height, width) / min(height, width) > 200:
256
+ raise BlinkError("image aspect ratio must be at most 200:1")
257
+ h, w = round(height / factor) * factor, round(width / factor) * factor
258
+ if h * w > limits.max_pixels:
259
+ scale = math.sqrt(height * width / limits.max_pixels)
260
+ h = max(factor, math.floor(height / scale / factor) * factor)
261
+ w = max(factor, math.floor(width / scale / factor) * factor)
262
+ elif h * w < limits.min_pixels:
263
+ scale = math.sqrt(limits.min_pixels / (height * width))
264
+ h, w = math.ceil(height * scale / factor) * factor, math.ceil(width * scale / factor) * factor
265
+ if not limits.min_pixels <= h * w <= limits.max_pixels:
266
+ raise BlinkError(f"image cannot fit between {limits.min_pixels} and {limits.max_pixels} pixels")
267
+ return h, w
268
+
269
+
270
+ def _decode_image(uri: str, index: int, loc: list, limits: ImageLimits) -> Image.Image:
271
+ from PIL import Image, ImageOps, UnidentifiedImageError
272
+
273
+ match = _IMAGE_DATA.fullmatch(uri)
274
+ if match is None:
275
+ raise ImageError("image must be a data:image/png, image/jpeg, or image/webp URI with base64 data", loc)
276
+ mime, payload = match.group(1).lower(), match.group(2)
277
+ if len(payload) > 4 * math.ceil(limits.max_bytes / 3):
278
+ raise ImageError(f"image {index} exceeds {limits.max_bytes} decoded bytes", loc)
279
+ try:
280
+ raw = base64.b64decode(payload, validate=True)
281
+ except binascii.Error:
282
+ raise ImageError(f"image {index} has invalid base64 data", loc) from None
283
+ if not raw or len(raw) > limits.max_bytes:
284
+ raise ImageError(f"image {index} must contain 1-{limits.max_bytes} decoded bytes", loc)
285
+ try:
286
+ with Image.open(io.BytesIO(raw)) as opened:
287
+ if opened.format != _IMAGE_FORMATS[mime]:
288
+ raise ImageError(f"image {index} does not match its {mime} MIME type", loc)
289
+ width, height = opened.size
290
+ if not width or not height or width * height > limits.max_source_pixels:
291
+ raise ImageError(f"image {index} exceeds {limits.max_source_pixels} source pixels", loc)
292
+ img = ImageOps.exif_transpose(opened)
293
+ try:
294
+ new_height, new_width = _image_size(img.height, img.width, limits)
295
+ except BlinkError as exc:
296
+ raise ImageError(f"image {index}: {exc}", loc) from exc
297
+ img = img.convert("RGB")
298
+ if img.size != (new_width, new_height):
299
+ img = img.resize((new_width, new_height), Image.Resampling.BICUBIC)
300
+ return img
301
+ except ImageError:
302
+ raise
303
+ except (OSError, UnidentifiedImageError, Image.DecompressionBombError, ValueError) as exc:
304
+ raise ImageError(f"image {index} cannot be decoded: {exc}", loc) from exc
305
+
306
+
307
+ def contains_image_uri(value) -> bool:
308
+ pending = [value]
309
+ while pending:
310
+ item = pending.pop()
311
+ if isinstance(item, str) and next(_scan_image_uris(item), None) is not None:
312
+ return True
313
+ if isinstance(item, dict):
314
+ pending.extend(item.values())
315
+ elif isinstance(item, list):
316
+ pending.extend(item)
317
+ return False
318
+
319
+
320
+ def _map_state_values(value, loc: list, transform):
321
+ if not isinstance(value, (dict, list)):
322
+ return transform(value, loc)
323
+ result = {} if isinstance(value, dict) else [None] * len(value)
324
+
325
+ def entries(node):
326
+ return iter(node.items()) if isinstance(node, dict) else iter(enumerate(node))
327
+
328
+ pending = [(entries(value), result, loc)]
329
+ while pending:
330
+ children, target, parent_loc = pending[-1]
331
+ try:
332
+ key, child = next(children)
333
+ except StopIteration:
334
+ pending.pop()
335
+ continue
336
+ child_loc = [*parent_loc, key]
337
+ if isinstance(child, (dict, list)):
338
+ nested = {} if isinstance(child, dict) else [None] * len(child)
339
+ target[key] = nested
340
+ pending.append((entries(child), nested, child_loc))
341
+ else:
342
+ target[key] = transform(child, child_loc)
343
+ return result
344
+
345
+
346
+ def inspect_images(state, images=_NO_IMAGES, limits: ImageLimits | None = None) -> ImageSubmission | None:
347
+ """Locate and count image submissions without decoding pixels or base64."""
348
+ if images is _NO_IMAGES and not contains_image_uri(state):
349
+ return None
350
+ if images is not _NO_IMAGES and not isinstance(images, list):
351
+ raise ImageError("images must be a list of data URIs", ["body", "images"])
352
+ count, first_loc, resolved = 0, None, limits
353
+
354
+ def record(loc: list) -> None:
355
+ nonlocal count, first_loc, resolved
356
+ if resolved is None:
357
+ resolved = image_limits()
358
+ count += 1
359
+ if count > resolved.max_images:
360
+ raise ImageError(f"at most {resolved.max_images} images per request", loc)
361
+ if first_loc is None:
362
+ first_loc = loc
363
+
364
+ pending = [(state, ["body", "state"])]
365
+ while pending:
366
+ value, loc = pending.pop()
367
+ if isinstance(value, str):
368
+ for _, _, error in _scan_image_uris(value):
369
+ if error is not None:
370
+ raise ImageError(error, loc)
371
+ record(loc)
372
+ elif isinstance(value, dict):
373
+ pending.extend((child, [*loc, key]) for key, child in reversed(tuple(value.items())))
374
+ elif isinstance(value, list):
375
+ pending.extend((value[i], [*loc, i]) for i in range(len(value) - 1, -1, -1))
376
+ if images is not _NO_IMAGES:
377
+ for i, uri in enumerate(images):
378
+ loc = ["body", "images", i]
379
+ if not isinstance(uri, str):
380
+ raise ImageError(f"image {count + 1} must be a data URI", loc)
381
+ first = next(_scan_image_uris(uri), None)
382
+ if first is None or first[0] != 0:
383
+ raise ImageError("image must be a data:image/png, image/jpeg, or image/webp URI with base64 data", loc)
384
+ if first[2] is not None:
385
+ raise ImageError(first[2], loc)
386
+ record(loc)
387
+ return ImageSubmission(state, images, resolved, first_loc) if count else None
388
+
389
+
390
+ def extract_images(state, images=_NO_IMAGES, limits: ImageLimits | None = None) -> ImageRequest | None:
391
+ """Lift inline or top-level data URIs; keep unique internal markers for exact image placement."""
392
+ submission = inspect_images(state, images, limits)
393
+ if submission is None:
394
+ return None
395
+ limits = submission.limits
396
+ found: list[Image.Image] = []
397
+ markers: list[str] = []
398
+ first_loc = None
399
+ nonce = secrets.token_hex(16)
400
+
401
+ def add(uri: str, loc: list) -> str:
402
+ nonlocal first_loc
403
+ index = len(found) + 1
404
+ if index > limits.max_images:
405
+ raise ImageError(f"at most {limits.max_images} images per request", loc)
406
+ decoded = _decode_image(uri, index, loc, limits)
407
+ marker = f"BLINK_IMAGE_{nonce}_{index}_END"
408
+ found.append(decoded)
409
+ markers.append(marker)
410
+ if first_loc is None:
411
+ first_loc = loc
412
+ return marker
413
+
414
+ def lift(value, loc: list):
415
+ if isinstance(value, str):
416
+ parts = []
417
+ offset = 0
418
+ for start, end, error in _scan_image_uris(value):
419
+ if error is not None:
420
+ raise ImageError(error, loc)
421
+ parts.extend((value[offset:start], add(value[start:end], loc)))
422
+ offset = end
423
+ if parts:
424
+ parts.append(value[offset:])
425
+ return "".join(parts)
426
+ return value
427
+
428
+ marked = _map_state_values(state, ["body", "state"], lift)
429
+ if images is not _NO_IMAGES:
430
+ attachments = []
431
+ for i, uri in enumerate(images):
432
+ if not isinstance(uri, str):
433
+ raise ImageError(f"image {len(found) + 1} must be a data URI", ["body", "images", i])
434
+ attachments.append(add(uri, ["body", "images", i]))
435
+ if attachments:
436
+ if isinstance(marked, str):
437
+ marked = marked + ("\n" if marked else "") + "\n".join(attachments)
438
+ elif isinstance(marked, list):
439
+ marked = [*marked, *attachments]
440
+ else:
441
+ marked = {"state": marked, "images": attachments}
442
+ if not found:
443
+ return None
444
+ names = {marker: f"[image {i}]" for i, marker in enumerate(markers, 1)}
445
+
446
+ def visible(value, _loc):
447
+ if isinstance(value, str):
448
+ for marker, placeholder in names.items():
449
+ value = value.replace(marker, placeholder)
450
+ return value
451
+ return value
452
+
453
+ return ImageRequest(_map_state_values(marked, ["body", "state"], visible),
454
+ marked, tuple(found), tuple(markers), limits, first_loc)
455
+
456
+
457
  # --- rendering (pure python; the model was trained on exactly this) -----------------
458
 
459
 
 
716
  name = "torch"
717
 
718
  def __init__(self, model_id: str = MODEL_ID, revision=None, temperature: float = TEMPERATURE,
719
+ token_budget: int = TOKEN_BUDGET, vision: bool = False, vision_tower: str | None = None,
720
+ limits: ImageLimits | None = None, model_name: str | None = None,
721
+ image_layout: str | None = None):
722
  import torch
723
  from transformers import AutoModelForCausalLM, AutoTokenizer
724
 
 
727
  self.token_budget = int(token_budget)
728
  self.tok = AutoTokenizer.from_pretrained(model_id, revision=revision)
729
  load = dict(revision=revision, dtype=torch.bfloat16, attn_implementation="sdpa")
730
+ model_name = (model_name or model_id).rstrip("/").split("/")[-1]
731
+ if vision_tower:
732
+ source, source_rev = vision_source(model_name, vision_tower)
733
+ if vision and not vision_tower and model_name not in NATIVE_VISION_MODELS:
734
+ raise BlinkError("--vision needs a model with its own vision tower or --vision-tower for a text model")
735
+ self.accepts_images = bool(vision or vision_tower)
736
+ self.image_layout = IMAGE_LAYOUT_DEFAULT if image_layout is None else image_layout
737
+ if self.image_layout not in IMAGE_LAYOUTS:
738
+ raise BlinkError("image_layout must be inline or first")
739
+ if self.accepts_images:
740
+ self.image_timing = os.environ.get("BLINK_IMAGE_TIMING", "0").lower() in ("1", "true", "on", "yes")
741
+ if not self.accepts_images:
742
+ if _on_zero_gpu():
743
+ # ZeroGPU: load on the host, then place on cuda at module level (emulated until
744
+ # a @spaces.GPU call attaches a real device).
745
+ self.model = AutoModelForCausalLM.from_pretrained(model_id, **load).to("cuda")
746
+ else:
747
+ device = "cuda" if torch.cuda.is_available() else "cpu"
748
+ self.model = AutoModelForCausalLM.from_pretrained(model_id, device_map=device, **load)
749
  else:
750
+ from transformers import AutoConfig, AutoModelForImageTextToText, AutoProcessor
751
+
752
+ if not vision_tower:
753
+ source, source_rev = model_id, revision
754
+ config = AutoConfig.from_pretrained(source, revision=source_rev)
755
+ if getattr(config, "vision_config", None) is None:
756
+ raise BlinkError("the selected vision tower has no vision configuration")
757
+ self.processor = AutoProcessor.from_pretrained(source, revision=source_rev)
758
+ if vision_tower:
759
+ text_config = AutoConfig.from_pretrained(model_id, revision=revision)
760
+ self._validate_graft_config(text_config, config)
761
+ image_processor = self.processor.image_processor
762
+ size = image_processor.size
763
+ self.image_limits = replace(limits or image_limits(), min_pixels=int(size["shortest_edge"]),
764
+ factor=int(image_processor.patch_size * image_processor.merge_size))
765
+ if self.image_limits.max_pixels < self.image_limits.min_pixels:
766
+ raise BlinkError(f"max-image-pixels must be at least {self.image_limits.min_pixels}")
767
+ vision_load = dict(dtype=torch.bfloat16, attn_implementation="sdpa", revision=source_rev)
768
+ if _on_zero_gpu():
769
+ self.model = AutoModelForImageTextToText.from_pretrained(source, **vision_load).to("cuda")
770
+ else:
771
+ device = "cuda" if torch.cuda.is_available() else "cpu"
772
+ self.model = AutoModelForImageTextToText.from_pretrained(source, device_map=device, **vision_load)
773
+ if vision_tower:
774
+ self._graft_text(model_id, revision)
775
  self.model.eval()
776
  self.pad_id = self.tok.pad_token_id if self.tok.pad_token_id is not None else 0
777
  self.labels, self.label_ids = self._verify_labels()
778
+ if self.accepts_images:
779
+ self._verify_image_labels()
780
  self.key = id(self)
781
  _LIVE[self.key] = self
782
 
783
+ def _validate_graft_config(self, text_config, base_config) -> None:
784
+ base_text = getattr(base_config, "text_config", None)
785
+ fields = ("model_type", "hidden_size", "num_hidden_layers", "intermediate_size",
786
+ "num_attention_heads", "num_key_value_heads", "head_dim", "vocab_size",
787
+ "tie_word_embeddings", "layer_types")
788
+ for field in fields:
789
+ text_value = getattr(text_config, field, None)
790
+ if text_value is None or text_value != getattr(base_text, field, None):
791
+ raise BlinkError(f"incompatible vision tower: text {field} differs from the checkpoint")
792
+ if getattr(base_config.vision_config, "out_hidden_size", None) != text_config.hidden_size:
793
+ raise BlinkError("incompatible vision tower: visual output does not match text hidden size")
794
+ for token, field in (("<|image_pad|>", "image_token_id"),
795
+ ("<|vision_start|>", "vision_start_token_id"),
796
+ ("<|vision_end|>", "vision_end_token_id")):
797
+ expected = getattr(base_config, field, None)
798
+ if (not isinstance(expected, int) or not 0 <= expected < text_config.vocab_size
799
+ or self.tok.convert_tokens_to_ids(token) != expected
800
+ or self.processor.tokenizer.convert_tokens_to_ids(token) != expected):
801
+ raise BlinkError(f"incompatible vision tower: {field} differs from the text tokenizer")
802
+
803
+ def _graft_text(self, model_id: str, revision) -> None:
804
+ from graft_keys import text_to_parent
805
+ from transformers import AutoModelForCausalLM
806
+
807
+ text_model = AutoModelForCausalLM.from_pretrained(model_id, revision=revision, device_map="cpu",
808
+ dtype=self.model.dtype, attn_implementation="sdpa")
809
+ target = self.model.state_dict()
810
+ copied = {}
811
+ for name, value in text_model.state_dict().items():
812
+ try:
813
+ dest = text_to_parent(name)
814
+ except ValueError as exc:
815
+ raise BlinkError(str(exc)) from exc
816
+ if dest not in target or target[dest].shape != value.shape or dest in copied:
817
+ raise BlinkError(f"text weight {name!r} cannot be grafted onto the vision model")
818
+ copied[dest] = value
819
+ missing, unexpected = self.model.load_state_dict(copied, strict=False)
820
+ unfilled = [name for name in missing if name.startswith("model.language_model.") or name == "lm_head.weight"]
821
+ if unexpected or unfilled:
822
+ raise BlinkError(f"incomplete text graft: {len(unfilled)} missing language weights, "
823
+ f"{len(unexpected)} unexpected weights")
824
+ del text_model, copied, target
825
+
826
  def _wrap(self, user: str) -> str:
827
  return self.tok.apply_chat_template(
828
  [{"role": "system", "content": SYSTEM}, {"role": "user", "content": user}],
 
853
  raise BlinkError(f"only {len(labels)} verified single-token labels")
854
  return labels, ids
855
 
856
+ def _verify_image_labels(self) -> None:
857
+ prompt = self.processor.apply_chat_template(
858
+ [{"role": "system", "content": SYSTEM}, {"role": "user", "content": [{"type": "text", "text": "x"}]}],
859
+ tokenize=False, add_generation_prompt=True, enable_thinking=False,
860
+ )
861
+ tok = self.processor.tokenizer
862
+ base = tok(prompt, add_special_tokens=False)["input_ids"]
863
+ for label, expected in zip(self.labels, self.label_ids):
864
+ got = tok(prompt + label, add_special_tokens=False)["input_ids"]
865
+ if got[:len(base)] != base or got[len(base):] != [expected]:
866
+ raise BlinkError(f"vision processor does not preserve the option-label token for {label}")
867
+
868
  def render(self, state, questions: dict):
869
  work = []
870
  for qkey, q in questions.items():
 
889
  )
890
  return work
891
 
892
+ def render_images(self, request: ImageRequest, questions: dict):
893
+ if not self.accepts_images:
894
+ raise ImageError("this model reads text only", request.loc)
895
+ # The training renderer also uses this method on an uninitialized engine.
896
+ layout = getattr(self, "image_layout", IMAGE_LAYOUT_DEFAULT)
897
+ if layout not in IMAGE_LAYOUTS:
898
+ raise BlinkError("image_layout must be inline or first")
899
+ started = time.perf_counter() if getattr(self, "image_timing", False) else None
900
+ work = []
901
+ split = re.compile("(" + "|".join(map(re.escape, request.markers)) + ")")
902
+ lookup = dict(zip(request.markers, request.images))
903
+ visible_markers = {marker: f"[image {i}]" for i, marker in enumerate(request.markers, 1)}
904
+ for qkey, q in questions.items():
905
+ items = question_options(q)
906
+ if len(items) > len(self.labels):
907
+ raise BlinkError(f"{len(items)} options per choice; {len(self.labels)} labels verified")
908
+ labels = self.labels[: len(items)]
909
+ user = user_message(request.marked_state, q, labels, items)
910
+ if layout == "first":
911
+ images = list(request.images)
912
+ content = [{"type": "image", "image": image} for image in images]
913
+ content.append({"type": "text", "text": split.sub(
914
+ lambda match: visible_markers[match.group()], user)})
915
+ else:
916
+ content, images = [], []
917
+ for part in split.split(user):
918
+ if part in lookup:
919
+ content.append({"type": "image", "image": lookup[part]})
920
+ images.append(lookup[part])
921
+ elif part:
922
+ content.append({"type": "text", "text": part})
923
+ prompt = self.processor.apply_chat_template(
924
+ [{"role": "system", "content": SYSTEM}, {"role": "user", "content": content}],
925
+ tokenize=False, add_generation_prompt=True, enable_thinking=False,
926
+ )
927
+ inputs = self.processor(text=[prompt], images=images, return_tensors="pt")
928
+ ids = inputs["input_ids"]
929
+ if ids.shape[1] > MAX_INPUT_TOKENS:
930
+ raise BlinkError(f"question {qkey!r} renders to {ids.shape[1]} tokens, over the maximum context "
931
+ f"length of {MAX_INPUT_TOKENS}")
932
+ grids = inputs["image_grid_thw"]
933
+ if len(grids) != len(images):
934
+ raise BlinkError(f"question {qkey!r} has mismatched image patches")
935
+ patch = int(self.processor.image_processor.patch_size)
936
+ merge = int(self.processor.image_processor.merge_size)
937
+ patches = [int(grid[0] * grid[1] * grid[2]) for grid in grids]
938
+ if any(count % merge**2 for count in patches):
939
+ raise BlinkError(f"question {qkey!r} has an unaligned image patch grid")
940
+ if any(count * patch**2 > self.image_limits.max_pixels for count in patches):
941
+ raise BlinkError(f"question {qkey!r} exceeds {self.image_limits.max_pixels} processed image pixels")
942
+ work.append({"qkey": qkey, "keys": [k for k, _ in items], "inputs": inputs,
943
+ "cand": self.label_ids[: len(items)], "image_pixels": sum(patches) * patch**2,
944
+ "visual_tokens": sum(count // merge**2 for count in patches)})
945
+ if started is not None:
946
+ self.last_image_processor_ms = (time.perf_counter() - started) * 1000
947
+ return work
948
+
949
+ def logits_images(self, request: ImageRequest, questions: dict) -> tuple[dict[str, list[float]], int]:
950
+ out, n_tokens = {}, 0
951
+ model_ms = processor_ms = encoder_ms = lm_ms = 0.0
952
+ for qkey, question in questions.items():
953
+ work = self.render_images(request, {qkey: question})
954
+ item = work[0]
955
+ if not out:
956
+ self.last_image_pixels = item["image_pixels"]
957
+ self.last_visual_tokens = item["visual_tokens"]
958
+ n_tokens += int(item["inputs"]["input_ids"].shape[1])
959
+ if getattr(self, "image_timing", False):
960
+ processor_ms += self.last_image_processor_ms
961
+ rows, elapsed, encoder, prefill = _forward_images(self.key, work)
962
+ out[qkey] = rows[0]
963
+ model_ms += elapsed
964
+ encoder_ms += encoder
965
+ lm_ms += prefill
966
+ del work, item, rows
967
+ self.last_model_ms = round(model_ms, 1)
968
+ self.last_image_processor_ms = processor_ms
969
+ self.last_vision_encoder_ms = round(encoder_ms, 3)
970
+ self.last_lm_prefill_ms = round(lm_ms, 3)
971
+ self.last_prefill_tokens = n_tokens
972
+ return out, n_tokens
973
+
974
  def logits(self, state, questions: dict, bias: dict | None = None) -> tuple[dict[str, list[float]], int]:
975
  del bias # demo-only shaping; the trained model reads the evidence instead
976
  work = self.render(state, questions)
 
1103
  return ids
1104
 
1105
 
1106
+ def _forward_shared(model, head, seqs, cands, prefix_len: int, pad_id: int, budget: int, out, device,
1107
+ backbone=None) -> None:
1108
  """Encode seqs[0][:prefix_len] once, then run every tail against an expanded copy of that cache."""
1109
  import torch
1110
 
1111
+ backbone = backbone if backbone is not None else model.model
1112
  prefix = torch.tensor([seqs[0][:prefix_len]], dtype=torch.long, device=device)
1113
+ cache = backbone(input_ids=prefix, use_cache=True).past_key_values
1114
  tails = [s[prefix_len:] for s in seqs]
1115
  rows_cap = max(1, PREFIX_KV_TOKENS // (prefix_len + max(len(t) for t in tails)))
1116
  for b in _batches([len(t) for t in tails], budget, max_rows=rows_cap):
1117
  ids = _padded(tails, b, pad_id).to(device)
1118
+ h = backbone(input_ids=ids, past_key_values=_expand_cache(cache, len(b)), use_cache=True).last_hidden_state
1119
  _read(model, head, h, [len(tails[i]) for i in b], cands, b, out, device)
1120
 
1121
 
 
1130
  model = engine.model
1131
  device = next(model.parameters()).device
1132
  head = model.lm_head.weight
1133
+ backbone = model.model.language_model if getattr(engine, "accepts_images", False) else model.model
1134
  budget = getattr(engine, "token_budget", TOKEN_BUDGET)
1135
  out: list = [None] * len(seqs)
1136
  t0 = time.perf_counter()
1137
  with torch.no_grad():
1138
  shared = _shared_prefix_len(seqs) if getattr(engine, "prefix_cache", PREFIX_CACHE) else 0
1139
  if shared > 0 and shared >= PREFIX_MIN_TOKENS:
1140
+ _forward_shared(model, head, seqs, cands, shared, engine.pad_id, budget, out, device, backbone)
1141
  else:
1142
  for b in _batches([len(s) for s in seqs], budget):
1143
  ids = _padded(seqs, b, engine.pad_id).to(device)
1144
+ h = backbone(input_ids=ids, use_cache=False).last_hidden_state
1145
  _read(model, head, h, [len(seqs[i]) for i in b], cands, b, out, device)
1146
  return out, round((time.perf_counter() - t0) * 1000, 1)
1147
 
1148
 
1149
+ def _timed_image_model(model, inputs, device):
1150
+ import torch
1151
+
1152
+ use_cuda = device.type == "cuda"
1153
+ starts, sections = {"encoder": [], "lm": []}, {"encoder": [], "lm": []}
1154
+
1155
+ def begin(name):
1156
+ stamp = torch.cuda.Event(enable_timing=True) if use_cuda else time.perf_counter()
1157
+ if use_cuda:
1158
+ stamp.record()
1159
+ starts[name].append(stamp)
1160
+
1161
+ def end(name):
1162
+ start = starts[name].pop()
1163
+ stamp = torch.cuda.Event(enable_timing=True) if use_cuda else time.perf_counter()
1164
+ if use_cuda:
1165
+ stamp.record()
1166
+ sections[name].append((start, stamp) if use_cuda else (stamp - start) * 1000)
1167
+
1168
+ handles = [
1169
+ model.model.visual.register_forward_pre_hook(lambda _mod, _args: begin("encoder")),
1170
+ model.model.visual.register_forward_hook(lambda _mod, _args, _out: end("encoder")),
1171
+ model.model.language_model.register_forward_pre_hook(lambda _mod, _args: begin("lm")),
1172
+ model.model.language_model.register_forward_hook(lambda _mod, _args, _out: end("lm")),
1173
+ ]
1174
+ try:
1175
+ hidden = model.model(**inputs, use_cache=False).last_hidden_state
1176
+ finally:
1177
+ for handle in handles:
1178
+ handle.remove()
1179
+ if not sections["encoder"] or not sections["lm"]:
1180
+ raise RuntimeError("image forward did not run both visual and language modules")
1181
+ if use_cuda:
1182
+ torch.cuda.synchronize(device)
1183
+ elapsed = lambda name: sum(start.elapsed_time(end) for start, end in sections[name])
1184
+ else:
1185
+ elapsed = lambda name: sum(sections[name])
1186
+ return hidden, elapsed("encoder"), elapsed("lm")
1187
+
1188
+
1189
+ @_gpu(GPU_DURATION)
1190
+ def _forward_images(key: int, work: list):
1191
+ import torch
1192
+
1193
+ engine = _LIVE[key]
1194
+ model = engine.model
1195
+ device = next(model.parameters()).device
1196
+ out = []
1197
+ t0 = time.perf_counter()
1198
+ encoder_ms = lm_ms = 0.0
1199
+ with torch.no_grad():
1200
+ for item in work:
1201
+ inputs = {k: v.to(device) if hasattr(v, "to") else v for k, v in item["inputs"].items()}
1202
+ if getattr(engine, "image_timing", False):
1203
+ hidden, encoder, lm = _timed_image_model(model, inputs, device)
1204
+ encoder_ms += encoder
1205
+ lm_ms += lm
1206
+ else:
1207
+ hidden = model.model(**inputs, use_cache=False).last_hidden_state
1208
+ length = (int(inputs["attention_mask"][0].sum().item()) if "attention_mask" in inputs
1209
+ else int(inputs["input_ids"].shape[1]))
1210
+ row = [None]
1211
+ _read(model, model.lm_head.weight, hidden, [length], [item["cand"]], [0], row, device)
1212
+ out.append(row[0])
1213
+ return out, round((time.perf_counter() - t0) * 1000, 1), round(encoder_ms, 3), round(lm_ms, 3)
1214
+
1215
  # --- hybrid engine ------------------------------------------------------------------
1216
 
1217
 
 
1225
  self.replay, self.live = replay, live
1226
  self.model_id = live.model_id
1227
  self.temperature = live.temperature
1228
+ self.accepts_images = getattr(live, "accepts_images", False)
1229
  self.hardware = replay.hardware if replay is not None else ""
1230
  self.last_source = None
1231
  self.last = None
 
1290
  return MockEngine(model_id)
1291
  if kind == "replay":
1292
  return ReplayEngine(replay_path(model_id), TEMPERATURE)
1293
+ model_name = model_id.rstrip("/").split("/")[-1]
1294
+ vision = VISION and model_name in NATIVE_VISION_MODELS
1295
+ tower = VISION_TOWER if model_name in VISION_BASES else None
1296
+ options = {"vision": vision, "vision_tower": tower} if vision or tower else {}
1297
  if kind == "hybrid":
1298
+ return HybridEngine(_matching_replay(model_id), TorchEngine(model_id, revision, TEMPERATURE, **options))
1299
+ return TorchEngine(model_id, revision, TEMPERATURE, **options)
1300
 
1301
 
1302
  def engine(model: str | None = None):
 
1318
  return [engine(m) for m in models()]
1319
 
1320
 
1321
+ def _prepare_images(state, images, eng, limits: ImageLimits | None):
1322
+ live = getattr(eng, "live", eng)
1323
+ if isinstance(state, ImageRequest):
1324
+ if not getattr(live, "accepts_images", False):
1325
+ raise ImageError("this model reads text only", state.loc)
1326
+ return state
1327
+ submission = (state if isinstance(state, ImageSubmission)
1328
+ else inspect_images(state, images, limits or getattr(live, "image_limits", None)))
1329
+ if submission is None:
1330
+ return state
1331
+ if not getattr(live, "accepts_images", False):
1332
+ raise ImageError("this model reads text only", submission.loc)
1333
+ return extract_images(submission.state, submission.images, submission.limits)
1334
+
1335
+
1336
  def decide(state, questions: dict, temperature: float | None = None, bias: dict | None = None,
1337
+ prefer: str = "live", model: str | None = None, images=_NO_IMAGES,
1338
+ limits: ImageLimits | None = None) -> dict:
1339
  """{state, questions} -> {answers, meta}. One forward pass, zero generated tokens.
1340
 
1341
  `bias` shapes the mock engine so the bundled examples read realistically without a
 
1349
  if T <= 0:
1350
  raise BlinkError("temperature must be positive")
1351
  t0 = time.perf_counter()
1352
+ if isinstance(state, (ImageRequest, ImageSubmission)) or images is not _NO_IMAGES or contains_image_uri(state):
1353
+ live = getattr(eng, "live", eng)
1354
+ decode_started = time.perf_counter() if getattr(live, "image_timing", False) else None
1355
+ prepared = _prepare_images(state, images, eng, limits)
1356
+ decode_ms = (time.perf_counter() - decode_started) * 1000 if decode_started is not None else 0.0
1357
+ else:
1358
+ prepared = state
1359
+ if isinstance(prepared, ImageRequest):
1360
+ live = getattr(eng, "live", eng)
1361
+ raw, n_tokens = live.logits_images(prepared, questions)
1362
+ if isinstance(eng, HybridEngine):
1363
+ eng.last_source = "torch"
1364
+ elif isinstance(eng, HybridEngine):
1365
  raw, n_tokens = eng.logits(state, questions, bias, prefer=prefer)
1366
  else:
1367
  raw, n_tokens = eng.logits(state, questions, bias)
 
1379
  "generated_tokens": 0,
1380
  "latency_ms": latency_ms,
1381
  }
1382
+ if isinstance(prepared, ImageRequest):
1383
+ meta["image_pixels"] = live.last_image_pixels
1384
+ meta["visual_tokens"] = live.last_visual_tokens
1385
+ if getattr(live, "image_timing", False):
1386
+ meta["image_preprocess_ms"] = round(decode_ms + live.last_image_processor_ms, 3)
1387
+ meta["vision_encoder_ms"] = live.last_vision_encoder_ms
1388
+ meta["lm_prefill_ms"] = live.last_lm_prefill_ms
1389
  if source == "replay":
1390
  meta["latency_ms"] = float(eng.last["latency_ms"])
1391
  if eng.hardware:
 
1410
  T = float(temperature if temperature is not None else eng.temperature)
1411
  if T <= 0:
1412
  raise BlinkError("temperature must be positive")
1413
+ if any(len(req) == 3 or isinstance(req[0], (ImageRequest, ImageSubmission))
1414
+ or contains_image_uri(req[0]) for req in requests):
1415
+ out = [None] * len(requests)
1416
+ text_indices = [i for i, req in enumerate(requests)
1417
+ if len(req) == 2 and not isinstance(req[0], (ImageRequest, ImageSubmission))
1418
+ and not contains_image_uri(req[0])]
1419
+ if text_indices:
1420
+ text_results = decide_many([requests[i] for i in text_indices], temperature, model)
1421
+ for i, result in zip(text_indices, text_results):
1422
+ out[i] = result
1423
+ text_indices = set(text_indices)
1424
+ for i, req in enumerate(requests):
1425
+ if i in text_indices:
1426
+ continue
1427
+ try:
1428
+ out[i] = decide(req[0], req[1], temperature=temperature, model=model,
1429
+ **({"images": req[2]} if len(req) == 3 else {}))
1430
+ except Exception as exc: # noqa: BLE001 - isolate each image request, as for text rendering
1431
+ out[i] = exc
1432
+ return out
1433
  live = getattr(eng, "live", eng)
1434
  packed = hasattr(live, "logits_rendered")
1435
  out: list = [None] * len(requests)
 
1508
  def alive(self) -> bool:
1509
  return self.worker.is_alive()
1510
 
1511
+ def submit(self, state, questions: dict, images=_NO_IMAGES, limits: ImageLimits | None = None) -> dict:
1512
  import concurrent.futures
1513
  import queue
1514
 
1515
  validate(questions) # a malformed request fails at once and never takes a place in the queue
1516
+ if images is not _NO_IMAGES or isinstance(state, (ImageRequest, ImageSubmission)) or contains_image_uri(state):
1517
+ eng = engine(self.model)
1518
+ live = getattr(eng, "live", eng)
1519
+ pending = (state if isinstance(state, (ImageRequest, ImageSubmission))
1520
+ else inspect_images(state, images, limits or getattr(live, "image_limits", None)))
1521
+ if pending is not None:
1522
+ if not getattr(live, "accepts_images", False):
1523
+ raise ImageError("this model reads text only", pending.loc)
1524
+ state = pending
1525
  if not self.worker.is_alive():
1526
  raise RuntimeError("the batching worker has stopped")
1527
  fut = concurrent.futures.Future()
graft_keys.py ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ """Map merged text weights to their matching vision-language parent."""
2
+
3
+
4
+ def text_to_parent(key: str) -> str:
5
+ if key == "lm_head.weight" or key.startswith("model.language_model."):
6
+ return key
7
+ if key.startswith("model."):
8
+ return "model.language_model." + key[len("model."):]
9
+ raise ValueError(f"unexpected key in the text checkpoint: {key}")
serve.py CHANGED
@@ -1,13 +1,19 @@
1
  """blink server: a Jev-compatible decision endpoint (the TypeSafe API's wire format).
2
 
3
  pip install "torch==2.13.0" "transformers==5.17.0" "flash-linear-attention==0.5.2" accelerate safetensors huggingface_hub
 
4
  hf download thegovind/blink-4b --revision v1.0 --local-dir blink-4b
5
  python blink-4b/serve.py --model ./blink-4b --port 8000
6
 
7
- POST /v1/systemone {"state": ..., "model": ..., "questions": {...}} -> {"model", "answers", "usage"}
8
- GET /v1/models -> {"models": [{"name", "description", "release_date"}]}
 
9
  GET /healthz -> {"ok", "model", "revision", "weights_verified", "hub_offline", "warmup", "kernels", "versions",
10
- "batching", "api_key_required"}
 
 
 
 
11
 
12
  A client written for the TypeSafe API works unchanged against this server once its base URL points here (for the
13
  official SDKs: TYPESAFE_BASE_URL=http://127.0.0.1:8000). The request's "model" is accepted and not used: this
@@ -118,6 +124,13 @@ def api_key(value: str):
118
  return key
119
 
120
 
 
 
 
 
 
 
 
121
  def bearer(header) -> str | None:
122
  """The token of an "Authorization: Bearer <token>" header, else None."""
123
  scheme, _, token = (header or "").strip().partition(" ")
@@ -146,6 +159,8 @@ def error_loc(blink, req: dict, message: str) -> list:
146
  def main() -> None:
147
  ap = argparse.ArgumentParser(description="Serve blink over a Jev-compatible HTTP API.")
148
  ap.add_argument("--model", default=os.environ.get("BLINK_MODEL", "thegovind/blink-4b"))
 
 
149
  ap.add_argument("--revision", default=os.environ.get("BLINK_REVISION"))
150
  ap.add_argument("--host", default="127.0.0.1")
151
  ap.add_argument("--port", type=int, default=8000)
@@ -161,8 +176,28 @@ def main() -> None:
161
  ap.add_argument("--api-key", type=api_key, default=os.environ.get("BLINK_API_KEY", ""),
162
  help="require 'Authorization: Bearer <key>' on /v1/systemone and /v1/models (default: open, any "
163
  "Authorization header is accepted and ignored); BLINK_API_KEY keeps it out of the process list")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
164
  a = ap.parse_args()
165
 
 
 
 
 
 
166
  local = os.path.isdir(a.model)
167
  if local:
168
  # the Hub libraries read these once, when they are first imported, so they must be set before that
@@ -172,6 +207,14 @@ def main() -> None:
172
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
173
  import blink
174
 
 
 
 
 
 
 
 
 
175
  busy = getattr(blink, "BlinkBusy", ()) # an older blink.py has no batching (and no BlinkBusy)
176
 
177
  if local:
@@ -183,7 +226,10 @@ def main() -> None:
183
  verified, bad = verify(root)
184
  if verified is False:
185
  sys.exit(f"weights.sha256 mismatch: {', '.join(bad)}")
186
- engine = blink.TorchEngine(root, None, blink.TEMPERATURE)
 
 
 
187
  blink._ENGINE = engine
188
  first = blink.decide(*WARMUP)["answers"]
189
  repeat_identical = blink.decide(*WARMUP)["answers"] == first
@@ -197,7 +243,9 @@ def main() -> None:
197
  hub_offline = None
198
  health = {"ok": True, "model": a.model, "revision": a.revision, "weights_verified": verified,
199
  "hub_offline": hub_offline, "warmup": {"repeat_identical": repeat_identical}, "kernels": kernels,
200
- "versions": ver}
 
 
201
  lock = threading.Lock()
202
  batcher = (blink.Batcher(a.batch_window_ms / 1000.0, a.max_batch_requests, max_queued=a.max_queued_requests)
203
  if a.batch_window_ms > 0 else None)
@@ -210,6 +258,7 @@ def main() -> None:
210
  "description": "blink: typed decisions (noul, choice, score) with option probabilities from one forward pass. "
211
  "This server serves one model; a request's model field is accepted and not used.",
212
  "release_date": "",
 
213
  }]}
214
 
215
  def current_health() -> dict:
@@ -287,21 +336,34 @@ def main() -> None:
287
  if not isinstance(req, dict):
288
  return self._refused(400, "the body must be a JSON object", ["body"], "value_error")
289
  try:
 
 
 
 
 
290
  if batcher is not None:
291
- out = batcher.submit(req.get("state"), req.get("questions"))
292
  else:
293
  with lock:
294
- out = blink.decide(req.get("state"), req.get("questions"))
295
  except blink.BlinkError as exc:
296
- return self._refused(422, str(exc), error_loc(blink, req, str(exc)), "value_error")
 
297
  except busy as exc:
298
  return self._fail(529, str(exc), headers={"Retry-After": "1"}, status_text="Overloaded")
299
  except Exception as exc: # noqa: BLE001 - report, keep serving
300
  return self._fail(500, f"{type(exc).__name__}: {exc}")
 
 
 
 
 
 
 
301
  return self._send(200, {
302
  "model": a.model,
303
  "answers": out["answers"],
304
- "usage": {"input_tokens": out["meta"]["input_tokens"], "output_tokens": 0},
305
  })
306
 
307
  server = ThreadingHTTPServer((a.host, a.port), Handler)
 
1
  """blink server: a Jev-compatible decision endpoint (the TypeSafe API's wire format).
2
 
3
  pip install "torch==2.13.0" "transformers==5.17.0" "flash-linear-attention==0.5.2" accelerate safetensors huggingface_hub
4
+ pip install "torchvision==0.28.0" # required when image input is enabled
5
  hf download thegovind/blink-4b --revision v1.0 --local-dir blink-4b
6
  python blink-4b/serve.py --model ./blink-4b --port 8000
7
 
8
+ POST /v1/systemone {"state": ..., "model": ..., "questions": {...}, "images": [...]} ->
9
+ {"model", "answers", "usage"}
10
+ GET /v1/models -> {"models": [{"name", "description", "release_date", "accepts_images"}]}
11
  GET /healthz -> {"ok", "model", "revision", "weights_verified", "hub_offline", "warmup", "kernels", "versions",
12
+ "batching", "api_key_required", "accepts_images"}
13
+
14
+ Image data URIs in state and the sibling images list are self-host extensions, not fields supported by hosted
15
+ TypeSafe models. Image requests alone add image_pixels and visual_tokens (for the shared image prefix) to usage;
16
+ answers keep their ordinary noul, choice and score fields. Text-only responses stay unchanged.
17
 
18
  A client written for the TypeSafe API works unchanged against this server once its base URL points here (for the
19
  official SDKs: TYPESAFE_BASE_URL=http://127.0.0.1:8000). The request's "model" is accepted and not used: this
 
124
  return key
125
 
126
 
127
+ def tower_spec(value: str) -> str:
128
+ repo, separator, revision = value.rpartition("@")
129
+ if not separator or not repo or not revision:
130
+ raise argparse.ArgumentTypeError("must be a repo@revision")
131
+ return value
132
+
133
+
134
  def bearer(header) -> str | None:
135
  """The token of an "Authorization: Bearer <token>" header, else None."""
136
  scheme, _, token = (header or "").strip().partition(" ")
 
159
  def main() -> None:
160
  ap = argparse.ArgumentParser(description="Serve blink over a Jev-compatible HTTP API.")
161
  ap.add_argument("--model", default=os.environ.get("BLINK_MODEL", "thegovind/blink-4b"))
162
+ ap.add_argument("--model-name", default=os.environ.get("BLINK_MODEL_NAME") or None,
163
+ help="logical model family when the checkpoint is stored in a renamed directory (BLINK_MODEL_NAME)")
164
  ap.add_argument("--revision", default=os.environ.get("BLINK_REVISION"))
165
  ap.add_argument("--host", default="127.0.0.1")
166
  ap.add_argument("--port", type=int, default=8000)
 
176
  ap.add_argument("--api-key", type=api_key, default=os.environ.get("BLINK_API_KEY", ""),
177
  help="require 'Authorization: Bearer <key>' on /v1/systemone and /v1/models (default: open, any "
178
  "Authorization header is accepted and ignored); BLINK_API_KEY keeps it out of the process list")
179
+ ap.add_argument("--vision", action="store_true",
180
+ default=os.environ.get("BLINK_VISION", "0").lower() in ("1", "true", "on", "yes"),
181
+ help="accept images with this model's own vision tower (BLINK_VISION=1)")
182
+ ap.add_argument("--vision-tower", type=tower_spec, default=os.environ.get("BLINK_VISION_TOWER") or None,
183
+ help="opt-in a matching text model's base vision tower from repo@revision (BLINK_VISION_TOWER)")
184
+ ap.add_argument("--image-layout", choices=("inline", "first"),
185
+ default=os.environ.get("BLINK_IMAGE_LAYOUT") or None,
186
+ help="place image tokens at state placeholders or before the user text (BLINK_IMAGE_LAYOUT)")
187
+ ap.add_argument("--max-images", type=int_range(1, 8), default=os.environ.get("BLINK_MAX_IMAGES", "2"))
188
+ ap.add_argument("--max-image-bytes", type=int_range(1024, 32 * 1024 * 1024),
189
+ default=os.environ.get("BLINK_MAX_IMAGE_BYTES", str(8 * 1024 * 1024)))
190
+ ap.add_argument("--max-image-pixels", type=int_range(65_536, 16_777_216),
191
+ default=os.environ.get("BLINK_MAX_IMAGE_PIXELS", str(1920 * 1088)))
192
+ ap.add_argument("--max-image-source-pixels", type=int_range(65_536, 64_000_000),
193
+ default=os.environ.get("BLINK_MAX_IMAGE_SOURCE_PIXELS", "20000000"))
194
  a = ap.parse_args()
195
 
196
+ model_name = a.model_name or a.model.rstrip("/").split("/")[-1]
197
+ if a.vision and not a.vision_tower and model_name not in (
198
+ "blink-mimo-9b", "Qwen3.5-4B"
199
+ ):
200
+ ap.error("--vision needs a model with its own vision tower or --vision-tower for a text model")
201
  local = os.path.isdir(a.model)
202
  if local:
203
  # the Hub libraries read these once, when they are first imported, so they must be set before that
 
207
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
208
  import blink
209
 
210
+ if a.image_layout is not None and a.image_layout not in blink.IMAGE_LAYOUTS:
211
+ ap.error("--image-layout must be inline or first")
212
+ if a.vision_tower:
213
+ try:
214
+ blink.vision_source(model_name, a.vision_tower)
215
+ except blink.BlinkError as exc:
216
+ ap.error(str(exc))
217
+ limits = blink.ImageLimits(a.max_images, a.max_image_bytes, a.max_image_pixels, a.max_image_source_pixels)
218
  busy = getattr(blink, "BlinkBusy", ()) # an older blink.py has no batching (and no BlinkBusy)
219
 
220
  if local:
 
226
  verified, bad = verify(root)
227
  if verified is False:
228
  sys.exit(f"weights.sha256 mismatch: {', '.join(bad)}")
229
+ options = ({"vision": a.vision, "vision_tower": a.vision_tower, "limits": limits,
230
+ "model_name": model_name, "image_layout": a.image_layout}
231
+ if a.vision or a.vision_tower else {})
232
+ engine = blink.TorchEngine(root, None, blink.TEMPERATURE, **options)
233
  blink._ENGINE = engine
234
  first = blink.decide(*WARMUP)["answers"]
235
  repeat_identical = blink.decide(*WARMUP)["answers"] == first
 
243
  hub_offline = None
244
  health = {"ok": True, "model": a.model, "revision": a.revision, "weights_verified": verified,
245
  "hub_offline": hub_offline, "warmup": {"repeat_identical": repeat_identical}, "kernels": kernels,
246
+ "versions": ver, "accepts_images": bool(getattr(engine, "accepts_images", False))}
247
+ if health["accepts_images"]:
248
+ health["image_layout"] = getattr(engine, "image_layout", a.image_layout or blink.IMAGE_LAYOUT_DEFAULT)
249
  lock = threading.Lock()
250
  batcher = (blink.Batcher(a.batch_window_ms / 1000.0, a.max_batch_requests, max_queued=a.max_queued_requests)
251
  if a.batch_window_ms > 0 else None)
 
258
  "description": "blink: typed decisions (noul, choice, score) with option probabilities from one forward pass. "
259
  "This server serves one model; a request's model field is accepted and not used.",
260
  "release_date": "",
261
+ "accepts_images": health["accepts_images"],
262
  }]}
263
 
264
  def current_health() -> dict:
 
336
  if not isinstance(req, dict):
337
  return self._refused(400, "the body must be a JSON object", ["body"], "value_error")
338
  try:
339
+ opts = {}
340
+ if "images" in req or blink.contains_image_uri(req.get("state")):
341
+ if "images" in req:
342
+ opts["images"] = req["images"]
343
+ opts["limits"] = limits
344
  if batcher is not None:
345
+ out = batcher.submit(req.get("state"), req.get("questions"), **opts)
346
  else:
347
  with lock:
348
+ out = blink.decide(req.get("state"), req.get("questions"), **opts)
349
  except blink.BlinkError as exc:
350
+ location = exc.loc if isinstance(exc, blink.ImageError) else error_loc(blink, req, str(exc))
351
+ return self._refused(422, str(exc), location, "value_error")
352
  except busy as exc:
353
  return self._fail(529, str(exc), headers={"Retry-After": "1"}, status_text="Overloaded")
354
  except Exception as exc: # noqa: BLE001 - report, keep serving
355
  return self._fail(500, f"{type(exc).__name__}: {exc}")
356
+ usage = {"input_tokens": out["meta"]["input_tokens"], "output_tokens": 0}
357
+ if "image_pixels" in out["meta"]:
358
+ usage["image_pixels"] = out["meta"]["image_pixels"]
359
+ usage["visual_tokens"] = out["meta"]["visual_tokens"]
360
+ for name in ("image_preprocess_ms", "vision_encoder_ms", "lm_prefill_ms"):
361
+ if name in out["meta"]:
362
+ usage[name] = out["meta"][name]
363
  return self._send(200, {
364
  "model": a.model,
365
  "answers": out["answers"],
366
+ "usage": usage,
367
  })
368
 
369
  server = ThreadingHTTPServer((a.host, a.port), Handler)