commit b88dfec5fd7f60bcab1ac64d2427a5714d7cf8e6 Author: Paulo Reyes Date: Wed Jan 28 03:24:04 2026 +0800 feat: Initialize FastAPI AI Gateway project structure with authentication, module management, and LLM API routing. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..684131f --- /dev/null +++ b/.gitignore @@ -0,0 +1,146 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +# .Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script or a tool rather than by hand +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +pytestdebug.log + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the Python version is individual. +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if you want to keep your development environment strictly separated from others, +# you can include Pipfile.lock in gitignore. +# Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +# pdm.lock + +# PEP 582; used by e.g. github.com/fannheyward/coc-pyright +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# OS X +.DS_Store diff --git a/api_documentation.md b/api_documentation.md new file mode 100644 index 0000000..e8310ad --- /dev/null +++ b/api_documentation.md @@ -0,0 +1,120 @@ +# Storyline AI Gateway - API Documentation + +This document provides technical details for interacting with the Storyline AI Gateway. All API endpoints are versioned under `/api/v1` except for administrative/internal functions. + +--- + +## 🔐 Authentication Types + +### 1. Master API Key (Administrative) +Used for managing modules or as a global override. +- **Header**: `X-API-Key` +- **Value**: Found in your `.env` file as `API_KEY`. + +### 2. Module Secret Key (Production) +Generated per e-learning module in the Admin Dashboard. +- **Header**: `X-API-Key` +- **Value**: The specific key created for your Storyline project. + +### 3. JWT Token (Dashboard Only) +Used by the HTML Admin interface. +- **Header**: `Authorization` +- **Value**: `Bearer ` + +--- + +## 🤖 LLM Endpoints (Public API) + +These are the endpoints you call from your Articulate Storyline JavaScript triggers. + +### 1. Gemini Chat +Processes requests using the Google Gemini 2.0 Flash model. +- **URL**: `http://localhost:8000/api/v1/gemini/chat` +- **Method**: `POST` +- **Headers**: `X-API-Key: ` + +**Sample Payload:** +```json +{ + "prompt": "Explain the concept of 'Growth Mindset' for a new employee.", + "context": "Onboarding Module 2024" +} +``` + +### 2. OpenAI Chat +Processes requests using GPT-3.5-Turbo (or configured OpenAI model). +- **URL**: `http://localhost:8000/api/v1/openai/chat` +- **Method**: `POST` +- **Headers**: `X-API-Key: ` + +**Sample Payload:** +```json +{ + "prompt": "Summarize the safety protocols for the warehouse.", + "context": "Safety Compliance Course" +} +``` + +### 3. Health Check +Verify if the gateway is online. +- **URL**: `http://localhost:8000/api/v1/storyline/health` +- **Method**: `GET` +- **Headers**: No Auth required. + +--- + +## 🛠️ Management Endpoints (Internal) + +These endpoints power the Admin Dashboard. They require a Bearer token or Master Key depending on implementation. + +### 1. Login +- **URL**: `http://localhost:8000/auth/login` +- **Method**: `POST` +- **Body**: `multipart/form-data` with `username` and `password`. + +### 2. Create New Module +- **URL**: `http://localhost:8000/internal/admin/modules` +- **Method**: `POST` +- **Headers**: `Authorization: Bearer ` + +**Sample Payload:** +```json +{ + "name": "Warehouse Safety 101", + "program": "OSHA Compliance", + "lob": "Logistics", + "job_code": "WH-OP-2" +} +``` + +### 3. Rotate Module Key +- **URL**: `http://localhost:8000/internal/admin/modules/{module_id}/rotate` +- **Method**: `POST` +- **Headers**: `Authorization: Bearer ` + +--- + +## 📊 Rate Limiting +All productive LLM endpoints are limited to **20 calls per minute**. If exceeded, the API will return: +- **Status**: `429 Too Many Requests` +- **Body**: `{"error": "Rate limit exceeded"}` + +--- + +## 💡 Storyline JavaScript Example + +```javascript +const response = await fetch("http://localhost:8000/api/v1/gemini/chat", { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-API-Key": "YOUR_MODULE_SECRET_KEY" + }, + body: JSON.stringify({ + prompt: "Help me write a professional email response.", + context: "Communication Skills Module" + }) +}); +const data = await response.json(); +console.log(data.response); +``` diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/admin_backend.py b/app/api/admin_backend.py new file mode 100644 index 0000000..ba8fa34 --- /dev/null +++ b/app/api/admin_backend.py @@ -0,0 +1,90 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from app.core.database import get_db +from app.core.security import get_current_user +from app.models.module import Module +import secrets +from pydantic import BaseModel +from datetime import datetime +from typing import List, Optional + +router = APIRouter() + +class ModuleCreate(BaseModel): + name: str + program: Optional[str] = None + lob: Optional[str] = None + job_code: Optional[str] = None + +class ModuleResponse(BaseModel): + id: int + name: str + secret_key: str + program: Optional[str] + lob: Optional[str] + job_code: Optional[str] + is_active: bool + created_at: datetime + last_rotated_at: datetime + + class Config: + from_attributes = True + +@router.post("/modules", response_model=ModuleResponse) +def create_module( + module_in: ModuleCreate, + db: Session = Depends(get_db), + current_user: str = Depends(get_current_user) +): + db_module = db.query(Module).filter(Module.name == module_in.name).first() + if db_module: + raise HTTPException(status_code=400, detail="Module already exists") + + new_key = secrets.token_hex(32) + db_module = Module( + name=module_in.name, + secret_key=new_key, + program=module_in.program, + lob=module_in.lob, + job_code=module_in.job_code + ) + db.add(db_module) + db.commit() + db.refresh(db_module) + return db_module + +@router.get("/modules", response_model=List[ModuleResponse]) +def get_modules( + db: Session = Depends(get_db), + current_user: str = Depends(get_current_user) +): + return db.query(Module).all() + +@router.post("/modules/{module_id}/rotate", response_model=ModuleResponse) +def rotate_module_key( + module_id: int, + db: Session = Depends(get_db), + current_user: str = Depends(get_current_user) +): + db_module = db.query(Module).filter(Module.id == module_id).first() + if not db_module: + raise HTTPException(status_code=404, detail="Module not found") + + db_module.secret_key = secrets.token_hex(32) + db_module.last_rotated_at = datetime.utcnow() + db.commit() + db.refresh(db_module) + return db_module + +@router.delete("/modules/{module_id}") +def delete_module( + module_id: int, + db: Session = Depends(get_db), + current_user: str = Depends(get_current_user) +): + db_module = db.query(Module).filter(Module.id == module_id).first() + if not db_module: + raise HTTPException(status_code=404, detail="Module not found") + db.delete(db_module) + db.commit() + return {"status": "success"} diff --git a/app/api/deps.py b/app/api/deps.py new file mode 100644 index 0000000..e2e99b5 --- /dev/null +++ b/app/api/deps.py @@ -0,0 +1,43 @@ +from fastapi import Security, HTTPException, status, Depends +from fastapi.security.api_key import APIKeyHeader +from sqlalchemy.orm import Session +from app.core.config import settings +from app.core.database import get_db +from app.models.module import Module +from cachetools import TTLCache + +api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False) + +# Cache keys for 5 minutes, store up to 1000 keys +# This prevents a Supabase round-trip on every message +auth_cache = TTLCache(maxsize=1000, ttl=300) + +async def get_api_key( + api_key_header: str = Security(api_key_header), + db: Session = Depends(get_db) +): + if not api_key_header: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="API Key missing" + ) + + # 1. Fallback to global static key (Admin) + if api_key_header == settings.API_KEY: + return api_key_header + + # 2. Check Cache first (VERY FAST) + if api_key_header in auth_cache: + return api_key_header + + # 3. Check Database for Module key (Database round-trip) + module = db.query(Module).filter(Module.secret_key == api_key_header, Module.is_active == True).first() + if module: + # Save to cache for next time + auth_cache[api_key_header] = True + return api_key_header + + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Could not validate credentials or API Key is inactive" + ) diff --git a/app/api/endpoints/admin.py b/app/api/endpoints/admin.py new file mode 100644 index 0000000..2eb2f1f --- /dev/null +++ b/app/api/endpoints/admin.py @@ -0,0 +1,66 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from app.core.database import get_db +from app.api.deps import get_api_key +from app.models.module import Module +import secrets +from pydantic import BaseModel +from datetime import datetime + +router = APIRouter() + +class ModuleCreate(BaseModel): + name: str + +class ModuleResponse(BaseModel): + id: int + name: str + secret_key: str + is_active: bool + + class Config: + from_attributes = True + +@router.post("/modules", response_model=ModuleResponse) +def create_module( + module_in: ModuleCreate, + db: Session = Depends(get_db), + api_key: str = Depends(get_api_key) # Only global admin key should be allowed here ideally +): + # Check if exists + db_module = db.query(Module).filter(Module.name == module_in.name).first() + if db_module: + raise HTTPException(status_code=400, detail="Module already exists") + + new_key = secrets.token_hex(32) + db_module = Module( + name=module_in.name, + secret_key=new_key + ) + db.add(db_module) + db.commit() + db.refresh(db_module) + return db_module + +@router.post("/modules/{module_id}/rotate", response_model=ModuleResponse) +def rotate_module_key( + module_id: int, + db: Session = Depends(get_db), + api_key: str = Depends(get_api_key) +): + db_module = db.query(Module).filter(Module.id == module_id).first() + if not db_module: + raise HTTPException(status_code=404, detail="Module not found") + + db_module.secret_key = secrets.token_hex(32) + db_module.last_rotated_at = datetime.utcnow() + db.commit() + db.refresh(db_module) + return db_module + +@router.get("/modules", response_model=list[ModuleResponse]) +def get_modules( + db: Session = Depends(get_db), + api_key: str = Depends(get_api_key) +): + return db.query(Module).all() diff --git a/app/api/endpoints/auth.py b/app/api/endpoints/auth.py new file mode 100644 index 0000000..57c38a6 --- /dev/null +++ b/app/api/endpoints/auth.py @@ -0,0 +1,26 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.security import OAuth2PasswordRequestForm +from app.core import security +from app.core.config import settings +from pydantic import BaseModel + +router = APIRouter() + +class Token(BaseModel): + access_token: str + token_type: str + +@router.post("/login", response_model=Token) +async def login(form_data: OAuth2PasswordRequestForm = Depends()): + # Simple admin check - in a real app, query a User table + if form_data.username == "admin" and form_data.password == settings.ADMIN_PASSWORD: + access_token = security.create_access_token( + data={"sub": form_data.username} + ) + return {"access_token": access_token, "token_type": "bearer"} + + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect username or password", + headers={"WWW-Authenticate": "Bearer"}, + ) diff --git a/app/api/endpoints/gemini.py b/app/api/endpoints/gemini.py new file mode 100644 index 0000000..7912b11 --- /dev/null +++ b/app/api/endpoints/gemini.py @@ -0,0 +1,54 @@ +from fastapi import APIRouter, Depends, Request +from app.api.deps import get_api_key +from app.core.limiter import limiter +from app.core.config import settings +from pydantic import BaseModel +from google import genai +import asyncio + +router = APIRouter() + +class LLMRequest(BaseModel): + prompt: str + context: str = "" + +# Shared client instance (global) +_client = None + +def get_gemini_client(): + global _client + if _client is None and settings.GOOGLE_API_KEY and settings.GOOGLE_API_KEY != "your-google-api-key": + _client = genai.Client(api_key=settings.GOOGLE_API_KEY, http_options={'api_version': 'v1alpha'}) + return _client + +@router.post("/chat") +@limiter.limit(settings.RATE_LIMIT) +async def gemini_chat( + request: Request, + chat_data: LLMRequest, + api_key: str = Depends(get_api_key) +): + client = get_gemini_client() + + try: + if not client: + return { + "status": "mock", + "model": "gemini", + "response": f"MOCK: Gemini response to '{chat_data.prompt}'" + } + + # Using the async generation method provided by the new google-genai library + # We use await to ensure we don't block the event loop + response = await client.aio.models.generate_content( + model="gemini-2.0-flash", + contents=chat_data.prompt + ) + + return { + "status": "success", + "model": "gemini", + "response": response.text + } + except Exception as e: + return {"status": "error", "detail": str(e)} diff --git a/app/api/endpoints/openai.py b/app/api/endpoints/openai.py new file mode 100644 index 0000000..f261f75 --- /dev/null +++ b/app/api/endpoints/openai.py @@ -0,0 +1,46 @@ +from fastapi import APIRouter, Depends, Request +from app.api.deps import get_api_key +from app.core.limiter import limiter +from app.core.config import settings +from pydantic import BaseModel +from openai import AsyncOpenAI +import asyncio + +router = APIRouter() + +class LLMRequest(BaseModel): + prompt: str + context: str = "" + +# Initialize Async client +client = None +if settings.OPENAI_API_KEY and settings.OPENAI_API_KEY != "your-openai-api-key": + client = AsyncOpenAI(api_key=settings.OPENAI_API_KEY) + +@router.post("/chat") +@limiter.limit(settings.RATE_LIMIT) +async def openai_chat( + request: Request, + chat_data: LLMRequest, + api_key: str = Depends(get_api_key) +): + try: + if not client: + return { + "status": "mock", + "model": "openai", + "response": f"MOCK: OpenAI response to '{chat_data.prompt}'" + } + + # Perform Async call to OpenAI + response = await client.chat.completions.create( + model="gpt-3.5-turbo", + messages=[{"role": "user", "content": chat_data.prompt}] + ) + return { + "status": "success", + "model": "openai", + "response": response.choices[0].message.content + } + except Exception as e: + return {"status": "error", "detail": str(e)} diff --git a/app/api/endpoints/storyline.py b/app/api/endpoints/storyline.py new file mode 100644 index 0000000..bf204d0 --- /dev/null +++ b/app/api/endpoints/storyline.py @@ -0,0 +1,33 @@ +from fastapi import APIRouter, Depends, Request +from app.api.deps import get_api_key +from app.core.limiter import limiter +from app.core.config import settings +from pydantic import BaseModel + +router = APIRouter() + +class ChatRequest(BaseModel): + prompt: str + context: str = "" + +@router.post("/chat") +@limiter.limit(settings.RATE_LIMIT) +async def story_chat( + request: Request, + chat_data: ChatRequest, + api_key: str = Depends(get_api_key) +): + # This is where you would call your LLM (OpenAI, Anthropic, etc.) + # For now, we return a mock response + return { + "status": "success", + "response": f"Processed prompt: {chat_data.prompt}", + "metadata": { + "characters_received": len(chat_data.prompt), + "context_length": len(chat_data.context) + } + } + +@router.get("/health") +async def health_check(): + return {"status": "healthy"} diff --git a/app/api/router.py b/app/api/router.py new file mode 100644 index 0000000..efc9698 --- /dev/null +++ b/app/api/router.py @@ -0,0 +1,7 @@ +from fastapi import APIRouter +from app.api.endpoints import storyline, gemini, openai + +api_router = APIRouter() +api_router.include_router(storyline.router, prefix="/storyline", tags=["storyline"]) +api_router.include_router(gemini.router, prefix="/gemini", tags=["gemini"]) +api_router.include_router(openai.router, prefix="/openai", tags=["openai"]) diff --git a/app/core/config.py b/app/core/config.py new file mode 100644 index 0000000..07393f8 --- /dev/null +++ b/app/core/config.py @@ -0,0 +1,18 @@ +import os +from dotenv import load_dotenv + +load_dotenv() + +class Settings: + PROJECT_NAME: str = "Storyline AI Gateway" + API_KEY: str = os.getenv("API_KEY", "storyline-secret-key-123") + RATE_LIMIT: str = "20/minute" + GOOGLE_API_KEY: str = os.getenv("GOOGLE_API_KEY") + OPENAI_API_KEY: str = os.getenv("OPENAI_API_KEY") + DATABASE_URL: str = os.getenv("DATABASE_URL") + SECRET_KEY: str = os.getenv("SECRET_KEY", "your-super-secret-key-change-me") + ALGORITHM: str = "HS256" + ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 + ADMIN_PASSWORD: str = os.getenv("ADMIN_PASSWORD", "admin123") + +settings = Settings() diff --git a/app/core/database.py b/app/core/database.py new file mode 100644 index 0000000..e64d75e --- /dev/null +++ b/app/core/database.py @@ -0,0 +1,24 @@ +from sqlalchemy import create_engine +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker +from app.core.config import settings + +# For development, we can use SQLite if DATABASE_URL is not set or for simplicity +# But we'll follow the user's request for PostgreSQL +SQLALCHEMY_DATABASE_URL = settings.DATABASE_URL or "sqlite:///./sql_app.db" + +engine = create_engine( + SQLALCHEMY_DATABASE_URL, + # check_same_thread is only needed for SQLite + **({"connect_args": {"check_same_thread": False}} if "sqlite" in SQLALCHEMY_DATABASE_URL else {}) +) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +Base = declarative_base() + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/app/core/limiter.py b/app/core/limiter.py new file mode 100644 index 0000000..38404a8 --- /dev/null +++ b/app/core/limiter.py @@ -0,0 +1,4 @@ +from slowapi import Limiter +from slowapi.util import get_remote_address + +limiter = Limiter(key_func=get_remote_address) diff --git a/app/core/security.py b/app/core/security.py new file mode 100644 index 0000000..320a68a --- /dev/null +++ b/app/core/security.py @@ -0,0 +1,41 @@ +from datetime import datetime, timedelta +from typing import Optional +from jose import JWTError, jwt +from passlib.context import CryptContext +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from app.core.config import settings + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login") + +def verify_password(plain_password, hashed_password): + return pwd_context.verify(plain_password, hashed_password) + +def get_password_hash(password): + return pwd_context.hash(password) + +def create_access_token(data: dict, expires_delta: Optional[timedelta] = None): + to_encode = data.copy() + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(minutes=15) + to_encode.update({"exp": expire}) + encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM) + return encoded_jwt + +async def get_current_user(token: str = Depends(oauth2_scheme)): + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + try: + payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]) + username: str = payload.get("sub") + if username is None: + raise credentials_exception + except JWTError: + raise credentials_exception + return username diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..cd01a8f --- /dev/null +++ b/app/main.py @@ -0,0 +1,56 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from slowapi import _rate_limit_exceeded_handler +from slowapi.errors import RateLimitExceeded + +from app.api.router import api_router +from app.core.config import settings +from app.core.limiter import limiter +from app.core.database import engine, Base +from app.models import module # Ensure models are loaded + +# Create tables +Base.metadata.create_all(bind=engine) + +from fastapi.staticfiles import StaticFiles +from app.api.endpoints import auth +from app.api import admin_backend + +def create_application() -> FastAPI: + application = FastAPI(title=settings.PROJECT_NAME) + + # Mount Static Files + application.mount("/static", StaticFiles(directory="app/static"), name="static") + + # Set up CORS + application.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + # Set up Rate Limiter + application.state.limiter = limiter + application.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) + + # Include routes + application.include_router(api_router, prefix="/api/v1") + application.include_router(auth.router, prefix="/auth", tags=["auth"]) + application.include_router(admin_backend.router, prefix="/internal/admin", tags=["internal-admin"]) + + return application + +app = create_application() + +from fastapi.responses import FileResponse +import os + +@app.get("/admin") +async def admin_panel(): + return FileResponse("app/static/admin.html") + +@app.get("/") +async def root(): + return {"message": "Welcome to Storyline AI Gateway", "docs": "/docs"} diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..ae54185 --- /dev/null +++ b/app/models/__init__.py @@ -0,0 +1 @@ +from app.models.module import Module diff --git a/app/models/module.py b/app/models/module.py new file mode 100644 index 0000000..b92ab4f --- /dev/null +++ b/app/models/module.py @@ -0,0 +1,16 @@ +from sqlalchemy import Column, Integer, String, Boolean, DateTime +from datetime import datetime +from app.core.database import Base + +class Module(Base): + __tablename__ = "modules" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String, unique=True, index=True) + secret_key = Column(String, unique=True, index=True) + program = Column(String, nullable=True) + lob = Column(String, nullable=True) + job_code = Column(String, nullable=True) + is_active = Column(Boolean, default=True) + created_at = Column(DateTime, default=datetime.utcnow) + last_rotated_at = Column(DateTime, default=datetime.utcnow) diff --git a/app/static/admin.html b/app/static/admin.html new file mode 100644 index 0000000..8571ec9 --- /dev/null +++ b/app/static/admin.html @@ -0,0 +1,665 @@ + + + + + + + Storyline AI Gateway - Admin + + + + + + + +
+
+
+ + +
+ + +
+ +
+ + +
+
+
+

E-Learning Modules

+ +
+
+ +
+
+
+
+ + + + +
Action successful!
+ + + + + \ No newline at end of file diff --git a/debug_db.py b/debug_db.py new file mode 100644 index 0000000..bfdd5f7 --- /dev/null +++ b/debug_db.py @@ -0,0 +1,18 @@ +import psycopg2 +import os +from dotenv import load_dotenv + +load_dotenv() + +def test_db(): + url = os.getenv("DATABASE_URL") + print(f"Testing connection to: {url.split('@')[-1]}") + try: + conn = psycopg2.connect(url) + print("Raw psycopg2 connection successful!") + conn.close() + except Exception as e: + print(f"Raw psycopg2 connection failed: {e}") + +if __name__ == "__main__": + test_db() diff --git a/implementation_plan.md b/implementation_plan.md new file mode 100644 index 0000000..c1d3ea0 --- /dev/null +++ b/implementation_plan.md @@ -0,0 +1,51 @@ +# Storyline AI Gateway - Implementation Plan + +## Project Overview +A FastAPI-based gateway for e-learning modules (Articulate Storyline) to access LLM services (Gemini, OpenAI) with centralized authentication and rate limiting. + +## Tech Stack +- **Framework**: FastAPI +- **Server**: Uvicorn +- **Rate Limiting**: Slowapi (limiter) +- **Auth**: Header-based API Key (`X-API-Key`) +- **LLMs**: Google Gemini, OpenAI + +## Directory Structure +- `app/main.py`: Application entry point and middleware configuration. +- `app/core/`: Configuration and utilities (limiter, settings). +- `app/api/deps.py`: Shared dependencies (authentication). +- `app/api/router.py`: API versioning and route aggregation. +- `app/api/endpoints/`: + - `storyline.py`: Generic endpoint. + - `gemini.py`: Dedicated Gemini endpoint. + - `openai.py`: Dedicated OpenAI endpoint. + +## Configuration +Managed via `.env` file: +- `API_KEY`: Secret key for Storyline modules. +- `GOOGLE_API_KEY`: API key for Google Generative AI. +- `OPENAI_API_KEY`: API key for OpenAI. +- `PORT`: Server port (default 8000). + +## API Endpoints +All endpoints are versioned under `/api/v1`. + +### 1. Gemini Chat +- **URL**: `/api/v1/gemini/chat` +- **Method**: POST +- **Headers**: `X-API-Key: ` +- **Body**: `{"prompt": "string", "context": "string"}` + +### 2. OpenAI Chat +- **URL**: `/api/v1/openai/chat` +- **Method**: POST +- **Headers**: `X-API-Key: ` +- **Body**: `{"prompt": "string", "context": "string"}` + +## Rate Limiting +- Applied globally/per endpoint: **20 calls per minute**. + +## Future Steps +- Add logging (WandB or file-based). +- Implement response caching. +- Add more LLM providers (Anthropic, etc.). diff --git a/main.py b/main.py new file mode 100644 index 0000000..0db427d --- /dev/null +++ b/main.py @@ -0,0 +1,9 @@ +import uvicorn +import os +from dotenv import load_dotenv + +load_dotenv() + +if __name__ == "__main__": + port = int(os.getenv("PORT", 8000)) + uvicorn.run("app.main:app", host="0.0.0.0", port=port, reload=True) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..850ffe7 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,14 @@ +fastapi +uvicorn +slowapi +python-dotenv +httpx +openai +google-genai +sqlalchemy +psycopg2-binary +alembic +python-jose[cryptography] +passlib[bcrypt] +python-multipart +cachetools diff --git a/storyline.db b/storyline.db new file mode 100644 index 0000000..4b175b9 Binary files /dev/null and b/storyline.db differ diff --git a/test_api.py b/test_api.py new file mode 100644 index 0000000..cb3e881 --- /dev/null +++ b/test_api.py @@ -0,0 +1,96 @@ +import httpx +import time + +BASE_URL = "http://127.0.0.1:8000/api/v1" +API_KEY = "storyline-secret-key-123" + +client = httpx.Client(timeout=30.0) + +def test_health(): + print("Testing Health Check...") + response = client.get(f"{BASE_URL}/storyline/health") + print(f"Status: {response.status_code}, Body: {response.json()}") + +def test_chat_unauthorized(): + print("\nTesting Chat (No Auth)...") + response = client.post(f"{BASE_URL}/storyline/chat", json={"prompt": "Hello"}) + print(f"Status: {response.status_code}, Body: {response.json()}") + +def test_chat_authorized(): + print("\nTesting Chat (Authorized)...") + headers = {"X-API-Key": API_KEY} + response = client.post( + f"{BASE_URL}/storyline/chat", + json={"prompt": "How do I use Storyline?", "context": "Module 1"}, + headers=headers + ) + print(f"Status: {response.status_code}, Body: {response.json()}") + +def test_rate_limit(): + print("\nTesting Rate Limit (making multiple calls)...") + headers = {"X-API-Key": API_KEY} + for i in range(22): + response = httpx.post( + f"{BASE_URL}/storyline/chat", + json={"prompt": f"Test {i}"}, + headers=headers + ) + if response.status_code == 429: + print(f"Rate limit hit at call {i+1}!") + return + print("Rate limit not hit (this is unexpected if limit is 20).") + +def test_gemini(): + print("\nTesting Gemini Endpoint...") + headers = {"X-API-Key": API_KEY} + response = client.post(f"{BASE_URL}/gemini/chat", json={"prompt": "Hello Gemini"}, headers=headers) + print(f"Status: {response.status_code}, Body: {response.json()}") + +def test_openai(): + print("\nTesting OpenAI Endpoint...") + headers = {"X-API-Key": API_KEY} + response = client.post(f"{BASE_URL}/openai/chat", json={"prompt": "Hello OpenAI"}, headers=headers) + print(f"Status: {response.status_code}, Body: {response.json()}") + +def test_module_management(): + print("\nTesting Module Management...") + headers = {"X-API-Key": API_KEY} + + # 1. Create a module + print("Creating module 'Articulate 101'...") + response = client.post( + f"{BASE_URL}/admin/modules", + json={"name": "Articulate 101"}, + headers=headers + ) + if response.status_code == 400: + print("Module already exists, getting list...") + response = client.get(f"{BASE_URL}/admin/modules", headers=headers) + module_data = response.json()[0] + else: + module_data = response.json() + + new_module_key = module_data["secret_key"] + print(f"Module created/found. Secret Key: {new_module_key}") + + # 2. Use the new module key to call Gemini + print("\nTesting Gemini with NEW module key...") + module_headers = {"X-API-Key": new_module_key} + response = client.post( + f"{BASE_URL}/gemini/chat", + json={"prompt": "Can you see this with my module key?"}, + headers=module_headers + ) + print(f"Status: {response.status_code}, Body: {response.json()}") + +if __name__ == "__main__": + try: + test_health() + test_chat_unauthorized() + test_chat_authorized() + test_gemini() + test_openai() + test_module_management() + # test_rate_limit() + except Exception as e: + print(f"Error: {e}") diff --git a/test_supabase.py b/test_supabase.py new file mode 100644 index 0000000..7c84d73 --- /dev/null +++ b/test_supabase.py @@ -0,0 +1,47 @@ +import httpx + +BASE_URL = "http://127.0.0.1:8000" +USERNAME = "admin" +PASSWORD = "Spear0yale!" + +def test_supabase_integration(): + client = httpx.Client(timeout=30.0) + + print("1. Logging into Admin Panel...") + login_data = {"username": USERNAME, "password": PASSWORD} + response = client.post(f"{BASE_URL}/auth/login", data=login_data) + + if response.status_code != 200: + print(f"Login failed: {response.status_code} - {response.text}") + return + + token = response.json()["access_token"] + headers = {"Authorization": f"Bearer {token}"} + print("Login successful.") + + print("\n2. Creating a module...") + module_name = "Supabase_Integration_Live" + response = client.post( + f"{BASE_URL}/internal/admin/modules", + json={"name": "Supabase_Integration_Live"}, + headers=headers + ) + + if response.status_code == 400 and "already exists" in response.text: + print("Module already exists, testing rotation...") + # Get modules to find the ID + get_res = client.get(f"{BASE_URL}/internal/admin/modules", headers=headers) + modules = get_res.json() + target = next(m for m in modules if m["name"] == "Supabase_Integration_Live") + + rotate_res = client.post(f"{BASE_URL}/internal/admin/modules/{target['id']}/rotate", headers=headers) + print(f"Rotation status: {rotate_res.status_code}") + print(f"New Key Sample: {rotate_res.json()['secret_key'][:10]}...") + elif response.status_code == 201 or response.status_code == 200: + print("Module created successfully on Supabase!") + print(f"Key: {response.json()['secret_key']}") + else: + print(f"Failed to create module: {response.status_code} - {response.text}") + +if __name__ == "__main__": + test_supabase_integration()