File size: 11,671 Bytes
38db1cd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
"""Hugging Face ZeroGPU Space hosting Qwen3-VL-Embedding-2B.

This is a drop-in replacement for the Modal deployment of `colab_server/`. It
speaks the **same wire contract** (`GET /health`, `POST /embed`,
`POST /embed_text`), so `app/embed_client.py` talks to it unchanged - only
`EMBEDDING_SERVER_URL` moves.

Two things are specific to ZeroGPU and deliberate:

1. **The model is placed on `cuda` at module level**, not lazily inside the
   GPU function. ZeroGPU runs a CUDA emulation layer outside `@spaces.GPU`
   so this works, and the docs are explicit that startup placement is far more
   efficient than transferring inside the decorated call.
2. **Only the forward pass is decorated.** A real GPU is attached for the
   duration of a `@spaces.GPU` call and released after, and daily quota is
   consumed by that time - so request parsing, base64 decoding and response
   building all stay outside it.

Embeddings are produced **directly from the page/tile image**. There is no
caption step anywhere in this file; that path is the OpenAI baseline and lives
in `app/embed_openai.py`.
"""

from __future__ import annotations

import base64
import binascii
import io
import logging
import os
import time
from typing import Annotated, Literal

import spaces
import torch
from fastapi import Header, HTTPException, Request
from fastapi.responses import JSONResponse
from gradio import Server
from PIL import Image, UnidentifiedImageError
from pydantic import BaseModel, Field, field_validator
from sentence_transformers import SentenceTransformer

logging.basicConfig(level=logging.INFO, format="%(asctime)s | %(levelname)-7s | %(message)s")
log = logging.getLogger("visualops.space")

# ======================================================================
# Configuration
# ======================================================================

MODEL_ID = os.environ.get("MODEL_ID", "Qwen/Qwen3-VL-Embedding-2B")

# The model card publishes up to 2048 dimensions. This is asserted rather than
# assumed: the local FAISS manifest pins the width, and a silent change would
# invalidate every stored vector while still returning plausible numbers.
EXPECTED_DIMENSION = int(os.environ.get("EXPECTED_DIMENSION", 2048))

MAX_BATCH_ITEMS = int(os.environ.get("MAX_BATCH_ITEMS", 64))
AUTH_TOKEN = os.environ.get("EMBEDDING_SERVER_TOKEN", "").strip()

# Seconds of GPU time requested per call. Quota is consumed by *effective*
# runtime, but a declared ceiling that is too low kills the call mid-batch.
GPU_DURATION_BASE = int(os.environ.get("GPU_DURATION_BASE", 20))
GPU_SECONDS_PER_IMAGE = float(os.environ.get("GPU_SECONDS_PER_IMAGE", 1.5))
GPU_SECONDS_PER_TEXT = float(os.environ.get("GPU_SECONDS_PER_TEXT", 0.2))

_STARTED_AT = time.time()


# ======================================================================
# Model - loaded once, at import, and reused by every request
# ======================================================================


def _load_model() -> tuple[SentenceTransformer, int, str]:
    """Load Qwen3-VL onto cuda at startup, per the ZeroGPU model-loading rule."""
    dtype = torch.float16
    model = SentenceTransformer(MODEL_ID, device="cuda", model_kwargs={"torch_dtype": dtype})
    model.eval()

    raw_dim = model.get_sentence_embedding_dimension()
    if not raw_dim:
        raise RuntimeError(f"{MODEL_ID} reported no embedding dimension")
    dim = int(raw_dim)
    if dim != EXPECTED_DIMENSION:
        # Loud, at startup: a width change must never reach the index quietly.
        raise RuntimeError(
            f"{MODEL_ID} produced {dim}-d embeddings, expected {EXPECTED_DIMENSION}. "
            "The FAISS manifest pins this width; refusing to serve."
        )
    return model, dim, str(dtype).replace("torch.", "")


_MODEL: SentenceTransformer | None = None
_DIMENSION = 0
_DTYPE = ""
_LOAD_ERROR = ""

try:
    _MODEL, _DIMENSION, _DTYPE = _load_model()
    log.info("loaded %s (%d-d, %s)", MODEL_ID, _DIMENSION, _DTYPE)
except Exception as exc:  # noqa: BLE001 - /health must survive to report why
    _LOAD_ERROR = f"{type(exc).__name__}: {exc}"
    log.error("model failed to load: %s", _LOAD_ERROR)


# ======================================================================
# Schemas - identical to colab_server/server.py
# ======================================================================


class EmbedImagesRequest(BaseModel):
    """Base64-encoded images. `data:` prefixes are accepted and stripped."""

    images: list[str] = Field(min_length=1)
    batch_size: int = Field(default=8, ge=1, le=32)
    dimension: int | None = Field(default=None, ge=8, description="Optional Matryoshka truncation.")

    @field_validator("images")
    @classmethod
    def _limit_batch(cls, v: list[str]) -> list[str]:
        if len(v) > MAX_BATCH_ITEMS:
            raise ValueError(f"at most {MAX_BATCH_ITEMS} images per request")
        return v


class EmbedTextRequest(BaseModel):
    texts: list[str] = Field(min_length=1)
    is_query: bool = Field(
        default=True,
        description="Queries and documents are encoded asymmetrically by this model.",
    )
    batch_size: int = Field(default=16, ge=1, le=64)
    dimension: int | None = Field(default=None, ge=8)

    @field_validator("texts")
    @classmethod
    def _limit_batch(cls, v: list[str]) -> list[str]:
        if len(v) > MAX_BATCH_ITEMS:
            raise ValueError(f"at most {MAX_BATCH_ITEMS} texts per request")
        return v


class EmbedResponse(BaseModel):
    embeddings: list[list[float]]
    dimension: int
    count: int
    model: str
    normalized: bool = True
    elapsed_ms: float


class HealthResponse(BaseModel):
    status: Literal["ok", "loading", "error"]
    model: str
    model_id: str
    backend: str
    device: str
    dtype: str
    dimension: int
    uptime_seconds: float
    detail: str | None = None


# ======================================================================
# GPU work - the only code that holds a real GPU
# ======================================================================


def _image_duration(images: list[Image.Image], batch_size: int) -> int:
    return int(GPU_DURATION_BASE + GPU_SECONDS_PER_IMAGE * len(images))


def _text_duration(texts: list[str], is_query: bool, batch_size: int) -> int:
    return int(GPU_DURATION_BASE + GPU_SECONDS_PER_TEXT * len(texts))


@spaces.GPU(duration=_image_duration)
@torch.inference_mode()
def embed_images_gpu(images: list[Image.Image], batch_size: int) -> list[list[float]]:
    """Embed page/tile images directly. No captioning, no text proxy."""
    assert _MODEL is not None
    encode = getattr(_MODEL, "encode_document", _MODEL.encode)
    vectors = encode(
        images, batch_size=batch_size, convert_to_numpy=True, normalize_embeddings=True
    )
    return [v.astype("float32").tolist() for v in vectors]


@spaces.GPU(duration=_text_duration)
@torch.inference_mode()
def embed_texts_gpu(texts: list[str], is_query: bool, batch_size: int) -> list[list[float]]:
    """Embed text into the *same* Qwen space the images live in."""
    assert _MODEL is not None
    encode = (
        _MODEL.encode_query
        if is_query and hasattr(_MODEL, "encode_query")
        else getattr(_MODEL, "encode_document", _MODEL.encode)
    )
    vectors = encode(
        texts, batch_size=batch_size, convert_to_numpy=True, normalize_embeddings=True
    )
    return [v.astype("float32").tolist() for v in vectors]


# ======================================================================
# Helpers
# ======================================================================


def _decode_image(payload: str, position: int) -> Image.Image:
    raw = payload.split(",", 1)[1] if payload.startswith("data:") else payload
    try:
        data = base64.b64decode(raw, validate=True)
    except (binascii.Error, ValueError) as exc:
        raise HTTPException(400, f"image[{position}] is not valid base64: {exc}") from exc
    try:
        return Image.open(io.BytesIO(data)).convert("RGB")
    except (UnidentifiedImageError, OSError) as exc:
        raise HTTPException(400, f"image[{position}] is not a readable image: {exc}") from exc


def _truncate(vectors: list[list[float]], dimension: int | None) -> list[list[float]]:
    """Matryoshka truncation, renormalised so cosine scores stay comparable."""
    if dimension is None or dimension >= _DIMENSION:
        return vectors
    out: list[list[float]] = []
    for vector in vectors:
        head = torch.tensor(vector[:dimension], dtype=torch.float32)
        out.append(torch.nn.functional.normalize(head, dim=0).tolist())
    return out


def _require_model() -> None:
    if _MODEL is None:
        raise HTTPException(503, f"model unavailable: {_LOAD_ERROR or 'not loaded'}")


async def require_token(authorization: Annotated[str | None, Header()] = None) -> None:
    """Optional bearer auth. Enabled by setting EMBEDDING_SERVER_TOKEN."""
    if not AUTH_TOKEN:
        return
    if authorization != f"Bearer {AUTH_TOKEN}":
        raise HTTPException(status_code=401, detail="invalid or missing bearer token")


# ======================================================================
# App - gradio.Server is a FastAPI subclass; ZeroGPU requires the Gradio SDK,
# and custom routes take priority over Gradio's own.
# ======================================================================

app = Server()


@app.exception_handler(ValueError)
async def _bad_request(request: Request, exc: ValueError) -> JSONResponse:
    return JSONResponse(status_code=400, content={"error": "bad_request", "detail": str(exc)})


@app.get("/health", response_model=HealthResponse)
async def health() -> HealthResponse:
    ready = _MODEL is not None
    return HealthResponse(
        status="ok" if ready else "error",
        model=MODEL_ID,
        model_id=MODEL_ID,
        backend="sentence-transformers",
        device="cuda-zerogpu",
        dtype=_DTYPE or "unknown",
        dimension=_DIMENSION,
        uptime_seconds=round(time.time() - _STARTED_AT, 2),
        detail=_LOAD_ERROR or None,
    )


@app.post("/embed", response_model=EmbedResponse)
async def embed_images(
    request: EmbedImagesRequest, _: Annotated[None, Header()] = None
) -> EmbedResponse:
    await require_token(_)
    _require_model()
    started = time.time()

    images = [_decode_image(payload, i) for i, payload in enumerate(request.images)]
    vectors = embed_images_gpu(images, request.batch_size)
    vectors = _truncate(vectors, request.dimension)

    return EmbedResponse(
        embeddings=vectors,
        dimension=len(vectors[0]) if vectors else 0,
        count=len(vectors),
        model=MODEL_ID,
        elapsed_ms=round((time.time() - started) * 1000, 2),
    )


@app.post("/embed_text", response_model=EmbedResponse)
async def embed_text(
    request: EmbedTextRequest, _: Annotated[None, Header()] = None
) -> EmbedResponse:
    await require_token(_)
    _require_model()
    started = time.time()

    vectors = embed_texts_gpu(request.texts, request.is_query, request.batch_size)
    vectors = _truncate(vectors, request.dimension)

    return EmbedResponse(
        embeddings=vectors,
        dimension=len(vectors[0]) if vectors else 0,
        count=len(vectors),
        model=MODEL_ID,
        elapsed_ms=round((time.time() - started) * 1000, 2),
    )


@app.get("/")
async def root() -> dict[str, object]:
    return {
        "service": "VisualOps Embedding Server (ZeroGPU)",
        "model": MODEL_ID,
        "dimension": _DIMENSION,
        "endpoints": ["/health", "/embed", "/embed_text"],
    }


if __name__ == "__main__":
    app.launch(show_error=True)