diff --git a/.gitignore b/.gitignore index 43b5af56..22bf05bc 100644 --- a/.gitignore +++ b/.gitignore @@ -128,6 +128,8 @@ ENV/ env.bak/ venv.bak/ +*.sqlite +.ruff_cache/ # Spyder project settings .spyderproject .spyproject diff --git a/README.md b/README.md index 83c2cfa1..d8817bd1 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,6 @@ # NilAI +Copy the `.env.sample` to `.env` to and replace the value of the `HUGGINGFACE_API_TOKEN` with the appropriate value. It is required to download Llama3.2 1B. ```shell docker compose up --build web diff --git a/db/.gitignore b/db/.gitignore new file mode 100644 index 00000000..6a91a439 --- /dev/null +++ b/db/.gitignore @@ -0,0 +1 @@ +*.sqlite \ No newline at end of file diff --git a/db/README.md b/db/README.md new file mode 100644 index 00000000..45b7c5c8 --- /dev/null +++ b/db/README.md @@ -0,0 +1,3 @@ +# DB + +This directory is meant to host the db data. \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 03b1fed8..24f88723 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,13 +1,13 @@ - services: - web: + nilai: build: context: . dockerfile: docker/Dockerfile ports: - "12345:12345" volumes: - - hugging_face_models:/root/.cache/huggingface + - ${PWD}/db/:/app/db/ # sqlite database for users + - hugging_face_models:/root/.cache/huggingface # cache models volumes: hugging_face_models: \ No newline at end of file diff --git a/docker/Dockerfile b/docker/Dockerfile index 49f951e3..24c2076a 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,6 +1,7 @@ FROM python:3.12-slim -COPY . /app +COPY --link nilai /app/nilai +COPY pyproject.toml uv.lock .env /app/ WORKDIR /app @@ -9,5 +10,7 @@ RUN uv sync EXPOSE 12345 -ENTRYPOINT ["uv", "run", "fastapi", "run", "nilai/server.py"] -CMD ["--host", "0.0.0.0", "--port", "12345"] \ No newline at end of file +# ENTRYPOINT ["uv", "run", "fastapi", "run", "nilai/main.py"] +# CMD ["--host", "0.0.0.0", "--port", "12345"] + +CMD ["uv", "run", "fastapi", "run", "nilai/main.py", "--host", "0.0.0.0", "--port", "12345"] \ No newline at end of file diff --git a/docker/README.md b/docker/README.md new file mode 100644 index 00000000..e82afc29 --- /dev/null +++ b/docker/README.md @@ -0,0 +1,11 @@ + +```shell +docker build -t nillion/nilai:latest -f docker/Dockerfile . + + +docker run \ + -p 12345:12345 \ + -v hugging_face_models:/root/.cache/huggingface \ + -v $(pwd)/users.sqlite:/app/users.sqlite \ + nillion/nilai:latest +``` \ No newline at end of file diff --git a/nilai/auth.py b/nilai/auth.py new file mode 100644 index 00000000..2f753bf9 --- /dev/null +++ b/nilai/auth.py @@ -0,0 +1,17 @@ +from fastapi import HTTPException, Security, status +from fastapi.security import APIKeyHeader + +from nilai.db import UserManager + +UserManager.initialize_db() + +api_key_header = APIKeyHeader(name="X-API-Key") + + +def get_user(api_key_header: str = Security(api_key_header)): + user = UserManager.check_api_key(api_key_header) + if user: + return user + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing or invalid API key" + ) diff --git a/nilai/db.py b/nilai/db.py new file mode 100644 index 00000000..49f54441 --- /dev/null +++ b/nilai/db.py @@ -0,0 +1,260 @@ +import logging +import uuid +from contextlib import contextmanager +from dataclasses import dataclass +from typing import Any, Dict, Generator, List, Optional + +import sqlalchemy +from sqlalchemy import Column, Integer, String, create_engine +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.orm import Session, sessionmaker +from sqlalchemy.pool import QueuePool + +# Configure logging + +logger = logging.getLogger(__name__) + + +# Database configuration with better defaults and connection pooling +class DatabaseConfig: + # Use environment variables in a real-world scenario + DATABASE_URL = "sqlite:///db/users.sqlite" + POOL_SIZE = 5 + MAX_OVERFLOW = 10 + POOL_TIMEOUT = 30 + POOL_RECYCLE = 3600 # Reconnect after 1 hour + + +# Create base and engine with improved configuration +Base = sqlalchemy.orm.declarative_base() +engine = create_engine( + DatabaseConfig.DATABASE_URL, + poolclass=QueuePool, + pool_size=DatabaseConfig.POOL_SIZE, + max_overflow=DatabaseConfig.MAX_OVERFLOW, + pool_timeout=DatabaseConfig.POOL_TIMEOUT, + pool_recycle=DatabaseConfig.POOL_RECYCLE, + echo=False, # Set to True for SQL logging during development +) + +# Create session factory with improved settings +SessionLocal = sessionmaker( + bind=engine, + autocommit=False, # Changed to False for more explicit transaction control + autoflush=False, # More control over when to flush + expire_on_commit=False, # Keep objects usable after session closes +) + + +# Enhanced User Model with additional constraints and validation +class User(Base): + __tablename__ = "users" + + userid = Column(String(36), primary_key=True, index=True) + name = Column(String(100), nullable=False) + apikey = Column(String(36), unique=True, nullable=False, index=True) + input_tokens = Column(Integer, default=0, nullable=False) + generated_tokens = Column(Integer, default=0, nullable=False) + + def __repr__(self): + return f"" + + +@dataclass +class UserData: + userid: str + name: str + apikey: str + input_tokens: int + generated_tokens: int + + +# Context manager for database sessions +@contextmanager +def get_db_session() -> "Generator[Session, Any, Any]": + """Provide a transactional scope for database operations.""" + session = SessionLocal() + try: + yield session + session.commit() + except SQLAlchemyError as e: + session.rollback() + logger.error(f"Database error: {e}") + raise + finally: + session.close() + + +class UserManager: + @staticmethod + def initialize_db() -> bool: + """ + Create database tables only if they do not already exist. + + Returns: + bool: True if tables were created, False if tables already existed + """ + try: + # Create an inspector to check existing tables + inspector = sqlalchemy.inspect(engine) + + # Check if the 'users' table already exists + if not inspector.has_table("users"): + # Create all tables that do not exist + Base.metadata.create_all(bind=engine) + logger.info("Database tables created successfully.") + return True + else: + logger.info("Database tables already exist. Skipping creation.") + return False + except SQLAlchemyError as e: + logger.error(f"Error checking or creating database tables: {e}") + raise + + @staticmethod + def generate_user_id() -> str: + """Generate a unique user ID.""" + return str(uuid.uuid4()) + + @staticmethod + def generate_api_key() -> str: + """Generate a unique API key.""" + return str(uuid.uuid4()) + + @staticmethod + def insert_user(name: str) -> Dict[str, str]: + """ + Insert a new user into the database. + + Args: + name (str): Name of the user + + Returns: + Dict containing userid and apikey + """ + userid = UserManager.generate_user_id() + apikey = UserManager.generate_api_key() + + try: + with get_db_session() as session: + user = User(userid=userid, name=name, apikey=apikey) + session.add(user) + logger.info(f"User {name} added successfully.") + return {"userid": userid, "apikey": apikey} + except SQLAlchemyError as e: + logger.error(f"Error inserting user: {e}") + raise + + @staticmethod + def check_api_key(api_key: str) -> Optional[str]: + """ + Validate an API key. + + Args: + api_key (str): API key to validate + + Returns: + User's name if API key is valid, None otherwise + """ + try: + with get_db_session() as session: + user = session.query(User).filter(User.apikey == api_key).first() + return user.name if user else None # type: ignore + except SQLAlchemyError as e: + logger.error(f"Error checking API key: {e}") + return None + + @staticmethod + def update_token_usage(userid: str, input_tokens: int, generated_tokens: int): + """ + Update token usage for a specific user. + + Args: + userid (str): User's unique ID + input_tokens (int): Number of input tokens + generated_tokens (int): Number of generated tokens + """ + try: + with get_db_session() as session: + user = session.query(User).filter(User.userid == userid).first() + if user: + user.input_tokens += input_tokens # type: ignore + user.generated_tokens += generated_tokens # type: ignore + logger.info(f"Updated token usage for user {userid}") + else: + logger.warning(f"User {userid} not found") + except SQLAlchemyError as e: + logger.error(f"Error updating token usage: {e}") + + @staticmethod + def get_all_users() -> Optional[List[UserData]]: + """ + Retrieve all users from the database. + + Returns: + Dict of users or None if no users found + """ + try: + with get_db_session() as session: + users = session.query(User).all() + return [ + UserData( + userid=user.userid, # type: ignore + name=user.name, # type: ignore + apikey=user.apikey, # type: ignore + input_tokens=user.input_tokens, # type: ignore + generated_tokens=user.generated_tokens, # type: ignore + ) + for user in users + ] + except SQLAlchemyError as e: + logger.error(f"Error retrieving all users: {e}") + return None + + @staticmethod + def get_user_token_usage(userid: str) -> Optional[Dict[str, int]]: + """ + Retrieve total token usage for a user. + + Args: + userid (str): User's unique ID + + Returns: + Dict of token usage or None if user not found + """ + try: + with get_db_session() as session: + user = session.query(User).filter(User.userid == userid).first() + if user: + return { + "input_tokens": user.input_tokens, + "generated_tokens": user.generated_tokens, + } # type: ignore + return None + except SQLAlchemyError as e: + logger.error(f"Error retrieving token usage: {e}") + return None + + +# Example Usage +if __name__ == "__main__": + # Initialize the database + UserManager.initialize_db() + + print(UserManager.get_all_users()) + + # Add some users + bob = UserManager.insert_user("Bob") + alice = UserManager.insert_user("Alice") + + print(f"Bob's details: {bob}") + print(f"Alice's details: {alice}") + + # Check API key + user_name = UserManager.check_api_key(bob["apikey"]) + print(f"API key validation: {user_name}") + + # Update and retrieve token usage + UserManager.update_token_usage(bob["userid"], input_tokens=50, generated_tokens=20) + usage = UserManager.get_user_token_usage(bob["userid"]) + print(f"Bob's token usage: {usage}") diff --git a/nilai/main.py b/nilai/main.py new file mode 100644 index 00000000..08cd5d9d --- /dev/null +++ b/nilai/main.py @@ -0,0 +1,58 @@ +# Fast API and serving +from fastapi import Depends, FastAPI + +from nilai.auth import get_user +from nilai.routers import private, public + +app = FastAPI( + title="NilAI", + description="An AI model serving platform based on TEE", + version="0.1.0", + terms_of_service="https://nillion.com", + contact={ + "name": "Nillion AI Support", + # "url": "https://nillion.com", + "email": "jose.cabrero@nillion.com", + }, + license_info={ + "name": "Apache 2.0", + "url": "https://www.apache.org/licenses/LICENSE-2.0", + }, + openapi_tags=[ + { + "name": "Attestation", + "description": "Retrieve attestation information", + }, + { + "name": "Chat", + "description": "Chat completion endpoint", + }, + { + "name": "Health", + "description": "Health check endpoint", + }, + { + "name": "Model", + "description": "Model information", + }, + ], +) + + +app.include_router(public.router) +app.include_router(private.router, dependencies=[Depends(get_user)]) + +if __name__ == "__main__": + import uvicorn + + # Path to your SSL certificate and key files + # SSL_CERTFILE = "/path/to/certificate.pem" # Replace with your certificate file path + # SSL_KEYFILE = "/path/to/private-key.pem" # Replace with your private key file path + + uvicorn.run( + app, + host="0.0.0.0", # Listen on all interfaces + port=12345, # Use port 8443 for HTTPS + # ssl_certfile=SSL_CERTFILE, + # ssl_keyfile=SSL_KEYFILE, + ) diff --git a/nilai/routers/__init__.py b/nilai/routers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/nilai/server.py b/nilai/routers/private.py similarity index 53% rename from nilai/server.py rename to nilai/routers/private.py index a3a42866..10edc751 100644 --- a/nilai/server.py +++ b/nilai/routers/private.py @@ -1,65 +1,32 @@ # Fast API and serving import time from base64 import b64encode +from typing import Any, List from uuid import uuid4 -from fastapi import Body, FastAPI, HTTPException -from fastapi.responses import JSONResponse +from fastapi import APIRouter, Body, Depends, HTTPException +from nilai.auth import get_user from nilai.crypto import sign_message + # Internal libraries -from nilai.model import (AttestationResponse, ChatRequest, ChatResponse, - Choice, HealthCheckResponse, Message, Model, Usage) - -app = FastAPI( - title="NilAI", - description="An AI model serving platform based on TEE", - version="0.1.0", - terms_of_service="https://nillion.com", - contact={ - "name": "Nillion AI Support", - # "url": "https://nillion.com", - "email": "jose.cabrero@nillion.com", - }, - license_info={ - "name": "Apache 2.0", - "url": "https://www.apache.org/licenses/LICENSE-2.0", - }, - openapi_tags=[ - { - "name": "Attestation", - "description": "Retrieve attestation information", - }, - { - "name": "Chat", - "description": "Chat completion endpoint", - }, - { - "name": "Health", - "description": "Health check endpoint", - }, - { - "name": "Model", - "description": "Model information", - }, - ], +from nilai.model import ( + AttestationResponse, + ChatRequest, + ChatResponse, + Choice, + Message, + Model, + Usage, ) +from nilai.state import state - -from nilai.state import AppState - -state = AppState() - - -# Health Check Endpoint -@app.get("/v1/health", tags=["Health"]) -async def health_check() -> HealthCheckResponse: - return HealthCheckResponse(status="ok", uptime=state.uptime) +router = APIRouter() # Model Information Endpoint -@app.get("/v1/model-info", tags=["Model"]) -async def get_model_info(): +@router.get("/v1/model-info", tags=["Model"]) +async def get_model_info(user: str = Depends(get_user)) -> dict: return { "model_name": state.models[0].name, "version": state.models[0].version, @@ -69,8 +36,8 @@ async def get_model_info(): # Attestation Report Endpoint -@app.get("/v1/attestation/report", tags=["Attestation"]) -async def get_attestation() -> AttestationResponse: +@router.get("/v1/attestation/report", tags=["Attestation"]) +async def get_attestation(user: str = Depends(get_user)) -> AttestationResponse: return AttestationResponse( verifying_key=state.verifying_key, cpu_attestation="...", @@ -79,13 +46,13 @@ async def get_attestation() -> AttestationResponse: # Available Models Endpoint -@app.get("/v1/models", tags=["Model"]) -async def get_models() -> dict[str, list[Model]]: +@router.get("/v1/models", tags=["Model"]) +async def get_models(user: str = Depends(get_user)) -> dict[str, list[Model]]: return {"models": state.models} # Chat Completion Endpoint -@app.post("/v1/chat/completions", tags=["Chat"]) +@router.post("/v1/chat/completions", tags=["Chat"]) def chat_completion( req: ChatRequest = Body( ChatRequest( @@ -95,7 +62,8 @@ def chat_completion( Message(role="user", content="What is your name?"), ], ) - ) + ), + user: str = Depends(get_user), ) -> ChatResponse: if not req.messages or len(req.messages) == 0: raise HTTPException(status_code=400, detail="The 'messages' field is required.") @@ -115,9 +83,10 @@ def chat_completion( ] # Generate response - generated = state.chat_pipeline( + generated: List[Any] = state.chat_pipeline( prompt, max_length=1024, num_return_sequences=1, truncation=True - ) + ) # type: ignore + print(type(generated)) if not generated or len(generated) == 0: raise HTTPException(status_code=500, detail="The model returned no output.") @@ -147,26 +116,8 @@ def chat_completion( ) # Sign the response - response_json = response.json() + response_json = response.model_dump_json() signature = sign_message(state.private_key, response_json) response.signature = b64encode(signature).decode() - return JSONResponse( - content=response.dict(), headers={"Content-Type": "application/json"} - ) - - -if __name__ == "__main__": - import uvicorn - - # Path to your SSL certificate and key files - # SSL_CERTFILE = "/path/to/certificate.pem" # Replace with your certificate file path - # SSL_KEYFILE = "/path/to/private-key.pem" # Replace with your private key file path - - uvicorn.run( - app, - host="0.0.0.0", # Listen on all interfaces - port=12345, # Use port 8443 for HTTPS - # ssl_certfile=SSL_CERTFILE, - # ssl_keyfile=SSL_KEYFILE, - ) + return response diff --git a/nilai/routers/public.py b/nilai/routers/public.py new file mode 100644 index 00000000..cd7f52d2 --- /dev/null +++ b/nilai/routers/public.py @@ -0,0 +1,14 @@ +# Fast API and serving +from fastapi import APIRouter + +# Internal libraries +from nilai.model import HealthCheckResponse +from nilai.state import state + +router = APIRouter() + + +# Health Check Endpoint +@router.get("/v1/health", tags=["Health"]) +async def health_check() -> HealthCheckResponse: + return HealthCheckResponse(status="ok", uptime=state.uptime) diff --git a/nilai/state.py b/nilai/state.py index 4fd0f0d1..ce0ed436 100644 --- a/nilai/state.py +++ b/nilai/state.py @@ -1,20 +1,21 @@ +import os import time import torch +from dotenv import load_dotenv from transformers import pipeline from nilai.crypto import generate_key_pair from nilai.model import Model -from dotenv import load_dotenv -import os - # Load the .env file load_dotenv() -# Application State Initialization -torch.set_num_threads(1) -torch.set_num_interop_threads(1) +# # Application State Initialization +# torch.set_num_threads(1) +# torch.set_num_interop_threads(1) + + class AppState: def __init__(self): self.private_key, self.public_key, self.verifying_key = generate_key_pair() @@ -57,3 +58,6 @@ def uptime(self): parts.append(f"{int(seconds)} seconds") return ", ".join(parts) + + +state = AppState() diff --git a/pyproject.toml b/pyproject.toml index f2dce978..57cc25b1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ dependencies = [ "fastapi[standard]>=0.115.5", "gunicorn>=23.0.0", "python-dotenv>=1.0.1", + "sqlalchemy>=2.0.36", "torch>=2.5.1", "transformers>=4.46.3", "uvicorn>=0.32.1", @@ -22,6 +23,7 @@ dependencies = [ dev = [ "black>=24.10.0", "isort>=5.13.2", + "pytest>=8.3.3", "ruff>=0.8.0", "uvicorn>=0.32.1", ] @@ -31,4 +33,4 @@ requires = ["setuptools>=42", "wheel"] build-backend = "setuptools.build_meta" [tool.setuptools.packages] -find = { include = ["nilai"] } \ No newline at end of file +find = { include = ["nilai"] } diff --git a/tests/test_db.py b/tests/test_db.py new file mode 100644 index 00000000..574bd26d --- /dev/null +++ b/tests/test_db.py @@ -0,0 +1,183 @@ +import uuid + +import pytest +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import StaticPool + +import nilai.db as db +# Import the classes and functions to test +from nilai.db import Base, DatabaseConfig, User, UserManager, get_db_session + + +@pytest.fixture(scope="function") +def in_memory_db(): + """ + Create an in-memory SQLite database for testing. + This ensures each test runs with a clean database. + """ + # Use in-memory SQLite database with StaticPool for thread safety + db.engine = create_engine( + "sqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + + # Create tables + Base.metadata.create_all(bind=db.engine) + + # Create a test session factory + db.SessionLocal = sessionmaker(bind=db.engine, autocommit=False, autoflush=False) + + try: + yield db.engine + finally: + Base.metadata.drop_all(bind=db.engine) + + +@pytest.fixture +def user_manager(in_memory_db): + """Fixture to provide a clean UserManager for each test.""" + return UserManager + + +class TestUserManager: + def test_generate_user_id(self, user_manager): + """Test that generate_user_id creates a valid UUID.""" + user_id = user_manager.generate_user_id() + + # Validate UUID + try: + uuid_obj = uuid.UUID(user_id) + assert str(uuid_obj) == user_id + except ValueError: + pytest.fail("Generated user ID is not a valid UUID") + + def test_generate_api_key(self, user_manager): + """Test that generate_api_key creates a valid UUID.""" + api_key = user_manager.generate_api_key() + + # Validate UUID + try: + uuid_obj = uuid.UUID(api_key) + assert str(uuid_obj) == api_key + except ValueError: + pytest.fail("Generated API key is not a valid UUID") + + def test_insert_user(self, user_manager): + """Test inserting a new user.""" + # Insert a user + user_data = user_manager.insert_user("Test User") + + # Validate returned data + assert "userid" in user_data + assert "apikey" in user_data + assert len(user_data["userid"]) > 0 + assert len(user_data["apikey"]) > 0 + + # Verify user can be retrieved + retrieved_user_tokens = user_manager.get_user_token_usage(user_data["userid"]) + assert retrieved_user_tokens is not None + assert retrieved_user_tokens["input_tokens"] == 0 + assert retrieved_user_tokens["generated_tokens"] == 0 + + def test_check_api_key(self, user_manager): + """Test API key validation.""" + # Insert a user + user_data = user_manager.insert_user("Check API User") + + # Check valid API key + user_name = user_manager.check_api_key(user_data["apikey"]) + assert user_name == "Check API User" + + # Check invalid API key + invalid_result = user_manager.check_api_key("invalid-api-key") + assert invalid_result is None + + def test_update_token_usage(self, user_manager): + """Test updating token usage for a user.""" + # Insert a user + user_data = user_manager.insert_user("Token User") + + # Update token usage + user_manager.update_token_usage( + user_data["userid"], input_tokens=100, generated_tokens=50 + ) + + # Verify token usage + token_usage = user_manager.get_user_token_usage(user_data["userid"]) + assert token_usage is not None + assert token_usage["input_tokens"] == 100 + assert token_usage["generated_tokens"] == 50 + + # Update again to check cumulative effect + user_manager.update_token_usage( + user_data["userid"], input_tokens=50, generated_tokens=25 + ) + + token_usage = user_manager.get_user_token_usage(user_data["userid"]) + assert token_usage is not None + assert token_usage["input_tokens"] == 150 + assert token_usage["generated_tokens"] == 75 + + def test_get_all_users(self, user_manager): + """Test retrieving all users.""" + # Insert multiple users + user1 = user_manager.insert_user("User 1") + user2 = user_manager.insert_user("User 2") + + # Retrieve all users + all_users = user_manager.get_all_users() + + user_names = [user.name for user in all_users] + assert all_users is not None + assert len(all_users) >= 2 + + # Verify user names + assert "User 1" in user_names + assert "User 2" in user_names + + def test_get_user_token_usage_nonexistent(self, user_manager): + """Test getting token usage for a non-existent user.""" + # Try to get token usage for a non-existent user + nonexistent_userid = "non-existent-user-id" + token_usage = user_manager.get_user_token_usage(nonexistent_userid) + + assert token_usage is None + + +class TestDatabaseInitialization: + def test_initialize_db(self, user_manager): + """Test database initialization.""" + # First call should return False (tables already exist) in this case + second_result = user_manager.initialize_db() + assert second_result is False + + +# Performance and Concurrency Tests +class TestConcurrency: + @pytest.mark.parametrize("num_users", [10, 50, 100]) + def test_bulk_user_creation(self, user_manager, num_users): + """Test creating multiple users concurrently.""" + users = [] + for i in range(num_users): + user = user_manager.insert_user(f"User {i}") + users.append(user) + + # Verify all users were created + all_users = user_manager.get_all_users() + assert all_users is not None + assert len(all_users) >= num_users + + +# Additional Tests Configuration +def pytest_configure(config): + """Configure pytest with additional settings.""" + config.addinivalue_line( + "markers", "concurrency: mark test to run with multiple concurrent operations" + ) + + +# Test Runner Configuration +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/uv.lock b/uv.lock index 757fb79f..0e30bbf4 100644 --- a/uv.lock +++ b/uv.lock @@ -278,6 +278,39 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c6/b2/454d6e7f0158951d8a78c2e1eb4f69ae81beb8dca5fee9809c6c99e9d0d0/fsspec-2024.10.0-py3-none-any.whl", hash = "sha256:03b9a6785766a4de40368b88906366755e2819e758b83705c88cd7cb5fe81871", size = 179641 }, ] +[[package]] +name = "greenlet" +version = "3.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2f/ff/df5fede753cc10f6a5be0931204ea30c35fa2f2ea7a35b25bdaf4fe40e46/greenlet-3.1.1.tar.gz", hash = "sha256:4ce3ac6cdb6adf7946475d7ef31777c26d94bccc377e070a7986bd2d5c515467", size = 186022 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/ec/bad1ac26764d26aa1353216fcbfa4670050f66d445448aafa227f8b16e80/greenlet-3.1.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:4afe7ea89de619adc868e087b4d2359282058479d7cfb94970adf4b55284574d", size = 274260 }, + { url = "https://files.pythonhosted.org/packages/66/d4/c8c04958870f482459ab5956c2942c4ec35cac7fe245527f1039837c17a9/greenlet-3.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f406b22b7c9a9b4f8aa9d2ab13d6ae0ac3e85c9a809bd590ad53fed2bf70dc79", size = 649064 }, + { url = "https://files.pythonhosted.org/packages/51/41/467b12a8c7c1303d20abcca145db2be4e6cd50a951fa30af48b6ec607581/greenlet-3.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c3a701fe5a9695b238503ce5bbe8218e03c3bcccf7e204e455e7462d770268aa", size = 663420 }, + { url = "https://files.pythonhosted.org/packages/27/8f/2a93cd9b1e7107d5c7b3b7816eeadcac2ebcaf6d6513df9abaf0334777f6/greenlet-3.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2846930c65b47d70b9d178e89c7e1a69c95c1f68ea5aa0a58646b7a96df12441", size = 658035 }, + { url = "https://files.pythonhosted.org/packages/57/5c/7c6f50cb12be092e1dccb2599be5a942c3416dbcfb76efcf54b3f8be4d8d/greenlet-3.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:99cfaa2110534e2cf3ba31a7abcac9d328d1d9f1b95beede58294a60348fba36", size = 660105 }, + { url = "https://files.pythonhosted.org/packages/f1/66/033e58a50fd9ec9df00a8671c74f1f3a320564c6415a4ed82a1c651654ba/greenlet-3.1.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1443279c19fca463fc33e65ef2a935a5b09bb90f978beab37729e1c3c6c25fe9", size = 613077 }, + { url = "https://files.pythonhosted.org/packages/19/c5/36384a06f748044d06bdd8776e231fadf92fc896bd12cb1c9f5a1bda9578/greenlet-3.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b7cede291382a78f7bb5f04a529cb18e068dd29e0fb27376074b6d0317bf4dd0", size = 1135975 }, + { url = "https://files.pythonhosted.org/packages/38/f9/c0a0eb61bdf808d23266ecf1d63309f0e1471f284300ce6dac0ae1231881/greenlet-3.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:23f20bb60ae298d7d8656c6ec6db134bca379ecefadb0b19ce6f19d1f232a942", size = 1163955 }, + { url = "https://files.pythonhosted.org/packages/43/21/a5d9df1d21514883333fc86584c07c2b49ba7c602e670b174bd73cfc9c7f/greenlet-3.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:7124e16b4c55d417577c2077be379514321916d5790fa287c9ed6f23bd2ffd01", size = 299655 }, + { url = "https://files.pythonhosted.org/packages/f3/57/0db4940cd7bb461365ca8d6fd53e68254c9dbbcc2b452e69d0d41f10a85e/greenlet-3.1.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:05175c27cb459dcfc05d026c4232f9de8913ed006d42713cb8a5137bd49375f1", size = 272990 }, + { url = "https://files.pythonhosted.org/packages/1c/ec/423d113c9f74e5e402e175b157203e9102feeb7088cee844d735b28ef963/greenlet-3.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:935e943ec47c4afab8965954bf49bfa639c05d4ccf9ef6e924188f762145c0ff", size = 649175 }, + { url = "https://files.pythonhosted.org/packages/a9/46/ddbd2db9ff209186b7b7c621d1432e2f21714adc988703dbdd0e65155c77/greenlet-3.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:667a9706c970cb552ede35aee17339a18e8f2a87a51fba2ed39ceeeb1004798a", size = 663425 }, + { url = "https://files.pythonhosted.org/packages/bc/f9/9c82d6b2b04aa37e38e74f0c429aece5eeb02bab6e3b98e7db89b23d94c6/greenlet-3.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b8a678974d1f3aa55f6cc34dc480169d58f2e6d8958895d68845fa4ab566509e", size = 657736 }, + { url = "https://files.pythonhosted.org/packages/d9/42/b87bc2a81e3a62c3de2b0d550bf91a86939442b7ff85abb94eec3fc0e6aa/greenlet-3.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:efc0f674aa41b92da8c49e0346318c6075d734994c3c4e4430b1c3f853e498e4", size = 660347 }, + { url = "https://files.pythonhosted.org/packages/37/fa/71599c3fd06336cdc3eac52e6871cfebab4d9d70674a9a9e7a482c318e99/greenlet-3.1.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0153404a4bb921f0ff1abeb5ce8a5131da56b953eda6e14b88dc6bbc04d2049e", size = 615583 }, + { url = "https://files.pythonhosted.org/packages/4e/96/e9ef85de031703ee7a4483489b40cf307f93c1824a02e903106f2ea315fe/greenlet-3.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:275f72decf9932639c1c6dd1013a1bc266438eb32710016a1c742df5da6e60a1", size = 1133039 }, + { url = "https://files.pythonhosted.org/packages/87/76/b2b6362accd69f2d1889db61a18c94bc743e961e3cab344c2effaa4b4a25/greenlet-3.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:c4aab7f6381f38a4b42f269057aee279ab0fc7bf2e929e3d4abfae97b682a12c", size = 1160716 }, + { url = "https://files.pythonhosted.org/packages/1f/1b/54336d876186920e185066d8c3024ad55f21d7cc3683c856127ddb7b13ce/greenlet-3.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:b42703b1cf69f2aa1df7d1030b9d77d3e584a70755674d60e710f0af570f3761", size = 299490 }, + { url = "https://files.pythonhosted.org/packages/5f/17/bea55bf36990e1638a2af5ba10c1640273ef20f627962cf97107f1e5d637/greenlet-3.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f1695e76146579f8c06c1509c7ce4dfe0706f49c6831a817ac04eebb2fd02011", size = 643731 }, + { url = "https://files.pythonhosted.org/packages/78/d2/aa3d2157f9ab742a08e0fd8f77d4699f37c22adfbfeb0c610a186b5f75e0/greenlet-3.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7876452af029456b3f3549b696bb36a06db7c90747740c5302f74a9e9fa14b13", size = 649304 }, + { url = "https://files.pythonhosted.org/packages/f1/8e/d0aeffe69e53ccff5a28fa86f07ad1d2d2d6537a9506229431a2a02e2f15/greenlet-3.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4ead44c85f8ab905852d3de8d86f6f8baf77109f9da589cb4fa142bd3b57b475", size = 646537 }, + { url = "https://files.pythonhosted.org/packages/05/79/e15408220bbb989469c8871062c97c6c9136770657ba779711b90870d867/greenlet-3.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8320f64b777d00dd7ccdade271eaf0cad6636343293a25074cc5566160e4de7b", size = 642506 }, + { url = "https://files.pythonhosted.org/packages/18/87/470e01a940307796f1d25f8167b551a968540fbe0551c0ebb853cb527dd6/greenlet-3.1.1-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6510bf84a6b643dabba74d3049ead221257603a253d0a9873f55f6a59a65f822", size = 602753 }, + { url = "https://files.pythonhosted.org/packages/e2/72/576815ba674eddc3c25028238f74d7b8068902b3968cbe456771b166455e/greenlet-3.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:04b013dc07c96f83134b1e99888e7a79979f1a247e2a9f59697fa14b5862ed01", size = 1122731 }, + { url = "https://files.pythonhosted.org/packages/ac/38/08cc303ddddc4b3d7c628c3039a61a3aae36c241ed01393d00c2fd663473/greenlet-3.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:411f015496fec93c1c8cd4e5238da364e1da7a124bcb293f085bf2860c32c6f6", size = 1142112 }, +] + [[package]] name = "gunicorn" version = "23.0.0" @@ -377,6 +410,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, ] +[[package]] +name = "iniconfig" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 }, +] + [[package]] name = "isort" version = "5.13.2" @@ -494,6 +536,7 @@ dependencies = [ { name = "fastapi", extra = ["standard"] }, { name = "gunicorn" }, { name = "python-dotenv" }, + { name = "sqlalchemy" }, { name = "torch" }, { name = "transformers" }, { name = "uvicorn" }, @@ -503,6 +546,7 @@ dependencies = [ dev = [ { name = "black" }, { name = "isort" }, + { name = "pytest" }, { name = "ruff" }, { name = "uvicorn" }, ] @@ -514,6 +558,7 @@ requires-dist = [ { name = "fastapi", extras = ["standard"], specifier = ">=0.115.5" }, { name = "gunicorn", specifier = ">=23.0.0" }, { name = "python-dotenv", specifier = ">=1.0.1" }, + { name = "sqlalchemy", specifier = ">=2.0.36" }, { name = "torch", specifier = ">=2.5.1" }, { name = "transformers", specifier = ">=4.46.3" }, { name = "uvicorn", specifier = ">=0.32.1" }, @@ -523,6 +568,7 @@ requires-dist = [ dev = [ { name = "black", specifier = ">=24.10.0" }, { name = "isort", specifier = ">=5.13.2" }, + { name = "pytest", specifier = ">=8.3.3" }, { name = "ruff", specifier = ">=0.8.0" }, { name = "uvicorn", specifier = ">=0.32.1" }, ] @@ -712,6 +758,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3c/a6/bc1012356d8ece4d66dd75c4b9fc6c1f6650ddd5991e421177d9f8f671be/platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb", size = 18439 }, ] +[[package]] +name = "pluggy" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 }, +] + [[package]] name = "psutil" version = "6.1.0" @@ -798,6 +853,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f7/3f/01c8b82017c199075f8f788d0d906b9ffbbc5a47dc9918a945e13d5a2bda/pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a", size = 1205513 }, ] +[[package]] +name = "pytest" +version = "8.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/6c/62bbd536103af674e227c41a8f3dcd022d591f6eed5facb5a0f31ee33bbc/pytest-8.3.3.tar.gz", hash = "sha256:70b98107bd648308a7952b06e6ca9a50bc660be218d53c257cc1fc94fda10181", size = 1442487 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/77/7440a06a8ead44c7757a64362dd22df5760f9b12dc5f11b6188cd2fc27a0/pytest-8.3.3-py3-none-any.whl", hash = "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2", size = 342341 }, +] + [[package]] name = "python-dotenv" version = "1.0.1" @@ -990,6 +1060,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, ] +[[package]] +name = "sqlalchemy" +version = "2.0.36" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "(python_full_version < '3.13' and platform_machine == 'AMD64') or (python_full_version < '3.13' and platform_machine == 'WIN32') or (python_full_version < '3.13' and platform_machine == 'aarch64') or (python_full_version < '3.13' and platform_machine == 'amd64') or (python_full_version < '3.13' and platform_machine == 'ppc64le') or (python_full_version < '3.13' and platform_machine == 'win32') or (python_full_version < '3.13' and platform_machine == 'x86_64')" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/65/9cbc9c4c3287bed2499e05033e207473504dc4df999ce49385fb1f8b058a/sqlalchemy-2.0.36.tar.gz", hash = "sha256:7f2767680b6d2398aea7082e45a774b2b0767b5c8d8ffb9c8b683088ea9b29c5", size = 9574485 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/bf/005dc47f0e57556e14512d5542f3f183b94fde46e15ff1588ec58ca89555/SQLAlchemy-2.0.36-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f7b64e6ec3f02c35647be6b4851008b26cff592a95ecb13b6788a54ef80bbdd4", size = 2092378 }, + { url = "https://files.pythonhosted.org/packages/94/65/f109d5720779a08e6e324ec89a744f5f92c48bd8005edc814bf72fbb24e5/SQLAlchemy-2.0.36-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:46331b00096a6db1fdc052d55b101dbbfc99155a548e20a0e4a8e5e4d1362855", size = 2082778 }, + { url = "https://files.pythonhosted.org/packages/60/f6/d9aa8c49c44f9b8c9b9dada1f12fa78df3d4c42aa2de437164b83ee1123c/SQLAlchemy-2.0.36-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdf3386a801ea5aba17c6410dd1dc8d39cf454ca2565541b5ac42a84e1e28f53", size = 3232191 }, + { url = "https://files.pythonhosted.org/packages/8a/ab/81d4514527c068670cb1d7ab62a81a185df53a7c379bd2a5636e83d09ede/SQLAlchemy-2.0.36-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac9dfa18ff2a67b09b372d5db8743c27966abf0e5344c555d86cc7199f7ad83a", size = 3243044 }, + { url = "https://files.pythonhosted.org/packages/35/b4/f87c014ecf5167dc669199cafdb20a7358ff4b1d49ce3622cc48571f811c/SQLAlchemy-2.0.36-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:90812a8933df713fdf748b355527e3af257a11e415b613dd794512461eb8a686", size = 3178511 }, + { url = "https://files.pythonhosted.org/packages/ea/09/badfc9293bc3ccba6ede05e5f2b44a760aa47d84da1fc5a326e963e3d4d9/SQLAlchemy-2.0.36-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1bc330d9d29c7f06f003ab10e1eaced295e87940405afe1b110f2eb93a233588", size = 3205147 }, + { url = "https://files.pythonhosted.org/packages/c8/60/70e681de02a13c4b27979b7b78da3058c49bacc9858c89ba672e030f03f2/SQLAlchemy-2.0.36-cp312-cp312-win32.whl", hash = "sha256:79d2e78abc26d871875b419e1fd3c0bca31a1cb0043277d0d850014599626c2e", size = 2062709 }, + { url = "https://files.pythonhosted.org/packages/b7/ed/f6cd9395e41bfe47dd253d74d2dfc3cab34980d4e20c8878cb1117306085/SQLAlchemy-2.0.36-cp312-cp312-win_amd64.whl", hash = "sha256:b544ad1935a8541d177cb402948b94e871067656b3a0b9e91dbec136b06a2ff5", size = 2088433 }, + { url = "https://files.pythonhosted.org/packages/78/5c/236398ae3678b3237726819b484f15f5c038a9549da01703a771f05a00d6/SQLAlchemy-2.0.36-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b5cc79df7f4bc3d11e4b542596c03826063092611e481fcf1c9dfee3c94355ef", size = 2087651 }, + { url = "https://files.pythonhosted.org/packages/a8/14/55c47420c0d23fb67a35af8be4719199b81c59f3084c28d131a7767b0b0b/SQLAlchemy-2.0.36-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3c01117dd36800f2ecaa238c65365b7b16497adc1522bf84906e5710ee9ba0e8", size = 2078132 }, + { url = "https://files.pythonhosted.org/packages/3d/97/1e843b36abff8c4a7aa2e37f9bea364f90d021754c2de94d792c2d91405b/SQLAlchemy-2.0.36-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9bc633f4ee4b4c46e7adcb3a9b5ec083bf1d9a97c1d3854b92749d935de40b9b", size = 3164559 }, + { url = "https://files.pythonhosted.org/packages/7b/c5/07f18a897b997f6d6b234fab2bf31dccf66d5d16a79fe329aefc95cd7461/SQLAlchemy-2.0.36-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e46ed38affdfc95d2c958de328d037d87801cfcbea6d421000859e9789e61c2", size = 3177897 }, + { url = "https://files.pythonhosted.org/packages/b3/cd/e16f3cbefd82b5c40b33732da634ec67a5f33b587744c7ab41699789d492/SQLAlchemy-2.0.36-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b2985c0b06e989c043f1dc09d4fe89e1616aadd35392aea2844f0458a989eacf", size = 3111289 }, + { url = "https://files.pythonhosted.org/packages/15/85/5b8a3b0bc29c9928aa62b5c91fcc8335f57c1de0a6343873b5f372e3672b/SQLAlchemy-2.0.36-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a121d62ebe7d26fec9155f83f8be5189ef1405f5973ea4874a26fab9f1e262c", size = 3139491 }, + { url = "https://files.pythonhosted.org/packages/a1/95/81babb6089938680dfe2cd3f88cd3fd39cccd1543b7cb603b21ad881bff1/SQLAlchemy-2.0.36-cp313-cp313-win32.whl", hash = "sha256:0572f4bd6f94752167adfd7c1bed84f4b240ee6203a95e05d1e208d488d0d436", size = 2060439 }, + { url = "https://files.pythonhosted.org/packages/c1/ce/5f7428df55660d6879d0522adc73a3364970b5ef33ec17fa125c5dbcac1d/SQLAlchemy-2.0.36-cp313-cp313-win_amd64.whl", hash = "sha256:8c78ac40bde930c60e0f78b3cd184c580f89456dd87fc08f9e3ee3ce8765ce88", size = 2084574 }, + { url = "https://files.pythonhosted.org/packages/b8/49/21633706dd6feb14cd3f7935fc00b60870ea057686035e1a99ae6d9d9d53/SQLAlchemy-2.0.36-py3-none-any.whl", hash = "sha256:fddbe92b4760c6f5d48162aef14824add991aeda8ddadb3c31d56eb15ca69f8e", size = 1883787 }, +] + [[package]] name = "starlette" version = "0.41.3"