Text Classification
Transformers
Safetensors
English
qwen3_5_text
text-generation
decision-model
typed-decisions
one-pass
option-probabilities
Instructions to use thegovind/blink-4b with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Transformers
How to use thegovind/blink-4b with Transformers:
# Use a pipeline as a high-level helper from transformers import pipeline pipe = pipeline("text-classification", model="thegovind/blink-4b")# Load model directly from transformers import AutoTokenizer, AutoModelForCausalLM tokenizer = AutoTokenizer.from_pretrained("thegovind/blink-4b") model = AutoModelForCausalLM.from_pretrained("thegovind/blink-4b", device_map="auto") - Notebooks
- Google Colab
- Kaggle
Code revision v1.3: opt-in screenshot input (self-hosted)
Browse files- Dockerfile +1 -1
- README.md +35 -6
- blink.py +675 -17
- graft_keys.py +9 -0
- 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.
|
| 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.
|
| 157 |
-
sys.path.insert(0, os.path.dirname(hf_hub_download("thegovind/blink-4b", "blink.py", revision="v1.
|
| 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.
|
| 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.
|
| 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
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 391 |
else:
|
| 392 |
-
|
| 393 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
| 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 =
|
| 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 =
|
| 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 =
|
| 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
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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": {...}
|
| 8 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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":
|
| 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)
|