Spaces:
Running
Running
| 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) | |
| 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") | |
| 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 | |
| } | |
| ] | |
| } | |
| 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) | |
| } | |
| } | |
| async def health(): | |
| """Health check endpoint""" | |
| if model is None: | |
| return JSONResponse({"status": "unhealthy", "reason": "model not loaded"}, status_code=503) | |
| return {"status": "healthy"} | |
| 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) | |