chmielvu commited on
Commit
5fc354b
·
verified ·
1 Parent(s): a144c98

Configure space for deployment

Browse files
Files changed (4) hide show
  1. Dockerfile +17 -0
  2. README.md +83 -6
  3. app.py +435 -0
  4. requirements.txt +8 -0
Dockerfile ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ ENV PYTHONDONTWRITEBYTECODE=1 \
4
+ PYTHONUNBUFFERED=1 \
5
+ PIP_NO_CACHE_DIR=1 \
6
+ PORT=7860
7
+
8
+ WORKDIR /app
9
+
10
+ COPY requirements.txt .
11
+ RUN pip install --upgrade pip && pip install -r requirements.txt
12
+
13
+ COPY app.py .
14
+
15
+ EXPOSE 7860
16
+
17
+ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
README.md CHANGED
@@ -1,10 +1,87 @@
1
  ---
2
- title: Code Embed Qwen Rerank Sentiment
3
- emoji: 🔥
4
- colorFrom: yellow
5
- colorTo: purple
6
  sdk: docker
7
- pinned: false
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Code-Embed-Qwen-rerank-sentiment
3
+ colorFrom: gray
4
+ colorTo: indigo
 
5
  sdk: docker
6
+ app_port: 7860
7
+ pinned: true
8
  ---
9
 
10
+ # Code-Embed-Qwen-rerank-sentiment
11
+
12
+ Lazy-loading CPU-first code and multimodal retrieval API. This is the only custom Space in the set because it needs code embeddings, a Qwen reranker, sentiment classification, and CLIP image embeddings without keeping every model resident in memory at once.
13
+
14
+ ## Model Set
15
+
16
+ - Text / code embeddings: `jinaai/jina-code-embeddings-0.5b`
17
+ - Reranker: `Qwen/Qwen3-Reranker-0.6B`
18
+ - Classifier: `clapAI/modernBERT-base-multilingual-sentiment`
19
+ - Image embeddings: `sentence-transformers/clip-ViT-B-32`
20
+
21
+ ## Endpoints
22
+
23
+ - `GET /health`
24
+ - `GET /models`
25
+ - `POST /embeddings`
26
+ - `POST /rerank`
27
+ - `POST /classify`
28
+ - `POST /embeddings_image`
29
+ - `GET /openapi.json`
30
+
31
+ ## Example Requests
32
+
33
+ ### Code Embeddings
34
+
35
+ ```bash
36
+ curl -X POST "$SPACE_URL/embeddings" \
37
+ -H "Content-Type: application/json" \
38
+ -d '{
39
+ "model": "code-embed",
40
+ "input": ["def quick_sort(arr): return sorted(arr)"]
41
+ }'
42
+ ```
43
+
44
+ ### Image Embeddings
45
+
46
+ ```bash
47
+ curl -X POST "$SPACE_URL/embeddings" \
48
+ -H "Content-Type: application/json" \
49
+ -d '{
50
+ "model": "clip-image",
51
+ "modality": "image",
52
+ "input": ["https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/coco_sample.png"]
53
+ }'
54
+ ```
55
+
56
+ ### Reranking
57
+
58
+ ```bash
59
+ curl -X POST "$SPACE_URL/rerank" \
60
+ -H "Content-Type: application/json" \
61
+ -d '{
62
+ "model": "code-rerank",
63
+ "query": "python quick sort implementation",
64
+ "documents": [
65
+ "def quick_sort(arr): return sorted(arr)",
66
+ "SELECT * FROM users ORDER BY created_at DESC"
67
+ ],
68
+ "return_documents": true
69
+ }'
70
+ ```
71
+
72
+ ### Classification
73
+
74
+ ```bash
75
+ curl -X POST "$SPACE_URL/classify" \
76
+ -H "Content-Type: application/json" \
77
+ -d '{
78
+ "model": "code-sentiment",
79
+ "input": ["The API is fast and easy to use."]
80
+ }'
81
+ ```
82
+
83
+ ## Notes
84
+
85
+ - The server lazy-loads one model family at a time to fit `cpu-basic` more safely.
86
+ - The first request after switching tasks will be slower because the model may need to be loaded.
87
+ - Jina embedding and reranking models are under `CC BY-NC 4.0`; verify that license for your use case.
app.py ADDED
@@ -0,0 +1,435 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import base64
2
+ import gc
3
+ import io
4
+ import math
5
+ import time
6
+ import uuid
7
+ from typing import Any, Literal
8
+
9
+ import numpy as np
10
+ import requests
11
+ import torch
12
+ import torch.nn.functional as F
13
+ from fastapi import FastAPI, HTTPException
14
+ from fastapi.responses import PlainTextResponse
15
+ from PIL import Image
16
+ from pydantic import BaseModel, Field
17
+ from sentence_transformers import SentenceTransformer
18
+ from transformers import AutoModelForCausalLM, AutoModelForSequenceClassification, AutoTokenizer
19
+
20
+ torch.set_grad_enabled(False)
21
+ torch.set_num_threads(2)
22
+
23
+ OWNER = "chmielvu"
24
+ APP_TITLE = "Code-Embed-Qwen-rerank-sentiment"
25
+ DEFAULT_MODEL = "default/not-specified"
26
+
27
+ MODEL_CONFIG = {
28
+ "code-embed": {
29
+ "repo_id": "jinaai/jina-code-embeddings-0.5b",
30
+ "kind": "sentence-transformer",
31
+ },
32
+ "clip-image": {
33
+ "repo_id": "sentence-transformers/clip-ViT-B-32",
34
+ "kind": "sentence-transformer",
35
+ },
36
+ "code-rerank": {
37
+ "repo_id": "Qwen/Qwen3-Reranker-0.6B",
38
+ "kind": "qwen-reranker",
39
+ },
40
+ "code-sentiment": {
41
+ "repo_id": "clapAI/modernBERT-base-multilingual-sentiment",
42
+ "kind": "sequence-classification",
43
+ },
44
+ }
45
+
46
+ QWEN_RERANK_INSTRUCTION = (
47
+ "Given a developer or code search query, retrieve relevant passages, issue text, "
48
+ "or code snippets that answer the query."
49
+ )
50
+
51
+ app = FastAPI(
52
+ title=APP_TITLE,
53
+ summary=(
54
+ "CPU-first lazy-loading inference API for code embeddings, reranking, "
55
+ "classification, and CLIP image embeddings."
56
+ ),
57
+ version="1.0.0",
58
+ )
59
+
60
+ _loaded_name: str | None = None
61
+ _loaded_kind: str | None = None
62
+ _loaded_bundle: dict[str, Any] = {}
63
+
64
+
65
+ class EmbeddingRequest(BaseModel):
66
+ input: str | list[str]
67
+ model: str = DEFAULT_MODEL
68
+ encoding_format: Literal["float", "base64"] = "float"
69
+ user: str | None = None
70
+ dimensions: int = 0
71
+ modality: Literal["text", "image"] = "text"
72
+
73
+
74
+ class RerankRequest(BaseModel):
75
+ query: str = Field(..., max_length=122880)
76
+ documents: list[str] = Field(..., min_length=1, max_length=2048)
77
+ return_documents: bool = False
78
+ raw_scores: bool = False
79
+ model: str = DEFAULT_MODEL
80
+ top_n: int | None = None
81
+
82
+
83
+ class ClassifyRequest(BaseModel):
84
+ input: list[str] = Field(..., min_length=1, max_length=2048)
85
+ model: str = DEFAULT_MODEL
86
+ raw_scores: bool = False
87
+
88
+
89
+ def _now_ts() -> int:
90
+ return int(time.time())
91
+
92
+
93
+ def _make_id(prefix: str) -> str:
94
+ return f"{prefix}-{uuid.uuid4().hex}"
95
+
96
+
97
+ def _resolve_model_name(route: str, requested: str, modality: str | None = None) -> str:
98
+ if requested != DEFAULT_MODEL:
99
+ if requested not in MODEL_CONFIG:
100
+ raise HTTPException(status_code=400, detail=f"Unknown model '{requested}'")
101
+ return requested
102
+ if route == "embeddings" and modality == "image":
103
+ return "clip-image"
104
+ defaults = {
105
+ "embeddings": "code-embed",
106
+ "rerank": "code-rerank",
107
+ "classify": "code-sentiment",
108
+ }
109
+ return defaults[route]
110
+
111
+
112
+ def _unload_current_model() -> None:
113
+ global _loaded_name, _loaded_kind, _loaded_bundle
114
+ _loaded_name = None
115
+ _loaded_kind = None
116
+ _loaded_bundle = {}
117
+ gc.collect()
118
+
119
+
120
+ def _load_sentence_transformer(repo_id: str) -> dict[str, Any]:
121
+ model = SentenceTransformer(repo_id, trust_remote_code=True, device="cpu")
122
+ return {"model": model}
123
+
124
+
125
+ def _load_qwen_reranker(repo_id: str) -> dict[str, Any]:
126
+ tokenizer = AutoTokenizer.from_pretrained(repo_id, padding_side="left")
127
+ if tokenizer.pad_token is None:
128
+ tokenizer.pad_token = tokenizer.eos_token
129
+ model = AutoModelForCausalLM.from_pretrained(repo_id).eval()
130
+ token_false_id = tokenizer.convert_tokens_to_ids("no")
131
+ token_true_id = tokenizer.convert_tokens_to_ids("yes")
132
+ prefix = (
133
+ "<|im_start|>system\n"
134
+ 'Judge whether the Document meets the requirements based on the Query and '
135
+ 'the Instruct provided. Note that the answer can only be "yes" or "no".'
136
+ "<|im_end|>\n<|im_start|>user\n"
137
+ )
138
+ suffix = "<|im_end|>\n<|im_start|>assistant\n<think>\n\n</think>\n\n"
139
+ prefix_tokens = tokenizer.encode(prefix, add_special_tokens=False)
140
+ suffix_tokens = tokenizer.encode(suffix, add_special_tokens=False)
141
+ return {
142
+ "model": model,
143
+ "tokenizer": tokenizer,
144
+ "token_false_id": token_false_id,
145
+ "token_true_id": token_true_id,
146
+ "prefix_tokens": prefix_tokens,
147
+ "suffix_tokens": suffix_tokens,
148
+ "max_length": 4096,
149
+ }
150
+
151
+
152
+ def _load_sequence_classifier(repo_id: str) -> dict[str, Any]:
153
+ tokenizer = AutoTokenizer.from_pretrained(repo_id)
154
+ model = AutoModelForSequenceClassification.from_pretrained(repo_id).eval()
155
+ return {"model": model, "tokenizer": tokenizer}
156
+
157
+
158
+ def _get_model_bundle(name: str) -> tuple[str, dict[str, Any]]:
159
+ global _loaded_name, _loaded_kind, _loaded_bundle
160
+ if _loaded_name == name:
161
+ return _loaded_kind or "", _loaded_bundle
162
+
163
+ _unload_current_model()
164
+ config = MODEL_CONFIG[name]
165
+ kind = config["kind"]
166
+ repo_id = config["repo_id"]
167
+
168
+ if kind == "sentence-transformer":
169
+ bundle = _load_sentence_transformer(repo_id)
170
+ elif kind == "qwen-reranker":
171
+ bundle = _load_qwen_reranker(repo_id)
172
+ elif kind == "sequence-classification":
173
+ bundle = _load_sequence_classifier(repo_id)
174
+ else:
175
+ raise HTTPException(status_code=500, detail=f"Unsupported kind '{kind}'")
176
+
177
+ _loaded_name = name
178
+ _loaded_kind = kind
179
+ _loaded_bundle = bundle
180
+ return kind, bundle
181
+
182
+
183
+ def _usage_from_strings(values: list[str], tokenizer: Any | None = None) -> dict[str, int]:
184
+ if tokenizer is None:
185
+ total = sum(max(1, len(value.split())) for value in values)
186
+ return {"prompt_tokens": total, "total_tokens": total}
187
+ total = 0
188
+ for value in values:
189
+ total += len(tokenizer.encode(value, add_special_tokens=True))
190
+ return {"prompt_tokens": total, "total_tokens": total}
191
+
192
+
193
+ def _truncate_embedding(vector: np.ndarray, dimensions: int) -> np.ndarray:
194
+ if dimensions and 0 < dimensions < vector.shape[0]:
195
+ vector = vector[:dimensions]
196
+ norm = np.linalg.norm(vector)
197
+ if norm > 0:
198
+ vector = vector / norm
199
+ return vector
200
+
201
+
202
+ def _vector_to_payload(vector: np.ndarray, encoding_format: str) -> list[float] | str:
203
+ vector = vector.astype(np.float32)
204
+ if encoding_format == "base64":
205
+ return base64.b64encode(vector.tobytes()).decode("ascii")
206
+ return vector.tolist()
207
+
208
+
209
+ def _normalize_inputs(value: str | list[str]) -> list[str]:
210
+ return value if isinstance(value, list) else [value]
211
+
212
+
213
+ def _load_image_from_input(value: str) -> Image.Image:
214
+ if value.startswith("data:"):
215
+ _, data = value.split(",", 1)
216
+ raw = base64.b64decode(data)
217
+ return Image.open(io.BytesIO(raw)).convert("RGB")
218
+ response = requests.get(value, timeout=30)
219
+ response.raise_for_status()
220
+ return Image.open(io.BytesIO(response.content)).convert("RGB")
221
+
222
+
223
+ def _format_rerank_pair(query: str, document: str) -> str:
224
+ return f"<Instruct>: {QWEN_RERANK_INSTRUCTION}\n<Query>: {query}\n<Document>: {document}"
225
+
226
+
227
+ def _score_rerank(query: str, documents: list[str], raw_scores: bool, bundle: dict[str, Any]) -> list[float]:
228
+ tokenizer = bundle["tokenizer"]
229
+ model = bundle["model"]
230
+ prefix_tokens = bundle["prefix_tokens"]
231
+ suffix_tokens = bundle["suffix_tokens"]
232
+ token_true_id = bundle["token_true_id"]
233
+ token_false_id = bundle["token_false_id"]
234
+ max_length = bundle["max_length"]
235
+
236
+ pairs = [_format_rerank_pair(query, document) for document in documents]
237
+ inputs = tokenizer(
238
+ pairs,
239
+ padding=False,
240
+ truncation="longest_first",
241
+ return_attention_mask=False,
242
+ max_length=max_length - len(prefix_tokens) - len(suffix_tokens),
243
+ )
244
+
245
+ for idx, token_ids in enumerate(inputs["input_ids"]):
246
+ inputs["input_ids"][idx] = prefix_tokens + token_ids + suffix_tokens
247
+
248
+ padded = tokenizer.pad(inputs, padding=True, return_tensors="pt", max_length=max_length)
249
+ logits = model(**padded).logits[:, -1, :]
250
+ true_logits = logits[:, token_true_id]
251
+ false_logits = logits[:, token_false_id]
252
+
253
+ if raw_scores:
254
+ return (true_logits - false_logits).detach().cpu().tolist()
255
+
256
+ stacked = torch.stack([false_logits, true_logits], dim=1)
257
+ probs = torch.nn.functional.softmax(stacked, dim=1)[:, 1]
258
+ return probs.detach().cpu().tolist()
259
+
260
+
261
+ def _classify_scores(texts: list[str], raw_scores: bool, bundle: dict[str, Any]) -> list[list[dict[str, float | str]]]:
262
+ tokenizer = bundle["tokenizer"]
263
+ model = bundle["model"]
264
+ encoded = tokenizer(
265
+ texts,
266
+ padding=True,
267
+ truncation=True,
268
+ max_length=1024,
269
+ return_tensors="pt",
270
+ )
271
+ logits = model(**encoded).logits.detach().cpu()
272
+ problem_type = getattr(model.config, "problem_type", None)
273
+
274
+ if problem_type == "multi_label_classification":
275
+ score_tensor = torch.sigmoid(logits)
276
+ else:
277
+ score_tensor = torch.softmax(logits, dim=-1)
278
+
279
+ label_lookup = model.config.id2label
280
+ results: list[list[dict[str, float | str]]] = []
281
+ for row_idx in range(logits.shape[0]):
282
+ values = logits[row_idx] if raw_scores else score_tensor[row_idx]
283
+ row = [
284
+ {
285
+ "label": label_lookup[col_idx],
286
+ "score": float(values[col_idx].item()),
287
+ }
288
+ for col_idx in range(values.shape[0])
289
+ ]
290
+ row.sort(key=lambda item: item["score"], reverse=True)
291
+ results.append(row)
292
+ return results
293
+
294
+
295
+ @app.get("/")
296
+ def root() -> dict[str, str]:
297
+ return {"message": APP_TITLE}
298
+
299
+
300
+ @app.get("/health")
301
+ def health() -> dict[str, float]:
302
+ return {"unix": time.time()}
303
+
304
+
305
+ @app.get("/models")
306
+ @app.get("/v1/models")
307
+ @app.get("/openai/v1/models")
308
+ def models() -> dict[str, Any]:
309
+ created = _now_ts()
310
+ return {
311
+ "object": "list",
312
+ "data": [
313
+ {
314
+ "id": model_name,
315
+ "object": "model",
316
+ "created": created,
317
+ "owned_by": OWNER,
318
+ "root": config["repo_id"],
319
+ }
320
+ for model_name, config in MODEL_CONFIG.items()
321
+ ],
322
+ }
323
+
324
+
325
+ @app.post("/embeddings")
326
+ @app.post("/v1/embeddings")
327
+ @app.post("/openai/v1/embeddings")
328
+ def embeddings(request: EmbeddingRequest) -> dict[str, Any]:
329
+ model_name = _resolve_model_name("embeddings", request.model, request.modality)
330
+ kind, bundle = _get_model_bundle(model_name)
331
+ if kind != "sentence-transformer":
332
+ raise HTTPException(status_code=400, detail=f"Model '{model_name}' does not support embeddings")
333
+
334
+ values = _normalize_inputs(request.input)
335
+ model = bundle["model"]
336
+
337
+ if request.modality == "image":
338
+ images = [_load_image_from_input(value) for value in values]
339
+ embeddings_np = np.asarray(model.encode(images, convert_to_numpy=True))
340
+ usage = {"prompt_tokens": 0, "total_tokens": 0}
341
+ else:
342
+ embeddings_np = np.asarray(model.encode(values, convert_to_numpy=True))
343
+ tokenizer = getattr(model, "tokenizer", None)
344
+ usage = _usage_from_strings(values, tokenizer)
345
+
346
+ data = []
347
+ for idx, vector in enumerate(embeddings_np):
348
+ vector = _truncate_embedding(vector, request.dimensions)
349
+ data.append(
350
+ {
351
+ "object": "embedding",
352
+ "embedding": _vector_to_payload(vector, request.encoding_format),
353
+ "index": idx,
354
+ }
355
+ )
356
+
357
+ return {
358
+ "object": "list",
359
+ "data": data,
360
+ "model": model_name,
361
+ "usage": usage,
362
+ "id": _make_id("emb"),
363
+ "created": _now_ts(),
364
+ }
365
+
366
+
367
+ @app.post("/embeddings_image")
368
+ def embeddings_image(request: EmbeddingRequest) -> dict[str, Any]:
369
+ image_request = EmbeddingRequest(
370
+ input=request.input,
371
+ model="clip-image" if request.model == DEFAULT_MODEL else request.model,
372
+ encoding_format=request.encoding_format,
373
+ user=request.user,
374
+ dimensions=request.dimensions,
375
+ modality="image",
376
+ )
377
+ return embeddings(image_request)
378
+
379
+
380
+ @app.post("/rerank")
381
+ @app.post("/v1/rerank")
382
+ @app.post("/openai/v1/rerank")
383
+ def rerank(request: RerankRequest) -> dict[str, Any]:
384
+ model_name = _resolve_model_name("rerank", request.model)
385
+ kind, bundle = _get_model_bundle(model_name)
386
+ if kind != "qwen-reranker":
387
+ raise HTTPException(status_code=400, detail=f"Model '{model_name}' does not support reranking")
388
+
389
+ scores = _score_rerank(request.query, request.documents, request.raw_scores, bundle)
390
+ results = []
391
+ for idx, score in enumerate(scores):
392
+ item = {"index": idx, "relevance_score": float(score)}
393
+ if request.return_documents:
394
+ item["document"] = request.documents[idx]
395
+ results.append(item)
396
+
397
+ results.sort(key=lambda item: item["relevance_score"], reverse=True)
398
+ if request.top_n is not None:
399
+ results = results[: request.top_n]
400
+
401
+ usage = _usage_from_strings([request.query] + request.documents, bundle["tokenizer"])
402
+ return {
403
+ "object": "rerank",
404
+ "results": results,
405
+ "model": model_name,
406
+ "usage": usage,
407
+ "id": _make_id("rerank"),
408
+ "created": _now_ts(),
409
+ }
410
+
411
+
412
+ @app.post("/classify")
413
+ @app.post("/v1/classify")
414
+ @app.post("/openai/v1/classify")
415
+ def classify(request: ClassifyRequest) -> dict[str, Any]:
416
+ model_name = _resolve_model_name("classify", request.model)
417
+ kind, bundle = _get_model_bundle(model_name)
418
+ if kind != "sequence-classification":
419
+ raise HTTPException(status_code=400, detail=f"Model '{model_name}' does not support classification")
420
+
421
+ data = _classify_scores(request.input, request.raw_scores, bundle)
422
+ usage = _usage_from_strings(request.input, bundle["tokenizer"])
423
+ return {
424
+ "object": "classify",
425
+ "data": data,
426
+ "model": model_name,
427
+ "usage": usage,
428
+ "id": _make_id("classify"),
429
+ "created": _now_ts(),
430
+ }
431
+
432
+
433
+ @app.get("/metrics", response_class=PlainTextResponse)
434
+ def metrics() -> str:
435
+ return ""
requirements.txt ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ fastapi==0.128.0
2
+ uvicorn[standard]==0.35.0
3
+ torch>=2.3.0
4
+ transformers>=4.57.0
5
+ sentence-transformers>=3.0.0
6
+ pillow>=10.0.0
7
+ requests>=2.32.0
8
+ numpy>=1.26.0