File size: 6,313 Bytes
e58161b
4c220a5
 
 
 
 
 
e58161b
4c220a5
 
 
 
 
 
 
 
 
e58161b
 
4c220a5
 
e58161b
4c220a5
 
 
 
 
 
e58161b
 
 
 
4c220a5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e58161b
 
 
4c220a5
e58161b
4c220a5
e58161b
 
 
 
 
 
 
 
 
 
 
 
 
4c220a5
e58161b
 
 
 
4c220a5
 
 
e58161b
 
 
 
 
 
 
 
4c220a5
e58161b
4c220a5
 
e58161b
 
4c220a5
 
 
 
 
 
 
 
e58161b
4c220a5
 
 
 
e58161b
4c220a5
 
e58161b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4c220a5
 
 
 
 
 
e58161b
 
4c220a5
e58161b
 
4c220a5
 
 
 
 
 
 
e58161b
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215

from fastapi import FastAPI, HTTPException
from fastapi.responses import JSONResponse
import numpy as np
from sentence_transformers import SentenceTransformer
import asyncio
import logging
from typing import List, Optional, Union
from pydantic import BaseModel
import time

# Setup logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

app = FastAPI(title="Embedding API")

MODEL_NAME = "all-MiniLM-L12-v2"

# Load model on startup
try:
    model = SentenceTransformer(MODEL_NAME)
    logger.info("Model loaded successfully")
except Exception as e:
    logger.error(f"Failed to load model: {e}")
    model = None

# Request batching queue
class EmbeddingRequest(BaseModel):
    input: Union[str, List[str]]
    model: str = MODEL_NAME
    encoding_format: Optional[str] = None

class BatchItem:
    def __init__(self, texts: List[str]):
        self.texts = texts
        self.future: asyncio.Future = asyncio.Future()

pending_batch: List[BatchItem] = []
batch_lock = asyncio.Lock()
batch_event = asyncio.Event()

BATCH_TIMEOUT = 0.05  # 50ms window to collect requests
MAX_BATCH_SIZE = 32


async def batch_processor():
    """Continuously process batches of requests"""
    global pending_batch
    
    while True:
        try:
            # Wait for requests or timeout
            try:
                await asyncio.wait_for(batch_event.wait(), timeout=BATCH_TIMEOUT)
                batch_event.clear()
            except asyncio.TimeoutError:
                pass
            
            async with batch_lock:
                if not pending_batch:
                    continue
                
                # Take up to MAX_BATCH_SIZE items
                batch_to_process = pending_batch[:MAX_BATCH_SIZE]
                pending_batch = pending_batch[MAX_BATCH_SIZE:]
                
                if not batch_to_process:
                    continue
            
            # Flatten all texts
            all_texts = []
            text_counts = []
            for item in batch_to_process:
                all_texts.extend(item.texts)
                text_counts.append(len(item.texts))
            
            logger.info(f"Processing batch of {len(batch_to_process)} requests, {len(all_texts)} texts total")
            
            # Compute embeddings
            start = time.time()
            embeddings = model.encode(all_texts, convert_to_numpy=True)
            elapsed = time.time() - start
            logger.info(f"Embedding computed in {elapsed:.2f}s")
            
            # Split embeddings back to individual requests
            idx = 0
            for item, count in zip(batch_to_process, text_counts):
                item_embeddings = embeddings[idx:idx+count].tolist()
                item.future.set_result(item_embeddings)
                idx += count
        
        except Exception as e:
            logger.error(f"Error in batch processor: {e}")
            async with batch_lock:
                for item in pending_batch:
                    if not item.future.done():
                        item.future.set_exception(e)
                pending_batch.clear()
            await asyncio.sleep(1)


@app.on_event("startup")
async def startup():
    """Start batch processor on app startup"""
    if model is None:
        raise RuntimeError("Model failed to load")
    asyncio.create_task(batch_processor())
    logger.info("Batch processor started")


@app.get("/v1/models")
async def list_models():
    """OpenAI-compatible models endpoint"""
    if model is None:
        raise HTTPException(status_code=503, detail="Model not ready")
    
    return {
        "object": "list",
        "data": [
            {
                "id": MODEL_NAME,
                "object": "model",
                "created": 0,
                "owned_by": "huggingface",
                "permission": [],
                "root": MODEL_NAME,
                "parent": None
            }
        ]
    }


@app.post("/v1/embeddings")
async def create_embeddings(request: EmbeddingRequest):
    if model is None:
        raise HTTPException(status_code=503, detail="Model not ready")
    
    # Normalize input to list
    if isinstance(request.input, str):
        texts = [request.input]
    else:
        texts = request.input
    
    if not texts:
        raise HTTPException(status_code=400, detail="input cannot be empty")
    
    if len(texts) > 512:
        raise HTTPException(status_code=400, detail="Cannot embed more than 512 texts at once")
    
    # Create batch item
    batch_item = BatchItem(texts)
    
    async with batch_lock:
        pending_batch.append(batch_item)
        
        # Signal processor if we hit batch size
        if len(pending_batch) >= MAX_BATCH_SIZE:
            batch_event.set()
    
    # Signal processor that there's work
    batch_event.set()
    
    # Wait for result with timeout
    try:
        embeddings_list = await asyncio.wait_for(batch_item.future, timeout=30.0)
    except asyncio.TimeoutError:
        raise HTTPException(status_code=504, detail="Embedding request timed out")
    
    # Format response as OpenAI-compatible
    data = []
    for idx, embedding in enumerate(embeddings_list):
        data.append({
            "object": "embedding",
            "embedding": embedding,
            "index": idx
        })
    
    return {
        "object": "list",
        "data": data,
        "model": MODEL_NAME,
        "usage": {
            "prompt_tokens": sum(len(text.split()) for text in texts),
            "total_tokens": sum(len(text.split()) for text in texts)
        }
    }


@app.get("/health")
async def health():
    """Health check endpoint"""
    if model is None:
        return JSONResponse({"status": "unhealthy", "reason": "model not loaded"}, status_code=503)
    return {"status": "healthy"}


@app.get("/")
async def root():
    """API info"""
    return {
        "name": "OpenAI-Compatible Embedding API",
        "model": MODEL_NAME,
        "endpoints": {
            "GET /v1/models": "List available models",
            "POST /v1/embeddings": "Create embeddings (OpenAI-compatible)",
            "GET /health": "Health check"
        }
    }


if __name__ == "__main__":
    import uvicorn
    uvicorn.run(app, host="0.0.0.0", port=7860)