SmartDataAlex commited on
Commit
ee4a727
·
verified ·
1 Parent(s): ab5235b

Restore Space from backup SmartDataAlex-Jupyter-c7b4fea26ef24db6722dbe9f25079b9021943a46.tar.gz

Browse files
.dockerignore CHANGED
@@ -8,5 +8,4 @@
8
  backup*
9
  tests
10
  scripts/*
11
- !scripts/download_model.py
12
- !scripts/bench_local.py
 
8
  backup*
9
  tests
10
  scripts/*
11
+ !scripts/download_model.py
 
.github/workflows/ci-deploy.yml ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Test, backup, deploy and verify
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ workflow_dispatch:
7
+
8
+ permissions:
9
+ contents: read
10
+
11
+ concurrency:
12
+ group: vectorization-hf-space
13
+ cancel-in-progress: false
14
+
15
+ jobs:
16
+ unit-tests:
17
+ runs-on: ubuntu-latest
18
+ steps:
19
+ - uses: actions/checkout@v4
20
+ - uses: actions/setup-python@v5
21
+ with:
22
+ python-version: '3.11'
23
+ cache: pip
24
+ - name: Install CPU-only dependencies
25
+ run: |
26
+ python -m pip install --upgrade pip
27
+ python -m pip install torch==2.6.0 --index-url https://download.pytorch.org/whl/cpu
28
+ python -m pip install -r requirements-dev.txt
29
+ - name: Test validation and authorization
30
+ run: pytest -q tests/test_app.py --junitxml=unit-results.xml
31
+ - uses: actions/upload-artifact@v4
32
+ if: always()
33
+ with:
34
+ name: unit-test-results
35
+ path: unit-results.xml
36
+ retention-days: 90
37
+
38
+ backup-and-deploy:
39
+ needs: unit-tests
40
+ if: github.ref == 'refs/heads/main' && vars.HF_DEPLOY_ENABLED == 'true'
41
+ runs-on: ubuntu-latest
42
+ timeout-minutes: 70
43
+ env:
44
+ HF_TOKEN: ${{ secrets.HF_TOKEN }}
45
+ API_TEST_KEY: ${{ secrets.API_TEST_KEY }}
46
+ SPACE_URL: https://smartdataalex-jupyter.hf.space
47
+ EXPECTED_SHA: ${{ github.sha }}
48
+ steps:
49
+ - uses: actions/checkout@v4
50
+ - uses: actions/setup-python@v5
51
+ with:
52
+ python-version: '3.11'
53
+ - run: python -m pip install 'huggingface-hub==0.30.2' 'httpx==0.28.1'
54
+ - name: Verify deployment credentials exist
55
+ run: |
56
+ test -n "$HF_TOKEN" || { echo 'Missing HF_TOKEN GitHub secret'; exit 1; }
57
+ test -n "$API_TEST_KEY" || { echo 'Missing API_TEST_KEY GitHub secret'; exit 1; }
58
+ - name: Backup existing Space before any modification
59
+ run: python scripts/backup_space.py
60
+ - name: Upload original Space snapshot
61
+ uses: actions/upload-artifact@v4
62
+ with:
63
+ name: space-backup-${{ github.run_id }}
64
+ path: ${{ runner.temp }}/hf-space-backup/
65
+ retention-days: 90
66
+ if-no-files-found: error
67
+ - name: Set release identifier
68
+ run: echo "$GITHUB_SHA" > VERSION
69
+ - name: Deploy backed-up revision to Hugging Face Docker Space
70
+ run: python scripts/deploy_space.py
71
+ - name: Smoke-test actual published API (must match this commit)
72
+ run: python scripts/smoke_live.py
73
+ - name: Upload deployment test results
74
+ if: always()
75
+ uses: actions/upload-artifact@v4
76
+ with:
77
+ name: live-api-results-${{ github.run_id }}
78
+ path: smoke-result.json
79
+ if-no-files-found: ignore
80
+ retention-days: 90
.gitignore ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ .pytest_cache/
3
+ .venv/
4
+ .env
5
+ *.pyc
6
+ backup*/
7
+ smoke-result.json
Dockerfile CHANGED
@@ -1,26 +1,31 @@
1
  FROM python:3.11-slim
 
2
  ENV PYTHONDONTWRITEBYTECODE=1 \
3
  PYTHONUNBUFFERED=1 \
4
  PIP_NO_CACHE_DIR=1 \
5
  E5_MODEL_DIR=/opt/models/multilingual-e5-small \
6
- QWEN_MODEL_DIR=/opt/models/qwen3-embedding-0.6b \
7
- COLBERT_MODEL_DIR=/opt/models/mlateon \
8
- MEMORY_BUDGET_MB=15360 \
9
- CPU_THREADS=2 \
10
- OMP_NUM_THREADS=2 \
11
- TOKENIZERS_PARALLELISM=false
12
  WORKDIR /service
 
13
  RUN apt-get update && apt-get install -y --no-install-recommends libgomp1 \
14
  && rm -rf /var/lib/apt/lists/*
15
- RUN pip install --no-cache-dir torch==2.8.0 --index-url https://download.pytorch.org/whl/cpu
16
- COPY requirements.txt ./
 
 
17
  RUN pip install --no-cache-dir -r requirements.txt
 
 
18
  COPY scripts/download_model.py ./scripts/download_model.py
19
- COPY scripts/bench_local.py ./scripts/bench_local.py
20
  RUN python scripts/download_model.py
 
21
  COPY app ./app
22
  COPY VERSION ./VERSION
 
 
23
  ENV HF_HUB_OFFLINE=1 TRANSFORMERS_OFFLINE=1
 
24
  RUN useradd --uid 1000 --create-home appuser
25
  USER appuser
26
  EXPOSE 7860
 
1
  FROM python:3.11-slim
2
+
3
  ENV PYTHONDONTWRITEBYTECODE=1 \
4
  PYTHONUNBUFFERED=1 \
5
  PIP_NO_CACHE_DIR=1 \
6
  E5_MODEL_DIR=/opt/models/multilingual-e5-small \
7
+ CPU_THREADS=2
8
+
 
 
 
 
9
  WORKDIR /service
10
+
11
  RUN apt-get update && apt-get install -y --no-install-recommends libgomp1 \
12
  && rm -rf /var/lib/apt/lists/*
13
+
14
+ # Explicit CPU-only PyTorch wheel; no CUDA or GPU required.
15
+ RUN pip install --no-cache-dir torch==2.6.0 --index-url https://download.pytorch.org/whl/cpu
16
+ COPY requirements.txt .
17
  RUN pip install --no-cache-dir -r requirements.txt
18
+
19
+ # Download only one copy of weights while building (immutable model revision).
20
  COPY scripts/download_model.py ./scripts/download_model.py
 
21
  RUN python scripts/download_model.py
22
+
23
  COPY app ./app
24
  COPY VERSION ./VERSION
25
+
26
+ # CPU inference is fully local at runtime.
27
  ENV HF_HUB_OFFLINE=1 TRANSFORMERS_OFFLINE=1
28
+
29
  RUN useradd --uid 1000 --create-home appuser
30
  USER appuser
31
  EXPOSE 7860
README.md CHANGED
@@ -8,147 +8,89 @@ app_port: 7860
8
  pinned: false
9
  ---
10
 
11
- # Vectorization API — three resident models
12
 
13
- CPU-only FastAPI inference service. **All three models load once, during startup**, and remain resident. Inference never loads model weights. No search, Qdrant, Manticore, reranking or Entity Linking.
14
 
15
- | Task | Model | Max tokens | Output |
16
- | --- | --- | ---: | --- |
17
- | Standard | `intfloat/multilingual-e5-small` | 512 | 384D |
18
- | Literature | `Qwen/Qwen3-Embedding-0.6B` | 32768 | 1024D |
19
- | ColBERT | `lightonai/mLateOn` | 8192 | 128D per token |
20
 
21
- ## API
 
 
 
22
 
23
- Legacy endpoints require `X-API-Key`; Voyage-compatible `/v1/embeddings` uses `Authorization: Bearer <API_KEY>`. Public endpoints: `/health`, `/docs` and `/openapi.json`.
24
-
25
- - `POST /embed`: `{"text":"Your text","type":"query"}` defaults to E5. The optional `model` accepts `e5`, `qwen`, or the corresponding full model name. The existing E5 response fields are unchanged: `text`, `type`, `tokens_count`, `embedding_model`, `dimensions`, `embedding`.
26
- - `POST /colbert/encode`: `{"text":"Your text","type":"passage"}` returns `embedding_model`, `tokens_count`, `dimensions` and `token_embeddings` (matrix of individual 128D vectors).
27
- - `GET /models`: readiness, dimensions, limits, normalization method, current/peak RSS and startup RAM readings for all models.
28
- - `GET /model`: original E5-only metadata contract.
29
- - `GET /health`: returns 200 only when all three models have completed startup.
30
-
31
- Example (replace URL and key):
32
 
33
  ```bash
34
- curl -sS -X POST 'http://localhost:7860/embed' \
35
- -H 'X-API-Key: YOUR_API_KEY' -H 'Content-Type: application/json' \
36
- -d '{"text":"What does the author mean?","type":"query","model":"qwen"}'
37
-
38
- curl -sS -X POST 'http://localhost:7860/colbert/encode' \
39
- -H 'X-API-Key: YOUR_API_KEY' -H 'Content-Type: application/json' \
40
- -d '{"text":"שלום עולם","type":"passage"}'
41
  ```
42
 
43
- E5 keeps `query: ` / `passage: ` prefixes, attention-mask-aware mean pooling, **no extra L2 normalization**. Qwen uses its official query instruction + last-token pooling + L2 normalization; documents have no instruction. PyLate mLateOn handles query and passage separately, retaining token vectors and their model-native normalization. Token limits are checked *including model markers and special tokens*, without silent truncation. Exceeding limits or using invalid input returns HTTP 422. When the shared inference gate is occupied, requests return HTTP 429 with `Retry-After`.
 
 
 
 
 
 
 
 
 
 
 
44
 
45
- ## Official Voyage AI SDK compatibility
46
 
47
- The **unmodified Voyage Python client** can talk directly to our service.
48
- The base URL includes `/v1` (not `/v1/embeddings`):
49
 
50
- ```python
51
- import voyageai
52
 
53
- client = voyageai.Client(
54
- api_key="YOUR_API_KEY",
55
- base_url="https://YOUR-SPACE.hf.space/v1",
56
- )
57
 
58
- documents = client.embed(
59
- ["Первый чанк", "Второй чанк"],
60
- model="e5", # or "qwen"
61
- input_type="document",
62
- truncation=False,
63
- )
64
- print(documents.embeddings) # list of 384D or 1024D vectors
65
- print(documents.total_tokens)
66
 
67
- query = client.embed(
68
- ["Что происходило в книге?"],
69
- model="qwen",
70
- input_type="query",
71
- )
72
- ```
73
 
74
- The official client sends `POST /v1/embeddings` with Bearer authorization,
75
- `input`, `model`, `input_type`, `truncation`, and optional output settings.
76
- Our response implements Voyage's standard `object`/`data`/`model`/`usage`
77
- envelope; the SDK's `EmbeddingsObject.embeddings` and `total_tokens` work.
78
-
79
- Model names: `e5`, `qwen`, `colbert` (or the corresponding full Hugging Face IDs).
80
- `input_type="query"` selects query instructions, `input_type="document"` selects
81
- document/passage encoding; an omitted type defaults to document.
82
- A single string or a list of 1–1000 strings is accepted.
83
-
84
- `model="colbert"` is a **non-standard multi-vector extension**:
85
- the standard Voyage client will expose `embeddings` as a 3-D array
86
- `[document][token][128]`. This is not interchangeable with regular
87
- Voyage dense embeddings or dense-only vector database integrations.
88
-
89
- All three models are already loaded in RAM. Batch calls use **actual model
90
- batch inference** where possible: E5 and Qwen run padded tensor batches,
91
- and mLateOn uses PyLate's batch encoding. The server partitions requests
92
- by padded token cost to protect 16-GiB CPU memory. Requests are limited to
93
- 65,536 aggregate tokens for dense vectors or 32,768 for ColBERT.
94
- Requests beyond these limits return HTTP 422 rather than truncating content;
95
- even `truncation=True` from Voyage's default SDK settings cannot override
96
- our no-silent-truncation policy. Only float output and the fixed model
97
- dimensions are supported; quantization, changing dimensions, and Voyage's
98
- other API families are not implemented.
99
-
100
- Unmodified-SDK integration smoke test:
101
 
102
  ```bash
103
- pip install voyageai==0.5.0
104
- SPACE_URL=http://localhost:7860 API_TEST_KEY='your-strong-api-key' \
105
- python scripts/smoke_voyage_sdk.py
106
- ```
107
-
108
- ## Deployment, RAM and rollback
109
-
110
- Docker downloads immutable checkpoints at image build, and runtime operates offline. Startup fails if any of the three models cannot be loaded or exceeds the configured resident memory budget. The default `MEMORY_BUDGET_MB=15360` reserves memory within a nominal 16-GiB container. Run exactly **one** Uvicorn worker and one concurrent inference.
111
-
112
- ```bash
113
- docker build -t vectorization:three-models .
114
- docker run --rm -p 7860:7860 --memory 16g \
115
- -e API_KEY='your-strong-api-key' vectorization:three-models
116
  ```
117
 
118
- 16 GiB RAM is a target, **not verified sufficient** for 32768-token Qwen inference. Validate longest contexts on the target server, including peak RSS, CPU time and stability. Do not lower the advertised token limits if tests fail.
119
-
120
- ### Tests
121
 
122
  ```bash
123
- python -m pip install torch==2.8.0 --index-url https://download.pytorch.org/whl/cpu
124
- python -m pip install -r requirements-dev.txt
125
- pytest -q tests/test_app.py --junitxml=unit-results.xml
126
-
127
- SPACE_URL=http://localhost:7860 API_TEST_KEY='your-strong-api-key' \
128
- python scripts/smoke_live.py
129
  ```
130
 
131
- Unit tests stub model weights. Only a real Space smoke test demonstrates end-to-end inference. For separate 16-GiB RAM/CPU benchmark runs using the downloaded Docker model weights:
132
 
133
  ```bash
134
- docker run --rm --memory 16g --entrypoint python -e BENCH_LONG=1 \
135
- -v "$PWD/reports:/service/reports" \
136
- -e BENCH_REPORT=/service/reports/bench-result.json \
137
- vectorization:three-models scripts/bench_local.py
138
  ```
139
 
140
- The benchmark loads all three models at startup and saves timings plus process RSS after every case.
141
-
142
- ### Preserve the existing Space
143
 
144
- GitHub Actions runs unit tests on PRs and pushes. **Automatic production deployment from `main` is opt-in:** set the GitHub repository variable `HF_AUTO_DEPLOY_ENABLED=true` and secrets `GIT_TOKEN` plus `API_TEST_KEY`. After a successful test run on each new commit to `main`, Actions backs up the current Space, deploys with commit/revision protection, and runs the old API and official Voyage SDK smoke tests. If a post-deployment smoke check fails, the workflow attempts a revision-guarded automatic restore from the backup. Manual deployment is also available from `main` through `workflow_dispatch` (`deploy=true`) with `HF_DEPLOY_ENABLED=true`. Commits to feature branches and PRs never deploy the production Space. Production runs and PR runs use separate concurrency groups, so a PR update cannot interrupt a deployment. The existing workflow first makes a complete backup of `SmartDataAlex/Jupyter`, uploads it as a CI artifact, deploys only if the Space revision matches the backup and runs real authenticated smoke tests. If the backup fails, deployment is blocked.
145
 
146
- Manual rollback using a saved backup:
147
 
148
  ```bash
149
- HF_TOKEN='space-scoped-write-token' python scripts/restore_space.py \
150
  SmartDataAlex-Jupyter-<revision>.tar.gz \
151
  --confirm-space SmartDataAlex/Jupyter
152
  ```
153
 
154
- Store a permanent backup separately; GitHub Actions artifacts expire.
 
8
  pinned: false
9
  ---
10
 
11
+ # Vectorization API
12
 
13
+ CPU-only FastAPI embedding service for `intfloat/multilingual-e5-small` (384 dimensions).
14
 
15
+ ## Endpoints
 
 
 
 
16
 
17
+ - `POST /embed` (requires `X-API-Key`) takes `{ "text": "Example text", "type": "query" }` or type `passage`.
18
+ - `GET /model` (requires `X-API-Key`) returns metadata, including dimensions and maximum token count.
19
+ - `GET /health` is public, and returns ready only after the model has loaded.
20
+ - `GET /docs` is public Swagger UI. Click **Authorize** and enter the API key to call protected endpoints.
21
 
22
+ Example:
 
 
 
 
 
 
 
 
23
 
24
  ```bash
25
+ curl -fsS -X POST 'https://smartdataalex-jupyter.hf.space/embed' \
26
+ -H 'Content-Type: application/json' \
27
+ -H 'X-API-Key: YOUR_API_KEY' \
28
+ -d '{"text":"How to find similar documents?","type":"query"}'
 
 
 
29
  ```
30
 
31
+ Sample response structure (embedding values are illustrative, complete real response contains 384 floats):
32
+
33
+ ```json
34
+ {
35
+ "text": "How to find similar documents?",
36
+ "type": "query",
37
+ "tokens_count": 10,
38
+ "embedding_model": "intfloat/multilingual-e5-small",
39
+ "dimensions": 384,
40
+ "embedding": [0.12, -0.03]
41
+ }
42
+ ```
43
 
44
+ The service adds `query: ` / `passage: ` according to `type`, uses the model's own tokenizer and attention-mask-aware mean pooling. `tokens_count` includes the prefix and tokenizer's special tokens. Inputs over 512 tokens, including prefix and special tokens, get HTTP 422; inputs are **never silently truncated**. Input length is limited to 4096 characters as an additional resource guard.
45
 
46
+ **No extra L2 normalization** is applied by the service. Configure your Qdrant vector collection with dimension `384` and distance `Cosine`; Qdrant will normalize stored vectors. No database, search, or document storage is implemented here.
 
47
 
48
+ ## Secrets and one-time deployment setup
 
49
 
50
+ 1. In **Hugging Face → SmartDataAlex/Jupyter → Settings → Secrets**, set `API_KEY` to a long, random, private value. Secrets are exposed to Docker as environment variables. Never commit this value.
51
+ 2. In GitHub repository secrets, set `HF_TOKEN` to a **fine-grained Hugging Face write token** restricted to the existing Space, and `API_TEST_KEY` to the *same* API key value (for post-deploy authenticated smoke tests).
52
+ 3. After verifying GitHub is connected to this repository and that you are ready for the Space's contents to be replaced, add GitHub repository variable `HF_DEPLOY_ENABLED` with value `true`.
53
+ 4. Each subsequent push to `main` tests source code, **backs up the current Space first**, deploys using a guarded Hugging Face Hub commit, and tests the deployed public HTTPS API. CI uploads JSON test results and the pre-deploy backup as GitHub Actions artifacts. If backup fails, deployment does **not** run.
54
 
55
+ **Important:** deployment replaces Space files after successful backup. It also refuses to overwrite the Space if its revision changed after the backup. This workflow intentionally does not deploy without `HF_DEPLOY_ENABLED=true`. Backups are retained as GitHub Actions artifacts for 90 days. Keep an additional durable copy of the initial backup before deleting old workflow artifacts; the Space's Git history also provides rollback revisions.
 
 
 
 
 
 
 
56
 
57
+ No additional `API_KEY` parameter is added to the JSON request body. All API key checks happen via the `X-API-Key` request header. If the runtime secret is missing, the application fails to start rather than permitting anonymous access.
 
 
 
 
 
58
 
59
+ ## Local development
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
 
61
  ```bash
62
+ # CPU PyTorch: install per https://pytorch.org/get-started/locally/
63
+ python -m pip install -r requirements-dev.txt
64
+ # Download the pinned model into E5_MODEL_DIR (Docker does this at build time)
65
+ export API_KEY='local-only-long-random-value'
66
+ export E5_MODEL_DIR='/path/to/downloaded/model'
67
+ uvicorn app.main:app --host 127.0.0.1 --port 7860
 
 
 
 
 
 
 
68
  ```
69
 
70
+ Unit tests do not download model weights:
 
 
71
 
72
  ```bash
73
+ pytest -q tests/test_app.py
 
 
 
 
 
74
  ```
75
 
76
+ Post-deploy check:
77
 
78
  ```bash
79
+ SPACE_URL='https://smartdataalex-jupyter.hf.space' \
80
+ API_TEST_KEY='your-key' \
81
+ python scripts/smoke_live.py
 
82
  ```
83
 
84
+ The actual Space URL must be verified once the Space is published.
 
 
85
 
86
+ ## Rollback
87
 
88
+ Retrieve the `space-backup-<run_id>` artifact from a previous GitHub Actions run, unzip it to obtain `SmartDataAlex-Jupyter-<revision>.tar.gz`, and run:
89
 
90
  ```bash
91
+ HF_TOKEN='your-space-scoped-write-token' python scripts/restore_space.py \
92
  SmartDataAlex-Jupyter-<revision>.tar.gz \
93
  --confirm-space SmartDataAlex/Jupyter
94
  ```
95
 
96
+ This operation explicitly overwrites the current Space content to restore the saved files; it does not run automatically after a failed smoke test, because automatic rollback could mask a deployment failure. Secrets managed in Hugging Face Space Settings are stored separately from the repository and are not contained in the archive.
VERSION CHANGED
@@ -1 +1 @@
1
- f2db93bf98da4215ba2bca5a47adf4565ba5e739
 
1
+ development
app/batching.py DELETED
@@ -1,133 +0,0 @@
1
- """True token-budgeted batched inference with resident models.
2
-
3
- The external service-wide inference gate is held by ModelRegistry for the full call.
4
- No model initialization, truncation, or extra E5 normalization occurs here.
5
- """
6
- from __future__ import annotations
7
-
8
- from app.embedding import EmbeddingResult, TooManyTokens
9
- from app.models import ColbertResult, validate_vector, INSTRUCTION
10
-
11
-
12
- def _split_by_padding_cost(counts, token_budget, max_size):
13
- """Partition ordered inputs, accounting for batch padding to longest text."""
14
- groups = []
15
- current = []
16
- current_max = 0
17
- for index, count in enumerate(counts):
18
- proposed_max = max(current_max, count)
19
- if current and (len(current) >= max_size or
20
- proposed_max * (len(current) + 1) > token_budget):
21
- groups.append(current)
22
- current = []
23
- current_max = 0
24
- current.append(index)
25
- current_max = max(current_max, count)
26
- if current:
27
- groups.append(current)
28
- return groups
29
-
30
-
31
- def _token_count(tokenizer, text):
32
- encoded = tokenizer(text, add_special_tokens=True, truncation=False)
33
- return len(encoded["input_ids"])
34
-
35
-
36
- def _dense_batch(engine, texts, kind, is_qwen):
37
- if is_qwen:
38
- strings = [
39
- f"Instruct: {INSTRUCTION}\nQuery:{text}" if kind == "query" else text
40
- for text in texts
41
- ]
42
- # Smaller padded batches limit CPU activation memory; single long
43
- # documents are still allowed up to the model's advertised context.
44
- budget, max_size = 4096, 16
45
- else:
46
- strings = [f"{kind}: {text}" for text in texts]
47
- budget, max_size = 8192, 64
48
-
49
- counts = [_token_count(engine.tokenizer, text) for text in strings]
50
- for count in counts:
51
- if count > engine.max_tokens:
52
- raise TooManyTokens(count, engine.max_tokens)
53
- # Bound total work in one HTTP request (not the model context limit).
54
- if sum(counts) > 65536:
55
- raise ValueError("Batch exceeds 65536 total input tokens")
56
-
57
- results = [None] * len(texts)
58
- for group in _split_by_padding_cost(counts, budget, max_size):
59
- inputs = [strings[i] for i in group]
60
- batch = engine.tokenizer(
61
- inputs, return_tensors="pt", padding=True,
62
- truncation=False, add_special_tokens=True,
63
- )
64
- with engine.torch.inference_mode():
65
- hidden = engine.model(**batch).last_hidden_state
66
- if is_qwen:
67
- # Qwen tokenizer is explicitly left padded. The final index
68
- # therefore refers to the final real token in every row.
69
- vectors = engine.torch.nn.functional.normalize(
70
- hidden[:, -1, :], p=2, dim=1,
71
- )
72
- else:
73
- mask = batch["attention_mask"].unsqueeze(-1)
74
- vectors = (hidden * mask).sum(dim=1) / mask.sum(dim=1).clamp(min=1)
75
-
76
- vectors = vectors.float().cpu().tolist()
77
-
78
- for index, vector in zip(group, vectors):
79
- validate_vector(vector, engine.dimensions)
80
- results[index] = EmbeddingResult(
81
- tokens_count=counts[index], embedding=vector,
82
- )
83
- return results
84
-
85
-
86
- def _colbert_batch(engine, texts, kind):
87
- model = engine.model
88
- query = kind == "query"
89
- prefix = model.query_prefix if query else model.document_prefix
90
- counts = [_token_count(model.tokenizer, prefix + text) for text in texts]
91
- for count in counts:
92
- if count > engine.max_tokens:
93
- raise TooManyTokens(count, engine.max_tokens)
94
- if sum(counts) > 32768:
95
- raise ValueError("ColBERT request exceeds 32768 total input tokens")
96
-
97
- results = [None] * len(texts)
98
- for group in _split_by_padding_cost(counts, 4096, 8):
99
- if query:
100
- model.query_length = max(32, max(counts[i] for i in group))
101
- outputs = model.encode(
102
- [texts[i] for i in group],
103
- is_query=query, batch_size=len(group), show_progress_bar=False,
104
- )
105
- if len(outputs) != len(group):
106
- raise RuntimeError("Unexpected PyLate batch length")
107
- for index, matrix in zip(group, outputs):
108
- if hasattr(matrix, "detach"):
109
- matrix = matrix.detach().float().cpu()
110
- if hasattr(matrix, "tolist"):
111
- matrix = matrix.tolist()
112
- vectors = [[float(value) for value in row] for row in matrix]
113
- if not vectors or len(vectors) > engine.max_tokens:
114
- raise RuntimeError("Unexpected PyLate output token count")
115
- for vector in vectors:
116
- validate_vector(vector, engine.dimensions)
117
- # Note: encoded tokens and emitted token vectors may differ
118
- # because PyLate can filter special/padding tokens.
119
- results[index] = ColbertResult(
120
- tokens_count=counts[index], token_embeddings=vectors,
121
- )
122
- return results
123
-
124
-
125
- def batch_encode(engine, texts, kind, is_qwen=False, is_colbert=False):
126
- """Native tensor batch for real models, single-item fallback for test doubles."""
127
- if is_colbert:
128
- if not hasattr(engine, "model") or not hasattr(engine.model, "tokenizer"):
129
- return [engine.encode(text, kind) for text in texts]
130
- return _colbert_batch(engine, texts, kind)
131
- if not hasattr(engine, "tokenizer") or not hasattr(engine, "model"):
132
- return [engine.encode(text, kind) for text in texts]
133
- return _dense_batch(engine, texts, kind, is_qwen)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/main.py CHANGED
@@ -1,9 +1,12 @@
1
- """FastAPI HTTP-only vectorization service."""
 
 
 
2
  import logging
3
  import os
4
  import secrets
5
- from pathlib import Path
6
  from contextlib import asynccontextmanager
 
7
  from typing import Literal
8
 
9
  from fastapi import Depends, FastAPI, HTTPException, Security
@@ -11,66 +14,51 @@ from fastapi.responses import RedirectResponse
11
  from fastapi.security import APIKeyHeader
12
  from pydantic import BaseModel, Field, field_validator
13
 
14
- from app.embedding import MODEL_ID, TooManyTokens
15
- from app.models import (
16
- ModelRegistry, QWEN_ID, COLBERT_ID, ServiceBusy, memory_mb,
 
17
  )
18
 
19
- log = logging.getLogger(__name__)
20
  api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
21
- EmbeddingModel = Literal[
22
- "e5", "qwen", "intfloat/multilingual-e5-small", "Qwen/Qwen3-Embedding-0.6B"
23
- ]
24
 
25
 
26
- def deployment_version():
27
  path = Path(__file__).resolve().parent.parent / "VERSION"
28
  return path.read_text(encoding="utf-8").strip() if path.is_file() else "development"
29
 
30
 
31
  @asynccontextmanager
32
- async def lifespan(application: FastAPI):
 
33
  if not os.getenv("API_KEY"):
34
- raise RuntimeError("API_KEY must be configured")
35
- # All three models become resident before health becomes ready.
36
- application.state.registry = ModelRegistry.load()
37
- try:
38
- yield
39
- finally:
40
- application.state.registry = None
41
-
42
 
43
- from app.voyage_api import router as voyage_router
44
 
45
  app = FastAPI(
46
  title="Vectorization API",
47
- version="2.0.0",
48
- description="Resident E5, Qwen3 and mLateOn CPU embedding API.",
49
  lifespan=lifespan,
50
  )
51
 
52
 
53
- app.include_router(voyage_router)
54
-
55
-
56
- class TextRequest(BaseModel):
57
- text: str = Field(min_length=1, description="Full input, no silent truncation")
58
- type: Literal["query", "passage"] = Field(examples=["query"])
59
 
60
  @field_validator("text")
61
  @classmethod
62
- def nonblank(cls, text):
63
  if not text.strip():
64
  raise ValueError("Text must not be blank")
65
  return text
66
 
67
 
68
- class EmbedRequest(TextRequest):
69
- model: EmbeddingModel | None = Field(
70
- default=None, examples=["qwen"], description="E5 by default"
71
- )
72
-
73
-
74
  class EmbedResponse(BaseModel):
75
  text: str
76
  type: Literal["query", "passage"]
@@ -80,13 +68,6 @@ class EmbedResponse(BaseModel):
80
  embedding: list[float]
81
 
82
 
83
- class ColbertResponse(BaseModel):
84
- embedding_model: str
85
- tokens_count: int
86
- dimensions: int
87
- token_embeddings: list[list[float]]
88
-
89
-
90
  class ModelResponse(BaseModel):
91
  embedding_model: str
92
  dimensions: int
@@ -95,31 +76,21 @@ class ModelResponse(BaseModel):
95
  version: str
96
 
97
 
98
- def require_api_key(provided: str | None = Security(api_key_header)):
99
  expected = os.getenv("API_KEY")
100
  if not expected or provided is None or not secrets.compare_digest(provided, expected):
101
- raise HTTPException(status_code=401, detail="Invalid or missing API key",
102
- headers={"WWW-Authenticate": "ApiKey"})
103
-
 
 
104
 
105
- def get_registry():
106
- registry = getattr(app.state, "registry", None)
107
- if registry is None:
108
- raise HTTPException(status_code=503, detail="Models are not ready")
109
- return registry
110
 
111
-
112
- def guard(callback):
113
- try:
114
- return callback()
115
- except TooManyTokens as exc:
116
- raise HTTPException(status_code=422, detail=str(exc)) from exc
117
- except ServiceBusy as exc:
118
- raise HTTPException(status_code=429, detail=str(exc),
119
- headers={"Retry-After": "2"}) from exc
120
- except Exception as exc:
121
- log.exception("Inference failed")
122
- raise HTTPException(status_code=500, detail="Inference failed") from exc
123
 
124
 
125
  @app.get("/", include_in_schema=False)
@@ -129,59 +100,35 @@ def root():
129
 
130
  @app.get("/health")
131
  def health():
132
- if getattr(app.state, "registry", None) is None:
133
- raise HTTPException(status_code=503, detail="Models are not ready")
134
  return {"status": "ok", "version": deployment_version()}
135
 
136
 
137
  @app.get("/model", response_model=ModelResponse, dependencies=[Depends(require_api_key)])
138
- def model_info(registry=Depends(get_registry)):
139
  return ModelResponse(
140
- embedding_model=MODEL_ID, dimensions=registry.e5.dimensions,
141
- max_input_tokens=registry.e5.max_tokens, version=deployment_version(),
 
 
142
  )
143
 
144
 
145
- @app.get("/models", dependencies=[Depends(require_api_key)])
146
- def models_info(registry=Depends(get_registry)):
147
- return {
148
- "version": deployment_version(),
149
- "models": [
150
- {"embedding_model": MODEL_ID, "purpose": "standard",
151
- "dimensions": registry.e5.dimensions,
152
- "max_input_tokens": registry.e5.max_tokens,
153
- "normalization": "none", "ready": True},
154
- {"embedding_model": QWEN_ID, "purpose": "literature",
155
- "dimensions": registry.qwen.dimensions,
156
- "max_input_tokens": registry.qwen.max_tokens,
157
- "normalization": "l2", "ready": True},
158
- {"embedding_model": COLBERT_ID, "purpose": "colbert",
159
- "dimensions": registry.colbert.dimensions,
160
- "max_input_tokens": registry.colbert.max_tokens,
161
- "normalization": "model-native", "ready": True},
162
- ],
163
- "resources": memory_mb(),
164
- "startup_memory": registry.startup_memory,
165
- }
166
-
167
-
168
- @app.post("/embed", response_model=EmbedResponse,
169
- dependencies=[Depends(require_api_key)])
170
- def embed(payload: EmbedRequest, registry=Depends(get_registry)):
171
- identifier, dimensions, result = guard(
172
- lambda: registry.embed(payload.text, payload.type, payload.model)
173
- )
174
  return EmbedResponse(
175
- text=payload.text, type=payload.type, tokens_count=result.tokens_count,
176
- embedding_model=identifier, dimensions=dimensions, embedding=result.embedding,
177
- )
178
-
179
-
180
- @app.post("/colbert/encode", response_model=ColbertResponse,
181
- dependencies=[Depends(require_api_key)])
182
- def colbert_encode(payload: TextRequest, registry=Depends(get_registry)):
183
- result = guard(lambda: registry.colbert_encode(payload.text, payload.type))
184
- return ColbertResponse(
185
- embedding_model=COLBERT_ID, dimensions=registry.colbert.dimensions,
186
- tokens_count=result.tokens_count, token_embeddings=result.token_embeddings,
187
  )
 
1
+ """HTTP service: authenticate, validate, and generate embeddings only."""
2
+
3
+ from __future__ import annotations
4
+
5
  import logging
6
  import os
7
  import secrets
 
8
  from contextlib import asynccontextmanager
9
+ from pathlib import Path
10
  from typing import Literal
11
 
12
  from fastapi import Depends, FastAPI, HTTPException, Security
 
14
  from fastapi.security import APIKeyHeader
15
  from pydantic import BaseModel, Field, field_validator
16
 
17
+ from app.embedding import (
18
+ MODEL_ID,
19
+ EmbeddingEngine,
20
+ TooManyTokens,
21
  )
22
 
23
+ logger = logging.getLogger(__name__)
24
  api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
 
 
 
25
 
26
 
27
+ def deployment_version() -> str:
28
  path = Path(__file__).resolve().parent.parent / "VERSION"
29
  return path.read_text(encoding="utf-8").strip() if path.is_file() else "development"
30
 
31
 
32
  @asynccontextmanager
33
+ async def lifespan(app: FastAPI):
34
+ # Fail closed: an unset secret must never expose unauthenticated inference.
35
  if not os.getenv("API_KEY"):
36
+ raise RuntimeError("API_KEY must be configured in Hugging Face Space Secrets")
37
+ app.state.engine = EmbeddingEngine.load()
38
+ yield
39
+ app.state.engine = None
 
 
 
 
40
 
 
41
 
42
  app = FastAPI(
43
  title="Vectorization API",
44
+ version="1.0.0",
45
+ description="CPU embedding service backed by intfloat/multilingual-e5-small.",
46
  lifespan=lifespan,
47
  )
48
 
49
 
50
+ class EmbedRequest(BaseModel):
51
+ text: str = Field(min_length=1, max_length=4096)
52
+ type: Literal["query", "passage"]
 
 
 
53
 
54
  @field_validator("text")
55
  @classmethod
56
+ def reject_blank_text(cls, text: str) -> str:
57
  if not text.strip():
58
  raise ValueError("Text must not be blank")
59
  return text
60
 
61
 
 
 
 
 
 
 
62
  class EmbedResponse(BaseModel):
63
  text: str
64
  type: Literal["query", "passage"]
 
68
  embedding: list[float]
69
 
70
 
 
 
 
 
 
 
 
71
  class ModelResponse(BaseModel):
72
  embedding_model: str
73
  dimensions: int
 
76
  version: str
77
 
78
 
79
+ def require_api_key(provided: str | None = Security(api_key_header)) -> None:
80
  expected = os.getenv("API_KEY")
81
  if not expected or provided is None or not secrets.compare_digest(provided, expected):
82
+ raise HTTPException(
83
+ status_code=401,
84
+ detail="Invalid or missing API key",
85
+ headers={"WWW-Authenticate": "ApiKey"},
86
+ )
87
 
 
 
 
 
 
88
 
89
+ def get_engine() -> EmbeddingEngine:
90
+ engine = getattr(app.state, "engine", None)
91
+ if engine is None:
92
+ raise HTTPException(status_code=503, detail="Model is not ready")
93
+ return engine
 
 
 
 
 
 
 
94
 
95
 
96
  @app.get("/", include_in_schema=False)
 
100
 
101
  @app.get("/health")
102
  def health():
103
+ if getattr(app.state, "engine", None) is None:
104
+ raise HTTPException(status_code=503, detail="Model is not ready")
105
  return {"status": "ok", "version": deployment_version()}
106
 
107
 
108
  @app.get("/model", response_model=ModelResponse, dependencies=[Depends(require_api_key)])
109
+ def model_info(engine: EmbeddingEngine = Depends(get_engine)) -> ModelResponse:
110
  return ModelResponse(
111
+ embedding_model=MODEL_ID,
112
+ dimensions=engine.dimensions,
113
+ max_input_tokens=engine.max_tokens,
114
+ version=deployment_version(),
115
  )
116
 
117
 
118
+ @app.post("/embed", response_model=EmbedResponse, dependencies=[Depends(require_api_key)])
119
+ def embed(payload: EmbedRequest, engine: EmbeddingEngine = Depends(get_engine)) -> EmbedResponse:
120
+ try:
121
+ result = engine.encode(payload.text, payload.type)
122
+ except TooManyTokens as exc:
123
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
124
+ except Exception as exc:
125
+ logger.exception("Embedding generation failed")
126
+ raise HTTPException(status_code=500, detail="Embedding generation failed") from exc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
127
  return EmbedResponse(
128
+ text=payload.text,
129
+ type=payload.type,
130
+ tokens_count=result.tokens_count,
131
+ embedding_model=MODEL_ID,
132
+ dimensions=engine.dimensions,
133
+ embedding=result.embedding,
 
 
 
 
 
 
134
  )
app/models.py DELETED
@@ -1,183 +0,0 @@
1
- """Resident E5, Qwen3 and PyLate mLateOn. No per-request model loading."""
2
- import gc
3
- import math
4
- import os
5
- import resource
6
- import threading
7
- from pathlib import Path
8
- from dataclasses import dataclass
9
-
10
- from app.embedding import EmbeddingEngine, EmbeddingResult, MODEL_ID as E5_ID, TooManyTokens
11
-
12
- QWEN_ID = "Qwen/Qwen3-Embedding-0.6B"
13
- COLBERT_ID = "lightonai/mLateOn"
14
- QWEN_MAX = 32768
15
- COLBERT_MAX = 8192
16
- INSTRUCTION = "Given a question, retrieve relevant passages that answer the question"
17
-
18
-
19
- class ServiceBusy(Exception):
20
- pass
21
-
22
-
23
- def memory_mb():
24
- current = None
25
- try:
26
- with open("/proc/self/status", encoding="utf-8") as f:
27
- for line in f:
28
- if line.startswith("VmRSS:"):
29
- current = round(int(line.split()[1]) / 1024, 1)
30
- break
31
- except OSError:
32
- pass
33
- peak = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
34
- return {"rss_mb": current, "peak_rss_mb": round(peak / 1024, 1),
35
- "budget_mb": int(os.getenv("MEMORY_BUDGET_MB", "15360"))}
36
-
37
-
38
- def validate_vector(vector, dimensions):
39
- if len(vector) != dimensions or any(not math.isfinite(v) for v in vector):
40
- raise RuntimeError("Invalid embedding vector")
41
-
42
-
43
- class QwenEngine:
44
- dimensions = 1024
45
- max_tokens = QWEN_MAX
46
-
47
- def __init__(self, tokenizer, model, torch):
48
- self.tokenizer, self.model, self.torch = tokenizer, model, torch
49
-
50
- @classmethod
51
- def load(cls):
52
- import torch
53
- from transformers import AutoModel, AutoTokenizer
54
- path = os.getenv("QWEN_MODEL_DIR", "/opt/models/qwen3-embedding-0.6b")
55
- tokenizer = AutoTokenizer.from_pretrained(path, padding_side="left",
56
- local_files_only=True)
57
- model = AutoModel.from_pretrained(path, torch_dtype=torch.float32,
58
- attn_implementation="sdpa",
59
- local_files_only=True).to("cpu").eval()
60
- if int(model.config.hidden_size) != 1024:
61
- raise RuntimeError("Incorrect Qwen output size")
62
- return cls(tokenizer, model, torch)
63
-
64
- def encode(self, text, kind):
65
- inp = (f"Instruct: {INSTRUCTION}\nQuery:{text}"
66
- if kind == "query" else text)
67
- tokens = self.tokenizer(inp, add_special_tokens=True, truncation=False,
68
- return_tensors="pt")
69
- count = int(tokens["attention_mask"].sum().item())
70
- if count > self.max_tokens:
71
- raise TooManyTokens(count, self.max_tokens)
72
- with self.torch.inference_mode():
73
- last = self.model(**tokens).last_hidden_state[:, -1, :]
74
- normalized = self.torch.nn.functional.normalize(last, p=2, dim=1)
75
- values = normalized[0].float().cpu().tolist()
76
- validate_vector(values, self.dimensions)
77
- return EmbeddingResult(tokens_count=count, embedding=values)
78
-
79
-
80
- @dataclass(frozen=True)
81
- class ColbertResult:
82
- tokens_count: int
83
- token_embeddings: list[list[float]]
84
-
85
-
86
- class ColbertEngine:
87
- dimensions = 128
88
- max_tokens = COLBERT_MAX
89
-
90
- def __init__(self, model):
91
- self.model = model
92
-
93
- @classmethod
94
- def load(cls):
95
- from pylate import models
96
- model = models.ColBERT(
97
- model_name_or_path=os.getenv("COLBERT_MODEL_DIR", "/opt/models/mlateon"),
98
- device="cpu", query_length=32, document_length=COLBERT_MAX,
99
- )
100
- model.eval()
101
- model.document_length = COLBERT_MAX
102
- return cls(model)
103
-
104
- def encode(self, text, kind):
105
- is_query = kind == "query"
106
- prefix = self.model.query_prefix if is_query else self.model.document_prefix
107
- tokens = self.model.tokenizer(f"{prefix}{text}", add_special_tokens=True,
108
- truncation=False)
109
- count = len(tokens["input_ids"])
110
- if count > self.max_tokens:
111
- raise TooManyTokens(count, self.max_tokens)
112
- if is_query:
113
- # PyLate pads queries to query_length. Never pad short queries to 8192.
114
- self.model.query_length = max(32, count)
115
- output = self.model.encode([text], is_query=is_query, batch_size=1,
116
- show_progress_bar=False)
117
- if len(output) != 1:
118
- raise RuntimeError("Unexpected PyLate batch size")
119
- matrix = output[0]
120
- if hasattr(matrix, "detach"):
121
- matrix = matrix.detach().float().cpu()
122
- if hasattr(matrix, "tolist"):
123
- matrix = matrix.tolist()
124
- vectors = [[float(x) for x in row] for row in matrix]
125
- if not vectors or len(vectors) > COLBERT_MAX:
126
- raise RuntimeError("Unexpected PyLate output length")
127
- for row in vectors:
128
- validate_vector(row, self.dimensions)
129
- return ColbertResult(tokens_count=len(vectors), token_embeddings=vectors)
130
-
131
-
132
- class ModelRegistry:
133
- def __init__(self, e5, qwen, colbert, startup_memory=None):
134
- self.e5, self.qwen, self.colbert = e5, qwen, colbert
135
- self.startup_memory = startup_memory or {}
136
- self._gate = threading.BoundedSemaphore(1)
137
-
138
- @classmethod
139
- def load(cls):
140
- import torch
141
- torch.set_num_threads(max(1, min(4, int(os.getenv("CPU_THREADS", "2")))))
142
- readings = {}
143
- e5 = EmbeddingEngine.load()
144
- readings["e5"] = memory_mb()
145
- qwen = QwenEngine.load()
146
- readings["qwen"] = memory_mb()
147
- colbert = ColbertEngine.load()
148
- readings["colbert"] = memory_mb()
149
- gc.collect()
150
- ram = memory_mb()
151
- if ram["rss_mb"] is not None and ram["rss_mb"] > ram["budget_mb"]:
152
- raise MemoryError("All resident models exceed RAM budget")
153
- return cls(e5, qwen, colbert, readings)
154
-
155
- def _run(self, callback):
156
- if not self._gate.acquire(blocking=False):
157
- raise ServiceBusy("Inference busy; retry later")
158
- try:
159
- return callback()
160
- finally:
161
- self._gate.release()
162
-
163
- def embed(self, text, kind, selected):
164
- engine = self.qwen if selected in ("qwen", QWEN_ID) else self.e5
165
- result = self._run(lambda: engine.encode(text, kind))
166
- return (QWEN_ID if engine is self.qwen else E5_ID), engine.dimensions, result
167
-
168
- def colbert_encode(self, text, kind):
169
- return self._run(lambda: self.colbert.encode(text, kind))
170
-
171
-
172
- def voyage_encode(self, texts, kind, canonical_model):
173
- """Atomic inference gate protects all microbatches in one HTTP request."""
174
- from app.batching import batch_encode
175
- if canonical_model == E5_ID:
176
- engine, mode = self.e5, {}
177
- elif canonical_model == QWEN_ID:
178
- engine, mode = self.qwen, {"is_qwen": True}
179
- elif canonical_model == COLBERT_ID:
180
- engine, mode = self.colbert, {"is_colbert": True}
181
- else:
182
- raise ValueError("Unknown model")
183
- return self._run(lambda: batch_encode(engine, texts, kind, **mode))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/voyage_api.py DELETED
@@ -1,130 +0,0 @@
1
- """Voyage AI Python SDK compatible /v1/embeddings facade.
2
-
3
- Voyage Client(base_url="https://host/v1", api_key="...").embed(...)
4
- works without a custom client. ColBERT's nested token vectors are an
5
- intentional 3-D extension; Voyage ordinarily returns one dense vector/input.
6
- """
7
- import os
8
- import secrets
9
- from typing import Literal
10
-
11
- from fastapi import APIRouter, Depends, HTTPException, Security
12
- from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
13
- from pydantic import BaseModel, Field, field_validator
14
-
15
- from app.embedding import MODEL_ID as E5_ID, TooManyTokens
16
- from app.models import COLBERT_ID, QWEN_ID, ServiceBusy, ModelRegistry
17
-
18
- router = APIRouter()
19
- bearer = HTTPBearer(auto_error=False)
20
- ModelName = Literal[
21
- "e5", "qwen", "colbert",
22
- "intfloat/multilingual-e5-small",
23
- "Qwen/Qwen3-Embedding-0.6B",
24
- "lightonai/mLateOn",
25
- ]
26
-
27
-
28
- class VoyageEmbedRequest(BaseModel):
29
- input: str | list[str] = Field(
30
- description="One text or a list of texts; max 1000 texts per request",
31
- examples=[["The first chunk", "The second chunk"]],
32
- )
33
- model: ModelName = Field(examples=["qwen"])
34
- input_type: Literal["query", "document"] | None = Field(default=None)
35
- truncation: bool = Field(
36
- default=True,
37
- description="Accepted for SDK compatibility; this service never truncates silently",
38
- )
39
- output_dtype: Literal["float", "float32"] | None = None
40
- output_dimension: int | None = None
41
- encoding_format: Literal["base64", "float"] | None = None
42
-
43
- @field_validator("input")
44
- @classmethod
45
- def check_input(cls, value):
46
- texts = [value] if isinstance(value, str) else value
47
- if not texts or len(texts) > 1000:
48
- raise ValueError("Batch must contain between 1 and 1000 texts")
49
- if any(not isinstance(text, str) or not text.strip() for text in texts):
50
- raise ValueError("Each input must be a nonblank string")
51
- return value
52
-
53
-
54
- def authenticate(credentials: HTTPAuthorizationCredentials | None = Security(bearer)):
55
- expected = os.getenv("API_KEY")
56
- if (
57
- not expected or credentials is None
58
- or not secrets.compare_digest(credentials.credentials, expected)
59
- ):
60
- raise HTTPException(
61
- status_code=401, detail="Invalid or missing API key",
62
- headers={"WWW-Authenticate": "Bearer"},
63
- )
64
-
65
-
66
- def resident_models():
67
- # import here avoids app/router circular module initialization.
68
- from app.main import app
69
- registry = getattr(app.state, "registry", None)
70
- if registry is None:
71
- raise HTTPException(status_code=503, detail="Models are not ready")
72
- return registry
73
-
74
-
75
- @router.post("/v1/embeddings", dependencies=[Depends(authenticate)])
76
- def embeddings(payload: VoyageEmbedRequest, registry: ModelRegistry = Depends(resident_models)):
77
- texts = [payload.input] if isinstance(payload.input, str) else payload.input
78
- model = payload.model
79
- canonical = {
80
- "e5": E5_ID, E5_ID: E5_ID,
81
- "qwen": QWEN_ID, QWEN_ID: QWEN_ID,
82
- "colbert": COLBERT_ID, COLBERT_ID: COLBERT_ID,
83
- }[model]
84
- dimensions = {
85
- E5_ID: registry.e5.dimensions,
86
- QWEN_ID: registry.qwen.dimensions,
87
- COLBERT_ID: registry.colbert.dimensions,
88
- }[canonical]
89
- if payload.output_dimension is not None and payload.output_dimension != dimensions:
90
- raise HTTPException(
91
- status_code=422,
92
- detail=f"Model {canonical} only supports {dimensions} dimensions",
93
- )
94
-
95
- # Voyage's None mode has no equivalent for asymmetric E5/Qwen/ColBERT.
96
- # Consistently treat it as 'document'. input_type remains recommended.
97
- kind = "query" if payload.input_type == "query" else "passage"
98
- try:
99
- results = registry.voyage_encode(texts, kind, canonical)
100
- except TooManyTokens as exc:
101
- raise HTTPException(status_code=422, detail=str(exc)) from exc
102
- except ServiceBusy as exc:
103
- raise HTTPException(
104
- status_code=429, detail=str(exc), headers={"Retry-After": "2"}
105
- ) from exc
106
- except ValueError as exc:
107
- raise HTTPException(status_code=422, detail=str(exc)) from exc
108
- except Exception as exc:
109
- import logging
110
- logging.getLogger(__name__).exception("Voyage embedding failed")
111
- raise HTTPException(status_code=500, detail="Embedding failed") from exc
112
-
113
- return {
114
- "object": "list",
115
- "data": [
116
- {
117
- "object": "embedding",
118
- "index": index,
119
- "embedding": (
120
- result.token_embeddings
121
- if canonical == COLBERT_ID else result.embedding
122
- ),
123
- }
124
- for index, result in enumerate(results)
125
- ],
126
- "model": model,
127
- "usage": {
128
- "total_tokens": sum(item.tokens_count for item in results),
129
- },
130
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
pyproject.toml ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ [tool.pytest.ini_options]
2
+ pythonpath = ["."]
3
+ testpaths = ["tests"]
requirements-dev.txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ -r requirements.txt
2
+ pytest==8.3.5
3
+ httpx==0.28.1
requirements.txt CHANGED
@@ -1,9 +1,6 @@
1
- torch==2.8.0+cpu
2
  fastapi==0.115.12
3
  uvicorn==0.34.2
4
- transformers==4.56.2
5
- huggingface-hub==0.36.0
6
- sentence-transformers==5.1.1
7
- pylate==1.3.4
8
  sentencepiece==0.2.0
9
  safetensors==0.5.3
 
 
1
  fastapi==0.115.12
2
  uvicorn==0.34.2
3
+ transformers==4.51.3
4
+ huggingface-hub==0.30.2
 
 
5
  sentencepiece==0.2.0
6
  safetensors==0.5.3
scripts/backup_space.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Fail-closed predeployment backup of the existing Hugging Face Space."""
2
+
3
+ import json
4
+ import os
5
+ import tarfile
6
+ from pathlib import Path
7
+
8
+ from huggingface_hub import HfApi, snapshot_download
9
+
10
+ SPACE = "SmartDataAlex/Jupyter"
11
+ ROOT = Path(os.environ.get("RUNNER_TEMP", "/tmp")) / "hf-space-backup"
12
+ TOKEN = os.environ.get("HF_TOKEN")
13
+ if not TOKEN:
14
+ raise RuntimeError("HF_TOKEN is required to read and back up the existing Space")
15
+
16
+ api = HfApi(token=TOKEN)
17
+ info = api.repo_info(repo_id=SPACE, repo_type="space")
18
+ revision = info.sha
19
+ if not revision:
20
+ raise RuntimeError("Cannot determine the current Space revision; refusing to deploy")
21
+
22
+ ROOT.mkdir(parents=True, exist_ok=True)
23
+ snapshot_path = Path(snapshot_download(
24
+ repo_id=SPACE,
25
+ repo_type="space",
26
+ revision=revision,
27
+ token=TOKEN,
28
+ local_dir=ROOT / "snapshot",
29
+ ))
30
+ files = [p for p in snapshot_path.rglob("*") if p.is_file() and ".cache" not in p.parts]
31
+ if not files:
32
+ raise RuntimeError("Space snapshot is empty; refusing to overwrite it")
33
+ archive = ROOT / f"SmartDataAlex-Jupyter-{revision}.tar.gz"
34
+ with tarfile.open(archive, "w:gz") as output:
35
+ for path in files:
36
+ output.add(path, arcname=path.relative_to(snapshot_path))
37
+ (ROOT / "backup-manifest.json").write_text(json.dumps({
38
+ "space": SPACE, "revision": revision, "files": len(files), "archive": archive.name
39
+ }, indent=2), encoding="utf-8")
40
+ print(f"Space backed up: revision={revision}, files={len(files)}, archive={archive}")
scripts/bench_local.py DELETED
@@ -1,131 +0,0 @@
1
- """Opt-in on-host three-resident-model CPU/RSS benchmark.
2
-
3
- Run from service root with model snapshots downloaded and API_KEY configured:
4
- BENCH_LONG=1 python scripts/bench_local.py
5
- Reports are checkpointed after every case to survive failures/OOM.
6
- """
7
- from __future__ import annotations
8
-
9
- import json
10
- import os
11
- import threading
12
- import time
13
- from datetime import datetime, timezone
14
- from pathlib import Path
15
-
16
- from app.embedding import MODEL_ID as E5_ID
17
- from app.models import ModelRegistry, QWEN_ID, COLBERT_ID, memory_mb
18
-
19
- REPORT = Path(os.getenv("BENCH_REPORT", "bench-result.json"))
20
- LONG = os.getenv("BENCH_LONG", "0") == "1"
21
- rows = []
22
- report = {"time": datetime.now(timezone.utc).isoformat(),
23
- "long_context": LONG, "startup": None, "cases": rows}
24
-
25
-
26
- def save():
27
- REPORT.write_text(json.dumps(report, indent=2, ensure_ascii=False),
28
- encoding="utf-8")
29
-
30
-
31
- def repeated_text(tokenizer, max_tokens: int, prefix: str = "") -> str:
32
- """Find a repetition count whose true tokenizer length is <= target.
33
-
34
- Model-specific input overhead (query instructions/markers) is counted.
35
- A result slightly shorter than target is reported, not mislabeled.
36
- """
37
- base = "Planet earth history. "
38
- def length(n):
39
- return len(tokenizer(prefix + base * n, add_special_tokens=True,
40
- truncation=False)["input_ids"])
41
- lo, hi = 0, max_tokens
42
- while lo < hi:
43
- mid = (lo + hi + 1) // 2
44
- if length(mid) <= max_tokens:
45
- lo = mid
46
- else:
47
- hi = mid - 1
48
- if lo == 0:
49
- raise RuntimeError("Model prefix exceeds the test target")
50
- return base * lo
51
-
52
-
53
- def measure(name, runner, token_count=None):
54
- readings = []
55
- finish = threading.Event()
56
-
57
- def monitor():
58
- while not finish.wait(0.05):
59
- value = memory_mb()["rss_mb"]
60
- if value is not None:
61
- readings.append(value)
62
-
63
- sampler = threading.Thread(target=monitor, daemon=True)
64
- sampler.start()
65
- started = time.perf_counter()
66
- result = {"case": name, "requested_tokens": token_count}
67
- try:
68
- payload = runner()
69
- result["status"] = "pass"
70
- result["output_tokens"] = payload.tokens_count
71
- if hasattr(payload, "embedding"):
72
- result["dimensions"] = len(payload.embedding)
73
- else:
74
- result["dimensions"] = len(payload.token_embeddings[0])
75
- result["vector_count"] = len(payload.token_embeddings)
76
- except Exception as error:
77
- result["status"] = "fail"
78
- result["error"] = f"{type(error).__name__}: {error}"
79
- finally:
80
- finish.set()
81
- sampler.join()
82
- result["elapsed_seconds"] = round(time.perf_counter() - started, 3)
83
- result["rss_mb"] = memory_mb()["rss_mb"]
84
- result["peak_sampled_rss_mb"] = max(readings, default=None)
85
- result["peak_process_rss_mb"] = memory_mb()["peak_rss_mb"]
86
- rows.append(result)
87
- save()
88
- print(result, flush=True)
89
-
90
-
91
- def main():
92
- started = time.perf_counter()
93
- registry = ModelRegistry.load() # all 3 weights resident, once
94
- report["startup"] = {
95
- "seconds": round(time.perf_counter() - started, 3),
96
- "per_model_rss": registry.startup_memory,
97
- "post_load_ram": memory_mb(),
98
- }
99
- save()
100
-
101
- for kind in ("query", "passage"):
102
- for language, text in (
103
- ("en", "A long voyage through the forest."),
104
- ("ru", "Долгое путешествие сквозь лес."),
105
- ("he", "מסע ארוך דרך היער."),
106
- ):
107
- measure(f"e5-{kind}-{language}",
108
- lambda t=text, k=kind: registry.e5.encode(t, k))
109
- measure(f"qwen-{kind}-{language}",
110
- lambda t=text, k=kind: registry.qwen.encode(t, k))
111
- measure(f"colbert-{kind}-{language}",
112
- lambda t=text, k=kind: registry.colbert.encode(t, k))
113
-
114
- if LONG:
115
- for target in (8192, 32768):
116
- text = repeated_text(registry.qwen.tokenizer, target)
117
- actual = len(registry.qwen.tokenizer(
118
- text, add_special_tokens=True, truncation=False)["input_ids"])
119
- measure(f"qwen-passage-{target}", lambda t=text: registry.qwen.encode(t, "passage"), actual)
120
-
121
- model = registry.colbert.model
122
- prefix = model.document_prefix
123
- text = repeated_text(model.tokenizer, 8192, prefix)
124
- count = len(model.tokenizer(
125
- prefix + text, add_special_tokens=True, truncation=False)["input_ids"])
126
- measure("colbert-passage-8192",
127
- lambda: registry.colbert.encode(text, "passage"), count)
128
-
129
-
130
- if __name__ == "__main__":
131
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
scripts/deploy_space.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Atomically replace Space content only if its predeployment backup is current."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+
9
+ from huggingface_hub import HfApi
10
+
11
+ SPACE = "SmartDataAlex/Jupyter"
12
+ ROOT = Path(os.environ.get("RUNNER_TEMP", "/tmp")) / "hf-space-backup"
13
+ TOKEN = os.environ.get("HF_TOKEN")
14
+
15
+
16
+ def main() -> None:
17
+ if not TOKEN:
18
+ raise RuntimeError("Missing HF_TOKEN")
19
+ manifest_path = ROOT / "backup-manifest.json"
20
+ if not manifest_path.is_file():
21
+ raise RuntimeError("No completed backup manifest; refusing to deploy")
22
+ backup = json.loads(manifest_path.read_text(encoding="utf-8"))
23
+ revision = backup.get("revision")
24
+ if backup.get("space") != SPACE or not revision:
25
+ raise RuntimeError("Backup is for a different Space or lacks a revision")
26
+ archive = ROOT / backup.get("archive", "")
27
+ if not archive.is_file() or archive.stat().st_size == 0:
28
+ raise RuntimeError("Backup archive missing; refusing to deploy")
29
+
30
+ api = HfApi(token=TOKEN)
31
+ current = api.repo_info(repo_id=SPACE, repo_type="space").sha
32
+ if current != revision:
33
+ raise RuntimeError("Space changed after backup; refusing to overwrite newer content")
34
+
35
+ # Upload_folder handles deletions and additions within the same commit.
36
+ # parent_commit prevents accidentally overwriting a concurrent Space update.
37
+ result = api.upload_folder(
38
+ repo_id=SPACE,
39
+ repo_type="space",
40
+ folder_path=".",
41
+ allow_patterns=[
42
+ "README.md", "Dockerfile", ".dockerignore", "VERSION",
43
+ "requirements.txt", "app/**", "scripts/download_model.py",
44
+ ],
45
+ delete_patterns="*",
46
+ parent_commit=revision,
47
+ commit_message=f"Deploy vectorization service from GitHub {os.environ.get('GITHUB_SHA', 'unknown')}",
48
+ )
49
+ print(f"Deployed {SPACE}: {result.commit_url}")
50
+
51
+
52
+ if __name__ == "__main__":
53
+ main()
scripts/download_model.py CHANGED
@@ -1,23 +1,19 @@
1
- """Pin and download the three model snapshots at Docker build, not runtime."""
 
2
  import os
 
3
  from huggingface_hub import snapshot_download
4
 
5
- SPECS = [
6
- ("intfloat/multilingual-e5-small",
7
- "fd1525a9fd15316a2d503bf26ab031a61d056e98",
8
- "E5_MODEL_DIR", "/opt/models/multilingual-e5-small"),
9
- ("Qwen/Qwen3-Embedding-0.6B",
10
- "97b0c614be4d77ee51c0cef4e5f07c00f9eb65b3",
11
- "QWEN_MODEL_DIR", "/opt/models/qwen3-embedding-0.6b"),
12
- ("lightonai/mLateOn",
13
- "edd378f",
14
- "COLBERT_MODEL_DIR", "/opt/models/mlateon"),
15
- ]
16
- for repo_id, revision, env_name, default in SPECS:
17
- path = snapshot_download(
18
- repo_id=repo_id, revision=revision,
19
- local_dir=os.getenv(env_name, default),
20
- ignore_patterns=["*.onnx", "*.h5", "*.msgpack", "*.ot",
21
- "*.bin", "*.pt", "*.md", "*.tflite", "*.gguf"],
22
- )
23
- print(f"Downloaded {repo_id}@{revision} to {path}", flush=True)
 
1
+ """Cache an immutable, minimal E5 model snapshot in the Docker image."""
2
+
3
  import os
4
+
5
  from huggingface_hub import snapshot_download
6
 
7
+ snapshot_download(
8
+ repo_id="intfloat/multilingual-e5-small",
9
+ revision="fd1525a9fd15316a2d503bf26ab031a61d056e98",
10
+ local_dir=os.getenv("E5_MODEL_DIR", "/opt/models/multilingual-e5-small"),
11
+ allow_patterns=[
12
+ "config.json",
13
+ "model.safetensors",
14
+ "sentencepiece.bpe.model",
15
+ "special_tokens_map.json",
16
+ "tokenizer.json",
17
+ "tokenizer_config.json",
18
+ ],
19
+ )
 
 
 
 
 
 
scripts/restore_space.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Explicit/manual rollback of a previously backed-up Hugging Face Space.
2
+
3
+ Use only with a trusted backup archive produced by backup_space.py.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import os
10
+ import tarfile
11
+ import tempfile
12
+ from pathlib import Path
13
+
14
+ from huggingface_hub import HfApi
15
+
16
+ SPACE = "SmartDataAlex/Jupyter"
17
+
18
+
19
+ def main() -> None:
20
+ parser = argparse.ArgumentParser(description="Restore existing Space files from a backup archive")
21
+ parser.add_argument("archive", type=Path)
22
+ parser.add_argument("--confirm-space", required=True, help=f"Must be exactly {SPACE}")
23
+ args = parser.parse_args()
24
+ if args.confirm_space != SPACE:
25
+ raise SystemExit(f"Refusing to restore: --confirm-space must equal {SPACE}")
26
+ token = os.getenv("HF_TOKEN")
27
+ if not token:
28
+ raise SystemExit("HF_TOKEN environment variable is required")
29
+ if not args.archive.is_file():
30
+ raise SystemExit("Backup archive not found")
31
+
32
+ with tempfile.TemporaryDirectory(prefix="space-rollback-") as temp:
33
+ dest = Path(temp)
34
+ with tarfile.open(args.archive, "r:gz") as backup:
35
+ members = backup.getmembers()
36
+ if not members or len(members) > 10000:
37
+ raise SystemExit("Invalid backup archive")
38
+ for member in members:
39
+ if (not member.isfile() or member.name.startswith("/")
40
+ or ".." in Path(member.name).parts):
41
+ raise SystemExit("Unsafe or unsupported backup entry")
42
+ backup.extractall(path=dest, filter="data")
43
+
44
+ HfApi(token=token).upload_folder(
45
+ repo_id=SPACE,
46
+ repo_type="space",
47
+ folder_path=str(dest),
48
+ delete_patterns="*",
49
+ commit_message=f"Restore Space from backup {args.archive.name}",
50
+ )
51
+ print(f"Restored {SPACE} from {args.archive.name}")
52
+
53
+
54
+ if __name__ == "__main__":
55
+ main()
scripts/smoke_live.py ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Test the deployed HTTPS service, not local code. Store JSON report for AI review."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import math
7
+ import os
8
+ import sys
9
+ import time
10
+ from datetime import datetime, timezone
11
+ from pathlib import Path
12
+
13
+ import httpx
14
+
15
+ SPACE_URL = os.getenv("SPACE_URL", "https://smartdataalex-jupyter.hf.space").rstrip("/")
16
+ KEY = os.environ.get("API_TEST_KEY", "")
17
+ EXPECTED_SHA = os.environ.get("EXPECTED_SHA", "")
18
+ MAX_WAIT_SECONDS = int(os.getenv("LIVE_WAIT_SECONDS", "2400"))
19
+ REPORT = Path(os.getenv("SMOKE_REPORT", "smoke-result.json"))
20
+ TESTS = []
21
+
22
+
23
+ def check(name: str, condition: bool, details: str = "") -> None:
24
+ TESTS.append({"name": name, "passed": bool(condition), "details": details})
25
+ if not condition:
26
+ raise AssertionError(f"{name} failed: {details}")
27
+
28
+
29
+ def cosine(a: list[float], b: list[float]) -> float:
30
+ dot = sum(x * y for x, y in zip(a, b))
31
+ na = math.sqrt(sum(x * x for x in a))
32
+ nb = math.sqrt(sum(y * y for y in b))
33
+ return dot / (na * nb)
34
+
35
+
36
+ def run() -> None:
37
+ if not KEY:
38
+ raise ValueError("API_TEST_KEY is required for real authenticated tests")
39
+ headers = {"X-API-Key": KEY}
40
+ with httpx.Client(timeout=90.0) as client:
41
+ deadline = time.monotonic() + MAX_WAIT_SECONDS
42
+ while time.monotonic() < deadline:
43
+ try:
44
+ health = client.get(f"{SPACE_URL}/health", timeout=20)
45
+ if health.status_code == 200 and health.json().get("status") == "ok":
46
+ version = health.json().get("version", "")
47
+ if not EXPECTED_SHA or version == EXPECTED_SHA:
48
+ break
49
+ except (httpx.HTTPError, ValueError):
50
+ pass
51
+ time.sleep(15)
52
+ else:
53
+ raise TimeoutError("Live service did not reach the expected release version")
54
+ check("public_health", health.status_code == 200)
55
+ check("expected_deployment", not EXPECTED_SHA or health.json().get("version") == EXPECTED_SHA)
56
+
57
+ unauthorized = client.post(f"{SPACE_URL}/embed", json={"text": "hello", "type": "query"})
58
+ check("missing_key_rejected", unauthorized.status_code == 401, str(unauthorized.status_code))
59
+ forbidden = client.post(f"{SPACE_URL}/embed", json={"text": "hello", "type": "query"}, headers={"X-API-Key": "wrong-key"})
60
+ check("wrong_key_rejected", forbidden.status_code == 401, str(forbidden.status_code))
61
+ check("model_metadata_protected", client.get(f"{SPACE_URL}/model").status_code == 401)
62
+
63
+ model_response = client.get(f"{SPACE_URL}/model", headers=headers)
64
+ check("model_metadata_authorized", model_response.status_code == 200)
65
+ info = model_response.json()
66
+ check("model_and_dimensions", info.get("embedding_model") == "intfloat/multilingual-e5-small" and info.get("dimensions") == 384)
67
+ check("no_extra_normalization", info.get("normalization") == "none")
68
+
69
+ def embed(text: str, kind: str = "query") -> dict:
70
+ response = client.post(f"{SPACE_URL}/embed", headers=headers, json={"text": text, "type": kind})
71
+ check(f"embed_{len(TESTS)}", response.status_code == 200, str(response.status_code))
72
+ return response.json()
73
+
74
+ sample = embed("How to find similar documents?")
75
+ sample_again = embed("How to find similar documents?")
76
+ check("response_echo", sample.get("text") == "How to find similar documents?" and sample.get("type") == "query")
77
+ check("token_count", isinstance(sample.get("tokens_count"), int) and sample["tokens_count"] > 0)
78
+ vector = sample["embedding"]
79
+ check("dimension_consistent", len(vector) == sample.get("dimensions") == 384)
80
+ check("vector_numeric", all(isinstance(x, (int, float)) and not isinstance(x, bool) and math.isfinite(x) for x in vector))
81
+ check("reproducibility", max(abs(a - b) for a, b in zip(vector, sample_again["embedding"])) < 1e-5)
82
+ check("passage_mode", embed("A short sentence.", "passage")["type"] == "passage")
83
+ check("unicode", embed("Проверка текста на русском языке")["dimensions"] == 384)
84
+
85
+ related = embed("How to search for similar documents?")
86
+ unrelated = embed("The strawberry cake is delicious.")
87
+ related_score = cosine(vector, related["embedding"])
88
+ unrelated_score = cosine(vector, unrelated["embedding"])
89
+ check("semantic_sanity", related_score > unrelated_score, f"related={related_score:.4f} unrelated={unrelated_score:.4f}")
90
+
91
+ for invalid in [{"text": "", "type": "query"}, {"text": "ok", "type": "invalid"}, {"type": "query"}]:
92
+ response = client.post(f"{SPACE_URL}/embed", headers=headers, json=invalid)
93
+ check("invalid_input_rejected", response.status_code == 422, str(response.status_code))
94
+
95
+
96
+ if __name__ == "__main__":
97
+ started = datetime.now(timezone.utc).isoformat()
98
+ error = None
99
+ try:
100
+ run()
101
+ except Exception as exc:
102
+ error = f"{type(exc).__name__}: {exc}"
103
+ report = {
104
+ "started_at": started, "space_url": SPACE_URL, "expected_sha": EXPECTED_SHA or None,
105
+ "passed": error is None, "error": error, "checks": TESTS,
106
+ }
107
+ REPORT.write_text(json.dumps(report, indent=2, ensure_ascii=False), encoding="utf-8")
108
+ print(json.dumps(report, ensure_ascii=False, indent=2))
109
+ sys.exit(1 if error is not None else 0)