feat: Initialize FastAPI AI Gateway project structure with authentication, module management, and LLM API routing.

This commit is contained in:
2026-01-28 03:24:04 +08:00
commit b88dfec5fd
26 changed files with 1691 additions and 0 deletions

146
.gitignore vendored Normal file
View File

@@ -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

120
api_documentation.md Normal file
View File

@@ -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 <token_from_login>`
---
## 🤖 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: <your_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: <your_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 <token>`
**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 <token>`
---
## 📊 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);
```

0
app/__init__.py Normal file
View File

90
app/api/admin_backend.py Normal file
View File

@@ -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"}

43
app/api/deps.py Normal file
View File

@@ -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"
)

View File

@@ -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()

26
app/api/endpoints/auth.py Normal file
View File

@@ -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"},
)

View File

@@ -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)}

View File

@@ -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)}

View File

@@ -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"}

7
app/api/router.py Normal file
View File

@@ -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"])

18
app/core/config.py Normal file
View File

@@ -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()

24
app/core/database.py Normal file
View File

@@ -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()

4
app/core/limiter.py Normal file
View File

@@ -0,0 +1,4 @@
from slowapi import Limiter
from slowapi.util import get_remote_address
limiter = Limiter(key_func=get_remote_address)

41
app/core/security.py Normal file
View File

@@ -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

56
app/main.py Normal file
View File

@@ -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"}

1
app/models/__init__.py Normal file
View File

@@ -0,0 +1 @@
from app.models.module import Module

16
app/models/module.py Normal file
View File

@@ -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)

665
app/static/admin.html Normal file
View File

@@ -0,0 +1,665 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Storyline AI Gateway - Admin</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600;800&display=swap" rel="stylesheet">
<style>
:root {
--primary: #6366f1;
--primary-hover: #4f46e5;
--bg: #0f172a;
--card-bg: #1e293b;
--text-main: #f8fafc;
--text-dim: #94a3b8;
--glass: rgba(30, 41, 59, 0.7);
--border: rgba(255, 255, 255, 0.1);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: 'Outfit', sans-serif;
}
body {
background-color: var(--bg);
color: var(--text-main);
min-height: 100vh;
display: flex;
flex-direction: column;
overflow-x: hidden;
}
.bg-gradient {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: radial-gradient(circle at 50% 50%, #1e1b4b 0%, #0f172a 100%);
z-index: -1;
}
.container {
max-width: 1000px;
margin: 0 auto;
padding: 2rem;
width: 100%;
}
header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 3rem;
animation: fadeInDown 0.8s ease-out;
}
.logo {
font-size: 1.5rem;
font-weight: 800;
background: linear-gradient(to right, #818cf8, #c084fc);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
/* Login Section */
#login-section {
display: flex;
justify-content: center;
align-items: center;
min-height: 70vh;
}
.login-card {
background: var(--glass);
backdrop-filter: blur(12px);
padding: 3rem;
border-radius: 24px;
border: 1px solid var(--border);
width: 100%;
max-width: 400px;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
animation: scaleUp 0.5s ease-out;
}
.login-card h2 {
margin-bottom: 2rem;
text-align: center;
font-weight: 600;
}
.form-group {
margin-bottom: 1.5rem;
}
label {
display: block;
margin-bottom: 0.5rem;
color: var(--text-dim);
font-size: 0.875rem;
}
input {
width: 100%;
padding: 0.75rem 1rem;
background: rgba(15, 23, 42, 0.5);
border: 1px solid var(--border);
border-radius: 12px;
color: white;
transition: all 0.3s ease;
}
input:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 4px rgba(99, 102, 241, 0.2);
}
button {
width: 100%;
padding: 0.75rem;
background: var(--primary);
color: white;
border: none;
border-radius: 12px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
button:hover {
background: var(--primary-hover);
transform: translateY(-2px);
box-shadow: 0 10px 20px -5px rgba(99, 102, 241, 0.5);
}
/* Dashboard Section */
#dashboard-section {
display: none;
}
.card {
background: var(--glass);
backdrop-filter: blur(12px);
padding: 2rem;
border-radius: 20px;
border: 1px solid var(--border);
margin-bottom: 2rem;
animation: fadeInUp 0.5s ease-out;
}
.flex-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
}
.module-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1.5rem;
}
.module-card {
background: rgba(15, 23, 42, 0.3);
border: 1px solid var(--border);
border-radius: 16px;
padding: 1.5rem;
transition: all 0.3s ease;
}
.module-card:hover {
border-color: rgba(99, 102, 241, 0.4);
transform: translateY(-5px);
}
.module-name {
font-weight: 600;
font-size: 1.125rem;
margin-bottom: 0.5rem;
}
.key-box {
background: #0f172a;
padding: 1rem 0.75rem;
border-radius: 12px;
font-family: 'Monaco', 'Consolas', monospace;
font-size: 0.8rem;
color: #818cf8;
margin: 1.25rem 0;
word-break: break-all;
border: 1px solid var(--border);
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.key-text {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
user-select: none;
}
.key-text.visible {
white-space: normal;
word-break: break-all;
}
.icon-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.icon-btn {
background: rgba(255, 255, 255, 0.05);
border: 1px solid var(--border);
color: var(--text-dim);
padding: 6px;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
}
.icon-btn:hover {
background: rgba(99, 102, 241, 0.1);
border-color: var(--primary);
color: var(--primary);
transform: scale(1.05);
}
.actions {
display: flex;
gap: 0.75rem;
margin-top: 1rem;
}
.btn-small {
padding: 0.6rem 1.2rem;
font-size: 0.85rem;
width: auto;
border-radius: 10px;
}
.btn-outline {
background: transparent;
border: 1px solid var(--primary);
color: var(--primary);
}
.btn-outline:hover {
background: var(--primary);
color: white;
}
.btn-danger {
background: #ef4444;
}
.btn-danger:hover {
background: #dc2626;
}
@keyframes fadeInDown {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes scaleUp {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
.modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(15, 23, 42, 0.8);
backdrop-filter: blur(8px);
display: flex;
align-items: center;
justify-content: center;
z-index: 2000;
}
.modal-content {
background: var(--card-bg);
padding: 2.5rem;
border-radius: 24px;
border: 1px solid var(--border);
width: 90%;
max-width: 500px;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
}
.modal-content h2 {
margin-bottom: 2rem;
}
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 1rem;
margin-top: 2rem;
}
.module-meta {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.75rem;
}
.badge {
background: rgba(99, 102, 241, 0.15);
color: #a5b4fc;
padding: 4px 8px;
border-radius: 6px;
font-size: 0.7rem;
font-weight: 600;
}
.hidden {
display: none !important;
}
.toast {
position: fixed;
bottom: 2rem;
right: 2rem;
padding: 1rem 2rem;
background: var(--primary);
border-radius: 12px;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.3);
transform: translateY(100px);
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
z-index: 1000;
}
.toast.show {
transform: translateY(0);
}
</style>
</head>
<body>
<div class="bg-gradient"></div>
<div class="container">
<header>
<div class="logo">Storyline Gateway Admin</div>
<button id="logout-btn" class="btn-small btn-outline hidden">Logout</button>
</header>
<!-- Login Section -->
<section id="login-section">
<div class="login-card">
<h2>Admin Login</h2>
<div class="form-group">
<label>Username</label>
<input type="text" id="username" value="admin">
</div>
<div class="form-group">
<label>Password</label>
<input type="password" id="password" placeholder="••••••••">
</div>
<button id="login-btn">Secure Login</button>
</div>
</section>
<!-- Dashboard Section -->
<section id="dashboard-section">
<div class="card">
<div class="flex-header">
<h2>E-Learning Modules</h2>
<button id="add-module-btn" class="btn-small">Create New Module</button>
</div>
<div id="module-list" class="module-grid">
<!-- Modules will be injected here -->
</div>
</div>
</section>
</div>
<!-- Create Module Modal -->
<div id="create-modal" class="modal hidden">
<div class="modal-content">
<h2>Create New Module</h2>
<div class="form-group">
<label>Module Name (Unique)</label>
<input type="text" id="new-module-name" placeholder="e.g. Onboarding 2024">
</div>
<div class="form-grid">
<div class="form-group">
<label>Program</label>
<input type="text" id="new-module-program" placeholder="e.g. Sales Training">
</div>
<div class="form-group">
<label>LOB (Line of Business)</label>
<input type="text" id="new-module-lob" placeholder="e.g. Retail">
</div>
</div>
<div class="form-group">
<label>Job Code</label>
<input type="text" id="new-module-job-code" placeholder="e.g. JC-990">
</div>
<div class="modal-actions">
<button id="cancel-create-btn" class="btn-small btn-outline">Cancel</button>
<button id="save-module-btn" class="btn-small">Create Module</button>
</div>
</div>
</div>
<div id="toast" class="toast">Action successful!</div>
<script>
let TOKEN = localStorage.getItem('admin_token');
const loginSection = document.getElementById('login-section');
const dashboardSection = document.getElementById('dashboard-section');
const logoutBtn = document.getElementById('logout-btn');
const moduleList = document.getElementById('module-list');
function showToast(msg) {
const toast = document.getElementById('toast');
toast.innerText = msg;
toast.classList.add('show');
setTimeout(() => toast.classList.remove('show'), 3000);
}
async function apiRequest(endpoint, method = 'GET', body = null) {
const options = {
method,
headers: {
'Authorization': `Bearer ${TOKEN}`
}
};
if (body) {
options.headers['Content-Type'] = 'application/json';
options.body = JSON.stringify(body);
}
const response = await fetch(endpoint, options);
if (response.status === 401) {
logout();
return null;
}
return response.json();
}
async function login() {
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
const formData = new FormData();
formData.append('username', username);
formData.append('password', password);
try {
const response = await fetch('/auth/login', {
method: 'POST',
body: formData
});
const data = await response.json();
if (data.access_token) {
TOKEN = data.access_token;
localStorage.setItem('admin_token', TOKEN);
showDashboard();
} else {
alert('Invalid credentials');
}
} catch (error) {
console.error('Login failed', error);
}
}
function logout() {
localStorage.removeItem('admin_token');
TOKEN = null;
loginSection.classList.remove('hidden');
dashboardSection.style.display = 'none';
logoutBtn.classList.add('hidden');
}
async function showDashboard() {
loginSection.classList.add('hidden');
dashboardSection.style.display = 'block';
logoutBtn.classList.remove('hidden');
loadModules();
}
async function loadModules() {
const modules = await apiRequest('/internal/admin/modules');
if (!modules) return;
moduleList.innerHTML = modules.map(m => `
<div class="module-card">
<div class="module-name">${m.name}</div>
<div class="text-dim" style="font-size: 0.8rem">Created: ${new Date(m.created_at).toLocaleDateString()}</div>
<div class="module-meta">
${m.program ? `<span class="badge" title="Program">${m.program}</span>` : ''}
${m.lob ? `<span class="badge" title="Line of Business">${m.lob}</span>` : ''}
${m.job_code ? `<span class="badge" title="Job Code">${m.job_code}</span>` : ''}
</div>
<div class="key-box">
<span class="key-text" id="key-${m.id}">••••••••••••••••••••••••••••••••</span>
<div class="icon-actions">
<button class="icon-btn" onclick="toggleKey(${m.id}, '${m.secret_key}')" title="Show/Hide Key">
<svg id="eye-icon-${m.id}" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"></path><circle cx="12" cy="12" r="3"></circle></svg>
</button>
<button class="icon-btn" onclick="copyKey('${m.secret_key}')" title="Copy Key">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"></rect><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"></path></svg>
</button>
</div>
</div>
<div class="actions">
<button class="btn-small btn-outline" onclick="rotateKey(${m.id})">Rotate Key</button>
<button class="btn-small btn-danger" onclick="deleteModule(${m.id})">Delete</button>
</div>
</div>
`).join('');
}
// Modal Logic
const createModal = document.getElementById('create-modal');
const openModalBtn = document.getElementById('add-module-btn');
const cancelModalBtn = document.getElementById('cancel-create-btn');
const saveModuleBtn = document.getElementById('save-module-btn');
openModalBtn.onclick = () => {
createModal.classList.remove('hidden');
document.getElementById('new-module-name').value = '';
document.getElementById('new-module-program').value = '';
document.getElementById('new-module-lob').value = '';
document.getElementById('new-module-job-code').value = '';
};
cancelModalBtn.onclick = () => createModal.classList.add('hidden');
saveModuleBtn.onclick = async () => {
const moduleData = {
name: document.getElementById('new-module-name').value,
program: document.getElementById('new-module-program').value,
lob: document.getElementById('new-module-lob').value,
job_code: document.getElementById('new-module-job-code').value
};
if (!moduleData.name) {
alert('Module name is required');
return;
}
const res = await apiRequest('/internal/admin/modules', 'POST', moduleData);
if (res && res.detail) {
alert(res.detail);
} else if (res) {
showToast('Module created successfully');
createModal.classList.add('hidden');
loadModules();
}
};
window.toggleKey = (id, key) => {
const el = document.getElementById(`key-${id}`);
const icon = document.getElementById(`eye-icon-${id}`);
const isHidden = el.innerText.includes('•');
if (isHidden) {
el.innerText = key;
el.classList.add('visible');
icon.innerHTML = '<path d="M9.88 9.88 2 20"></path><path d="M2 12s3-7 10-7a9.77 9.77 0 0 1 5 1.45"></path><path d="M22 12s-3 7-10 7a9.77 9.77 0 0 1-5-1.45"></path><path d="M15.12 15.12a3 3 0 1 1-4.24-4.24"></path><line x1="1" y1="1" x2="23" y2="23"></line>';
} else {
el.innerText = '••••••••••••••••••••••••••••••••';
el.classList.remove('visible');
icon.innerHTML = '<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"></path><circle cx="12" cy="12" r="3"></circle>';
}
};
window.copyKey = (key) => {
navigator.clipboard.writeText(key);
showToast('Key copied to clipboard!');
};
window.rotateKey = async (id) => {
if (confirm('Are you sure? Previous key will stop working immediately.')) {
await apiRequest(`/internal/admin/modules/${id}/rotate`, 'POST');
showToast('Key rotated successfully');
loadModules();
}
};
window.deleteModule = async (id) => {
if (confirm('Delete this module? This cannot be undone.')) {
await apiRequest(`/internal/admin/modules/${id}`, 'DELETE');
showToast('Module deleted');
loadModules();
}
};
document.getElementById('login-btn').onclick = login;
document.getElementById('login-btn').onclick = login;
logoutBtn.onclick = logout;
// Auto-login check
if (TOKEN) {
showDashboard();
}
</script>
</body>
</html>

18
debug_db.py Normal file
View File

@@ -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()

51
implementation_plan.md Normal file
View File

@@ -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: <your_key>`
- **Body**: `{"prompt": "string", "context": "string"}`
### 2. OpenAI Chat
- **URL**: `/api/v1/openai/chat`
- **Method**: POST
- **Headers**: `X-API-Key: <your_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.).

9
main.py Normal file
View File

@@ -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)

14
requirements.txt Normal file
View File

@@ -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

BIN
storyline.db Normal file

Binary file not shown.

96
test_api.py Normal file
View File

@@ -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}")

47
test_supabase.py Normal file
View File

@@ -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()