SmartDataAlex commited on
Commit
0458600
·
verified ·
1 Parent(s): 9ac3412

Deploy vectorization service from GitHub be8a53f8cee433cb3de44a139be2fab8e47b1ccb

Browse files
Files changed (6) hide show
  1. README.md +49 -0
  2. VERSION +1 -1
  3. app/batching.py +10 -4
  4. app/main.py +2 -0
  5. app/models.py +13 -0
  6. app/openai_api.py +111 -0
README.md CHANGED
@@ -42,6 +42,55 @@ curl -sS -X POST 'http://localhost:7860/colbert/encode' \
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.
 
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
+ ## OpenAI-compatible API (separate from Voyage)
46
+
47
+ **OpenAI SDK-compatible base URL:** \`https://YOUR-SPACE.hf.space/openai/v1\`.
48
+ This separate namespace is necessary because the existing Voyage-compatible
49
+ \`/v1/embeddings\` endpoint is already in production and must retain its default
50
+ \`input_type\` handling. Point OpenAI-compatible custom-provider integrations
51
+ to the base URL ending in \`/openai/v1\`, not to the Voyage \`/v1\` URL.
52
+
53
+ - \`POST /openai/v1/embeddings\`: accepts \`model\`, \`input\` (string or string array),
54
+ optional \`dimensions\`, \`encoding_format\` (\`float\` or \`base64\`) and \`user\`.
55
+ Responds in the OpenAI \`data[].embedding\` / \`usage.prompt_tokens\` /
56
+ \`usage.total_tokens\` format. Only fixed native dimensions are supported.
57
+ - \`GET /openai/v1/models\`: dense model aliases for provider selection.
58
+ - Bearer authentication uses the **same** \`API_KEY\` as Voyage; no new secrets.
59
+ - Dense E5 and Qwen3 only. ColBERT retains \`/colbert/encode\` and Voyage extension.
60
+ - **Crucial:** this endpoint forwards raw input to the tokenizer **without adding any
61
+ query/document instructions**. Supply prefixes in the client configuration.
62
+ It never silently truncates, and unsupported dimensions return an error.
63
+
64
+ For E5 configure:
65
+ - Document prefix: \`passage: \`
66
+ - Query prefix: \`query: \`
67
+
68
+ For Qwen3:
69
+ - Document prefix: *empty*
70
+ - Query prefix: \`Instruct: Given a question, retrieve relevant passages that answer the question\nQuery:\`
71
+
72
+ The Qwen query prefix contains an actual newline before \`Query:\`; ensure the
73
+ client inserts a newline character rather than the two characters backslash+n.
74
+ Use \`Query:\` immediately before the question, matching the model's original
75
+ instruction template.
76
+
77
+ Example with the standard Python OpenAI SDK:
78
+
79
+ \`\`\`python
80
+ from openai import OpenAI
81
+
82
+ client = OpenAI(
83
+ base_url="https://YOUR-SPACE.hf.space/openai/v1",
84
+ api_key="YOUR_API_KEY",
85
+ )
86
+ # The calling application has to apply the configured prefix.
87
+ embedding = client.embeddings.create(
88
+ model="e5",
89
+ input=["passage: Первое предложение", "passage: Второе предложение"],
90
+ )
91
+ print(len(embedding.data[0].embedding)) # 384
92
+ \`\`\`
93
+
94
  ## Official Voyage AI SDK compatibility
95
 
96
  The **unmodified Voyage Python client** can talk directly to our service.
VERSION CHANGED
@@ -1 +1 @@
1
- 6f9bd192211bf40966f92934e10f54f002c3c28b
 
1
+ be8a53f8cee433cb3de44a139be2fab8e47b1ccb
app/batching.py CHANGED
@@ -33,8 +33,12 @@ def _token_count(tokenizer, text):
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
@@ -122,7 +126,7 @@ def _colbert_batch(engine, texts, kind):
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"):
@@ -130,4 +134,6 @@ def batch_encode(engine, texts, kind, is_qwen=False, is_colbert=False):
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)
 
 
 
33
  return len(encoded["input_ids"])
34
 
35
 
36
+ def _dense_batch(engine, texts, kind, is_qwen, raw=False):
37
+ if raw:
38
+ # OpenAI-compatible callers provide preformatted text; never add prompts.
39
+ strings = list(texts)
40
+ budget, max_size = (4096, 16) if is_qwen else (8192, 64)
41
+ elif is_qwen:
42
  strings = [
43
  f"Instruct: {INSTRUCTION}\nQuery:{text}" if kind == "query" else text
44
  for text in texts
 
126
  return results
127
 
128
 
129
+ def batch_encode(engine, texts, kind, is_qwen=False, is_colbert=False, raw=False):
130
  """Native tensor batch for real models, single-item fallback for test doubles."""
131
  if is_colbert:
132
  if not hasattr(engine, "model") or not hasattr(engine.model, "tokenizer"):
 
134
  return _colbert_batch(engine, texts, kind)
135
  if not hasattr(engine, "tokenizer") or not hasattr(engine, "model"):
136
  return [engine.encode(text, kind) for text in texts]
137
+ if raw and (not hasattr(engine, "tokenizer") or not hasattr(engine, "model")):
138
+ raise RuntimeError("Raw embeddings require the loaded model and tokenizer")
139
+ return _dense_batch(engine, texts, kind, is_qwen, raw=raw)
app/main.py CHANGED
@@ -41,6 +41,7 @@ async def lifespan(application: FastAPI):
41
 
42
 
43
  from app.voyage_api import router as voyage_router
 
44
 
45
  app = FastAPI(
46
  title="Vectorization API",
@@ -51,6 +52,7 @@ app = FastAPI(
51
 
52
 
53
  app.include_router(voyage_router)
 
54
 
55
 
56
  class TextRequest(BaseModel):
 
41
 
42
 
43
  from app.voyage_api import router as voyage_router
44
+ from app.openai_api import router as openai_router
45
 
46
  app = FastAPI(
47
  title="Vectorization API",
 
52
 
53
 
54
  app.include_router(voyage_router)
55
+ app.include_router(openai_router)
56
 
57
 
58
  class TextRequest(BaseModel):
app/models.py CHANGED
@@ -165,6 +165,19 @@ class ModelRegistry:
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
 
 
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 openai_encode(self, texts, canonical_model):
169
+ """Raw OpenAI-compatible embeddings: client supplies any query/document prefix."""
170
+ from app.batching import batch_encode
171
+ if canonical_model == E5_ID:
172
+ engine, is_qwen = self.e5, False
173
+ elif canonical_model == QWEN_ID:
174
+ engine, is_qwen = self.qwen, True
175
+ else:
176
+ raise ValueError("OpenAI embeddings only support dense E5 and Qwen models")
177
+ return self._run(lambda: batch_encode(
178
+ engine, texts, "passage", is_qwen=is_qwen, raw=True,
179
+ ))
180
+
181
  def colbert_encode(self, text, kind):
182
  return self._run(lambda: self.colbert.encode(text, kind))
183
 
app/openai_api.py ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """OpenAI SDK-compatible embeddings API, intentionally separate from Voyage routes.
2
+
3
+ Configure OpenAI(base_url="https://HOST/openai/v1", api_key="...").
4
+ The client is responsible for model-specific query/document prefixes.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import base64
9
+ import logging
10
+ import struct
11
+ from typing import Literal
12
+
13
+ from fastapi import APIRouter, Depends, HTTPException
14
+ from fastapi.responses import JSONResponse
15
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
16
+
17
+ from app.embedding import MODEL_ID as E5_ID, TooManyTokens
18
+ from app.models import ModelRegistry, QWEN_ID, ServiceBusy
19
+ from app.voyage_api import authenticate, resident_models
20
+
21
+ router = APIRouter(prefix="/openai/v1", tags=["OpenAI-compatible"])
22
+ log = logging.getLogger(__name__)
23
+ DenseModelName = Literal[
24
+ "e5", "qwen", "intfloat/multilingual-e5-small", "Qwen/Qwen3-Embedding-0.6B"
25
+ ]
26
+
27
+
28
+ class OpenAIEmbeddingRequest(BaseModel):
29
+ model_config = ConfigDict(extra="forbid")
30
+ model: DenseModelName
31
+ input: str | list[str] = Field(
32
+ description="Raw input; client must add model-specific query/document prefixes"
33
+ )
34
+ dimensions: int | None = Field(default=None, ge=1)
35
+ encoding_format: Literal["float", "base64"] = "float"
36
+ user: str | None = None
37
+
38
+ @field_validator("input")
39
+ @classmethod
40
+ def validate_input(cls, value):
41
+ texts = [value] if isinstance(value, str) else value
42
+ if not texts or len(texts) > 1000:
43
+ raise ValueError("input must contain between 1 and 1000 texts")
44
+ if any(not text.strip() for text in texts):
45
+ raise ValueError("input cannot contain blank strings")
46
+ return value
47
+
48
+
49
+ def api_error(status: int, message: str, error_type="invalid_request_error", code=None):
50
+ return JSONResponse(status_code=status, content={
51
+ "error": {"message": message, "type": error_type, "param": None, "code": code}
52
+ })
53
+
54
+
55
+ @router.post("/embeddings", dependencies=[Depends(authenticate)])
56
+ def create_embedding(
57
+ request: OpenAIEmbeddingRequest,
58
+ registry: ModelRegistry = Depends(resident_models),
59
+ ):
60
+ model = request.model
61
+ canonical = QWEN_ID if model in ("qwen", QWEN_ID) else E5_ID
62
+ engine = registry.qwen if canonical == QWEN_ID else registry.e5
63
+ if request.dimensions is not None and request.dimensions != engine.dimensions:
64
+ return api_error(
65
+ 400,
66
+ f"Model {model} supports exactly {engine.dimensions} dimensions",
67
+ code="unsupported_dimensions",
68
+ )
69
+
70
+ texts = [request.input] if isinstance(request.input, str) else request.input
71
+ try:
72
+ results = registry.openai_encode(texts, canonical)
73
+ except TooManyTokens as exc:
74
+ return api_error(400, str(exc), code="context_length_exceeded")
75
+ except ValueError as exc:
76
+ return api_error(400, str(exc))
77
+ except ServiceBusy as exc:
78
+ response = api_error(429, str(exc), "rate_limit_error", "rate_limit_exceeded")
79
+ response.headers["Retry-After"] = "2"
80
+ return response
81
+ except Exception:
82
+ log.exception("OpenAI-compatible embedding failed")
83
+ return api_error(500, "Embedding generation failed", "server_error")
84
+
85
+ def encode(vector):
86
+ if request.encoding_format == "float":
87
+ return vector
88
+ raw = struct.pack(f"<{len(vector)}f", *vector)
89
+ return base64.b64encode(raw).decode("ascii")
90
+
91
+ total_tokens = sum(result.tokens_count for result in results)
92
+ return {
93
+ "object": "list",
94
+ "data": [
95
+ {"object": "embedding", "index": index, "embedding": encode(item.embedding)}
96
+ for index, item in enumerate(results)
97
+ ],
98
+ "model": model,
99
+ "usage": {"prompt_tokens": total_tokens, "total_tokens": total_tokens},
100
+ }
101
+
102
+
103
+ @router.get("/models", dependencies=[Depends(authenticate)])
104
+ def list_models():
105
+ return {
106
+ "object": "list",
107
+ "data": [
108
+ {"id": mid, "object": "model", "created": 0, "owned_by": "local"}
109
+ for mid in ("e5", "qwen", E5_ID, QWEN_ID)
110
+ ],
111
+ }