Refactor Nginx configuration and update project dependencies

- Cleaned up the Nginx configuration for the Coolify deployment, ensuring proper formatting and organization.
- Updated the `pyproject.toml` file to maintain consistent formatting.
- Reformatted the `requirements.txt` file for better readability while keeping the dependencies unchanged.
This commit is contained in:
zwitschi committed 2026-09-03 15:05:27 +02:00
1 parent 3b8c30c289
commit a4b874598d
88 files changed
+9766 -9766

No files matched your search

+22 -22
View File
@@ -1,22 +1,22 @@
FROM python:3.12-slim
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
curl \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements and install Python dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY . .
# Expose port
EXPOSE 12015
# Run the application
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "12015"]
FROM python:3.12-slim
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
curl \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements and install Python dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY . .
# Expose port
EXPOSE 12015
# Run the application
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "12015"]
+68 -68
View File
@@ -1,68 +1,68 @@
# Backend — FastAPI REST API
REST API for authentication, user management, AI generation (text/image/video), and admin operations.
## Tech Stack
| Layer | Choice |
| ----------- | ----------------------------- |
| Framework | FastAPI |
| Database | DuckDB (embedded) |
| HTTP client | httpx (OpenRouter proxy) |
| Auth | JWT (access + refresh tokens) |
| Server | uvicorn |
## Project Structure
```txt
backend/
app/
__init__.py Package init (empty)
main.py FastAPI app — lifespan, CORS, router registration
db.py DuckDB singleton, schema migrations, write lock
dependencies.py Dependency injection (get_current_user, etc.)
models/ Pydantic + DB models
__init__.py
auth.py Auth schemas (login, register, token)
users.py User schemas
ai.py AI generation request/response schemas
routers/ API route handlers
__init__.py
auth.py POST /auth/login, /auth/register, /auth/logout
users.py GET/PUT /users/me, admin user management
admin.py GET /admin/stats, video job admin CRUD
ai.py GET /models
generate.py POST /generate/text, /generate/image, /generate/video
images.py GET/POST /images, /images/<id>/file
models.py Model cache management
services/ Business logic
__init__.py
auth.py Auth logic (password hashing, JWT)
users.py User CRUD
openrouter.py OpenRouter API client
models.py Model listing and cache
video_worker.py Background video generation worker
tests/ pytest suite
Dockerfile Production container
requirements.txt Runtime dependencies
requirements-dev.txt Dev dependencies
```
## Running locally
```bash
cd backend
uvicorn app.main:app --reload --port 12015
```
API docs at [http://localhost:12015/docs](http://localhost:12015/docs).
## Running tests
```bash
pytest backend/tests/
```
## Architecture
See [docs/ARCHITECTURE.md](../docs/ARCHITECTURE.md) for full documentation (arc42 template).
# Backend — FastAPI REST API
REST API for authentication, user management, AI generation (text/image/video), and admin operations.
## Tech Stack
| Layer | Choice |
| ----------- | ----------------------------- |
| Framework | FastAPI |
| Database | DuckDB (embedded) |
| HTTP client | httpx (OpenRouter proxy) |
| Auth | JWT (access + refresh tokens) |
| Server | uvicorn |
## Project Structure
```txt
backend/
app/
__init__.py Package init (empty)
main.py FastAPI app — lifespan, CORS, router registration
db.py DuckDB singleton, schema migrations, write lock
dependencies.py Dependency injection (get_current_user, etc.)
models/ Pydantic + DB models
__init__.py
auth.py Auth schemas (login, register, token)
users.py User schemas
ai.py AI generation request/response schemas
routers/ API route handlers
__init__.py
auth.py POST /auth/login, /auth/register, /auth/logout
users.py GET/PUT /users/me, admin user management
admin.py GET /admin/stats, video job admin CRUD
ai.py GET /models
generate.py POST /generate/text, /generate/image, /generate/video
images.py GET/POST /images, /images/<id>/file
models.py Model cache management
services/ Business logic
__init__.py
auth.py Auth logic (password hashing, JWT)
users.py User CRUD
openrouter.py OpenRouter API client
models.py Model listing and cache
video_worker.py Background video generation worker
tests/ pytest suite
Dockerfile Production container
requirements.txt Runtime dependencies
requirements-dev.txt Dev dependencies
```
## Running locally
```bash
cd backend
uvicorn app.main:app --reload --port 12015
```
API docs at [http://localhost:12015/docs](http://localhost:12015/docs).
## Running tests
```bash
pytest backend/tests/
```
## Architecture
See [docs/ARCHITECTURE.md](../docs/ARCHITECTURE.md) for full documentation (arc42 template).
+149 -149
View File
@@ -1,149 +1,149 @@
"""DuckDB singleton connection with asyncio write lock and schema migrations."""
import asyncio
import os
import duckdb
_conn: duckdb.DuckDBPyConnection | None = None
_write_lock = asyncio.Lock()
def get_db_path() -> str:
return os.getenv("DB_PATH", "data/app.db")
def init_db(path: str | None = None) -> duckdb.DuckDBPyConnection:
"""Open (or reuse) the DuckDB connection and run schema migrations."""
global _conn
if _conn is not None:
return _conn
db_path = path or get_db_path()
if db_path != ":memory:":
os.makedirs(os.path.dirname(db_path), exist_ok=True)
_conn = duckdb.connect(db_path)
_run_migrations(_conn)
return _conn
def get_conn() -> duckdb.DuckDBPyConnection:
"""Return the active connection; raises if not yet initialised."""
if _conn is None:
raise RuntimeError("Database not initialised. Call init_db() first.")
return _conn
def close_db() -> None:
"""Close the connection (called on app shutdown)."""
global _conn
if _conn is not None:
_conn.close()
_conn = None
def get_write_lock() -> asyncio.Lock:
"""Return the asyncio lock that serialises write operations."""
return _write_lock
def _run_migrations(conn: duckdb.DuckDBPyConnection) -> None:
conn.execute("""
CREATE TABLE IF NOT EXISTS users (
id UUID DEFAULT uuid() PRIMARY KEY,
email VARCHAR NOT NULL UNIQUE,
password_hash VARCHAR NOT NULL,
role VARCHAR DEFAULT 'user',
created_at TIMESTAMP DEFAULT now(),
updated_at TIMESTAMP DEFAULT now()
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS refresh_tokens (
jti UUID DEFAULT uuid() PRIMARY KEY,
user_id UUID NOT NULL,
issued_at TIMESTAMP DEFAULT now(),
expires_at TIMESTAMP NOT NULL,
revoked BOOLEAN DEFAULT false
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS uploaded_images (
id UUID DEFAULT uuid() PRIMARY KEY,
user_id UUID NOT NULL,
filename VARCHAR NOT NULL,
content_type VARCHAR NOT NULL,
file_path VARCHAR NOT NULL,
size_bytes BIGINT NOT NULL,
created_at TIMESTAMP DEFAULT now()
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS models_cache (
id UUID DEFAULT uuid() PRIMARY KEY,
model_id VARCHAR NOT NULL UNIQUE,
name VARCHAR NOT NULL,
modality VARCHAR NOT NULL,
context_length BIGINT,
pricing JSON,
fetched_at TIMESTAMP NOT NULL
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS generated_images (
id UUID DEFAULT uuid() PRIMARY KEY,
user_id UUID NOT NULL,
model_id VARCHAR NOT NULL,
prompt VARCHAR NOT NULL,
image_data VARCHAR NOT NULL,
created_at TIMESTAMP DEFAULT now()
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS generated_videos (
id UUID DEFAULT uuid() PRIMARY KEY,
user_id UUID NOT NULL,
job_id VARCHAR NOT NULL,
model_id VARCHAR NOT NULL,
prompt VARCHAR NOT NULL,
polling_url VARCHAR,
status VARCHAR NOT NULL DEFAULT 'pending',
video_url VARCHAR,
created_at TIMESTAMP DEFAULT now(),
updated_at TIMESTAMP DEFAULT now()
)
""")
# Migration: add output_modalities column if absent (stores JSON array string)
conn.execute("""
ALTER TABLE models_cache ADD COLUMN IF NOT EXISTS output_modalities VARCHAR
""")
# Migration: add video job request params + generation type
conn.execute("""
ALTER TABLE generated_videos ADD COLUMN IF NOT EXISTS request_params VARCHAR
""")
conn.execute("""
ALTER TABLE generated_videos ADD COLUMN IF NOT EXISTS generation_type VARCHAR DEFAULT 'text_to_video'
""")
conn.execute("""
ALTER TABLE generated_videos ADD COLUMN IF NOT EXISTS error VARCHAR
""")
_seed_admin(conn)
def _seed_admin(conn: duckdb.DuckDBPyConnection) -> None:
"""Insert the default admin user if it doesn't already exist."""
from passlib.context import CryptContext
_pwd = CryptContext(schemes=["bcrypt"], deprecated="auto")
email = os.getenv("ADMIN_EMAIL", "ai@allucanget.biz")
password = os.getenv("ADMIN_PASSWORD", "admin123")
existing = conn.execute(
"SELECT id FROM users WHERE email = ?", [email]
).fetchone()
if existing is None:
password_hash = _pwd.hash(password)
conn.execute(
"""
INSERT INTO users (email, password_hash, role)
VALUES (?, ?, 'admin')
""",
[email, password_hash],
)
"""DuckDB singleton connection with asyncio write lock and schema migrations."""
import asyncio
import os
import duckdb
_conn: duckdb.DuckDBPyConnection | None = None
_write_lock = asyncio.Lock()
def get_db_path() -> str:
return os.getenv("DB_PATH", "data/app.db")
def init_db(path: str | None = None) -> duckdb.DuckDBPyConnection:
"""Open (or reuse) the DuckDB connection and run schema migrations."""
global _conn
if _conn is not None:
return _conn
db_path = path or get_db_path()
if db_path != ":memory:":
os.makedirs(os.path.dirname(db_path), exist_ok=True)
_conn = duckdb.connect(db_path)
_run_migrations(_conn)
return _conn
def get_conn() -> duckdb.DuckDBPyConnection:
"""Return the active connection; raises if not yet initialised."""
if _conn is None:
raise RuntimeError("Database not initialised. Call init_db() first.")
return _conn
def close_db() -> None:
"""Close the connection (called on app shutdown)."""
global _conn
if _conn is not None:
_conn.close()
_conn = None
def get_write_lock() -> asyncio.Lock:
"""Return the asyncio lock that serialises write operations."""
return _write_lock
def _run_migrations(conn: duckdb.DuckDBPyConnection) -> None:
conn.execute("""
CREATE TABLE IF NOT EXISTS users (
id UUID DEFAULT uuid() PRIMARY KEY,
email VARCHAR NOT NULL UNIQUE,
password_hash VARCHAR NOT NULL,
role VARCHAR DEFAULT 'user',
created_at TIMESTAMP DEFAULT now(),
updated_at TIMESTAMP DEFAULT now()
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS refresh_tokens (
jti UUID DEFAULT uuid() PRIMARY KEY,
user_id UUID NOT NULL,
issued_at TIMESTAMP DEFAULT now(),
expires_at TIMESTAMP NOT NULL,
revoked BOOLEAN DEFAULT false
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS uploaded_images (
id UUID DEFAULT uuid() PRIMARY KEY,
user_id UUID NOT NULL,
filename VARCHAR NOT NULL,
content_type VARCHAR NOT NULL,
file_path VARCHAR NOT NULL,
size_bytes BIGINT NOT NULL,
created_at TIMESTAMP DEFAULT now()
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS models_cache (
id UUID DEFAULT uuid() PRIMARY KEY,
model_id VARCHAR NOT NULL UNIQUE,
name VARCHAR NOT NULL,
modality VARCHAR NOT NULL,
context_length BIGINT,
pricing JSON,
fetched_at TIMESTAMP NOT NULL
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS generated_images (
id UUID DEFAULT uuid() PRIMARY KEY,
user_id UUID NOT NULL,
model_id VARCHAR NOT NULL,
prompt VARCHAR NOT NULL,
image_data VARCHAR NOT NULL,
created_at TIMESTAMP DEFAULT now()
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS generated_videos (
id UUID DEFAULT uuid() PRIMARY KEY,
user_id UUID NOT NULL,
job_id VARCHAR NOT NULL,
model_id VARCHAR NOT NULL,
prompt VARCHAR NOT NULL,
polling_url VARCHAR,
status VARCHAR NOT NULL DEFAULT 'pending',
video_url VARCHAR,
created_at TIMESTAMP DEFAULT now(),
updated_at TIMESTAMP DEFAULT now()
)
""")
# Migration: add output_modalities column if absent (stores JSON array string)
conn.execute("""
ALTER TABLE models_cache ADD COLUMN IF NOT EXISTS output_modalities VARCHAR
""")
# Migration: add video job request params + generation type
conn.execute("""
ALTER TABLE generated_videos ADD COLUMN IF NOT EXISTS request_params VARCHAR
""")
conn.execute("""
ALTER TABLE generated_videos ADD COLUMN IF NOT EXISTS generation_type VARCHAR DEFAULT 'text_to_video'
""")
conn.execute("""
ALTER TABLE generated_videos ADD COLUMN IF NOT EXISTS error VARCHAR
""")
_seed_admin(conn)
def _seed_admin(conn: duckdb.DuckDBPyConnection) -> None:
"""Insert the default admin user if it doesn't already exist."""
from passlib.context import CryptContext
_pwd = CryptContext(schemes=["bcrypt"], deprecated="auto")
email = os.getenv("ADMIN_EMAIL", "ai@allucanget.biz")
password = os.getenv("ADMIN_PASSWORD", "admin123")
existing = conn.execute(
"SELECT id FROM users WHERE email = ?", [email]
).fetchone()
if existing is None:
password_hash = _pwd.hash(password)
conn.execute(
"""
INSERT INTO users (email, password_hash, role)
VALUES (?, ?, 'admin')
""",
[email, password_hash],
)
+42 -42
View File
@@ -1,42 +1,42 @@
"""FastAPI dependencies (e.g. authenticated user extraction)."""
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jose import JWTError
from .services.auth import decode_token
_bearer = HTTPBearer()
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(_bearer),
) -> dict:
"""Extract and validate the Bearer JWT. Returns the token payload."""
credentials_error = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials.",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = decode_token(credentials.credentials)
except JWTError:
raise credentials_error
if payload.get("type") != "access":
raise credentials_error
user_id: str | None = payload.get("sub")
if user_id is None:
raise credentials_error
return {"id": user_id, "email": payload.get("email"), "role": payload.get("role")}
async def require_admin(current_user: dict = Depends(get_current_user)) -> dict:
"""Raise 403 if the authenticated user is not an admin."""
if current_user.get("role") != "admin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin access required.",
)
return current_user
"""FastAPI dependencies (e.g. authenticated user extraction)."""
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jose import JWTError
from .services.auth import decode_token
_bearer = HTTPBearer()
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(_bearer),
) -> dict:
"""Extract and validate the Bearer JWT. Returns the token payload."""
credentials_error = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials.",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = decode_token(credentials.credentials)
except JWTError:
raise credentials_error
if payload.get("type") != "access":
raise credentials_error
user_id: str | None = payload.get("sub")
if user_id is None:
raise credentials_error
return {"id": user_id, "email": payload.get("email"), "role": payload.get("role")}
async def require_admin(current_user: dict = Depends(get_current_user)) -> dict:
"""Raise 403 if the authenticated user is not an admin."""
if current_user.get("role") != "admin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin access required.",
)
return current_user
+60 -60
View File
@@ -1,60 +1,60 @@
from .routers import auth
from .routers import users
from .routers import admin
from .routers import ai
from .routers import generate
from .routers import images
from .routers import models
from .db import close_db, get_conn, get_write_lock, init_db
from .services.video_worker import run_worker
import asyncio
import os
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from dotenv import load_dotenv
load_dotenv()
@asynccontextmanager
async def lifespan(app: FastAPI):
init_db()
worker_task = asyncio.create_task(run_worker(get_conn(), get_write_lock()))
yield
worker_task.cancel()
try:
await worker_task
except asyncio.CancelledError:
pass
close_db()
app = FastAPI(
title="All You Can GET AI Biz API",
description="Multi-modal AI generation API powered by openrouter.ai",
version="0.1.0",
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=[os.getenv("CORS_ORIGINS", "http://localhost:12016")],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(auth.router)
app.include_router(users.router)
app.include_router(admin.router)
app.include_router(ai.router)
app.include_router(generate.router)
app.include_router(images.router)
app.include_router(models.router)
@app.get("/health", tags=["health"])
async def health() -> dict:
return {"status": "ok"}
from .routers import auth
from .routers import users
from .routers import admin
from .routers import ai
from .routers import generate
from .routers import images
from .routers import models
from .db import close_db, get_conn, get_write_lock, init_db
from .services.video_worker import run_worker
import asyncio
import os
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from dotenv import load_dotenv
load_dotenv()
@asynccontextmanager
async def lifespan(app: FastAPI):
init_db()
worker_task = asyncio.create_task(run_worker(get_conn(), get_write_lock()))
yield
worker_task.cancel()
try:
await worker_task
except asyncio.CancelledError:
pass
close_db()
app = FastAPI(
title="All You Can GET AI Biz API",
description="Multi-modal AI generation API powered by openrouter.ai",
version="0.1.0",
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=[os.getenv("CORS_ORIGINS", "http://localhost:12016")],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(auth.router)
app.include_router(users.router)
app.include_router(admin.router)
app.include_router(ai.router)
app.include_router(generate.router)
app.include_router(images.router)
app.include_router(models.router)
@app.get("/health", tags=["health"])
async def health() -> dict:
return {"status": "ok"}
+102 -102
View File
@@ -1,102 +1,102 @@
"""Pydantic schemas for AI generation endpoints."""
from typing import Any
from pydantic import BaseModel
class ChatMessage(BaseModel):
role: str # "user" | "assistant" | "system"
content: str
class ChatRequest(BaseModel):
model: str
messages: list[ChatMessage]
temperature: float = 0.7
max_tokens: int = 1024
class ChatResponse(BaseModel):
id: str
model: str
content: str
usage: dict[str, Any] | None = None
class ModelInfo(BaseModel):
id: str
name: str
context_length: int | None = None
pricing: dict[str, Any] | None = None
# --- Text generation ---
class TextRequest(BaseModel):
model: str
prompt: str = ""
system_prompt: str | None = None
messages: list[ChatMessage] | None = None
temperature: float = 0.7
max_tokens: int = 1024
class TextResponse(BaseModel):
id: str
model: str
content: str
usage: dict[str, Any] | None = None
# --- Image generation ---
class ImageRequest(BaseModel):
model: str
prompt: str
n: int = 1
size: str = "1024x1024"
aspect_ratio: str | None = None # e.g. "1:1", "16:9", "9:16"
image_size: str | None = None # e.g. "0.5K", "1K", "2K", "4K"
class ImageResult(BaseModel):
url: str | None = None
b64_json: str | None = None
revised_prompt: str | None = None
image_id: str | None = None # UUID of stored row in generated_images
class ImageResponse(BaseModel):
id: str
model: str
images: list[ImageResult]
# --- Video generation ---
class VideoRequest(BaseModel):
model: str
prompt: str
duration_seconds: int | None = None
aspect_ratio: str = "16:9"
resolution: str | None = None # e.g. "480p", "720p", "1080p"
class VideoFromImageRequest(BaseModel):
model: str
image_url: str
prompt: str
duration_seconds: int | None = None
aspect_ratio: str = "16:9"
resolution: str | None = None # e.g. "480p", "720p", "1080p"
class VideoResponse(BaseModel):
id: str # This is the job_id from the provider
db_id: str | None = None # This is the UUID from our generated_videos table
model: str
status: str # "queued" | "processing" | "completed" | "failed"
polling_url: str | None = None
video_urls: list[str] | None = None
video_url: str | None = None # first entry of video_urls for convenience
error: str | None = None
metadata: dict[str, Any] | None = None
"""Pydantic schemas for AI generation endpoints."""
from typing import Any
from pydantic import BaseModel
class ChatMessage(BaseModel):
role: str # "user" | "assistant" | "system"
content: str
class ChatRequest(BaseModel):
model: str
messages: list[ChatMessage]
temperature: float = 0.7
max_tokens: int = 1024
class ChatResponse(BaseModel):
id: str
model: str
content: str
usage: dict[str, Any] | None = None
class ModelInfo(BaseModel):
id: str
name: str
context_length: int | None = None
pricing: dict[str, Any] | None = None
# --- Text generation ---
class TextRequest(BaseModel):
model: str
prompt: str = ""
system_prompt: str | None = None
messages: list[ChatMessage] | None = None
temperature: float = 0.7
max_tokens: int = 1024
class TextResponse(BaseModel):
id: str
model: str
content: str
usage: dict[str, Any] | None = None
# --- Image generation ---
class ImageRequest(BaseModel):
model: str
prompt: str
n: int = 1
size: str = "1024x1024"
aspect_ratio: str | None = None # e.g. "1:1", "16:9", "9:16"
image_size: str | None = None # e.g. "0.5K", "1K", "2K", "4K"
class ImageResult(BaseModel):
url: str | None = None
b64_json: str | None = None
revised_prompt: str | None = None
image_id: str | None = None # UUID of stored row in generated_images
class ImageResponse(BaseModel):
id: str
model: str
images: list[ImageResult]
# --- Video generation ---
class VideoRequest(BaseModel):
model: str
prompt: str
duration_seconds: int | None = None
aspect_ratio: str = "16:9"
resolution: str | None = None # e.g. "480p", "720p", "1080p"
class VideoFromImageRequest(BaseModel):
model: str
image_url: str
prompt: str
duration_seconds: int | None = None
aspect_ratio: str = "16:9"
resolution: str | None = None # e.g. "480p", "720p", "1080p"
class VideoResponse(BaseModel):
id: str # This is the job_id from the provider
db_id: str | None = None # This is the UUID from our generated_videos table
model: str
status: str # "queued" | "processing" | "completed" | "failed"
polling_url: str | None = None
video_urls: list[str] | None = None
video_url: str | None = None # first entry of video_urls for convenience
error: str | None = None
metadata: dict[str, Any] | None = None
+22 -22
View File
@@ -1,22 +1,22 @@
"""Pydantic schemas for authentication endpoints."""
from pydantic import BaseModel, EmailStr
class RegisterRequest(BaseModel):
email: EmailStr
password: str
class LoginRequest(BaseModel):
email: EmailStr
password: str
class TokenResponse(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
class RefreshRequest(BaseModel):
refresh_token: str
"""Pydantic schemas for authentication endpoints."""
from pydantic import BaseModel, EmailStr
class RegisterRequest(BaseModel):
email: EmailStr
password: str
class LoginRequest(BaseModel):
email: EmailStr
password: str
class TokenResponse(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
class RefreshRequest(BaseModel):
refresh_token: str
+17 -17
View File
@@ -1,17 +1,17 @@
"""Pydantic schemas for user management endpoints."""
from pydantic import BaseModel, EmailStr
class UserResponse(BaseModel):
id: str
email: str
role: str
class UpdateUserRequest(BaseModel):
email: EmailStr | None = None
password: str | None = None
class SetRoleRequest(BaseModel):
role: str
"""Pydantic schemas for user management endpoints."""
from pydantic import BaseModel, EmailStr
class UserResponse(BaseModel):
id: str
email: str
role: str
class UpdateUserRequest(BaseModel):
email: EmailStr | None = None
password: str | None = None
class SetRoleRequest(BaseModel):
role: str
+227 -227
View File
@@ -1,227 +1,227 @@
"""Admin router: operational endpoints for application management."""
from datetime import datetime, timedelta, timezone
from typing import Any
from fastapi import APIRouter, Depends
from ..db import get_conn, get_write_lock
from ..dependencies import require_admin
from ..services import models as models_service
from ..services.models import mark_timed_out_video_jobs
router = APIRouter(prefix="/admin", tags=["admin"])
@router.get("/stats")
async def get_stats(_: dict = Depends(require_admin)) -> dict:
"""Return aggregate statistics: user counts and token counts."""
conn = get_conn()
sql_user_count = "SELECT COUNT(*) FROM users"
sql_user_counts = "SELECT role, COUNT(*) FROM users GROUP BY role ORDER BY role"
sql_token_count = "SELECT COUNT(*) FROM refresh_tokens"
sql_tokens_active = "SELECT COUNT(*) FROM refresh_tokens WHERE revoked = false AND expires_at > ?"
now = datetime.now(timezone.utc)
total_users_row = conn.execute(sql_user_count).fetchone()
total_users = total_users_row[0] if total_users_row else 0
users_by_role = conn.execute(sql_user_counts).fetchall()
total_tokens_row = conn.execute(sql_token_count).fetchone()
total_tokens = total_tokens_row[0] if total_tokens_row else 0
active_tokens_row = conn.execute(sql_tokens_active, [now]).fetchone()
active_tokens = active_tokens_row[0] if active_tokens_row else 0
return {
"users": {
"total": total_users,
"by_role": {row[0]: row[1] for row in users_by_role},
},
"refresh_tokens": {
"total": total_tokens,
"active": active_tokens,
"revoked_or_expired": total_tokens - active_tokens,
},
}
@router.get("/health/db")
async def db_health(_: dict = Depends(require_admin)) -> dict:
"""Verify DuckDB is reachable."""
conn = get_conn()
result_row = conn.execute("SELECT 1").fetchone()
result = result_row[0] if result_row else 0
return {"status": "ok" if result == 1 else "error"}
@router.post("/tokens/purge", status_code=200)
async def purge_tokens(_: dict = Depends(require_admin)) -> dict:
"""Delete all expired or revoked refresh tokens. Returns count removed."""
conn = get_conn()
lock = get_write_lock()
now = datetime.now(timezone.utc)
sql_count = "SELECT COUNT(*) FROM refresh_tokens"
sql_delete = "DELETE FROM refresh_tokens WHERE revoked = true OR expires_at <= ?"
async with lock:
before_row = conn.execute(sql_count).fetchone()
before = before_row[0] if before_row else 0
conn.execute(sql_delete, [now])
after_row = conn.execute(sql_count).fetchone()
after = after_row[0] if after_row else 0
return {"deleted": before - after, "remaining": after}
@router.get("/models/status")
async def get_model_status(_: dict = Depends(require_admin)) -> dict[str, Any]:
"""Return model cache status: last update time and model count."""
conn = get_conn()
return models_service.get_cache_status(conn)
@router.get("/models")
async def get_all_models(_: dict = Depends(require_admin)) -> list[dict[str, Any]]:
"""Return all cached models."""
conn = get_conn()
return models_service.get_cached_models(conn)
@router.post("/models/refresh", status_code=200)
async def refresh_models(
_: dict = Depends(require_admin),
) -> dict[str, str | int | None]:
"""Force a refresh of the model cache from OpenRouter."""
conn = get_conn()
lock = get_write_lock()
async with lock:
count = await models_service.refresh_models_cache(conn)
status = models_service.get_cache_status(conn)
return {
"status": "ok",
"refreshed": count,
"total_models": status.get("model_count"),
"last_updated": status.get("last_updated"),
}
@router.get("/videos")
async def admin_list_video_jobs(_: dict = Depends(require_admin)) -> list[dict[str, Any]]:
"""Return all video generation jobs across all users."""
conn = get_conn()
rows = conn.execute(
"""
SELECT
v.id, v.job_id, v.user_id, u.email, v.model_id, v.prompt,
v.status, v.video_url, v.created_at, v.updated_at
FROM generated_videos v
LEFT JOIN users u ON v.user_id = u.id
ORDER BY v.created_at DESC
"""
).fetchall()
return [
{
"id": str(row[0]),
"job_id": row[1],
"user_id": str(row[2]),
"user_email": row[3],
"model_id": row[4],
"prompt": row[5],
"status": row[6],
"video_url": row[7],
"created_at": row[8].isoformat() if row[8] else None,
"updated_at": row[9].isoformat() if row[9] else None,
}
for row in rows
]
@router.post("/videos/{job_id}/cancel", status_code=200)
async def admin_cancel_video_job(job_id: str, _: dict = Depends(require_admin)) -> dict[str, str]:
"""Mark a video job as 'cancelled'. Does not stop the provider job."""
conn = get_conn()
lock = get_write_lock()
now = datetime.now(timezone.utc)
async with lock:
conn.execute(
"UPDATE generated_videos SET status = 'cancelled', updated_at = ? WHERE id = ?", [
now, job_id]
)
return {"status": "ok", "job_id": job_id}
@router.post("/videos/purge", status_code=200)
async def admin_purge_video_jobs(_: dict = Depends(require_admin)) -> dict[str, Any]:
"""Delete all completed, failed, or cancelled jobs older than 30 days."""
conn = get_conn()
lock = get_write_lock()
thirty_days_ago = datetime.now(
timezone.utc) - timedelta(days=30)
sql_count = "SELECT COUNT(*) FROM generated_videos"
sql_delete = """
DELETE FROM generated_videos
WHERE status IN ('completed', 'failed', 'cancelled')
AND updated_at < ?
"""
async with lock:
before_row = conn.execute(sql_count).fetchone()
before = before_row[0] if before_row else 0
conn.execute(sql_delete, [thirty_days_ago])
after_row = conn.execute(sql_count).fetchone()
after = after_row[0] if after_row else 0
return {"deleted": before - after, "remaining": after}
@router.post("/videos/timed-out", status_code=200)
async def admin_mark_timed_out(_: dict = Depends(require_admin)) -> dict[str, int]:
"""Mark video jobs that have been in 'queued' or 'processing' status for too long as 'failed'."""
conn = get_conn()
count = mark_timed_out_video_jobs(conn, timeout_minutes=120)
return {"timed_out": count}
@router.post("/videos/{job_id}/retry", status_code=200)
async def admin_retry_video_job(job_id: str, _: dict = Depends(require_admin)) -> dict[str, str]:
"""Reset a failed or cancelled video job back to 'queued' for reprocessing."""
conn = get_conn()
lock = get_write_lock()
now = datetime.now(timezone.utc)
async with lock:
row = conn.execute(
"SELECT status FROM generated_videos WHERE id = ?", [job_id]
).fetchone()
if row is None:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="Job not found")
if row[0] not in ("failed", "cancelled"):
from fastapi import HTTPException
raise HTTPException(
status_code=400, detail=f"Cannot retry job with status '{row[0]}'")
conn.execute(
"UPDATE generated_videos SET status = 'queued', updated_at = ? WHERE id = ?",
[now, job_id],
)
return {"status": "ok", "job_id": job_id}
@router.delete("/videos/{job_id}", status_code=200)
async def admin_delete_video_job(job_id: str, _: dict = Depends(require_admin)) -> dict[str, str]:
"""Permanently delete a video job record."""
conn = get_conn()
lock = get_write_lock()
async with lock:
row = conn.execute(
"SELECT id FROM generated_videos WHERE id = ?", [job_id]
).fetchone()
if row is None:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="Job not found")
conn.execute("DELETE FROM generated_videos WHERE id = ?", [job_id])
return {"status": "ok", "job_id": job_id}
"""Admin router: operational endpoints for application management."""
from datetime import datetime, timedelta, timezone
from typing import Any
from fastapi import APIRouter, Depends
from ..db import get_conn, get_write_lock
from ..dependencies import require_admin
from ..services import models as models_service
from ..services.models import mark_timed_out_video_jobs
router = APIRouter(prefix="/admin", tags=["admin"])
@router.get("/stats")
async def get_stats(_: dict = Depends(require_admin)) -> dict:
"""Return aggregate statistics: user counts and token counts."""
conn = get_conn()
sql_user_count = "SELECT COUNT(*) FROM users"
sql_user_counts = "SELECT role, COUNT(*) FROM users GROUP BY role ORDER BY role"
sql_token_count = "SELECT COUNT(*) FROM refresh_tokens"
sql_tokens_active = "SELECT COUNT(*) FROM refresh_tokens WHERE revoked = false AND expires_at > ?"
now = datetime.now(timezone.utc)
total_users_row = conn.execute(sql_user_count).fetchone()
total_users = total_users_row[0] if total_users_row else 0
users_by_role = conn.execute(sql_user_counts).fetchall()
total_tokens_row = conn.execute(sql_token_count).fetchone()
total_tokens = total_tokens_row[0] if total_tokens_row else 0
active_tokens_row = conn.execute(sql_tokens_active, [now]).fetchone()
active_tokens = active_tokens_row[0] if active_tokens_row else 0
return {
"users": {
"total": total_users,
"by_role": {row[0]: row[1] for row in users_by_role},
},
"refresh_tokens": {
"total": total_tokens,
"active": active_tokens,
"revoked_or_expired": total_tokens - active_tokens,
},
}
@router.get("/health/db")
async def db_health(_: dict = Depends(require_admin)) -> dict:
"""Verify DuckDB is reachable."""
conn = get_conn()
result_row = conn.execute("SELECT 1").fetchone()
result = result_row[0] if result_row else 0
return {"status": "ok" if result == 1 else "error"}
@router.post("/tokens/purge", status_code=200)
async def purge_tokens(_: dict = Depends(require_admin)) -> dict:
"""Delete all expired or revoked refresh tokens. Returns count removed."""
conn = get_conn()
lock = get_write_lock()
now = datetime.now(timezone.utc)
sql_count = "SELECT COUNT(*) FROM refresh_tokens"
sql_delete = "DELETE FROM refresh_tokens WHERE revoked = true OR expires_at <= ?"
async with lock:
before_row = conn.execute(sql_count).fetchone()
before = before_row[0] if before_row else 0
conn.execute(sql_delete, [now])
after_row = conn.execute(sql_count).fetchone()
after = after_row[0] if after_row else 0
return {"deleted": before - after, "remaining": after}
@router.get("/models/status")
async def get_model_status(_: dict = Depends(require_admin)) -> dict[str, Any]:
"""Return model cache status: last update time and model count."""
conn = get_conn()
return models_service.get_cache_status(conn)
@router.get("/models")
async def get_all_models(_: dict = Depends(require_admin)) -> list[dict[str, Any]]:
"""Return all cached models."""
conn = get_conn()
return models_service.get_cached_models(conn)
@router.post("/models/refresh", status_code=200)
async def refresh_models(
_: dict = Depends(require_admin),
) -> dict[str, str | int | None]:
"""Force a refresh of the model cache from OpenRouter."""
conn = get_conn()
lock = get_write_lock()
async with lock:
count = await models_service.refresh_models_cache(conn)
status = models_service.get_cache_status(conn)
return {
"status": "ok",
"refreshed": count,
"total_models": status.get("model_count"),
"last_updated": status.get("last_updated"),
}
@router.get("/videos")
async def admin_list_video_jobs(_: dict = Depends(require_admin)) -> list[dict[str, Any]]:
"""Return all video generation jobs across all users."""
conn = get_conn()
rows = conn.execute(
"""
SELECT
v.id, v.job_id, v.user_id, u.email, v.model_id, v.prompt,
v.status, v.video_url, v.created_at, v.updated_at
FROM generated_videos v
LEFT JOIN users u ON v.user_id = u.id
ORDER BY v.created_at DESC
"""
).fetchall()
return [
{
"id": str(row[0]),
"job_id": row[1],
"user_id": str(row[2]),
"user_email": row[3],
"model_id": row[4],
"prompt": row[5],
"status": row[6],
"video_url": row[7],
"created_at": row[8].isoformat() if row[8] else None,
"updated_at": row[9].isoformat() if row[9] else None,
}
for row in rows
]
@router.post("/videos/{job_id}/cancel", status_code=200)
async def admin_cancel_video_job(job_id: str, _: dict = Depends(require_admin)) -> dict[str, str]:
"""Mark a video job as 'cancelled'. Does not stop the provider job."""
conn = get_conn()
lock = get_write_lock()
now = datetime.now(timezone.utc)
async with lock:
conn.execute(
"UPDATE generated_videos SET status = 'cancelled', updated_at = ? WHERE id = ?", [
now, job_id]
)
return {"status": "ok", "job_id": job_id}
@router.post("/videos/purge", status_code=200)
async def admin_purge_video_jobs(_: dict = Depends(require_admin)) -> dict[str, Any]:
"""Delete all completed, failed, or cancelled jobs older than 30 days."""
conn = get_conn()
lock = get_write_lock()
thirty_days_ago = datetime.now(
timezone.utc) - timedelta(days=30)
sql_count = "SELECT COUNT(*) FROM generated_videos"
sql_delete = """
DELETE FROM generated_videos
WHERE status IN ('completed', 'failed', 'cancelled')
AND updated_at < ?
"""
async with lock:
before_row = conn.execute(sql_count).fetchone()
before = before_row[0] if before_row else 0
conn.execute(sql_delete, [thirty_days_ago])
after_row = conn.execute(sql_count).fetchone()
after = after_row[0] if after_row else 0
return {"deleted": before - after, "remaining": after}
@router.post("/videos/timed-out", status_code=200)
async def admin_mark_timed_out(_: dict = Depends(require_admin)) -> dict[str, int]:
"""Mark video jobs that have been in 'queued' or 'processing' status for too long as 'failed'."""
conn = get_conn()
count = mark_timed_out_video_jobs(conn, timeout_minutes=120)
return {"timed_out": count}
@router.post("/videos/{job_id}/retry", status_code=200)
async def admin_retry_video_job(job_id: str, _: dict = Depends(require_admin)) -> dict[str, str]:
"""Reset a failed or cancelled video job back to 'queued' for reprocessing."""
conn = get_conn()
lock = get_write_lock()
now = datetime.now(timezone.utc)
async with lock:
row = conn.execute(
"SELECT status FROM generated_videos WHERE id = ?", [job_id]
).fetchone()
if row is None:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="Job not found")
if row[0] not in ("failed", "cancelled"):
from fastapi import HTTPException
raise HTTPException(
status_code=400, detail=f"Cannot retry job with status '{row[0]}'")
conn.execute(
"UPDATE generated_videos SET status = 'queued', updated_at = ? WHERE id = ?",
[now, job_id],
)
return {"status": "ok", "job_id": job_id}
@router.delete("/videos/{job_id}", status_code=200)
async def admin_delete_video_job(job_id: str, _: dict = Depends(require_admin)) -> dict[str, str]:
"""Permanently delete a video job record."""
conn = get_conn()
lock = get_write_lock()
async with lock:
row = conn.execute(
"SELECT id FROM generated_videos WHERE id = ?", [job_id]
).fetchone()
if row is None:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="Job not found")
conn.execute("DELETE FROM generated_videos WHERE id = ?", [job_id])
return {"status": "ok", "job_id": job_id}
+63 -63
View File
@@ -1,63 +1,63 @@
"""AI router: model listing and chat completions via OpenRouter."""
from fastapi import APIRouter, Depends, HTTPException, status
from ..dependencies import get_current_user
from ..models.ai import ChatRequest, ChatResponse, ModelInfo
from ..services import openrouter
router = APIRouter(prefix="/ai", tags=["ai"])
@router.get("/models", response_model=list[ModelInfo])
async def get_models(_: dict = Depends(get_current_user)) -> list[ModelInfo]:
"""List available AI models from OpenRouter."""
try:
raw = await openrouter.list_models()
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"OpenRouter error: {exc}",
)
return [
ModelInfo(
id=m.get("id", ""),
name=m.get("name", m.get("id", "")),
context_length=m.get("context_length"),
pricing=m.get("pricing"),
)
for m in raw
]
@router.post("/chat", response_model=ChatResponse)
async def chat(
body: ChatRequest,
_: dict = Depends(get_current_user),
) -> ChatResponse:
"""Send a chat completion request through OpenRouter."""
try:
result = await openrouter.chat_completion(
model=body.model,
messages=[m.model_dump() for m in body.messages],
temperature=body.temperature,
max_tokens=body.max_tokens,
)
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"OpenRouter error: {exc}",
)
try:
choice = result["choices"][0]
return ChatResponse(
id=result["id"],
model=result.get("model", body.model),
content=choice["message"]["content"],
usage=result.get("usage"),
)
except (KeyError, IndexError) as exc:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"Unexpected response format from OpenRouter: {exc}",
)
"""AI router: model listing and chat completions via OpenRouter."""
from fastapi import APIRouter, Depends, HTTPException, status
from ..dependencies import get_current_user
from ..models.ai import ChatRequest, ChatResponse, ModelInfo
from ..services import openrouter
router = APIRouter(prefix="/ai", tags=["ai"])
@router.get("/models", response_model=list[ModelInfo])
async def get_models(_: dict = Depends(get_current_user)) -> list[ModelInfo]:
"""List available AI models from OpenRouter."""
try:
raw = await openrouter.list_models()
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"OpenRouter error: {exc}",
)
return [
ModelInfo(
id=m.get("id", ""),
name=m.get("name", m.get("id", "")),
context_length=m.get("context_length"),
pricing=m.get("pricing"),
)
for m in raw
]
@router.post("/chat", response_model=ChatResponse)
async def chat(
body: ChatRequest,
_: dict = Depends(get_current_user),
) -> ChatResponse:
"""Send a chat completion request through OpenRouter."""
try:
result = await openrouter.chat_completion(
model=body.model,
messages=[m.model_dump() for m in body.messages],
temperature=body.temperature,
max_tokens=body.max_tokens,
)
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"OpenRouter error: {exc}",
)
try:
choice = result["choices"][0]
return ChatResponse(
id=result["id"],
model=result.get("model", body.model),
content=choice["message"]["content"],
usage=result.get("usage"),
)
except (KeyError, IndexError) as exc:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"Unexpected response format from OpenRouter: {exc}",
)
+108 -108
View File
@@ -1,108 +1,108 @@
"""Auth router: register, login, refresh, logout."""
import uuid
from fastapi import APIRouter, HTTPException, status
from jose import JWTError
from fastapi import Depends
from ..dependencies import get_current_user
from ..models.auth import LoginRequest, RefreshRequest, RegisterRequest, TokenResponse
from ..services.auth import (
authenticate_user,
create_access_token,
create_refresh_token,
decode_token,
register_user,
revoke_refresh_token,
store_refresh_token,
validate_refresh_token_jti,
)
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/register", status_code=status.HTTP_201_CREATED)
async def register(body: RegisterRequest) -> dict:
try:
user = await register_user(body.email, body.password)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail=str(exc))
return {"id": user["id"], "email": user["email"], "role": user["role"]}
@router.post("/login", response_model=TokenResponse)
async def login(body: LoginRequest) -> TokenResponse:
user = await authenticate_user(body.email, body.password)
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid credentials.",
headers={"WWW-Authenticate": "Bearer"},
)
jti = str(uuid.uuid4())
await store_refresh_token(user["id"], jti)
return TokenResponse(
access_token=create_access_token(
user["id"], user["email"], user["role"]),
refresh_token=create_refresh_token(user["id"], jti),
)
@router.post("/refresh", response_model=TokenResponse)
async def refresh(body: RefreshRequest) -> TokenResponse:
credentials_error = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired refresh token.",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = decode_token(body.refresh_token)
except JWTError:
raise credentials_error
if payload.get("type") != "refresh":
raise credentials_error
user_id: str = payload.get("sub", "")
jti: str = payload.get("jti", "")
if not await validate_refresh_token_jti(jti, user_id):
raise credentials_error
# Rotate: revoke old JTI, issue new pair
await revoke_refresh_token(jti)
new_jti = str(uuid.uuid4())
await store_refresh_token(user_id, new_jti)
from ..db import get_conn
conn = get_conn()
sql_fetch = "SELECT email, role FROM users WHERE id = ?"
row = conn.execute(sql_fetch, [user_id]).fetchone()
if row is None:
raise credentials_error
return TokenResponse(
access_token=create_access_token(user_id, row[0], row[1]),
refresh_token=create_refresh_token(user_id, new_jti),
)
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
async def logout(body: RefreshRequest) -> None:
try:
payload = decode_token(body.refresh_token)
except JWTError:
return # Already invalid — treat as success
jti = payload.get("jti", "")
if jti:
await revoke_refresh_token(jti)
@router.get("/validate")
async def validate(
current_user: dict = Depends(get_current_user),
) -> dict:
"""Return the token payload if the access token is valid."""
return current_user
"""Auth router: register, login, refresh, logout."""
import uuid
from fastapi import APIRouter, HTTPException, status
from jose import JWTError
from fastapi import Depends
from ..dependencies import get_current_user
from ..models.auth import LoginRequest, RefreshRequest, RegisterRequest, TokenResponse
from ..services.auth import (
authenticate_user,
create_access_token,
create_refresh_token,
decode_token,
register_user,
revoke_refresh_token,
store_refresh_token,
validate_refresh_token_jti,
)
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/register", status_code=status.HTTP_201_CREATED)
async def register(body: RegisterRequest) -> dict:
try:
user = await register_user(body.email, body.password)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail=str(exc))
return {"id": user["id"], "email": user["email"], "role": user["role"]}
@router.post("/login", response_model=TokenResponse)
async def login(body: LoginRequest) -> TokenResponse:
user = await authenticate_user(body.email, body.password)
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid credentials.",
headers={"WWW-Authenticate": "Bearer"},
)
jti = str(uuid.uuid4())
await store_refresh_token(user["id"], jti)
return TokenResponse(
access_token=create_access_token(
user["id"], user["email"], user["role"]),
refresh_token=create_refresh_token(user["id"], jti),
)
@router.post("/refresh", response_model=TokenResponse)
async def refresh(body: RefreshRequest) -> TokenResponse:
credentials_error = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired refresh token.",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = decode_token(body.refresh_token)
except JWTError:
raise credentials_error
if payload.get("type") != "refresh":
raise credentials_error
user_id: str = payload.get("sub", "")
jti: str = payload.get("jti", "")
if not await validate_refresh_token_jti(jti, user_id):
raise credentials_error
# Rotate: revoke old JTI, issue new pair
await revoke_refresh_token(jti)
new_jti = str(uuid.uuid4())
await store_refresh_token(user_id, new_jti)
from ..db import get_conn
conn = get_conn()
sql_fetch = "SELECT email, role FROM users WHERE id = ?"
row = conn.execute(sql_fetch, [user_id]).fetchone()
if row is None:
raise credentials_error
return TokenResponse(
access_token=create_access_token(user_id, row[0], row[1]),
refresh_token=create_refresh_token(user_id, new_jti),
)
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
async def logout(body: RefreshRequest) -> None:
try:
payload = decode_token(body.refresh_token)
except JWTError:
return # Already invalid — treat as success
jti = payload.get("jti", "")
if jti:
await revoke_refresh_token(jti)
@router.get("/validate")
async def validate(
current_user: dict = Depends(get_current_user),
) -> dict:
"""Return the token payload if the access token is valid."""
return current_user
+403 -403
View File
@@ -1,403 +1,403 @@
"""Generate router: text, image, video, and image-to-video generation."""
import json
from datetime import datetime, timezone
import httpx
from fastapi import APIRouter, Depends, HTTPException, status
from ..db import get_conn, get_write_lock
from ..dependencies import get_current_user
from ..models.ai import (
ImageRequest,
ImageResponse,
ImageResult,
TextRequest,
TextResponse,
VideoFromImageRequest,
VideoRequest,
VideoResponse,
)
from ..services import openrouter
from ..services.models import get_model_output_modalities
router = APIRouter(prefix="/generate", tags=["generate"])
@router.post("/text", response_model=TextResponse)
async def generate_text(
body: TextRequest,
_: dict = Depends(get_current_user),
) -> TextResponse:
"""Generate text from a prompt using a chat model."""
if body.messages:
messages = [{"role": m.role, "content": m.content}
for m in body.messages]
if body.system_prompt and (not messages or messages[0]["role"] != "system"):
messages.insert(
0, {"role": "system", "content": body.system_prompt})
else:
messages = []
if body.system_prompt:
messages.append({"role": "system", "content": body.system_prompt})
messages.append({"role": "user", "content": body.prompt})
try:
result = await openrouter.chat_completion(
model=body.model,
messages=messages,
temperature=body.temperature,
max_tokens=body.max_tokens,
)
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY, detail=f"OpenRouter error: {exc}")
try:
choice = result["choices"][0]
return TextResponse(
id=result["id"],
model=result.get("model", body.model),
content=choice["message"]["content"],
usage=result.get("usage"),
)
except (KeyError, IndexError) as exc:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"Unexpected response format: {exc}")
@router.post("/image", response_model=ImageResponse)
async def generate_image(
body: ImageRequest,
current_user: dict = Depends(get_current_user),
) -> ImageResponse:
"""Generate images from a prompt using the chat completions endpoint.
All OpenRouter image models use /chat/completions with a modalities param.
Models that output only images use ["image"]; those that also output text
use ["image", "text"]. We look this up from the model cache; default to
["image", "text"] when the model is not yet cached.
"""
# Determine modalities from cache; default ["image", "text"] works for most models
try:
conn = get_conn()
cached_modalities = get_model_output_modalities(conn, body.model)
except Exception:
cached_modalities = []
if cached_modalities:
# If cache says model only outputs image (no text), use ["image"]
modalities = ["image"] if set(cached_modalities) == {
"image"} else ["image", "text"]
else:
# Safe default: ["image", "text"]; works for Gemini, GPT-image etc.
# For image-only models that fail with this, the error surfaces to the user.
modalities = ["image", "text"]
image_config: dict = {}
if body.aspect_ratio:
image_config["aspect_ratio"] = body.aspect_ratio
if body.image_size:
image_config["image_size"] = body.image_size
try:
result = await openrouter.generate_image_chat(
model=body.model,
prompt=body.prompt,
modalities=modalities,
image_config=image_config if image_config else None,
)
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY, detail=f"OpenRouter error: {exc}")
try:
message = result.get("choices", [{}])[0].get("message", {})
images = []
for item in message.get("images", []):
img_url = item.get("image_url", {}).get("url")
images.append(ImageResult(
url=img_url,
b64_json=None,
revised_prompt=message.get("content") or None,
))
if not images:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="No images returned by model. Verify the model supports image generation.",
)
# Persist each image to DB
user_id = current_user.get("id") or current_user.get("sub")
now = datetime.now(timezone.utc).replace(tzinfo=None)
stored: list[ImageResult] = []
sql_insert = "INSERT INTO generated_images (user_id, model_id, prompt, image_data, created_at) VALUES (?, ?, ?, ?, ?) RETURNING id"
async with get_write_lock():
conn = get_conn()
for img in images:
if img.url:
row = conn.execute(
sql_insert, [user_id, body.model, body.prompt, img.url, now],).fetchone()
image_id = str(row[0]) if row else None
else:
image_id = None
stored.append(ImageResult(
url=img.url,
b64_json=img.b64_json,
revised_prompt=img.revised_prompt,
image_id=image_id,
))
return ImageResponse(
id=result.get("id", ""),
model=result.get("model", body.model),
images=stored,
)
except HTTPException:
raise
except (KeyError, TypeError) as exc:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"Unexpected response format: {exc}")
@router.get("/images")
async def list_generated_images(
current_user: dict = Depends(get_current_user),
) -> list[dict]:
"""Return all generated images for the current user, newest first."""
user_id = current_user.get("id") or current_user.get("sub")
conn = get_conn()
sql_fetch = "SELECT id, model_id, prompt, image_data, created_at FROM generated_images WHERE user_id = ? ORDER BY created_at DESC"
rows = conn.execute(sql_fetch, [user_id]).fetchall()
return [
{
"id": str(r[0]),
"model_id": r[1],
"prompt": r[2],
"image_data": r[3],
"created_at": r[4].isoformat() if r[4] else None,
}
for r in rows
]
@router.get("/images/{image_id}")
async def get_generated_image(
image_id: str,
current_user: dict = Depends(get_current_user),
) -> dict:
"""Return details for a single generated image."""
user_id = current_user.get("id") or current_user.get("sub")
conn = get_conn()
row = conn.execute(
"""SELECT id, model_id, prompt, image_data, created_at
FROM generated_images
WHERE id = ? AND user_id = ?""",
[image_id, user_id],
).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Image not found")
return {
"id": str(row[0]),
"model_id": row[1],
"prompt": row[2],
"image_data": row[3],
"created_at": row[4].isoformat() if row[4] else None,
}
@router.post("/video", response_model=VideoResponse)
async def generate_video(
body: VideoRequest,
current_user: dict = Depends(get_current_user),
) -> VideoResponse:
"""Queue a text-to-video generation job for background processing."""
user_id = current_user.get("id") or current_user.get("sub")
now = datetime.now(timezone.utc).replace(tzinfo=None)
request_params = json.dumps({
"model": body.model,
"prompt": body.prompt,
"duration_seconds": body.duration_seconds,
"aspect_ratio": body.aspect_ratio,
"resolution": body.resolution,
})
db_id = None
async with get_write_lock():
conn = get_conn()
row = conn.execute(
"""INSERT INTO generated_videos
(user_id, job_id, model_id, prompt, status, request_params, generation_type, created_at, updated_at)
VALUES (?, ?, ?, ?, 'queued', ?, 'text_to_video', ?, ?) RETURNING id""",
[user_id, "", body.model, body.prompt, request_params, now, now],
).fetchone()
if row:
db_id = str(row[0])
return VideoResponse(
id="",
db_id=db_id,
model=body.model,
status="queued",
)
@router.post("/video/from-image", response_model=VideoResponse)
async def generate_video_from_image(
body: VideoFromImageRequest,
current_user: dict = Depends(get_current_user),
) -> VideoResponse:
"""Queue an image-to-video generation job for background processing."""
user_id = current_user.get("id") or current_user.get("sub")
now = datetime.now(timezone.utc).replace(tzinfo=None)
request_params = json.dumps({
"model": body.model,
"image_url": body.image_url,
"prompt": body.prompt,
"duration_seconds": body.duration_seconds,
"aspect_ratio": body.aspect_ratio,
"resolution": body.resolution,
})
db_id = None
async with get_write_lock():
conn = get_conn()
row = conn.execute(
"""INSERT INTO generated_videos
(user_id, job_id, model_id, prompt, status, request_params, generation_type, created_at, updated_at)
VALUES (?, ?, ?, ?, 'queued', ?, 'image_to_video', ?, ?) RETURNING id""",
[user_id, "", body.model, body.prompt, request_params, now, now],
).fetchone()
if row:
db_id = str(row[0])
return VideoResponse(
id="",
db_id=db_id,
model=body.model,
status="queued",
)
@router.get("/video/status", response_model=VideoResponse)
async def poll_video_status(
polling_url: str,
current_user: dict = Depends(get_current_user),
) -> VideoResponse:
"""Poll status of a video generation job; updates DB row when completed/failed."""
try:
result = await openrouter.poll_video_status(polling_url)
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY, detail=f"OpenRouter error: {exc}")
job_status = result.get("status", "processing")
urls = result.get("unsigned_urls") or result.get("video_urls")
video_url = (urls or [None])[0]
# Update DB row for this job when terminal state reached
if job_status in ("completed", "failed"):
now = datetime.now(timezone.utc).replace(tzinfo=None)
async with get_write_lock():
conn = get_conn()
conn.execute(
"""UPDATE generated_videos
SET status = ?, video_url = ?, updated_at = ?
WHERE job_id = ?""",
[job_status, video_url, now, result.get("id", "")],
)
return VideoResponse(
id=result.get("id", ""),
model=result.get("model", ""),
status=job_status,
polling_url=result.get("polling_url"),
video_urls=urls,
video_url=video_url,
error=result.get("error"),
metadata=result.get("metadata"),
)
@router.get("/videos")
async def list_generated_videos(
current_user: dict = Depends(get_current_user),
) -> list[dict]:
"""Return all generated video jobs for the current user, newest first."""
user_id = current_user.get("id") or current_user.get("sub")
conn = get_conn()
rows = conn.execute(
"""SELECT id, job_id, model_id, prompt, polling_url, status, video_url, error, created_at
FROM generated_videos
WHERE user_id = ?
ORDER BY created_at DESC""",
[user_id],
).fetchall()
return [
{
"id": str(r[0]),
"job_id": r[1],
"model_id": r[2],
"prompt": r[3],
"polling_url": r[4],
"status": r[5],
"video_url": r[6],
"error": r[7],
"created_at": r[8].isoformat() if r[8] else None,
}
for r in rows
]
@router.get("/videos/{video_id}")
async def get_generated_video(
video_id: str,
current_user: dict = Depends(get_current_user),
) -> dict:
"""Return details for a single video generation job."""
user_id = current_user.get("id") or current_user.get("sub")
conn = get_conn()
row = conn.execute(
"""SELECT id, job_id, model_id, prompt, polling_url, status, video_url, error, created_at, updated_at
FROM generated_videos
WHERE id = ? AND user_id = ?""",
[video_id, user_id],
).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Video job not found")
return {
"id": str(row[0]),
"job_id": row[1],
"model_id": row[2],
"prompt": row[3],
"polling_url": row[4],
"status": row[5],
"video_url": row[6],
"error": row[7],
"created_at": row[8].isoformat() if row[8] else None,
"updated_at": row[9].isoformat() if row[9] else None,
}
@router.post("/videos/{video_id}/cancel", status_code=200)
async def cancel_video_job(
video_id: str,
current_user: dict = Depends(get_current_user),
) -> dict[str, str]:
"""Mark a video job as 'cancelled' if it belongs to the current user and is not terminal."""
user_id = current_user.get("id") or current_user.get("sub")
conn = get_conn()
row = conn.execute(
"SELECT status FROM generated_videos WHERE id = ? AND user_id = ?",
[video_id, user_id],
).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Video job not found")
job_status = row[0]
if job_status in ("completed", "failed", "cancelled"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Cannot cancel job with status '{job_status}'",
)
now = datetime.now(timezone.utc).replace(tzinfo=None)
async with get_write_lock():
conn.execute(
"UPDATE generated_videos SET status = 'cancelled', updated_at = ? WHERE id = ?",
[now, video_id],
)
return {"status": "ok", "job_id": video_id}
"""Generate router: text, image, video, and image-to-video generation."""
import json
from datetime import datetime, timezone
import httpx
from fastapi import APIRouter, Depends, HTTPException, status
from ..db import get_conn, get_write_lock
from ..dependencies import get_current_user
from ..models.ai import (
ImageRequest,
ImageResponse,
ImageResult,
TextRequest,
TextResponse,
VideoFromImageRequest,
VideoRequest,
VideoResponse,
)
from ..services import openrouter
from ..services.models import get_model_output_modalities
router = APIRouter(prefix="/generate", tags=["generate"])
@router.post("/text", response_model=TextResponse)
async def generate_text(
body: TextRequest,
_: dict = Depends(get_current_user),
) -> TextResponse:
"""Generate text from a prompt using a chat model."""
if body.messages:
messages = [{"role": m.role, "content": m.content}
for m in body.messages]
if body.system_prompt and (not messages or messages[0]["role"] != "system"):
messages.insert(
0, {"role": "system", "content": body.system_prompt})
else:
messages = []
if body.system_prompt:
messages.append({"role": "system", "content": body.system_prompt})
messages.append({"role": "user", "content": body.prompt})
try:
result = await openrouter.chat_completion(
model=body.model,
messages=messages,
temperature=body.temperature,
max_tokens=body.max_tokens,
)
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY, detail=f"OpenRouter error: {exc}")
try:
choice = result["choices"][0]
return TextResponse(
id=result["id"],
model=result.get("model", body.model),
content=choice["message"]["content"],
usage=result.get("usage"),
)
except (KeyError, IndexError) as exc:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"Unexpected response format: {exc}")
@router.post("/image", response_model=ImageResponse)
async def generate_image(
body: ImageRequest,
current_user: dict = Depends(get_current_user),
) -> ImageResponse:
"""Generate images from a prompt using the chat completions endpoint.
All OpenRouter image models use /chat/completions with a modalities param.
Models that output only images use ["image"]; those that also output text
use ["image", "text"]. We look this up from the model cache; default to
["image", "text"] when the model is not yet cached.
"""
# Determine modalities from cache; default ["image", "text"] works for most models
try:
conn = get_conn()
cached_modalities = get_model_output_modalities(conn, body.model)
except Exception:
cached_modalities = []
if cached_modalities:
# If cache says model only outputs image (no text), use ["image"]
modalities = ["image"] if set(cached_modalities) == {
"image"} else ["image", "text"]
else:
# Safe default: ["image", "text"]; works for Gemini, GPT-image etc.
# For image-only models that fail with this, the error surfaces to the user.
modalities = ["image", "text"]
image_config: dict = {}
if body.aspect_ratio:
image_config["aspect_ratio"] = body.aspect_ratio
if body.image_size:
image_config["image_size"] = body.image_size
try:
result = await openrouter.generate_image_chat(
model=body.model,
prompt=body.prompt,
modalities=modalities,
image_config=image_config if image_config else None,
)
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY, detail=f"OpenRouter error: {exc}")
try:
message = result.get("choices", [{}])[0].get("message", {})
images = []
for item in message.get("images", []):
img_url = item.get("image_url", {}).get("url")
images.append(ImageResult(
url=img_url,
b64_json=None,
revised_prompt=message.get("content") or None,
))
if not images:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="No images returned by model. Verify the model supports image generation.",
)
# Persist each image to DB
user_id = current_user.get("id") or current_user.get("sub")
now = datetime.now(timezone.utc).replace(tzinfo=None)
stored: list[ImageResult] = []
sql_insert = "INSERT INTO generated_images (user_id, model_id, prompt, image_data, created_at) VALUES (?, ?, ?, ?, ?) RETURNING id"
async with get_write_lock():
conn = get_conn()
for img in images:
if img.url:
row = conn.execute(
sql_insert, [user_id, body.model, body.prompt, img.url, now],).fetchone()
image_id = str(row[0]) if row else None
else:
image_id = None
stored.append(ImageResult(
url=img.url,
b64_json=img.b64_json,
revised_prompt=img.revised_prompt,
image_id=image_id,
))
return ImageResponse(
id=result.get("id", ""),
model=result.get("model", body.model),
images=stored,
)
except HTTPException:
raise
except (KeyError, TypeError) as exc:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"Unexpected response format: {exc}")
@router.get("/images")
async def list_generated_images(
current_user: dict = Depends(get_current_user),
) -> list[dict]:
"""Return all generated images for the current user, newest first."""
user_id = current_user.get("id") or current_user.get("sub")
conn = get_conn()
sql_fetch = "SELECT id, model_id, prompt, image_data, created_at FROM generated_images WHERE user_id = ? ORDER BY created_at DESC"
rows = conn.execute(sql_fetch, [user_id]).fetchall()
return [
{
"id": str(r[0]),
"model_id": r[1],
"prompt": r[2],
"image_data": r[3],
"created_at": r[4].isoformat() if r[4] else None,
}
for r in rows
]
@router.get("/images/{image_id}")
async def get_generated_image(
image_id: str,
current_user: dict = Depends(get_current_user),
) -> dict:
"""Return details for a single generated image."""
user_id = current_user.get("id") or current_user.get("sub")
conn = get_conn()
row = conn.execute(
"""SELECT id, model_id, prompt, image_data, created_at
FROM generated_images
WHERE id = ? AND user_id = ?""",
[image_id, user_id],
).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Image not found")
return {
"id": str(row[0]),
"model_id": row[1],
"prompt": row[2],
"image_data": row[3],
"created_at": row[4].isoformat() if row[4] else None,
}
@router.post("/video", response_model=VideoResponse)
async def generate_video(
body: VideoRequest,
current_user: dict = Depends(get_current_user),
) -> VideoResponse:
"""Queue a text-to-video generation job for background processing."""
user_id = current_user.get("id") or current_user.get("sub")
now = datetime.now(timezone.utc).replace(tzinfo=None)
request_params = json.dumps({
"model": body.model,
"prompt": body.prompt,
"duration_seconds": body.duration_seconds,
"aspect_ratio": body.aspect_ratio,
"resolution": body.resolution,
})
db_id = None
async with get_write_lock():
conn = get_conn()
row = conn.execute(
"""INSERT INTO generated_videos
(user_id, job_id, model_id, prompt, status, request_params, generation_type, created_at, updated_at)
VALUES (?, ?, ?, ?, 'queued', ?, 'text_to_video', ?, ?) RETURNING id""",
[user_id, "", body.model, body.prompt, request_params, now, now],
).fetchone()
if row:
db_id = str(row[0])
return VideoResponse(
id="",
db_id=db_id,
model=body.model,
status="queued",
)
@router.post("/video/from-image", response_model=VideoResponse)
async def generate_video_from_image(
body: VideoFromImageRequest,
current_user: dict = Depends(get_current_user),
) -> VideoResponse:
"""Queue an image-to-video generation job for background processing."""
user_id = current_user.get("id") or current_user.get("sub")
now = datetime.now(timezone.utc).replace(tzinfo=None)
request_params = json.dumps({
"model": body.model,
"image_url": body.image_url,
"prompt": body.prompt,
"duration_seconds": body.duration_seconds,
"aspect_ratio": body.aspect_ratio,
"resolution": body.resolution,
})
db_id = None
async with get_write_lock():
conn = get_conn()
row = conn.execute(
"""INSERT INTO generated_videos
(user_id, job_id, model_id, prompt, status, request_params, generation_type, created_at, updated_at)
VALUES (?, ?, ?, ?, 'queued', ?, 'image_to_video', ?, ?) RETURNING id""",
[user_id, "", body.model, body.prompt, request_params, now, now],
).fetchone()
if row:
db_id = str(row[0])
return VideoResponse(
id="",
db_id=db_id,
model=body.model,
status="queued",
)
@router.get("/video/status", response_model=VideoResponse)
async def poll_video_status(
polling_url: str,
current_user: dict = Depends(get_current_user),
) -> VideoResponse:
"""Poll status of a video generation job; updates DB row when completed/failed."""
try:
result = await openrouter.poll_video_status(polling_url)
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY, detail=f"OpenRouter error: {exc}")
job_status = result.get("status", "processing")
urls = result.get("unsigned_urls") or result.get("video_urls")
video_url = (urls or [None])[0]
# Update DB row for this job when terminal state reached
if job_status in ("completed", "failed"):
now = datetime.now(timezone.utc).replace(tzinfo=None)
async with get_write_lock():
conn = get_conn()
conn.execute(
"""UPDATE generated_videos
SET status = ?, video_url = ?, updated_at = ?
WHERE job_id = ?""",
[job_status, video_url, now, result.get("id", "")],
)
return VideoResponse(
id=result.get("id", ""),
model=result.get("model", ""),
status=job_status,
polling_url=result.get("polling_url"),
video_urls=urls,
video_url=video_url,
error=result.get("error"),
metadata=result.get("metadata"),
)
@router.get("/videos")
async def list_generated_videos(
current_user: dict = Depends(get_current_user),
) -> list[dict]:
"""Return all generated video jobs for the current user, newest first."""
user_id = current_user.get("id") or current_user.get("sub")
conn = get_conn()
rows = conn.execute(
"""SELECT id, job_id, model_id, prompt, polling_url, status, video_url, error, created_at
FROM generated_videos
WHERE user_id = ?
ORDER BY created_at DESC""",
[user_id],
).fetchall()
return [
{
"id": str(r[0]),
"job_id": r[1],
"model_id": r[2],
"prompt": r[3],
"polling_url": r[4],
"status": r[5],
"video_url": r[6],
"error": r[7],
"created_at": r[8].isoformat() if r[8] else None,
}
for r in rows
]
@router.get("/videos/{video_id}")
async def get_generated_video(
video_id: str,
current_user: dict = Depends(get_current_user),
) -> dict:
"""Return details for a single video generation job."""
user_id = current_user.get("id") or current_user.get("sub")
conn = get_conn()
row = conn.execute(
"""SELECT id, job_id, model_id, prompt, polling_url, status, video_url, error, created_at, updated_at
FROM generated_videos
WHERE id = ? AND user_id = ?""",
[video_id, user_id],
).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Video job not found")
return {
"id": str(row[0]),
"job_id": row[1],
"model_id": row[2],
"prompt": row[3],
"polling_url": row[4],
"status": row[5],
"video_url": row[6],
"error": row[7],
"created_at": row[8].isoformat() if row[8] else None,
"updated_at": row[9].isoformat() if row[9] else None,
}
@router.post("/videos/{video_id}/cancel", status_code=200)
async def cancel_video_job(
video_id: str,
current_user: dict = Depends(get_current_user),
) -> dict[str, str]:
"""Mark a video job as 'cancelled' if it belongs to the current user and is not terminal."""
user_id = current_user.get("id") or current_user.get("sub")
conn = get_conn()
row = conn.execute(
"SELECT status FROM generated_videos WHERE id = ? AND user_id = ?",
[video_id, user_id],
).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Video job not found")
job_status = row[0]
if job_status in ("completed", "failed", "cancelled"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Cannot cancel job with status '{job_status}'",
)
now = datetime.now(timezone.utc).replace(tzinfo=None)
async with get_write_lock():
conn.execute(
"UPDATE generated_videos SET status = 'cancelled', updated_at = ? WHERE id = ?",
[now, video_id],
)
return {"status": "ok", "job_id": video_id}
+150 -150
View File
@@ -1,150 +1,150 @@
"""Images router: upload reference images and list user's uploads."""
import os
import uuid
from fastapi import APIRouter, Depends, HTTPException, UploadFile, status
from fastapi.responses import FileResponse
from ..db import get_conn, get_write_lock
from ..dependencies import get_current_user
router = APIRouter(prefix="/images", tags=["images"])
UPLOAD_DIR = os.getenv("UPLOAD_DIR", "data/uploads")
MAX_SIZE_BYTES = 10 * 1024 * 1024 # 10 MB
ALLOWED_CONTENT_TYPES = {"image/jpeg", "image/png", "image/webp", "image/gif"}
@router.post("/upload", status_code=status.HTTP_201_CREATED)
async def upload_image(
file: UploadFile,
current_user: dict = Depends(get_current_user),
) -> dict:
"""Upload a reference image and store metadata in DuckDB."""
if file.content_type not in ALLOWED_CONTENT_TYPES:
raise HTTPException(
status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
detail=f"Unsupported content type '{file.content_type}'. Allowed: {sorted(ALLOWED_CONTENT_TYPES)}",
)
data = await file.read()
if len(data) > MAX_SIZE_BYTES:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail=f"File exceeds maximum allowed size of {MAX_SIZE_BYTES // (1024*1024)} MB.",
)
user_id = current_user["id"]
image_id = str(uuid.uuid4())
ext = (file.filename or "").rsplit(
".", 1)[-1].lower() if "." in (file.filename or "") else "bin"
safe_filename = f"{image_id}.{ext}"
user_dir = os.path.join(UPLOAD_DIR, user_id)
os.makedirs(user_dir, exist_ok=True)
file_path = os.path.join(user_dir, safe_filename)
with open(file_path, "wb") as f:
f.write(data)
async with get_write_lock():
conn = get_conn()
conn.execute(
"""
INSERT INTO uploaded_images (id, user_id, filename, content_type, file_path, size_bytes)
VALUES (?, ?, ?, ?, ?, ?)
""",
[image_id, user_id, file.filename or safe_filename,
file.content_type, file_path, len(data)],
)
return {
"id": image_id,
"filename": file.filename or safe_filename,
"content_type": file.content_type,
"size_bytes": len(data),
}
@router.get("/", status_code=status.HTTP_200_OK)
async def list_images(
current_user: dict = Depends(get_current_user),
) -> list[dict]:
"""Return all uploaded images for the current user."""
conn = get_conn()
rows = conn.execute(
"""
SELECT id, filename, content_type, size_bytes, created_at
FROM uploaded_images
WHERE user_id = ?
ORDER BY created_at DESC
""",
[current_user["id"]],
).fetchall()
return [
{
"id": str(row[0]),
"filename": row[1],
"content_type": row[2],
"size_bytes": row[3],
"created_at": row[4].isoformat() if row[4] else None,
}
for row in rows
]
@router.get("/{image_id}", status_code=status.HTTP_200_OK)
async def get_image_details(
image_id: str,
current_user: dict = Depends(get_current_user),
) -> dict:
"""Return metadata for a single uploaded image."""
conn = get_conn()
row = conn.execute(
"""
SELECT id, filename, content_type, size_bytes, created_at
FROM uploaded_images
WHERE id = ? AND user_id = ?
""",
[image_id, current_user["id"]],
).fetchone()
if not row:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Image not found"
)
return {
"id": str(row[0]),
"filename": row[1],
"content_type": row[2],
"size_bytes": row[3],
"created_at": row[4].isoformat() if row[4] else None,
}
@router.get("/{image_id}/file", status_code=status.HTTP_200_OK)
async def serve_image(
image_id: str,
current_user: dict = Depends(get_current_user),
) -> FileResponse:
"""Serve the raw image file. Only accessible by the owning user."""
conn = get_conn()
row = conn.execute(
"SELECT file_path, content_type, user_id FROM uploaded_images WHERE id = ?",
[image_id],
).fetchone()
if row is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Image not found.")
if str(row[2]) != current_user["id"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Access denied.")
file_path: str = row[0]
if not os.path.isfile(file_path):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Image file missing.")
return FileResponse(file_path, media_type=row[1])
"""Images router: upload reference images and list user's uploads."""
import os
import uuid
from fastapi import APIRouter, Depends, HTTPException, UploadFile, status
from fastapi.responses import FileResponse
from ..db import get_conn, get_write_lock
from ..dependencies import get_current_user
router = APIRouter(prefix="/images", tags=["images"])
UPLOAD_DIR = os.getenv("UPLOAD_DIR", "data/uploads")
MAX_SIZE_BYTES = 10 * 1024 * 1024 # 10 MB
ALLOWED_CONTENT_TYPES = {"image/jpeg", "image/png", "image/webp", "image/gif"}
@router.post("/upload", status_code=status.HTTP_201_CREATED)
async def upload_image(
file: UploadFile,
current_user: dict = Depends(get_current_user),
) -> dict:
"""Upload a reference image and store metadata in DuckDB."""
if file.content_type not in ALLOWED_CONTENT_TYPES:
raise HTTPException(
status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
detail=f"Unsupported content type '{file.content_type}'. Allowed: {sorted(ALLOWED_CONTENT_TYPES)}",
)
data = await file.read()
if len(data) > MAX_SIZE_BYTES:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail=f"File exceeds maximum allowed size of {MAX_SIZE_BYTES // (1024*1024)} MB.",
)
user_id = current_user["id"]
image_id = str(uuid.uuid4())
ext = (file.filename or "").rsplit(
".", 1)[-1].lower() if "." in (file.filename or "") else "bin"
safe_filename = f"{image_id}.{ext}"
user_dir = os.path.join(UPLOAD_DIR, user_id)
os.makedirs(user_dir, exist_ok=True)
file_path = os.path.join(user_dir, safe_filename)
with open(file_path, "wb") as f:
f.write(data)
async with get_write_lock():
conn = get_conn()
conn.execute(
"""
INSERT INTO uploaded_images (id, user_id, filename, content_type, file_path, size_bytes)
VALUES (?, ?, ?, ?, ?, ?)
""",
[image_id, user_id, file.filename or safe_filename,
file.content_type, file_path, len(data)],
)
return {
"id": image_id,
"filename": file.filename or safe_filename,
"content_type": file.content_type,
"size_bytes": len(data),
}
@router.get("/", status_code=status.HTTP_200_OK)
async def list_images(
current_user: dict = Depends(get_current_user),
) -> list[dict]:
"""Return all uploaded images for the current user."""
conn = get_conn()
rows = conn.execute(
"""
SELECT id, filename, content_type, size_bytes, created_at
FROM uploaded_images
WHERE user_id = ?
ORDER BY created_at DESC
""",
[current_user["id"]],
).fetchall()
return [
{
"id": str(row[0]),
"filename": row[1],
"content_type": row[2],
"size_bytes": row[3],
"created_at": row[4].isoformat() if row[4] else None,
}
for row in rows
]
@router.get("/{image_id}", status_code=status.HTTP_200_OK)
async def get_image_details(
image_id: str,
current_user: dict = Depends(get_current_user),
) -> dict:
"""Return metadata for a single uploaded image."""
conn = get_conn()
row = conn.execute(
"""
SELECT id, filename, content_type, size_bytes, created_at
FROM uploaded_images
WHERE id = ? AND user_id = ?
""",
[image_id, current_user["id"]],
).fetchone()
if not row:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Image not found"
)
return {
"id": str(row[0]),
"filename": row[1],
"content_type": row[2],
"size_bytes": row[3],
"created_at": row[4].isoformat() if row[4] else None,
}
@router.get("/{image_id}/file", status_code=status.HTTP_200_OK)
async def serve_image(
image_id: str,
current_user: dict = Depends(get_current_user),
) -> FileResponse:
"""Serve the raw image file. Only accessible by the owning user."""
conn = get_conn()
row = conn.execute(
"SELECT file_path, content_type, user_id FROM uploaded_images WHERE id = ?",
[image_id],
).fetchone()
if row is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Image not found.")
if str(row[2]) != current_user["id"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Access denied.")
file_path: str = row[0]
if not os.path.isfile(file_path):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Image file missing.")
return FileResponse(file_path, media_type=row[1])
+47 -47
View File
@@ -1,47 +1,47 @@
"""Models router: list and refresh the OpenRouter model cache."""
from fastapi import APIRouter, Depends, HTTPException, Query, status
from ..db import get_conn, get_write_lock
from ..dependencies import get_current_user, require_admin
from ..services import models as models_service
router = APIRouter(prefix="/models", tags=["models"])
@router.get("/")
async def list_models(
modality: str | None = Query(
None,
description="Filter by output modality: text, image, video, audio",
),
_: dict = Depends(get_current_user),
):
"""Return cached models. Auto-refreshes cache if stale (older than 24 h)."""
conn = get_conn()
if models_service.is_cache_stale(conn):
async with get_write_lock():
# Re-check inside lock to avoid redundant parallel refreshes
if models_service.is_cache_stale(conn):
try:
await models_service.refresh_models_cache(conn)
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"Failed to refresh model cache: {exc}",
)
return models_service.get_cached_models(conn, modality)
@router.post("/refresh", status_code=200)
async def refresh_models(_: dict = Depends(require_admin)):
"""Force-refresh the model cache from OpenRouter. Admin only."""
conn = get_conn()
async with get_write_lock():
try:
count = await models_service.refresh_models_cache(conn)
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"OpenRouter error: {exc}",
)
return {"refreshed": count}
"""Models router: list and refresh the OpenRouter model cache."""
from fastapi import APIRouter, Depends, HTTPException, Query, status
from ..db import get_conn, get_write_lock
from ..dependencies import get_current_user, require_admin
from ..services import models as models_service
router = APIRouter(prefix="/models", tags=["models"])
@router.get("/")
async def list_models(
modality: str | None = Query(
None,
description="Filter by output modality: text, image, video, audio",
),
_: dict = Depends(get_current_user),
):
"""Return cached models. Auto-refreshes cache if stale (older than 24 h)."""
conn = get_conn()
if models_service.is_cache_stale(conn):
async with get_write_lock():
# Re-check inside lock to avoid redundant parallel refreshes
if models_service.is_cache_stale(conn):
try:
await models_service.refresh_models_cache(conn)
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"Failed to refresh model cache: {exc}",
)
return models_service.get_cached_models(conn, modality)
@router.post("/refresh", status_code=200)
async def refresh_models(_: dict = Depends(require_admin)):
"""Force-refresh the model cache from OpenRouter. Admin only."""
conn = get_conn()
async with get_write_lock():
try:
count = await models_service.refresh_models_cache(conn)
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"OpenRouter error: {exc}",
)
return {"refreshed": count}
+86 -86
View File
@@ -1,86 +1,86 @@
"""Users router: self-service profile and admin user management."""
from fastapi import APIRouter, Depends, HTTPException, status
from ..dependencies import get_current_user, require_admin
from ..models.users import SetRoleRequest, UpdateUserRequest, UserResponse
from ..services.users import (
delete_user,
get_user,
list_users,
set_user_role,
update_user,
)
router = APIRouter(prefix="/users", tags=["users"])
ALLOWED_ROLES = {"user", "admin"}
# ---------------------------------------------------------------------------
# Self-service
# ---------------------------------------------------------------------------
@router.get("/me", response_model=UserResponse)
async def get_me(current_user: dict = Depends(get_current_user)) -> UserResponse:
user = await get_user(current_user["id"])
if user is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="User not found.")
return UserResponse(**user)
@router.put("/me", response_model=UserResponse)
async def update_me(
body: UpdateUserRequest,
current_user: dict = Depends(get_current_user),
) -> UserResponse:
try:
user = await update_user(current_user["id"], email=body.email, password=body.password)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail=str(exc))
if user is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="User not found.")
return UserResponse(**user)
# ---------------------------------------------------------------------------
# Admin
# ---------------------------------------------------------------------------
@router.get("", response_model=list[UserResponse])
async def get_all_users(_: dict = Depends(require_admin)) -> list[UserResponse]:
users = await list_users()
return [UserResponse(**u) for u in users]
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def remove_user(
user_id: str,
current_user: dict = Depends(require_admin),
) -> None:
if user_id == current_user["id"]:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot delete your own account.",
)
await delete_user(user_id)
@router.put("/{user_id}/role", response_model=UserResponse)
async def change_role(
user_id: str,
body: SetRoleRequest,
_: dict = Depends(require_admin),
) -> UserResponse:
if body.role not in ALLOWED_ROLES:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Role must be one of: {', '.join(sorted(ALLOWED_ROLES))}.",
)
user = await set_user_role(user_id, body.role)
if user is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="User not found.")
return UserResponse(**user)
"""Users router: self-service profile and admin user management."""
from fastapi import APIRouter, Depends, HTTPException, status
from ..dependencies import get_current_user, require_admin
from ..models.users import SetRoleRequest, UpdateUserRequest, UserResponse
from ..services.users import (
delete_user,
get_user,
list_users,
set_user_role,
update_user,
)
router = APIRouter(prefix="/users", tags=["users"])
ALLOWED_ROLES = {"user", "admin"}
# ---------------------------------------------------------------------------
# Self-service
# ---------------------------------------------------------------------------
@router.get("/me", response_model=UserResponse)
async def get_me(current_user: dict = Depends(get_current_user)) -> UserResponse:
user = await get_user(current_user["id"])
if user is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="User not found.")
return UserResponse(**user)
@router.put("/me", response_model=UserResponse)
async def update_me(
body: UpdateUserRequest,
current_user: dict = Depends(get_current_user),
) -> UserResponse:
try:
user = await update_user(current_user["id"], email=body.email, password=body.password)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail=str(exc))
if user is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="User not found.")
return UserResponse(**user)
# ---------------------------------------------------------------------------
# Admin
# ---------------------------------------------------------------------------
@router.get("", response_model=list[UserResponse])
async def get_all_users(_: dict = Depends(require_admin)) -> list[UserResponse]:
users = await list_users()
return [UserResponse(**u) for u in users]
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def remove_user(
user_id: str,
current_user: dict = Depends(require_admin),
) -> None:
if user_id == current_user["id"]:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot delete your own account.",
)
await delete_user(user_id)
@router.put("/{user_id}/role", response_model=UserResponse)
async def change_role(
user_id: str,
body: SetRoleRequest,
_: dict = Depends(require_admin),
) -> UserResponse:
if body.role not in ALLOWED_ROLES:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Role must be one of: {', '.join(sorted(ALLOWED_ROLES))}.",
)
user = await set_user_role(user_id, body.role)
if user is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="User not found.")
return UserResponse(**user)
+127 -127
View File
@@ -1,127 +1,127 @@
"""Authentication service: password hashing, JWT creation/verification, token management."""
import os
from datetime import datetime, timedelta, timezone
from typing import Any
from jose import JWTError, jwt
from passlib.context import CryptContext
from ..db import get_conn, get_write_lock
_pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
ACCESS_TOKEN_EXPIRE_MINUTES = 15
REFRESH_TOKEN_EXPIRE_DAYS = 7
ALGORITHM = "HS256"
def _secret() -> str:
secret = os.getenv("JWT_SECRET")
if not secret:
raise RuntimeError("JWT_SECRET environment variable is not set.")
return secret
# --- Password ---
def hash_password(plain: str) -> str:
return _pwd_context.hash(plain)
def verify_password(plain: str, hashed: str) -> bool:
return _pwd_context.verify(plain, hashed)
# --- Tokens ---
def create_access_token(user_id: str, email: str, role: str) -> str:
expire = datetime.now(timezone.utc) + \
timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
payload = {
"sub": user_id,
"email": email,
"role": role,
"exp": expire,
"type": "access",
}
return jwt.encode(payload, _secret(), algorithm=ALGORITHM)
def create_refresh_token(user_id: str, jti: str) -> str:
expire = datetime.now(timezone.utc) + \
timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
payload = {
"sub": user_id,
"jti": jti,
"exp": expire,
"type": "refresh",
}
return jwt.encode(payload, _secret(), algorithm=ALGORITHM)
def decode_token(token: str) -> dict[str, Any]:
"""Decode and validate a JWT. Raises JWTError on failure."""
return jwt.decode(token, _secret(), algorithms=[ALGORITHM])
# --- Database operations ---
async def register_user(email: str, password: str) -> dict[str, Any]:
"""Insert a new user. Returns the created user row."""
conn = get_conn()
lock = get_write_lock()
sql_check = "SELECT id FROM users WHERE email = ?"
sql_insert = "INSERT INTO users (email, password_hash) VALUES (?, ?)"
sql_fetch = "SELECT id, email, role FROM users WHERE email = ?"
async with lock:
existing = conn.execute(sql_check, [email]).fetchone()
if existing:
raise ValueError("Email already registered.")
conn.execute(sql_insert, [email, hash_password(password)],)
row = conn.execute(sql_fetch, [email]).fetchone()
if row is None:
raise RuntimeError("Failed to fetch user after registration.")
return {"id": str(row[0]), "email": row[1], "role": row[2]}
async def authenticate_user(email: str, password: str) -> dict[str, Any] | None:
"""Return user dict if credentials are valid, else None."""
conn = get_conn()
sql_fetch = "SELECT id, email, password_hash, role FROM users WHERE email = ?"
row = conn.execute(sql_fetch, [email]).fetchone()
if row is None or not verify_password(password, row[2]):
return None
return {"id": str(row[0]), "email": row[1], "role": row[3]}
async def store_refresh_token(user_id: str, jti: str) -> None:
"""Persist a refresh token JTI in the database."""
conn = get_conn()
lock = get_write_lock()
sql_insert = "INSERT INTO refresh_tokens (jti, user_id, expires_at) VALUES (?, ?, ?)"
from datetime import timedelta
expires_at = datetime.now(timezone.utc) + \
timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
async with lock:
conn.execute(sql_insert, [jti, user_id, expires_at])
async def revoke_refresh_token(jti: str) -> None:
"""Mark a refresh token as revoked."""
conn = get_conn()
lock = get_write_lock()
sql_update = "UPDATE refresh_tokens SET revoked = true WHERE jti = ?"
async with lock:
conn.execute(sql_update, [jti])
async def validate_refresh_token_jti(jti: str, user_id: str) -> bool:
"""Return True if the JTI exists, is not revoked, and belongs to user_id."""
conn = get_conn()
now = datetime.now(timezone.utc)
sql_select = """
SELECT 1 FROM refresh_tokens
WHERE jti = ? AND user_id = ? AND revoked = false AND expires_at > ?
"""
row = conn.execute(sql_select, [jti, user_id, now]).fetchone()
return row is not None
"""Authentication service: password hashing, JWT creation/verification, token management."""
import os
from datetime import datetime, timedelta, timezone
from typing import Any
from jose import JWTError, jwt
from passlib.context import CryptContext
from ..db import get_conn, get_write_lock
_pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
ACCESS_TOKEN_EXPIRE_MINUTES = 15
REFRESH_TOKEN_EXPIRE_DAYS = 7
ALGORITHM = "HS256"
def _secret() -> str:
secret = os.getenv("JWT_SECRET")
if not secret:
raise RuntimeError("JWT_SECRET environment variable is not set.")
return secret
# --- Password ---
def hash_password(plain: str) -> str:
return _pwd_context.hash(plain)
def verify_password(plain: str, hashed: str) -> bool:
return _pwd_context.verify(plain, hashed)
# --- Tokens ---
def create_access_token(user_id: str, email: str, role: str) -> str:
expire = datetime.now(timezone.utc) + \
timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
payload = {
"sub": user_id,
"email": email,
"role": role,
"exp": expire,
"type": "access",
}
return jwt.encode(payload, _secret(), algorithm=ALGORITHM)
def create_refresh_token(user_id: str, jti: str) -> str:
expire = datetime.now(timezone.utc) + \
timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
payload = {
"sub": user_id,
"jti": jti,
"exp": expire,
"type": "refresh",
}
return jwt.encode(payload, _secret(), algorithm=ALGORITHM)
def decode_token(token: str) -> dict[str, Any]:
"""Decode and validate a JWT. Raises JWTError on failure."""
return jwt.decode(token, _secret(), algorithms=[ALGORITHM])
# --- Database operations ---
async def register_user(email: str, password: str) -> dict[str, Any]:
"""Insert a new user. Returns the created user row."""
conn = get_conn()
lock = get_write_lock()
sql_check = "SELECT id FROM users WHERE email = ?"
sql_insert = "INSERT INTO users (email, password_hash) VALUES (?, ?)"
sql_fetch = "SELECT id, email, role FROM users WHERE email = ?"
async with lock:
existing = conn.execute(sql_check, [email]).fetchone()
if existing:
raise ValueError("Email already registered.")
conn.execute(sql_insert, [email, hash_password(password)],)
row = conn.execute(sql_fetch, [email]).fetchone()
if row is None:
raise RuntimeError("Failed to fetch user after registration.")
return {"id": str(row[0]), "email": row[1], "role": row[2]}
async def authenticate_user(email: str, password: str) -> dict[str, Any] | None:
"""Return user dict if credentials are valid, else None."""
conn = get_conn()
sql_fetch = "SELECT id, email, password_hash, role FROM users WHERE email = ?"
row = conn.execute(sql_fetch, [email]).fetchone()
if row is None or not verify_password(password, row[2]):
return None
return {"id": str(row[0]), "email": row[1], "role": row[3]}
async def store_refresh_token(user_id: str, jti: str) -> None:
"""Persist a refresh token JTI in the database."""
conn = get_conn()
lock = get_write_lock()
sql_insert = "INSERT INTO refresh_tokens (jti, user_id, expires_at) VALUES (?, ?, ?)"
from datetime import timedelta
expires_at = datetime.now(timezone.utc) + \
timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
async with lock:
conn.execute(sql_insert, [jti, user_id, expires_at])
async def revoke_refresh_token(jti: str) -> None:
"""Mark a refresh token as revoked."""
conn = get_conn()
lock = get_write_lock()
sql_update = "UPDATE refresh_tokens SET revoked = true WHERE jti = ?"
async with lock:
conn.execute(sql_update, [jti])
async def validate_refresh_token_jti(jti: str, user_id: str) -> bool:
"""Return True if the JTI exists, is not revoked, and belongs to user_id."""
conn = get_conn()
now = datetime.now(timezone.utc)
sql_select = """
SELECT 1 FROM refresh_tokens
WHERE jti = ? AND user_id = ? AND revoked = false AND expires_at > ?
"""
row = conn.execute(sql_select, [jti, user_id, now]).fetchone()
return row is not None
+246 -246
View File
@@ -1,246 +1,246 @@
"""Model cache service: fetch from OpenRouter, store in DuckDB."""
import json
from datetime import datetime, timedelta, timezone
from typing import Any
import duckdb
from . import openrouter
CACHE_TTL_HOURS = 24
def _normalize_modality(raw: str) -> str:
"""Normalize OpenRouter modality labels to canonical values."""
value = (raw or "").strip().lower()
if value in {"text", "image", "video", "audio", "embeddings", "embedding"}:
return "embeddings" if value == "embedding" else value
if "image" in value:
return "image"
if "video" in value:
return "video"
if "audio" in value:
return "audio"
if "embed" in value:
return "embeddings"
return "text"
def _parse_modality(raw_modality: str) -> str:
"""Extract output modality from OpenRouter architecture.modality string.
Examples: "text->text", "text+image->text", "text->image", "text->video"
"""
output = raw_modality.split(
"->", 1)[-1] if "->" in raw_modality else raw_modality
return _normalize_modality(output)
def _extract_output_modality(model: dict[str, Any]) -> str:
"""Extract output modality using OpenRouter schema, fallback to legacy field."""
architecture = model.get("architecture") or {}
output_modalities = architecture.get(
"output_modalities") or model.get("output_modalities")
if isinstance(output_modalities, list) and output_modalities:
return _normalize_modality(str(output_modalities[0]))
raw_modality = architecture.get(
"modality") or model.get("modality") or "text->text"
if isinstance(raw_modality, str):
return _parse_modality(raw_modality)
return "text"
async def _fetch_models_for_cache() -> list[dict[str, Any]]:
"""Fetch broad + modality-specific lists and merge unique models by id."""
by_id: dict[str, dict[str, Any]] = {}
# Primary fetch: all modalities (per OpenRouter docs).
primary = await openrouter.list_models(output_modalities="all")
for model in primary:
model_id = model.get("id")
if model_id:
by_id[model_id] = model
# Warmup fetches: some providers surface better results with explicit modality filter.
for modality in ("image", "video", "audio", "embeddings", "text"):
try:
subset = await openrouter.list_models(output_modalities=modality)
except Exception:
continue
for model in subset:
model_id = model.get("id")
if model_id and model_id not in by_id:
by_id[model_id] = model
return list(by_id.values())
async def refresh_models_cache(conn: duckdb.DuckDBPyConnection) -> int:
"""Fetch all models from OpenRouter and replace the cache. Returns count stored."""
raw = await _fetch_models_for_cache()
# Use naive UTC to avoid DuckDB TIMESTAMP tz-stripping inconsistencies
now = datetime.now(timezone.utc).replace(tzinfo=None)
conn.execute("DELETE FROM models_cache")
count = 0
for m in raw:
modality = _extract_output_modality(m)
pricing = m.get("pricing")
model_id = m.get("id", "")
if not model_id:
continue
# Full output_modalities array from architecture (for proper modalities param in image gen)
architecture = m.get("architecture") or {}
raw_output_modalities: list | None = (
architecture.get("output_modalities") or m.get("output_modalities")
)
output_modalities_json: str | None = (
json.dumps([_normalize_modality(str(v))
for v in raw_output_modalities])
if isinstance(raw_output_modalities, list)
else None
)
conn.execute(
"""
INSERT INTO models_cache (model_id, name, modality, context_length, pricing, fetched_at, output_modalities)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (model_id) DO UPDATE SET
name = excluded.name,
modality = excluded.modality,
context_length = excluded.context_length,
pricing = excluded.pricing,
fetched_at = excluded.fetched_at,
output_modalities = excluded.output_modalities
""",
[
model_id,
m.get("name", model_id),
modality,
m.get("context_length"),
json.dumps(pricing) if pricing else None,
now,
output_modalities_json,
],
)
count += 1
return count
def is_cache_stale(conn: duckdb.DuckDBPyConnection) -> bool:
"""Return True if cache is empty or last fetched more than CACHE_TTL_HOURS ago."""
row = conn.execute("SELECT MAX(fetched_at) FROM models_cache").fetchone()
if not row or row[0] is None:
return True
last_fetched = row[0]
# DuckDB TIMESTAMP is always naive; compare against naive UTC
if last_fetched.tzinfo is not None:
last_fetched = last_fetched.replace(tzinfo=None)
now_naive = datetime.now(timezone.utc).replace(tzinfo=None)
return now_naive - last_fetched > timedelta(hours=CACHE_TTL_HOURS)
def get_cached_models(
conn: duckdb.DuckDBPyConnection,
modality: str | None = None,
) -> list[dict[str, Any]]:
"""Return cached models, optionally filtered by modality, ordered by name."""
if modality:
rows = conn.execute(
"""
SELECT model_id, name, modality, context_length, pricing
FROM models_cache
WHERE modality = ?
ORDER BY name
""",
[modality],
).fetchall()
else:
rows = conn.execute(
"""
SELECT model_id, name, modality, context_length, pricing
FROM models_cache
ORDER BY name
"""
).fetchall()
result = []
for row in rows:
pricing = None
if row[4]:
try:
pricing = json.loads(row[4])
except (json.JSONDecodeError, TypeError):
pricing = None
result.append({
"id": row[0],
"name": row[1],
"modality": row[2],
"context_length": row[3],
"pricing": pricing,
})
return result
def get_model_output_modalities(
conn: duckdb.DuckDBPyConnection,
model_id: str,
) -> list[str]:
"""Return output_modalities list for a model; empty list if not found."""
row = conn.execute(
"SELECT output_modalities FROM models_cache WHERE model_id = ?",
[model_id],
).fetchone()
if not row or not row[0]:
return []
try:
return json.loads(row[0])
except (json.JSONDecodeError, TypeError):
return []
def get_cache_status(conn: duckdb.DuckDBPyConnection) -> dict[str, Any]:
"""Return cache last update time and model count."""
row = conn.execute(
"SELECT MAX(fetched_at), COUNT(*) FROM models_cache"
).fetchone()
last_updated, model_count = (row[0], row[1]) if row else (None, 0)
return {"last_updated": last_updated, "model_count": model_count}
def mark_timed_out_video_jobs(conn: duckdb.DuckDBPyConnection, timeout_minutes: int = 120) -> int:
"""Mark video jobs that have been in 'queued' or 'processing' status for too long as 'failed'.
Returns the number of jobs marked as timed out.
"""
timeout_threshold = datetime.now(
timezone.utc) - timedelta(minutes=timeout_minutes)
# Find timed out jobs
timed_out_rows = conn.execute(
"""
SELECT id FROM generated_videos
WHERE status IN ('queued', 'processing')
AND updated_at < ?
""",
[timeout_threshold]
).fetchall()
if not timed_out_rows:
return 0
job_ids = [row[0] for row in timed_out_rows]
placeholders = ",".join(["?"] * len(job_ids))
# Update them to failed
conn.execute(
f"""
UPDATE generated_videos
SET status = 'failed', updated_at = ?
WHERE id IN ({placeholders})
""",
[datetime.now(timezone.utc)] + job_ids
)
return len(job_ids)
"""Model cache service: fetch from OpenRouter, store in DuckDB."""
import json
from datetime import datetime, timedelta, timezone
from typing import Any
import duckdb
from . import openrouter
CACHE_TTL_HOURS = 24
def _normalize_modality(raw: str) -> str:
"""Normalize OpenRouter modality labels to canonical values."""
value = (raw or "").strip().lower()
if value in {"text", "image", "video", "audio", "embeddings", "embedding"}:
return "embeddings" if value == "embedding" else value
if "image" in value:
return "image"
if "video" in value:
return "video"
if "audio" in value:
return "audio"
if "embed" in value:
return "embeddings"
return "text"
def _parse_modality(raw_modality: str) -> str:
"""Extract output modality from OpenRouter architecture.modality string.
Examples: "text->text", "text+image->text", "text->image", "text->video"
"""
output = raw_modality.split(
"->", 1)[-1] if "->" in raw_modality else raw_modality
return _normalize_modality(output)
def _extract_output_modality(model: dict[str, Any]) -> str:
"""Extract output modality using OpenRouter schema, fallback to legacy field."""
architecture = model.get("architecture") or {}
output_modalities = architecture.get(
"output_modalities") or model.get("output_modalities")
if isinstance(output_modalities, list) and output_modalities:
return _normalize_modality(str(output_modalities[0]))
raw_modality = architecture.get(
"modality") or model.get("modality") or "text->text"
if isinstance(raw_modality, str):
return _parse_modality(raw_modality)
return "text"
async def _fetch_models_for_cache() -> list[dict[str, Any]]:
"""Fetch broad + modality-specific lists and merge unique models by id."""
by_id: dict[str, dict[str, Any]] = {}
# Primary fetch: all modalities (per OpenRouter docs).
primary = await openrouter.list_models(output_modalities="all")
for model in primary:
model_id = model.get("id")
if model_id:
by_id[model_id] = model
# Warmup fetches: some providers surface better results with explicit modality filter.
for modality in ("image", "video", "audio", "embeddings", "text"):
try:
subset = await openrouter.list_models(output_modalities=modality)
except Exception:
continue
for model in subset:
model_id = model.get("id")
if model_id and model_id not in by_id:
by_id[model_id] = model
return list(by_id.values())
async def refresh_models_cache(conn: duckdb.DuckDBPyConnection) -> int:
"""Fetch all models from OpenRouter and replace the cache. Returns count stored."""
raw = await _fetch_models_for_cache()
# Use naive UTC to avoid DuckDB TIMESTAMP tz-stripping inconsistencies
now = datetime.now(timezone.utc).replace(tzinfo=None)
conn.execute("DELETE FROM models_cache")
count = 0
for m in raw:
modality = _extract_output_modality(m)
pricing = m.get("pricing")
model_id = m.get("id", "")
if not model_id:
continue
# Full output_modalities array from architecture (for proper modalities param in image gen)
architecture = m.get("architecture") or {}
raw_output_modalities: list | None = (
architecture.get("output_modalities") or m.get("output_modalities")
)
output_modalities_json: str | None = (
json.dumps([_normalize_modality(str(v))
for v in raw_output_modalities])
if isinstance(raw_output_modalities, list)
else None
)
conn.execute(
"""
INSERT INTO models_cache (model_id, name, modality, context_length, pricing, fetched_at, output_modalities)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (model_id) DO UPDATE SET
name = excluded.name,
modality = excluded.modality,
context_length = excluded.context_length,
pricing = excluded.pricing,
fetched_at = excluded.fetched_at,
output_modalities = excluded.output_modalities
""",
[
model_id,
m.get("name", model_id),
modality,
m.get("context_length"),
json.dumps(pricing) if pricing else None,
now,
output_modalities_json,
],
)
count += 1
return count
def is_cache_stale(conn: duckdb.DuckDBPyConnection) -> bool:
"""Return True if cache is empty or last fetched more than CACHE_TTL_HOURS ago."""
row = conn.execute("SELECT MAX(fetched_at) FROM models_cache").fetchone()
if not row or row[0] is None:
return True
last_fetched = row[0]
# DuckDB TIMESTAMP is always naive; compare against naive UTC
if last_fetched.tzinfo is not None:
last_fetched = last_fetched.replace(tzinfo=None)
now_naive = datetime.now(timezone.utc).replace(tzinfo=None)
return now_naive - last_fetched > timedelta(hours=CACHE_TTL_HOURS)
def get_cached_models(
conn: duckdb.DuckDBPyConnection,
modality: str | None = None,
) -> list[dict[str, Any]]:
"""Return cached models, optionally filtered by modality, ordered by name."""
if modality:
rows = conn.execute(
"""
SELECT model_id, name, modality, context_length, pricing
FROM models_cache
WHERE modality = ?
ORDER BY name
""",
[modality],
).fetchall()
else:
rows = conn.execute(
"""
SELECT model_id, name, modality, context_length, pricing
FROM models_cache
ORDER BY name
"""
).fetchall()
result = []
for row in rows:
pricing = None
if row[4]:
try:
pricing = json.loads(row[4])
except (json.JSONDecodeError, TypeError):
pricing = None
result.append({
"id": row[0],
"name": row[1],
"modality": row[2],
"context_length": row[3],
"pricing": pricing,
})
return result
def get_model_output_modalities(
conn: duckdb.DuckDBPyConnection,
model_id: str,
) -> list[str]:
"""Return output_modalities list for a model; empty list if not found."""
row = conn.execute(
"SELECT output_modalities FROM models_cache WHERE model_id = ?",
[model_id],
).fetchone()
if not row or not row[0]:
return []
try:
return json.loads(row[0])
except (json.JSONDecodeError, TypeError):
return []
def get_cache_status(conn: duckdb.DuckDBPyConnection) -> dict[str, Any]:
"""Return cache last update time and model count."""
row = conn.execute(
"SELECT MAX(fetched_at), COUNT(*) FROM models_cache"
).fetchone()
last_updated, model_count = (row[0], row[1]) if row else (None, 0)
return {"last_updated": last_updated, "model_count": model_count}
def mark_timed_out_video_jobs(conn: duckdb.DuckDBPyConnection, timeout_minutes: int = 120) -> int:
"""Mark video jobs that have been in 'queued' or 'processing' status for too long as 'failed'.
Returns the number of jobs marked as timed out.
"""
timeout_threshold = datetime.now(
timezone.utc) - timedelta(minutes=timeout_minutes)
# Find timed out jobs
timed_out_rows = conn.execute(
"""
SELECT id FROM generated_videos
WHERE status IN ('queued', 'processing')
AND updated_at < ?
""",
[timeout_threshold]
).fetchall()
if not timed_out_rows:
return 0
job_ids = [row[0] for row in timed_out_rows]
placeholders = ",".join(["?"] * len(job_ids))
# Update them to failed
conn.execute(
f"""
UPDATE generated_videos
SET status = 'failed', updated_at = ?
WHERE id IN ({placeholders})
""",
[datetime.now(timezone.utc)] + job_ids
)
return len(job_ids)
+213 -213
View File
@@ -1,213 +1,213 @@
"""OpenRouter API client (OpenAI-compatible interface)."""
import os
from typing import Any
import httpx
OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
def _api_key() -> str:
key = os.getenv("OPENROUTER_API_KEY")
if not key:
raise RuntimeError(
"OPENROUTER_API_KEY environment variable is not set.")
return key
def _headers() -> dict[str, str]:
return {
"Authorization": f"Bearer {_api_key()}",
"Content-Type": "application/json",
"HTTP-Referer": os.getenv("APP_URL", "https://ai.allucanget.biz"),
"X-Title": os.getenv("APP_NAME", "All You Can GET AI"),
}
async def list_models(
output_modalities: str = "all",
category: str | None = None,
supported_parameters: str | None = None,
) -> list[dict[str, Any]]:
"""Return available models from OpenRouter.
Docs: GET /models supports query filters like output_modalities.
"""
base_url = os.getenv("OPENROUTER_BASE_URL", OPENROUTER_BASE_URL)
params: dict[str, str] = {"output_modalities": output_modalities}
if category:
params["category"] = category
if supported_parameters:
params["supported_parameters"] = supported_parameters
async with httpx.AsyncClient(timeout=15) as client:
resp = client.build_request(
"GET", f"{base_url}/models", headers=_headers(), params=params)
response = await client.send(resp)
response.raise_for_status()
return response.json().get("data", [])
async def chat_completion(
model: str,
messages: list[dict[str, str]],
temperature: float = 0.7,
max_tokens: int = 1024,
) -> dict[str, Any]:
"""Send a chat completion request to OpenRouter."""
base_url = os.getenv("OPENROUTER_BASE_URL", OPENROUTER_BASE_URL)
payload = {
"model": model,
"messages": messages,
"temperature": temperature,
"max_tokens": max_tokens,
}
async with httpx.AsyncClient(timeout=60) as client:
resp = client.build_request(
"POST", f"{base_url}/chat/completions", headers=_headers(), json=payload
)
response = await client.send(resp)
response.raise_for_status()
return response.json()
async def generate_image(
model: str,
prompt: str,
n: int = 1,
size: str = "1024x1024",
) -> dict[str, Any]:
"""Request image generation via OpenRouter /images/generations."""
base_url = os.getenv("OPENROUTER_BASE_URL", OPENROUTER_BASE_URL)
payload = {"model": model, "prompt": prompt, "n": n, "size": size}
async with httpx.AsyncClient(timeout=120) as client:
resp = client.build_request(
"POST", f"{base_url}/images/generations", headers=_headers(), json=payload
)
response = await client.send(resp)
response.raise_for_status()
return response.json()
async def generate_video(
model: str,
prompt: str,
duration_seconds: int | None = None,
aspect_ratio: str = "16:9",
resolution: str | None = None,
generate_audio: bool | None = None,
) -> dict[str, Any]:
"""Request text-to-video generation via OpenRouter POST /videos."""
base_url = os.getenv("OPENROUTER_BASE_URL", OPENROUTER_BASE_URL)
payload: dict[str, Any] = {
"model": model,
"prompt": prompt,
"aspect_ratio": aspect_ratio,
}
if duration_seconds is not None:
# API uses 'duration' not 'duration_seconds'
payload["duration"] = duration_seconds
if resolution is not None:
payload["resolution"] = resolution
if generate_audio is not None:
payload["generate_audio"] = generate_audio
async with httpx.AsyncClient(timeout=120) as client:
resp = client.build_request(
"POST", f"{base_url}/videos", headers=_headers(), json=payload
)
response = await client.send(resp)
response.raise_for_status()
return response.json()
async def generate_video_from_image(
model: str,
image_url: str,
prompt: str,
duration_seconds: int | None = None,
aspect_ratio: str = "16:9",
resolution: str | None = None,
generate_audio: bool | None = None,
) -> dict[str, Any]:
"""Request image-to-video generation via OpenRouter POST /videos.
Uses frame_images array with first_frame as per OpenRouter API spec.
"""
base_url = os.getenv("OPENROUTER_BASE_URL", OPENROUTER_BASE_URL)
payload: dict[str, Any] = {
"model": model,
"prompt": prompt,
"aspect_ratio": aspect_ratio,
"frame_images": [
{
"type": "image_url",
"image_url": {"url": image_url},
"frame_type": "first_frame",
}
],
}
if duration_seconds is not None:
payload["duration"] = duration_seconds
if resolution is not None:
payload["resolution"] = resolution
if generate_audio is not None:
payload["generate_audio"] = generate_audio
async with httpx.AsyncClient(timeout=120) as client:
resp = client.build_request(
"POST", f"{base_url}/videos", headers=_headers(), json=payload
)
response = await client.send(resp)
response.raise_for_status()
return response.json()
async def poll_video_status(polling_url: str) -> dict[str, Any]:
"""Check the status of a video generation job via its polling_url."""
async with httpx.AsyncClient(timeout=15) as client:
resp = client.build_request("GET", polling_url, headers=_headers())
response = await client.send(resp)
response.raise_for_status()
return response.json()
async def list_video_models() -> list[dict[str, Any]]:
"""Return video generation models from the dedicated /videos/models endpoint."""
base_url = os.getenv("OPENROUTER_BASE_URL", OPENROUTER_BASE_URL)
async with httpx.AsyncClient(timeout=15) as client:
resp = client.build_request(
"GET", f"{base_url}/videos/models", headers=_headers()
)
response = await client.send(resp)
response.raise_for_status()
return response.json().get("data", [])
async def generate_image_chat(
model: str,
prompt: str,
modalities: list[str] | None = None,
image_config: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Request image generation via Chat Completions with modalities.
Used by models like FLUX.2 Klein 4B and GPT-5 Image Mini that output
images through the chat completions endpoint rather than /images/generations.
"""
base_url = os.getenv("OPENROUTER_BASE_URL", OPENROUTER_BASE_URL)
if modalities is None:
# Image-only models (FLUX) vs multimodal (GPT-5 Image Mini)
modalities = ["image"]
payload: dict[str, Any] = {
"model": model,
"messages": [{"role": "user", "content": prompt}],
"modalities": modalities,
}
if image_config:
payload["image_config"] = image_config
async with httpx.AsyncClient(timeout=120) as client:
resp = client.build_request(
"POST", f"{base_url}/chat/completions", headers=_headers(), json=payload
)
response = await client.send(resp)
response.raise_for_status()
return response.json()
"""OpenRouter API client (OpenAI-compatible interface)."""
import os
from typing import Any
import httpx
OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
def _api_key() -> str:
key = os.getenv("OPENROUTER_API_KEY")
if not key:
raise RuntimeError(
"OPENROUTER_API_KEY environment variable is not set.")
return key
def _headers() -> dict[str, str]:
return {
"Authorization": f"Bearer {_api_key()}",
"Content-Type": "application/json",
"HTTP-Referer": os.getenv("APP_URL", "https://ai.allucanget.biz"),
"X-Title": os.getenv("APP_NAME", "All You Can GET AI"),
}
async def list_models(
output_modalities: str = "all",
category: str | None = None,
supported_parameters: str | None = None,
) -> list[dict[str, Any]]:
"""Return available models from OpenRouter.
Docs: GET /models supports query filters like output_modalities.
"""
base_url = os.getenv("OPENROUTER_BASE_URL", OPENROUTER_BASE_URL)
params: dict[str, str] = {"output_modalities": output_modalities}
if category:
params["category"] = category
if supported_parameters:
params["supported_parameters"] = supported_parameters
async with httpx.AsyncClient(timeout=15) as client:
resp = client.build_request(
"GET", f"{base_url}/models", headers=_headers(), params=params)
response = await client.send(resp)
response.raise_for_status()
return response.json().get("data", [])
async def chat_completion(
model: str,
messages: list[dict[str, str]],
temperature: float = 0.7,
max_tokens: int = 1024,
) -> dict[str, Any]:
"""Send a chat completion request to OpenRouter."""
base_url = os.getenv("OPENROUTER_BASE_URL", OPENROUTER_BASE_URL)
payload = {
"model": model,
"messages": messages,
"temperature": temperature,
"max_tokens": max_tokens,
}
async with httpx.AsyncClient(timeout=60) as client:
resp = client.build_request(
"POST", f"{base_url}/chat/completions", headers=_headers(), json=payload
)
response = await client.send(resp)
response.raise_for_status()
return response.json()
async def generate_image(
model: str,
prompt: str,
n: int = 1,
size: str = "1024x1024",
) -> dict[str, Any]:
"""Request image generation via OpenRouter /images/generations."""
base_url = os.getenv("OPENROUTER_BASE_URL", OPENROUTER_BASE_URL)
payload = {"model": model, "prompt": prompt, "n": n, "size": size}
async with httpx.AsyncClient(timeout=120) as client:
resp = client.build_request(
"POST", f"{base_url}/images/generations", headers=_headers(), json=payload
)
response = await client.send(resp)
response.raise_for_status()
return response.json()
async def generate_video(
model: str,
prompt: str,
duration_seconds: int | None = None,
aspect_ratio: str = "16:9",
resolution: str | None = None,
generate_audio: bool | None = None,
) -> dict[str, Any]:
"""Request text-to-video generation via OpenRouter POST /videos."""
base_url = os.getenv("OPENROUTER_BASE_URL", OPENROUTER_BASE_URL)
payload: dict[str, Any] = {
"model": model,
"prompt": prompt,
"aspect_ratio": aspect_ratio,
}
if duration_seconds is not None:
# API uses 'duration' not 'duration_seconds'
payload["duration"] = duration_seconds
if resolution is not None:
payload["resolution"] = resolution
if generate_audio is not None:
payload["generate_audio"] = generate_audio
async with httpx.AsyncClient(timeout=120) as client:
resp = client.build_request(
"POST", f"{base_url}/videos", headers=_headers(), json=payload
)
response = await client.send(resp)
response.raise_for_status()
return response.json()
async def generate_video_from_image(
model: str,
image_url: str,
prompt: str,
duration_seconds: int | None = None,
aspect_ratio: str = "16:9",
resolution: str | None = None,
generate_audio: bool | None = None,
) -> dict[str, Any]:
"""Request image-to-video generation via OpenRouter POST /videos.
Uses frame_images array with first_frame as per OpenRouter API spec.
"""
base_url = os.getenv("OPENROUTER_BASE_URL", OPENROUTER_BASE_URL)
payload: dict[str, Any] = {
"model": model,
"prompt": prompt,
"aspect_ratio": aspect_ratio,
"frame_images": [
{
"type": "image_url",
"image_url": {"url": image_url},
"frame_type": "first_frame",
}
],
}
if duration_seconds is not None:
payload["duration"] = duration_seconds
if resolution is not None:
payload["resolution"] = resolution
if generate_audio is not None:
payload["generate_audio"] = generate_audio
async with httpx.AsyncClient(timeout=120) as client:
resp = client.build_request(
"POST", f"{base_url}/videos", headers=_headers(), json=payload
)
response = await client.send(resp)
response.raise_for_status()
return response.json()
async def poll_video_status(polling_url: str) -> dict[str, Any]:
"""Check the status of a video generation job via its polling_url."""
async with httpx.AsyncClient(timeout=15) as client:
resp = client.build_request("GET", polling_url, headers=_headers())
response = await client.send(resp)
response.raise_for_status()
return response.json()
async def list_video_models() -> list[dict[str, Any]]:
"""Return video generation models from the dedicated /videos/models endpoint."""
base_url = os.getenv("OPENROUTER_BASE_URL", OPENROUTER_BASE_URL)
async with httpx.AsyncClient(timeout=15) as client:
resp = client.build_request(
"GET", f"{base_url}/videos/models", headers=_headers()
)
response = await client.send(resp)
response.raise_for_status()
return response.json().get("data", [])
async def generate_image_chat(
model: str,
prompt: str,
modalities: list[str] | None = None,
image_config: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Request image generation via Chat Completions with modalities.
Used by models like FLUX.2 Klein 4B and GPT-5 Image Mini that output
images through the chat completions endpoint rather than /images/generations.
"""
base_url = os.getenv("OPENROUTER_BASE_URL", OPENROUTER_BASE_URL)
if modalities is None:
# Image-only models (FLUX) vs multimodal (GPT-5 Image Mini)
modalities = ["image"]
payload: dict[str, Any] = {
"model": model,
"messages": [{"role": "user", "content": prompt}],
"modalities": modalities,
}
if image_config:
payload["image_config"] = image_config
async with httpx.AsyncClient(timeout=120) as client:
resp = client.build_request(
"POST", f"{base_url}/chat/completions", headers=_headers(), json=payload
)
response = await client.send(resp)
response.raise_for_status()
return response.json()
+86 -86
View File
@@ -1,86 +1,86 @@
"""User management service: CRUD helpers against DuckDB."""
from typing import Any
from ..db import get_conn, get_write_lock
from .auth import hash_password
async def get_user(user_id: str) -> dict[str, Any] | None:
conn = get_conn()
row = conn.execute(
"SELECT id, email, role FROM users WHERE id = ?", [user_id]
).fetchone()
if row is None:
return None
return {"id": str(row[0]), "email": row[1], "role": row[2]}
async def list_users() -> list[dict[str, Any]]:
conn = get_conn()
rows = conn.execute(
"SELECT id, email, role FROM users ORDER BY email").fetchall()
return [{"id": str(r[0]), "email": r[1], "role": r[2]} for r in rows]
async def update_user(
user_id: str,
email: str | None = None,
password: str | None = None,
) -> dict[str, Any] | None:
"""Update email and/or password. Returns updated user or None if not found."""
conn = get_conn()
lock = get_write_lock()
if email is None and password is None:
return await get_user(user_id)
async with lock:
if email is not None:
existing = conn.execute(
"SELECT id FROM users WHERE email = ? AND id != ?", [
email, user_id]
).fetchone()
if existing:
raise ValueError("Email already in use.")
conn.execute(
"UPDATE users SET email = ?, updated_at = now() WHERE id = ?",
[email, user_id],
)
if password is not None:
conn.execute(
"UPDATE users SET password_hash = ?, updated_at = now() WHERE id = ?",
[hash_password(password), user_id],
)
row = conn.execute(
"SELECT id, email, role FROM users WHERE id = ?", [user_id]
).fetchone()
if row is None:
return None
return {"id": str(row[0]), "email": row[1], "role": row[2]}
async def set_user_role(user_id: str, role: str) -> dict[str, Any] | None:
conn = get_conn()
lock = get_write_lock()
async with lock:
conn.execute(
"UPDATE users SET role = ?, updated_at = now() WHERE id = ?",
[role, user_id],
)
row = conn.execute(
"SELECT id, email, role FROM users WHERE id = ?", [user_id]
).fetchone()
if row is None:
return None
return {"id": str(row[0]), "email": row[1], "role": row[2]}
async def delete_user(user_id: str) -> bool:
"""Delete user and their refresh tokens. Returns True if a row was removed."""
conn = get_conn()
lock = get_write_lock()
async with lock:
conn.execute("DELETE FROM refresh_tokens WHERE user_id = ?", [user_id])
conn.execute("DELETE FROM users WHERE id = ?", [user_id])
return True
"""User management service: CRUD helpers against DuckDB."""
from typing import Any
from ..db import get_conn, get_write_lock
from .auth import hash_password
async def get_user(user_id: str) -> dict[str, Any] | None:
conn = get_conn()
row = conn.execute(
"SELECT id, email, role FROM users WHERE id = ?", [user_id]
).fetchone()
if row is None:
return None
return {"id": str(row[0]), "email": row[1], "role": row[2]}
async def list_users() -> list[dict[str, Any]]:
conn = get_conn()
rows = conn.execute(
"SELECT id, email, role FROM users ORDER BY email").fetchall()
return [{"id": str(r[0]), "email": r[1], "role": r[2]} for r in rows]
async def update_user(
user_id: str,
email: str | None = None,
password: str | None = None,
) -> dict[str, Any] | None:
"""Update email and/or password. Returns updated user or None if not found."""
conn = get_conn()
lock = get_write_lock()
if email is None and password is None:
return await get_user(user_id)
async with lock:
if email is not None:
existing = conn.execute(
"SELECT id FROM users WHERE email = ? AND id != ?", [
email, user_id]
).fetchone()
if existing:
raise ValueError("Email already in use.")
conn.execute(
"UPDATE users SET email = ?, updated_at = now() WHERE id = ?",
[email, user_id],
)
if password is not None:
conn.execute(
"UPDATE users SET password_hash = ?, updated_at = now() WHERE id = ?",
[hash_password(password), user_id],
)
row = conn.execute(
"SELECT id, email, role FROM users WHERE id = ?", [user_id]
).fetchone()
if row is None:
return None
return {"id": str(row[0]), "email": row[1], "role": row[2]}
async def set_user_role(user_id: str, role: str) -> dict[str, Any] | None:
conn = get_conn()
lock = get_write_lock()
async with lock:
conn.execute(
"UPDATE users SET role = ?, updated_at = now() WHERE id = ?",
[role, user_id],
)
row = conn.execute(
"SELECT id, email, role FROM users WHERE id = ?", [user_id]
).fetchone()
if row is None:
return None
return {"id": str(row[0]), "email": row[1], "role": row[2]}
async def delete_user(user_id: str) -> bool:
"""Delete user and their refresh tokens. Returns True if a row was removed."""
conn = get_conn()
lock = get_write_lock()
async with lock:
conn.execute("DELETE FROM refresh_tokens WHERE user_id = ?", [user_id])
conn.execute("DELETE FROM users WHERE id = ?", [user_id])
return True
+159 -159
View File
@@ -1,159 +1,159 @@
"""Background worker: processes queued/processing video generation jobs."""
import asyncio
import json
import logging
from datetime import datetime, timezone
import duckdb
from . import openrouter
from .models import mark_timed_out_video_jobs
logger = logging.getLogger(__name__)
# Interval between worker ticks (seconds)
WORKER_INTERVAL = 15
# Jobs to process per tick (prevents unbounded bursts)
BATCH_SIZE = 5
async def process_queued_jobs(conn: duckdb.DuckDBPyConnection, lock: asyncio.Lock) -> int:
"""Submit queued jobs to OpenRouter and transition them to 'processing'."""
rows = conn.execute(
"""SELECT id, generation_type, request_params
FROM generated_videos
WHERE status = 'queued' AND request_params IS NOT NULL
ORDER BY created_at ASC
LIMIT ?""",
[BATCH_SIZE],
).fetchall()
processed = 0
for row in rows:
db_id, generation_type, raw_params = str(row[0]), row[1], row[2]
try:
params = json.loads(raw_params)
except (json.JSONDecodeError, TypeError):
logger.error("Bad request_params for video job %s", db_id)
continue
try:
if generation_type == "image_to_video":
result = await openrouter.generate_video_from_image(
model=params["model"],
image_url=params.get("image_url", ""),
prompt=params.get("prompt", ""),
duration_seconds=params.get("duration_seconds"),
aspect_ratio=params.get("aspect_ratio", "16:9"),
resolution=params.get("resolution"),
)
else:
result = await openrouter.generate_video(
model=params["model"],
prompt=params.get("prompt", ""),
duration_seconds=params.get("duration_seconds"),
aspect_ratio=params.get("aspect_ratio", "16:9"),
resolution=params.get("resolution"),
)
except Exception as exc:
logger.warning("OpenRouter call failed for job %s: %s", db_id, exc)
now = datetime.now(timezone.utc).replace(tzinfo=None)
async with lock:
conn.execute(
"UPDATE generated_videos SET status = 'failed', error = ?, updated_at = ? WHERE id = ?",
[str(exc), now, db_id],
)
continue
job_id = result.get("id", "")
polling_url = result.get("polling_url")
new_status = result.get("status", "processing")
# Normalise terminal statuses returned immediately (rare but possible)
if new_status not in ("queued", "processing", "completed", "failed", "cancelled"):
new_status = "processing"
urls = result.get("unsigned_urls") or result.get("video_urls")
video_url = (urls or [None])[0]
now = datetime.now(timezone.utc).replace(tzinfo=None)
async with lock:
conn.execute(
"""UPDATE generated_videos
SET job_id = ?, polling_url = ?, status = ?, video_url = ?, updated_at = ?
WHERE id = ?""",
[job_id, polling_url, new_status, video_url, now, db_id],
)
processed += 1
logger.info("Video job %s → %s (provider id: %s)",
db_id, new_status, job_id)
return processed
async def process_processing_jobs(conn: duckdb.DuckDBPyConnection, lock: asyncio.Lock) -> int:
"""Poll in-progress jobs and update to 'completed' or 'failed'."""
rows = conn.execute(
"""SELECT id, polling_url
FROM generated_videos
WHERE status = 'processing' AND polling_url IS NOT NULL
ORDER BY updated_at ASC
LIMIT ?""",
[BATCH_SIZE],
).fetchall()
updated = 0
for row in rows:
db_id, polling_url = str(row[0]), row[1]
try:
result = await openrouter.poll_video_status(polling_url)
except Exception as exc:
logger.warning("Polling failed for job %s: %s", db_id, exc)
continue
job_status = result.get("status", "processing")
if job_status not in ("completed", "failed"):
continue # still in-progress — check again next tick
urls = result.get("unsigned_urls") or result.get("video_urls")
video_url = (urls or [None])[0]
error_msg = result.get("error")
now = datetime.now(timezone.utc).replace(tzinfo=None)
async with lock:
conn.execute(
"""UPDATE generated_videos
SET status = ?, video_url = ?, error = ?, updated_at = ?
WHERE id = ?""",
[job_status, video_url, error_msg, now, db_id],
)
updated += 1
logger.info("Video job %s → %s", db_id, job_status)
return updated
async def worker_tick(conn: duckdb.DuckDBPyConnection, lock: asyncio.Lock) -> None:
"""Single worker tick: submit queued, poll processing, expire timed-out."""
queued = await process_queued_jobs(conn, lock)
polled = await process_processing_jobs(conn, lock)
async with lock:
timed_out = mark_timed_out_video_jobs(conn, timeout_minutes=120)
if queued or polled or timed_out:
logger.info(
"Worker tick: submitted=%d polled=%d timed_out=%d",
queued, polled, timed_out,
)
async def run_worker(conn: duckdb.DuckDBPyConnection, lock: asyncio.Lock) -> None:
"""Infinite loop: run a worker tick every WORKER_INTERVAL seconds."""
logger.info("Video worker started (interval=%ds)", WORKER_INTERVAL)
while True:
try:
await worker_tick(conn, lock)
except asyncio.CancelledError:
logger.info("Video worker stopped.")
return
except Exception as exc:
logger.exception("Unexpected error in video worker: %s", exc)
await asyncio.sleep(WORKER_INTERVAL)
"""Background worker: processes queued/processing video generation jobs."""
import asyncio
import json
import logging
from datetime import datetime, timezone
import duckdb
from . import openrouter
from .models import mark_timed_out_video_jobs
logger = logging.getLogger(__name__)
# Interval between worker ticks (seconds)
WORKER_INTERVAL = 15
# Jobs to process per tick (prevents unbounded bursts)
BATCH_SIZE = 5
async def process_queued_jobs(conn: duckdb.DuckDBPyConnection, lock: asyncio.Lock) -> int:
"""Submit queued jobs to OpenRouter and transition them to 'processing'."""
rows = conn.execute(
"""SELECT id, generation_type, request_params
FROM generated_videos
WHERE status = 'queued' AND request_params IS NOT NULL
ORDER BY created_at ASC
LIMIT ?""",
[BATCH_SIZE],
).fetchall()
processed = 0
for row in rows:
db_id, generation_type, raw_params = str(row[0]), row[1], row[2]
try:
params = json.loads(raw_params)
except (json.JSONDecodeError, TypeError):
logger.error("Bad request_params for video job %s", db_id)
continue
try:
if generation_type == "image_to_video":
result = await openrouter.generate_video_from_image(
model=params["model"],
image_url=params.get("image_url", ""),
prompt=params.get("prompt", ""),
duration_seconds=params.get("duration_seconds"),
aspect_ratio=params.get("aspect_ratio", "16:9"),
resolution=params.get("resolution"),
)
else:
result = await openrouter.generate_video(
model=params["model"],
prompt=params.get("prompt", ""),
duration_seconds=params.get("duration_seconds"),
aspect_ratio=params.get("aspect_ratio", "16:9"),
resolution=params.get("resolution"),
)
except Exception as exc:
logger.warning("OpenRouter call failed for job %s: %s", db_id, exc)
now = datetime.now(timezone.utc).replace(tzinfo=None)
async with lock:
conn.execute(
"UPDATE generated_videos SET status = 'failed', error = ?, updated_at = ? WHERE id = ?",
[str(exc), now, db_id],
)
continue
job_id = result.get("id", "")
polling_url = result.get("polling_url")
new_status = result.get("status", "processing")
# Normalise terminal statuses returned immediately (rare but possible)
if new_status not in ("queued", "processing", "completed", "failed", "cancelled"):
new_status = "processing"
urls = result.get("unsigned_urls") or result.get("video_urls")
video_url = (urls or [None])[0]
now = datetime.now(timezone.utc).replace(tzinfo=None)
async with lock:
conn.execute(
"""UPDATE generated_videos
SET job_id = ?, polling_url = ?, status = ?, video_url = ?, updated_at = ?
WHERE id = ?""",
[job_id, polling_url, new_status, video_url, now, db_id],
)
processed += 1
logger.info("Video job %s → %s (provider id: %s)",
db_id, new_status, job_id)
return processed
async def process_processing_jobs(conn: duckdb.DuckDBPyConnection, lock: asyncio.Lock) -> int:
"""Poll in-progress jobs and update to 'completed' or 'failed'."""
rows = conn.execute(
"""SELECT id, polling_url
FROM generated_videos
WHERE status = 'processing' AND polling_url IS NOT NULL
ORDER BY updated_at ASC
LIMIT ?""",
[BATCH_SIZE],
).fetchall()
updated = 0
for row in rows:
db_id, polling_url = str(row[0]), row[1]
try:
result = await openrouter.poll_video_status(polling_url)
except Exception as exc:
logger.warning("Polling failed for job %s: %s", db_id, exc)
continue
job_status = result.get("status", "processing")
if job_status not in ("completed", "failed"):
continue # still in-progress — check again next tick
urls = result.get("unsigned_urls") or result.get("video_urls")
video_url = (urls or [None])[0]
error_msg = result.get("error")
now = datetime.now(timezone.utc).replace(tzinfo=None)
async with lock:
conn.execute(
"""UPDATE generated_videos
SET status = ?, video_url = ?, error = ?, updated_at = ?
WHERE id = ?""",
[job_status, video_url, error_msg, now, db_id],
)
updated += 1
logger.info("Video job %s → %s", db_id, job_status)
return updated
async def worker_tick(conn: duckdb.DuckDBPyConnection, lock: asyncio.Lock) -> None:
"""Single worker tick: submit queued, poll processing, expire timed-out."""
queued = await process_queued_jobs(conn, lock)
polled = await process_processing_jobs(conn, lock)
async with lock:
timed_out = mark_timed_out_video_jobs(conn, timeout_minutes=120)
if queued or polled or timed_out:
logger.info(
"Worker tick: submitted=%d polled=%d timed_out=%d",
queued, polled, timed_out,
)
async def run_worker(conn: duckdb.DuckDBPyConnection, lock: asyncio.Lock) -> None:
"""Infinite loop: run a worker tick every WORKER_INTERVAL seconds."""
logger.info("Video worker started (interval=%ds)", WORKER_INTERVAL)
while True:
try:
await worker_tick(conn, lock)
except asyncio.CancelledError:
logger.info("Video worker stopped.")
return
except Exception as exc:
logger.exception("Unexpected error in video worker: %s", exc)
await asyncio.sleep(WORKER_INTERVAL)
+13 -13
View File
@@ -1,13 +1,13 @@
# Dev-only dependencies for local development and testing
# Production dependencies are in requirements.txt
pytest
pytest-asyncio
Flask
gunicorn
Pygments
tomli
exceptiongroup
iniconfig
pluggy
# Dev-only dependencies for local development and testing
# Production dependencies are in requirements.txt
pytest
pytest-asyncio
Flask
gunicorn
Pygments
tomli
exceptiongroup
iniconfig
pluggy
+20 -20
View File
@@ -1,21 +1,21 @@
anyio
bcrypt==4.0.1
blinker
certifi
cryptography
dnspython
duckdb
ecdsa
email-validator
fastapi
httpcore
httpx
Jinja2
MarkupSafe
packaging
passlib==1.7.4
pydantic
python-dotenv
python-jose
python-multipart
anyio
bcrypt==4.0.1
blinker
certifi
cryptography
dnspython
duckdb
ecdsa
email-validator
fastapi
httpcore
httpx
Jinja2
MarkupSafe
packaging
passlib==1.7.4
pydantic
python-dotenv
python-jose
python-multipart
uvicorn
+119 -119
View File
@@ -1,119 +1,119 @@
"""Integration tests for admin endpoints."""
import os
import pytest
import pytest_asyncio
from httpx import AsyncClient, ASGITransport
from app.main import app
from app import db as db_module
os.environ.setdefault("JWT_SECRET", "test-secret-key-for-testing-only")
@pytest.fixture(autouse=True)
def fresh_db():
db_module._conn = None
db_module.init_db(":memory:")
yield
db_module.close_db()
db_module._conn = None
@pytest_asyncio.fixture
async def client(fresh_db):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
async def _register_login(client, email="user@example.com", password="secret123"):
await client.post("/auth/register", json={"email": email, "password": password})
resp = await client.post("/auth/login", json={"email": email, "password": password})
return resp.json()
async def _admin_token(client, email="admin@example.com", password="secret123"):
tokens = await _register_login(client, email, password)
me = await client.get("/users/me", headers={"Authorization": f"Bearer {tokens['access_token']}"})
db_module.get_conn().execute(
"UPDATE users SET role = 'admin' WHERE id = ?", [me.json()["id"]])
login = await client.post("/auth/login", json={"email": email, "password": password})
return login.json()["access_token"]
# ---------------------------------------------------------------------------
# GET /admin/stats
# ---------------------------------------------------------------------------
async def test_stats_as_admin(client):
await _register_login(client, "user1@example.com")
await _register_login(client, "user2@example.com")
token = await _admin_token(client)
resp = await client.get("/admin/stats", headers={"Authorization": f"Bearer {token}"})
assert resp.status_code == 200
data = resp.json()
# 2 users + 1 admin + 1 seeded admin (ai@allucanget.biz)
assert data["users"]["total"] == 4
assert "by_role" in data["users"]
assert "refresh_tokens" in data
async def test_stats_as_regular_user(client):
tokens = await _register_login(client)
resp = await client.get("/admin/stats", headers={"Authorization": f"Bearer {tokens['access_token']}"})
assert resp.status_code == 403
async def test_stats_unauthenticated(client):
resp = await client.get("/admin/stats")
assert resp.status_code == 401
# ---------------------------------------------------------------------------
# GET /admin/health/db
# ---------------------------------------------------------------------------
async def test_db_health_as_admin(client):
token = await _admin_token(client)
resp = await client.get("/admin/health/db", headers={"Authorization": f"Bearer {token}"})
assert resp.status_code == 200
assert resp.json()["status"] == "ok"
async def test_db_health_as_regular_user(client):
tokens = await _register_login(client)
resp = await client.get("/admin/health/db", headers={"Authorization": f"Bearer {tokens['access_token']}"})
assert resp.status_code == 403
# ---------------------------------------------------------------------------
# POST /admin/tokens/purge
# ---------------------------------------------------------------------------
async def test_purge_removes_revoked_tokens(client):
tokens = await _register_login(client, "user@example.com")
refresh_token = tokens["refresh_token"]
# Logout to revoke the token
await client.post("/auth/logout", json={"refresh_token": refresh_token})
token = await _admin_token(client)
resp = await client.post("/admin/tokens/purge", headers={"Authorization": f"Bearer {token}"})
assert resp.status_code == 200
data = resp.json()
assert data["deleted"] >= 1
async def test_purge_nothing_to_remove(client):
token = await _admin_token(client)
resp = await client.post("/admin/tokens/purge", headers={"Authorization": f"Bearer {token}"})
assert resp.status_code == 200
# Admin login issued one active token — nothing to purge
assert resp.json()["deleted"] == 0
async def test_purge_as_regular_user(client):
tokens = await _register_login(client)
resp = await client.post("/admin/tokens/purge", headers={"Authorization": f"Bearer {tokens['access_token']}"})
assert resp.status_code == 403
"""Integration tests for admin endpoints."""
import os
import pytest
import pytest_asyncio
from httpx import AsyncClient, ASGITransport
from app.main import app
from app import db as db_module
os.environ.setdefault("JWT_SECRET", "test-secret-key-for-testing-only")
@pytest.fixture(autouse=True)
def fresh_db():
db_module._conn = None
db_module.init_db(":memory:")
yield
db_module.close_db()
db_module._conn = None
@pytest_asyncio.fixture
async def client(fresh_db):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
async def _register_login(client, email="user@example.com", password="secret123"):
await client.post("/auth/register", json={"email": email, "password": password})
resp = await client.post("/auth/login", json={"email": email, "password": password})
return resp.json()
async def _admin_token(client, email="admin@example.com", password="secret123"):
tokens = await _register_login(client, email, password)
me = await client.get("/users/me", headers={"Authorization": f"Bearer {tokens['access_token']}"})
db_module.get_conn().execute(
"UPDATE users SET role = 'admin' WHERE id = ?", [me.json()["id"]])
login = await client.post("/auth/login", json={"email": email, "password": password})
return login.json()["access_token"]
# ---------------------------------------------------------------------------
# GET /admin/stats
# ---------------------------------------------------------------------------
async def test_stats_as_admin(client):
await _register_login(client, "user1@example.com")
await _register_login(client, "user2@example.com")
token = await _admin_token(client)
resp = await client.get("/admin/stats", headers={"Authorization": f"Bearer {token}"})
assert resp.status_code == 200
data = resp.json()
# 2 users + 1 admin + 1 seeded admin (ai@allucanget.biz)
assert data["users"]["total"] == 4
assert "by_role" in data["users"]
assert "refresh_tokens" in data
async def test_stats_as_regular_user(client):
tokens = await _register_login(client)
resp = await client.get("/admin/stats", headers={"Authorization": f"Bearer {tokens['access_token']}"})
assert resp.status_code == 403
async def test_stats_unauthenticated(client):
resp = await client.get("/admin/stats")
assert resp.status_code == 401
# ---------------------------------------------------------------------------
# GET /admin/health/db
# ---------------------------------------------------------------------------
async def test_db_health_as_admin(client):
token = await _admin_token(client)
resp = await client.get("/admin/health/db", headers={"Authorization": f"Bearer {token}"})
assert resp.status_code == 200
assert resp.json()["status"] == "ok"
async def test_db_health_as_regular_user(client):
tokens = await _register_login(client)
resp = await client.get("/admin/health/db", headers={"Authorization": f"Bearer {tokens['access_token']}"})
assert resp.status_code == 403
# ---------------------------------------------------------------------------
# POST /admin/tokens/purge
# ---------------------------------------------------------------------------
async def test_purge_removes_revoked_tokens(client):
tokens = await _register_login(client, "user@example.com")
refresh_token = tokens["refresh_token"]
# Logout to revoke the token
await client.post("/auth/logout", json={"refresh_token": refresh_token})
token = await _admin_token(client)
resp = await client.post("/admin/tokens/purge", headers={"Authorization": f"Bearer {token}"})
assert resp.status_code == 200
data = resp.json()
assert data["deleted"] >= 1
async def test_purge_nothing_to_remove(client):
token = await _admin_token(client)
resp = await client.post("/admin/tokens/purge", headers={"Authorization": f"Bearer {token}"})
assert resp.status_code == 200
# Admin login issued one active token — nothing to purge
assert resp.json()["deleted"] == 0
async def test_purge_as_regular_user(client):
tokens = await _register_login(client)
resp = await client.post("/admin/tokens/purge", headers={"Authorization": f"Bearer {tokens['access_token']}"})
assert resp.status_code == 403
+172 -172
View File
@@ -1,172 +1,172 @@
"""Tests for AI endpoints — OpenRouter HTTP calls are fully mocked."""
import os
import pytest
import pytest_asyncio
from unittest.mock import AsyncMock, patch
from httpx import AsyncClient, ASGITransport
from app.main import app
from app import db as db_module
os.environ.setdefault("JWT_SECRET", "test-secret-key-for-testing-only")
os.environ.setdefault("OPENROUTER_API_KEY", "test-key")
FAKE_MODELS = [
{"id": "openai/gpt-4o", "name": "GPT-4o", "context_length": 128000, "pricing": {"prompt": "0.000005"}},
{"id": "anthropic/claude-3-haiku", "name": "Claude 3 Haiku", "context_length": 200000, "pricing": {}},
]
FAKE_CHAT_RESPONSE = {
"id": "gen-abc123",
"model": "openai/gpt-4o",
"choices": [{"message": {"role": "assistant", "content": "Hello! How can I help?"}}],
"usage": {"prompt_tokens": 10, "completion_tokens": 8, "total_tokens": 18},
}
@pytest.fixture(autouse=True)
def fresh_db():
db_module._conn = None
db_module.init_db(":memory:")
yield
db_module.close_db()
db_module._conn = None
@pytest_asyncio.fixture
async def client(fresh_db):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
async def _user_token(client):
await client.post("/auth/register", json={"email": "user@example.com", "password": "secret123"})
resp = await client.post("/auth/login", json={"email": "user@example.com", "password": "secret123"})
return resp.json()["access_token"]
# ---------------------------------------------------------------------------
# GET /ai/models
# ---------------------------------------------------------------------------
async def test_list_models(client):
token = await _user_token(client)
with patch(
"app.routers.ai.openrouter.list_models",
new_callable=AsyncMock,
return_value=FAKE_MODELS,
):
resp = await client.get("/ai/models", headers={"Authorization": f"Bearer {token}"})
assert resp.status_code == 200
data = resp.json()
assert len(data) == 2
assert data[0]["id"] == "openai/gpt-4o"
assert data[1]["name"] == "Claude 3 Haiku"
async def test_list_models_unauthenticated(client):
resp = await client.get("/ai/models")
assert resp.status_code == 401
async def test_list_models_upstream_error(client):
token = await _user_token(client)
with patch(
"app.routers.ai.openrouter.list_models",
new_callable=AsyncMock,
side_effect=Exception("Connection refused"),
):
resp = await client.get("/ai/models", headers={"Authorization": f"Bearer {token}"})
assert resp.status_code == 502
assert "OpenRouter error" in resp.json()["detail"]
# ---------------------------------------------------------------------------
# POST /ai/chat
# ---------------------------------------------------------------------------
async def test_chat_success(client):
token = await _user_token(client)
with patch(
"app.routers.ai.openrouter.chat_completion",
new_callable=AsyncMock,
return_value=FAKE_CHAT_RESPONSE,
):
resp = await client.post(
"/ai/chat",
json={
"model": "openai/gpt-4o",
"messages": [{"role": "user", "content": "Hello"}],
},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
data = resp.json()
assert data["id"] == "gen-abc123"
assert data["model"] == "openai/gpt-4o"
assert data["content"] == "Hello! How can I help?"
assert data["usage"]["total_tokens"] == 18
async def test_chat_passes_parameters(client):
token = await _user_token(client)
mock = AsyncMock(return_value=FAKE_CHAT_RESPONSE)
with patch("app.routers.ai.openrouter.chat_completion", new_callable=AsyncMock, return_value=FAKE_CHAT_RESPONSE) as mock:
await client.post(
"/ai/chat",
json={
"model": "anthropic/claude-3-haiku",
"messages": [{"role": "user", "content": "Hi"}],
"temperature": 0.3,
"max_tokens": 512,
},
headers={"Authorization": f"Bearer {token}"},
)
mock.assert_called_once_with(
model="anthropic/claude-3-haiku",
messages=[{"role": "user", "content": "Hi"}],
temperature=0.3,
max_tokens=512,
)
async def test_chat_unauthenticated(client):
resp = await client.post(
"/ai/chat",
json={"model": "openai/gpt-4o", "messages": [{"role": "user", "content": "Hi"}]},
)
assert resp.status_code == 401
async def test_chat_upstream_error(client):
token = await _user_token(client)
with patch(
"app.routers.ai.openrouter.chat_completion",
new_callable=AsyncMock,
side_effect=Exception("timeout"),
):
resp = await client.post(
"/ai/chat",
json={"model": "openai/gpt-4o", "messages": [{"role": "user", "content": "Hi"}]},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 502
async def test_chat_malformed_upstream_response(client):
token = await _user_token(client)
with patch(
"app.routers.ai.openrouter.chat_completion",
new_callable=AsyncMock,
return_value={"id": "x", "choices": []}, # empty choices
):
resp = await client.post(
"/ai/chat",
json={"model": "openai/gpt-4o", "messages": [{"role": "user", "content": "Hi"}]},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 502
"""Tests for AI endpoints — OpenRouter HTTP calls are fully mocked."""
import os
import pytest
import pytest_asyncio
from unittest.mock import AsyncMock, patch
from httpx import AsyncClient, ASGITransport
from app.main import app
from app import db as db_module
os.environ.setdefault("JWT_SECRET", "test-secret-key-for-testing-only")
os.environ.setdefault("OPENROUTER_API_KEY", "test-key")
FAKE_MODELS = [
{"id": "openai/gpt-4o", "name": "GPT-4o", "context_length": 128000, "pricing": {"prompt": "0.000005"}},
{"id": "anthropic/claude-3-haiku", "name": "Claude 3 Haiku", "context_length": 200000, "pricing": {}},
]
FAKE_CHAT_RESPONSE = {
"id": "gen-abc123",
"model": "openai/gpt-4o",
"choices": [{"message": {"role": "assistant", "content": "Hello! How can I help?"}}],
"usage": {"prompt_tokens": 10, "completion_tokens": 8, "total_tokens": 18},
}
@pytest.fixture(autouse=True)
def fresh_db():
db_module._conn = None
db_module.init_db(":memory:")
yield
db_module.close_db()
db_module._conn = None
@pytest_asyncio.fixture
async def client(fresh_db):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
async def _user_token(client):
await client.post("/auth/register", json={"email": "user@example.com", "password": "secret123"})
resp = await client.post("/auth/login", json={"email": "user@example.com", "password": "secret123"})
return resp.json()["access_token"]
# ---------------------------------------------------------------------------
# GET /ai/models
# ---------------------------------------------------------------------------
async def test_list_models(client):
token = await _user_token(client)
with patch(
"app.routers.ai.openrouter.list_models",
new_callable=AsyncMock,
return_value=FAKE_MODELS,
):
resp = await client.get("/ai/models", headers={"Authorization": f"Bearer {token}"})
assert resp.status_code == 200
data = resp.json()
assert len(data) == 2
assert data[0]["id"] == "openai/gpt-4o"
assert data[1]["name"] == "Claude 3 Haiku"
async def test_list_models_unauthenticated(client):
resp = await client.get("/ai/models")
assert resp.status_code == 401
async def test_list_models_upstream_error(client):
token = await _user_token(client)
with patch(
"app.routers.ai.openrouter.list_models",
new_callable=AsyncMock,
side_effect=Exception("Connection refused"),
):
resp = await client.get("/ai/models", headers={"Authorization": f"Bearer {token}"})
assert resp.status_code == 502
assert "OpenRouter error" in resp.json()["detail"]
# ---------------------------------------------------------------------------
# POST /ai/chat
# ---------------------------------------------------------------------------
async def test_chat_success(client):
token = await _user_token(client)
with patch(
"app.routers.ai.openrouter.chat_completion",
new_callable=AsyncMock,
return_value=FAKE_CHAT_RESPONSE,
):
resp = await client.post(
"/ai/chat",
json={
"model": "openai/gpt-4o",
"messages": [{"role": "user", "content": "Hello"}],
},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
data = resp.json()
assert data["id"] == "gen-abc123"
assert data["model"] == "openai/gpt-4o"
assert data["content"] == "Hello! How can I help?"
assert data["usage"]["total_tokens"] == 18
async def test_chat_passes_parameters(client):
token = await _user_token(client)
mock = AsyncMock(return_value=FAKE_CHAT_RESPONSE)
with patch("app.routers.ai.openrouter.chat_completion", new_callable=AsyncMock, return_value=FAKE_CHAT_RESPONSE) as mock:
await client.post(
"/ai/chat",
json={
"model": "anthropic/claude-3-haiku",
"messages": [{"role": "user", "content": "Hi"}],
"temperature": 0.3,
"max_tokens": 512,
},
headers={"Authorization": f"Bearer {token}"},
)
mock.assert_called_once_with(
model="anthropic/claude-3-haiku",
messages=[{"role": "user", "content": "Hi"}],
temperature=0.3,
max_tokens=512,
)
async def test_chat_unauthenticated(client):
resp = await client.post(
"/ai/chat",
json={"model": "openai/gpt-4o", "messages": [{"role": "user", "content": "Hi"}]},
)
assert resp.status_code == 401
async def test_chat_upstream_error(client):
token = await _user_token(client)
with patch(
"app.routers.ai.openrouter.chat_completion",
new_callable=AsyncMock,
side_effect=Exception("timeout"),
):
resp = await client.post(
"/ai/chat",
json={"model": "openai/gpt-4o", "messages": [{"role": "user", "content": "Hi"}]},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 502
async def test_chat_malformed_upstream_response(client):
token = await _user_token(client)
with patch(
"app.routers.ai.openrouter.chat_completion",
new_callable=AsyncMock,
return_value={"id": "x", "choices": []}, # empty choices
):
resp = await client.post(
"/ai/chat",
json={"model": "openai/gpt-4o", "messages": [{"role": "user", "content": "Hi"}]},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 502
+103 -103
View File
@@ -1,103 +1,103 @@
"""Integration tests for auth endpoints using in-memory DuckDB."""
from app.main import app
from app import db as db_module
from httpx import AsyncClient, ASGITransport
import os
import pytest
import pytest_asyncio
os.environ.setdefault("JWT_SECRET", "test-secret-key-for-testing-only")
@pytest.fixture(autouse=True)
def fresh_db():
"""Reset the DB singleton to a fresh in-memory DB for each test."""
db_module._conn = None
db_module.init_db(":memory:")
yield
db_module.close_db()
db_module._conn = None
@pytest_asyncio.fixture
async def client(fresh_db):
"""HTTP client wired to the app; explicitly depends on fresh_db to guarantee ordering."""
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
async def test_register_success(client):
resp = await client.post("/auth/register", json={"email": "user@example.com", "password": "secret123"})
assert resp.status_code == 201
data = resp.json()
assert data["email"] == "user@example.com"
assert data["role"] == "user"
assert "id" in data
async def test_register_duplicate_email(client):
payload = {"email": "dup@example.com", "password": "secret123"}
await client.post("/auth/register", json=payload)
resp = await client.post("/auth/register", json=payload)
assert resp.status_code == 409
async def test_login_success(client):
await client.post("/auth/register", json={"email": "user@example.com", "password": "secret123"})
resp = await client.post("/auth/login", json={"email": "user@example.com", "password": "secret123"})
assert resp.status_code == 200
data = resp.json()
assert "access_token" in data
assert "refresh_token" in data
assert data["token_type"] == "bearer"
async def test_login_wrong_password(client):
await client.post("/auth/register", json={"email": "user@example.com", "password": "secret123"})
resp = await client.post("/auth/login", json={"email": "user@example.com", "password": "wrong"})
assert resp.status_code == 401
async def test_login_unknown_user(client):
resp = await client.post("/auth/login", json={"email": "nobody@example.com", "password": "x"})
assert resp.status_code == 401
async def test_refresh_success(client):
await client.post("/auth/register", json={"email": "user@example.com", "password": "secret123"})
login = await client.post("/auth/login", json={"email": "user@example.com", "password": "secret123"})
refresh_token = login.json()["refresh_token"]
resp = await client.post("/auth/refresh", json={"refresh_token": refresh_token})
assert resp.status_code == 200
data = resp.json()
assert "access_token" in data
assert "refresh_token" in data
# New refresh token must differ (rotation)
assert data["refresh_token"] != refresh_token
async def test_refresh_revoked_token(client):
await client.post("/auth/register", json={"email": "user@example.com", "password": "secret123"})
login = await client.post("/auth/login", json={"email": "user@example.com", "password": "secret123"})
refresh_token = login.json()["refresh_token"]
# Use once (rotates)
await client.post("/auth/refresh", json={"refresh_token": refresh_token})
# Try to reuse old token — must fail
resp = await client.post("/auth/refresh", json={"refresh_token": refresh_token})
assert resp.status_code == 401
async def test_logout_success(client):
await client.post("/auth/register", json={"email": "user@example.com", "password": "secret123"})
login = await client.post("/auth/login", json={"email": "user@example.com", "password": "secret123"})
refresh_token = login.json()["refresh_token"]
resp = await client.post("/auth/logout", json={"refresh_token": refresh_token})
assert resp.status_code == 204
# Refresh after logout must fail
resp2 = await client.post("/auth/refresh", json={"refresh_token": refresh_token})
assert resp2.status_code == 401
"""Integration tests for auth endpoints using in-memory DuckDB."""
from app.main import app
from app import db as db_module
from httpx import AsyncClient, ASGITransport
import os
import pytest
import pytest_asyncio
os.environ.setdefault("JWT_SECRET", "test-secret-key-for-testing-only")
@pytest.fixture(autouse=True)
def fresh_db():
"""Reset the DB singleton to a fresh in-memory DB for each test."""
db_module._conn = None
db_module.init_db(":memory:")
yield
db_module.close_db()
db_module._conn = None
@pytest_asyncio.fixture
async def client(fresh_db):
"""HTTP client wired to the app; explicitly depends on fresh_db to guarantee ordering."""
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
async def test_register_success(client):
resp = await client.post("/auth/register", json={"email": "user@example.com", "password": "secret123"})
assert resp.status_code == 201
data = resp.json()
assert data["email"] == "user@example.com"
assert data["role"] == "user"
assert "id" in data
async def test_register_duplicate_email(client):
payload = {"email": "dup@example.com", "password": "secret123"}
await client.post("/auth/register", json=payload)
resp = await client.post("/auth/register", json=payload)
assert resp.status_code == 409
async def test_login_success(client):
await client.post("/auth/register", json={"email": "user@example.com", "password": "secret123"})
resp = await client.post("/auth/login", json={"email": "user@example.com", "password": "secret123"})
assert resp.status_code == 200
data = resp.json()
assert "access_token" in data
assert "refresh_token" in data
assert data["token_type"] == "bearer"
async def test_login_wrong_password(client):
await client.post("/auth/register", json={"email": "user@example.com", "password": "secret123"})
resp = await client.post("/auth/login", json={"email": "user@example.com", "password": "wrong"})
assert resp.status_code == 401
async def test_login_unknown_user(client):
resp = await client.post("/auth/login", json={"email": "nobody@example.com", "password": "x"})
assert resp.status_code == 401
async def test_refresh_success(client):
await client.post("/auth/register", json={"email": "user@example.com", "password": "secret123"})
login = await client.post("/auth/login", json={"email": "user@example.com", "password": "secret123"})
refresh_token = login.json()["refresh_token"]
resp = await client.post("/auth/refresh", json={"refresh_token": refresh_token})
assert resp.status_code == 200
data = resp.json()
assert "access_token" in data
assert "refresh_token" in data
# New refresh token must differ (rotation)
assert data["refresh_token"] != refresh_token
async def test_refresh_revoked_token(client):
await client.post("/auth/register", json={"email": "user@example.com", "password": "secret123"})
login = await client.post("/auth/login", json={"email": "user@example.com", "password": "secret123"})
refresh_token = login.json()["refresh_token"]
# Use once (rotates)
await client.post("/auth/refresh", json={"refresh_token": refresh_token})
# Try to reuse old token — must fail
resp = await client.post("/auth/refresh", json={"refresh_token": refresh_token})
assert resp.status_code == 401
async def test_logout_success(client):
await client.post("/auth/register", json={"email": "user@example.com", "password": "secret123"})
login = await client.post("/auth/login", json={"email": "user@example.com", "password": "secret123"})
refresh_token = login.json()["refresh_token"]
resp = await client.post("/auth/logout", json={"refresh_token": refresh_token})
assert resp.status_code == 204
# Refresh after logout must fail
resp2 = await client.post("/auth/refresh", json={"refresh_token": refresh_token})
assert resp2.status_code == 401
+230 -230
View File
@@ -1,230 +1,230 @@
"""Tests for DuckDB initialization and schema."""
import asyncio
import pytest
import duckdb
from app import db as db_module
@pytest.fixture(autouse=True)
def fresh_db():
"""Use an in-memory DB for each test and reset global state."""
db_module._conn = None
yield
db_module.close_db()
db_module._conn = None
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
def test_init_creates_users_table():
conn = db_module.init_db(":memory:")
result = conn.execute(
"SELECT table_name FROM information_schema.tables WHERE table_name = 'users'"
).fetchone()
assert result is not None
def test_init_creates_refresh_tokens_table():
conn = db_module.init_db(":memory:")
result = conn.execute(
"SELECT table_name FROM information_schema.tables WHERE table_name = 'refresh_tokens'"
).fetchone()
assert result is not None
def test_init_is_idempotent():
conn1 = db_module.init_db(":memory:")
conn2 = db_module.init_db(":memory:")
assert conn1 is conn2
def test_get_conn_raises_before_init():
with pytest.raises(RuntimeError, match="not initialised"):
db_module.get_conn()
def test_get_conn_returns_connection_after_init():
db_module.init_db(":memory:")
conn = db_module.get_conn()
assert conn is not None
def test_close_db_resets_connection():
db_module.init_db(":memory:")
db_module.close_db()
with pytest.raises(RuntimeError):
db_module.get_conn()
# ---------------------------------------------------------------------------
# Schema: users table columns and defaults
# ---------------------------------------------------------------------------
def test_users_columns():
conn = db_module.init_db(":memory:")
cols = {
row[0]
for row in conn.execute(
"SELECT column_name FROM information_schema.columns WHERE table_name = 'users'"
).fetchall()
}
assert {"id", "email", "password_hash",
"role", "created_at", "updated_at"} <= cols
def test_users_default_role_is_user():
conn = db_module.init_db(":memory:")
conn.execute(
"INSERT INTO users (email, password_hash) VALUES ('a@example.com', 'hash')"
)
row = conn.execute(
"SELECT role FROM users WHERE email = 'a@example.com'").fetchone()
assert row[0] == "user"
def test_users_id_auto_generated():
conn = db_module.init_db(":memory:")
conn.execute(
"INSERT INTO users (email, password_hash) VALUES ('b@example.com', 'hash')"
)
row = conn.execute(
"SELECT id FROM users WHERE email = 'b@example.com'").fetchone()
assert row[0] is not None
def test_users_email_unique_constraint():
conn = db_module.init_db(":memory:")
conn.execute(
"INSERT INTO users (email, password_hash) VALUES ('c@example.com', 'h')")
with pytest.raises(Exception):
conn.execute(
"INSERT INTO users (email, password_hash) VALUES ('c@example.com', 'h2')")
def test_users_timestamps_auto_set():
conn = db_module.init_db(":memory:")
conn.execute(
"INSERT INTO users (email, password_hash) VALUES ('d@example.com', 'hash')"
)
row = conn.execute(
"SELECT created_at, updated_at FROM users WHERE email = 'd@example.com'"
).fetchone()
assert row[0] is not None
assert row[1] is not None
# ---------------------------------------------------------------------------
# Schema: refresh_tokens table columns and defaults
# ---------------------------------------------------------------------------
def test_refresh_tokens_columns():
conn = db_module.init_db(":memory:")
cols = {
row[0]
for row in conn.execute(
"SELECT column_name FROM information_schema.columns WHERE table_name = 'refresh_tokens'"
).fetchall()
}
assert {"jti", "user_id", "issued_at", "expires_at", "revoked"} <= cols
def test_refresh_tokens_default_revoked_false():
conn = db_module.init_db(":memory:")
conn.execute(
"INSERT INTO users (email, password_hash) VALUES ('e@example.com', 'h')")
user_id = conn.execute(
"SELECT id FROM users WHERE email = 'e@example.com'").fetchone()[0]
conn.execute(
"INSERT INTO refresh_tokens (user_id, expires_at) VALUES (?, now() + INTERVAL 7 DAY)",
[user_id],
)
row = conn.execute("SELECT revoked FROM refresh_tokens WHERE user_id = ?", [
user_id]).fetchone()
assert row[0] is False
def test_refresh_tokens_jti_auto_generated():
conn = db_module.init_db(":memory:")
conn.execute(
"INSERT INTO users (email, password_hash) VALUES ('f@example.com', 'h')")
user_id = conn.execute(
"SELECT id FROM users WHERE email = 'f@example.com'").fetchone()[0]
conn.execute(
"INSERT INTO refresh_tokens (user_id, expires_at) VALUES (?, now() + INTERVAL 7 DAY)",
[user_id],
)
row = conn.execute("SELECT jti FROM refresh_tokens WHERE user_id = ?", [
user_id]).fetchone()
assert row[0] is not None
# ---------------------------------------------------------------------------
# Write lock
# ---------------------------------------------------------------------------
def test_get_write_lock_returns_asyncio_lock():
lock = db_module.get_write_lock()
assert isinstance(lock, asyncio.Lock)
def test_get_write_lock_returns_same_instance():
lock1 = db_module.get_write_lock()
lock2 = db_module.get_write_lock()
assert lock1 is lock2
async def test_write_lock_serialises_concurrent_writes():
"""Two coroutines acquiring the lock must not overlap."""
conn = db_module.init_db(":memory:")
lock = db_module.get_write_lock()
order = []
async def writer(label: str):
async with lock:
order.append(f"{label}-start")
await asyncio.sleep(0) # yield to event loop
order.append(f"{label}-end")
await asyncio.gather(writer("A"), writer("B"))
# Each writer's start and end must be adjacent (no interleaving)
assert order.index("A-start") + 1 == order.index("A-end") or \
order.index("B-start") + 1 == order.index("B-end")
# ---------------------------------------------------------------------------
# Admin seed user
# ---------------------------------------------------------------------------
def test_seed_admin_user_created_on_init():
import os
conn = db_module.init_db(":memory:")
row = conn.execute(
"SELECT email, role FROM users WHERE email = 'ai@allucanget.biz'"
).fetchone()
assert row is not None
assert row[0] == "ai@allucanget.biz"
assert row[1] == "admin"
def test_seed_admin_is_idempotent():
conn = db_module.init_db(":memory:")
# Simulate re-running seed (second init_db call reuses connection, so call _seed_admin directly)
db_module._seed_admin(conn)
count = conn.execute(
"SELECT COUNT(*) FROM users WHERE email = 'ai@allucanget.biz'"
).fetchone()[0]
assert count == 1
def test_seed_admin_email_env_override(monkeypatch):
monkeypatch.setenv("ADMIN_EMAIL", "custom@example.com")
monkeypatch.setenv("ADMIN_PASSWORD", "custompass")
conn = db_module.init_db(":memory:")
row = conn.execute(
"SELECT email, role FROM users WHERE email = 'custom@example.com'"
).fetchone()
assert row is not None
assert row[1] == "admin"
"""Tests for DuckDB initialization and schema."""
import asyncio
import pytest
import duckdb
from app import db as db_module
@pytest.fixture(autouse=True)
def fresh_db():
"""Use an in-memory DB for each test and reset global state."""
db_module._conn = None
yield
db_module.close_db()
db_module._conn = None
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
def test_init_creates_users_table():
conn = db_module.init_db(":memory:")
result = conn.execute(
"SELECT table_name FROM information_schema.tables WHERE table_name = 'users'"
).fetchone()
assert result is not None
def test_init_creates_refresh_tokens_table():
conn = db_module.init_db(":memory:")
result = conn.execute(
"SELECT table_name FROM information_schema.tables WHERE table_name = 'refresh_tokens'"
).fetchone()
assert result is not None
def test_init_is_idempotent():
conn1 = db_module.init_db(":memory:")
conn2 = db_module.init_db(":memory:")
assert conn1 is conn2
def test_get_conn_raises_before_init():
with pytest.raises(RuntimeError, match="not initialised"):
db_module.get_conn()
def test_get_conn_returns_connection_after_init():
db_module.init_db(":memory:")
conn = db_module.get_conn()
assert conn is not None
def test_close_db_resets_connection():
db_module.init_db(":memory:")
db_module.close_db()
with pytest.raises(RuntimeError):
db_module.get_conn()
# ---------------------------------------------------------------------------
# Schema: users table columns and defaults
# ---------------------------------------------------------------------------
def test_users_columns():
conn = db_module.init_db(":memory:")
cols = {
row[0]
for row in conn.execute(
"SELECT column_name FROM information_schema.columns WHERE table_name = 'users'"
).fetchall()
}
assert {"id", "email", "password_hash",
"role", "created_at", "updated_at"} <= cols
def test_users_default_role_is_user():
conn = db_module.init_db(":memory:")
conn.execute(
"INSERT INTO users (email, password_hash) VALUES ('a@example.com', 'hash')"
)
row = conn.execute(
"SELECT role FROM users WHERE email = 'a@example.com'").fetchone()
assert row[0] == "user"
def test_users_id_auto_generated():
conn = db_module.init_db(":memory:")
conn.execute(
"INSERT INTO users (email, password_hash) VALUES ('b@example.com', 'hash')"
)
row = conn.execute(
"SELECT id FROM users WHERE email = 'b@example.com'").fetchone()
assert row[0] is not None
def test_users_email_unique_constraint():
conn = db_module.init_db(":memory:")
conn.execute(
"INSERT INTO users (email, password_hash) VALUES ('c@example.com', 'h')")
with pytest.raises(Exception):
conn.execute(
"INSERT INTO users (email, password_hash) VALUES ('c@example.com', 'h2')")
def test_users_timestamps_auto_set():
conn = db_module.init_db(":memory:")
conn.execute(
"INSERT INTO users (email, password_hash) VALUES ('d@example.com', 'hash')"
)
row = conn.execute(
"SELECT created_at, updated_at FROM users WHERE email = 'd@example.com'"
).fetchone()
assert row[0] is not None
assert row[1] is not None
# ---------------------------------------------------------------------------
# Schema: refresh_tokens table columns and defaults
# ---------------------------------------------------------------------------
def test_refresh_tokens_columns():
conn = db_module.init_db(":memory:")
cols = {
row[0]
for row in conn.execute(
"SELECT column_name FROM information_schema.columns WHERE table_name = 'refresh_tokens'"
).fetchall()
}
assert {"jti", "user_id", "issued_at", "expires_at", "revoked"} <= cols
def test_refresh_tokens_default_revoked_false():
conn = db_module.init_db(":memory:")
conn.execute(
"INSERT INTO users (email, password_hash) VALUES ('e@example.com', 'h')")
user_id = conn.execute(
"SELECT id FROM users WHERE email = 'e@example.com'").fetchone()[0]
conn.execute(
"INSERT INTO refresh_tokens (user_id, expires_at) VALUES (?, now() + INTERVAL 7 DAY)",
[user_id],
)
row = conn.execute("SELECT revoked FROM refresh_tokens WHERE user_id = ?", [
user_id]).fetchone()
assert row[0] is False
def test_refresh_tokens_jti_auto_generated():
conn = db_module.init_db(":memory:")
conn.execute(
"INSERT INTO users (email, password_hash) VALUES ('f@example.com', 'h')")
user_id = conn.execute(
"SELECT id FROM users WHERE email = 'f@example.com'").fetchone()[0]
conn.execute(
"INSERT INTO refresh_tokens (user_id, expires_at) VALUES (?, now() + INTERVAL 7 DAY)",
[user_id],
)
row = conn.execute("SELECT jti FROM refresh_tokens WHERE user_id = ?", [
user_id]).fetchone()
assert row[0] is not None
# ---------------------------------------------------------------------------
# Write lock
# ---------------------------------------------------------------------------
def test_get_write_lock_returns_asyncio_lock():
lock = db_module.get_write_lock()
assert isinstance(lock, asyncio.Lock)
def test_get_write_lock_returns_same_instance():
lock1 = db_module.get_write_lock()
lock2 = db_module.get_write_lock()
assert lock1 is lock2
async def test_write_lock_serialises_concurrent_writes():
"""Two coroutines acquiring the lock must not overlap."""
conn = db_module.init_db(":memory:")
lock = db_module.get_write_lock()
order = []
async def writer(label: str):
async with lock:
order.append(f"{label}-start")
await asyncio.sleep(0) # yield to event loop
order.append(f"{label}-end")
await asyncio.gather(writer("A"), writer("B"))
# Each writer's start and end must be adjacent (no interleaving)
assert order.index("A-start") + 1 == order.index("A-end") or \
order.index("B-start") + 1 == order.index("B-end")
# ---------------------------------------------------------------------------
# Admin seed user
# ---------------------------------------------------------------------------
def test_seed_admin_user_created_on_init():
import os
conn = db_module.init_db(":memory:")
row = conn.execute(
"SELECT email, role FROM users WHERE email = 'ai@allucanget.biz'"
).fetchone()
assert row is not None
assert row[0] == "ai@allucanget.biz"
assert row[1] == "admin"
def test_seed_admin_is_idempotent():
conn = db_module.init_db(":memory:")
# Simulate re-running seed (second init_db call reuses connection, so call _seed_admin directly)
db_module._seed_admin(conn)
count = conn.execute(
"SELECT COUNT(*) FROM users WHERE email = 'ai@allucanget.biz'"
).fetchone()[0]
assert count == 1
def test_seed_admin_email_env_override(monkeypatch):
monkeypatch.setenv("ADMIN_EMAIL", "custom@example.com")
monkeypatch.setenv("ADMIN_PASSWORD", "custompass")
conn = db_module.init_db(":memory:")
row = conn.execute(
"SELECT email, role FROM users WHERE email = 'custom@example.com'"
).fetchone()
assert row is not None
assert row[1] == "admin"
File diff suppressed because it is too large. Load diff
+184 -184
View File
@@ -1,184 +1,184 @@
"""Tests for image upload and retrieval endpoints."""
import io
import os
import pytest
import pytest_asyncio
from httpx import AsyncClient, ASGITransport
from app.main import app
from app import db as db_module
os.environ.setdefault("JWT_SECRET", "test-secret-key-for-testing-only")
# Use a temp dir so file I/O works without polluting project data/
os.environ.setdefault("UPLOAD_DIR", "/tmp/test_uploads")
@pytest.fixture(autouse=True)
def fresh_db():
db_module._conn = None
db_module.init_db(":memory:")
yield
db_module.close_db()
db_module._conn = None
@pytest_asyncio.fixture
async def client(fresh_db):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
async def _user_token(client) -> str:
await client.post("/auth/register", json={"email": "user@example.com", "password": "secret123"})
resp = await client.post("/auth/login", json={"email": "user@example.com", "password": "secret123"})
return resp.json()["access_token"]
async def _other_token(client) -> str:
await client.post("/auth/register", json={"email": "other@example.com", "password": "secret123"})
resp = await client.post("/auth/login", json={"email": "other@example.com", "password": "secret123"})
return resp.json()["access_token"]
def _png_bytes() -> bytes:
"""Minimal valid 1x1 PNG."""
return (
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01"
b"\x08\x02\x00\x00\x00\x90wS\xde\x00\x00\x00\x0cIDATx\x9cc\xf8\x0f\x00"
b"\x00\x01\x01\x00\x05\x18\xd4n\x00\x00\x00\x00IEND\xaeB`\x82"
)
# ---------------------------------------------------------------------------
# POST /images/upload
# ---------------------------------------------------------------------------
async def test_upload_image_success(client):
token = await _user_token(client)
resp = await client.post(
"/images/upload",
files={"file": ("test.png", io.BytesIO(_png_bytes()), "image/png")},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 201
data = resp.json()
assert data["filename"] == "test.png"
assert data["content_type"] == "image/png"
assert "id" in data
assert data["size_bytes"] > 0
async def test_upload_image_unauthenticated(client):
resp = await client.post(
"/images/upload",
files={"file": ("test.png", io.BytesIO(_png_bytes()), "image/png")},
)
assert resp.status_code == 401
async def test_upload_image_unsupported_type(client):
token = await _user_token(client)
resp = await client.post(
"/images/upload",
files={"file": ("doc.pdf", io.BytesIO(
b"%PDF-fake"), "application/pdf")},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 415
async def test_upload_image_too_large(client, monkeypatch):
import app.routers.images as images_mod
monkeypatch.setattr(images_mod, "MAX_SIZE_BYTES", 5)
token = await _user_token(client)
resp = await client.post(
"/images/upload",
files={"file": ("big.png", io.BytesIO(b"x" * 10), "image/png")},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 413
# ---------------------------------------------------------------------------
# GET /images/
# ---------------------------------------------------------------------------
async def test_list_images_empty(client):
token = await _user_token(client)
resp = await client.get("/images/", headers={"Authorization": f"Bearer {token}"})
assert resp.status_code == 200
assert resp.json() == []
async def test_list_images_returns_own_only(client):
token = await _user_token(client)
other = await _other_token(client)
# Upload one image as user, one as other
for tok, name in [(token, "mine.png"), (other, "theirs.png")]:
await client.post(
"/images/upload",
files={"file": (name, io.BytesIO(_png_bytes()), "image/png")},
headers={"Authorization": f"Bearer {tok}"},
)
resp = await client.get("/images/", headers={"Authorization": f"Bearer {token}"})
assert resp.status_code == 200
items = resp.json()
assert len(items) == 1
assert items[0]["filename"] == "mine.png"
async def test_list_images_unauthenticated(client):
resp = await client.get("/images/")
assert resp.status_code == 401
# ---------------------------------------------------------------------------
# GET /images/{id}/file
# ---------------------------------------------------------------------------
async def test_serve_image_success(client):
token = await _user_token(client)
up = await client.post(
"/images/upload",
files={"file": ("pixel.png", io.BytesIO(_png_bytes()), "image/png")},
headers={"Authorization": f"Bearer {token}"},
)
image_id = up.json()["id"]
resp = await client.get(
f"/images/{image_id}/file",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
assert resp.headers["content-type"].startswith("image/png")
assert resp.content == _png_bytes()
async def test_serve_image_wrong_user(client):
token = await _user_token(client)
other = await _other_token(client)
up = await client.post(
"/images/upload",
files={"file": ("secret.png", io.BytesIO(_png_bytes()), "image/png")},
headers={"Authorization": f"Bearer {token}"},
)
image_id = up.json()["id"]
resp = await client.get(
f"/images/{image_id}/file",
headers={"Authorization": f"Bearer {other}"},
)
assert resp.status_code == 403
async def test_serve_image_not_found(client):
token = await _user_token(client)
resp = await client.get(
"/images/00000000-0000-0000-0000-000000000000/file",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 404
"""Tests for image upload and retrieval endpoints."""
import io
import os
import pytest
import pytest_asyncio
from httpx import AsyncClient, ASGITransport
from app.main import app
from app import db as db_module
os.environ.setdefault("JWT_SECRET", "test-secret-key-for-testing-only")
# Use a temp dir so file I/O works without polluting project data/
os.environ.setdefault("UPLOAD_DIR", "/tmp/test_uploads")
@pytest.fixture(autouse=True)
def fresh_db():
db_module._conn = None
db_module.init_db(":memory:")
yield
db_module.close_db()
db_module._conn = None
@pytest_asyncio.fixture
async def client(fresh_db):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
async def _user_token(client) -> str:
await client.post("/auth/register", json={"email": "user@example.com", "password": "secret123"})
resp = await client.post("/auth/login", json={"email": "user@example.com", "password": "secret123"})
return resp.json()["access_token"]
async def _other_token(client) -> str:
await client.post("/auth/register", json={"email": "other@example.com", "password": "secret123"})
resp = await client.post("/auth/login", json={"email": "other@example.com", "password": "secret123"})
return resp.json()["access_token"]
def _png_bytes() -> bytes:
"""Minimal valid 1x1 PNG."""
return (
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01"
b"\x08\x02\x00\x00\x00\x90wS\xde\x00\x00\x00\x0cIDATx\x9cc\xf8\x0f\x00"
b"\x00\x01\x01\x00\x05\x18\xd4n\x00\x00\x00\x00IEND\xaeB`\x82"
)
# ---------------------------------------------------------------------------
# POST /images/upload
# ---------------------------------------------------------------------------
async def test_upload_image_success(client):
token = await _user_token(client)
resp = await client.post(
"/images/upload",
files={"file": ("test.png", io.BytesIO(_png_bytes()), "image/png")},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 201
data = resp.json()
assert data["filename"] == "test.png"
assert data["content_type"] == "image/png"
assert "id" in data
assert data["size_bytes"] > 0
async def test_upload_image_unauthenticated(client):
resp = await client.post(
"/images/upload",
files={"file": ("test.png", io.BytesIO(_png_bytes()), "image/png")},
)
assert resp.status_code == 401
async def test_upload_image_unsupported_type(client):
token = await _user_token(client)
resp = await client.post(
"/images/upload",
files={"file": ("doc.pdf", io.BytesIO(
b"%PDF-fake"), "application/pdf")},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 415
async def test_upload_image_too_large(client, monkeypatch):
import app.routers.images as images_mod
monkeypatch.setattr(images_mod, "MAX_SIZE_BYTES", 5)
token = await _user_token(client)
resp = await client.post(
"/images/upload",
files={"file": ("big.png", io.BytesIO(b"x" * 10), "image/png")},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 413
# ---------------------------------------------------------------------------
# GET /images/
# ---------------------------------------------------------------------------
async def test_list_images_empty(client):
token = await _user_token(client)
resp = await client.get("/images/", headers={"Authorization": f"Bearer {token}"})
assert resp.status_code == 200
assert resp.json() == []
async def test_list_images_returns_own_only(client):
token = await _user_token(client)
other = await _other_token(client)
# Upload one image as user, one as other
for tok, name in [(token, "mine.png"), (other, "theirs.png")]:
await client.post(
"/images/upload",
files={"file": (name, io.BytesIO(_png_bytes()), "image/png")},
headers={"Authorization": f"Bearer {tok}"},
)
resp = await client.get("/images/", headers={"Authorization": f"Bearer {token}"})
assert resp.status_code == 200
items = resp.json()
assert len(items) == 1
assert items[0]["filename"] == "mine.png"
async def test_list_images_unauthenticated(client):
resp = await client.get("/images/")
assert resp.status_code == 401
# ---------------------------------------------------------------------------
# GET /images/{id}/file
# ---------------------------------------------------------------------------
async def test_serve_image_success(client):
token = await _user_token(client)
up = await client.post(
"/images/upload",
files={"file": ("pixel.png", io.BytesIO(_png_bytes()), "image/png")},
headers={"Authorization": f"Bearer {token}"},
)
image_id = up.json()["id"]
resp = await client.get(
f"/images/{image_id}/file",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
assert resp.headers["content-type"].startswith("image/png")
assert resp.content == _png_bytes()
async def test_serve_image_wrong_user(client):
token = await _user_token(client)
other = await _other_token(client)
up = await client.post(
"/images/upload",
files={"file": ("secret.png", io.BytesIO(_png_bytes()), "image/png")},
headers={"Authorization": f"Bearer {token}"},
)
image_id = up.json()["id"]
resp = await client.get(
f"/images/{image_id}/file",
headers={"Authorization": f"Bearer {other}"},
)
assert resp.status_code == 403
async def test_serve_image_not_found(client):
token = await _user_token(client)
resp = await client.get(
"/images/00000000-0000-0000-0000-000000000000/file",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 404
+366 -366
View File
@@ -1,366 +1,366 @@
"""Tests for model cache service and router."""
import json
import os
from datetime import datetime, timedelta, timezone
from unittest.mock import AsyncMock, patch
import pytest
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
from app import db as db_module
from app.main import app
from app.services.models import (
_extract_output_modality,
_normalize_modality,
_parse_modality,
get_cached_models,
get_model_output_modalities,
is_cache_stale,
refresh_models_cache,
)
os.environ.setdefault("JWT_SECRET", "test-secret-key-for-testing-only")
os.environ.setdefault("OPENROUTER_API_KEY", "test-key")
FAKE_MODELS_RAW = [
{
"id": "openai/gpt-4o",
"name": "GPT-4o",
"context_length": 128000,
"pricing": {"prompt": "0.000005"},
"architecture": {"modality": "text->text", "output_modalities": ["text"]},
},
{
"id": "anthropic/claude-3-haiku",
"name": "Claude 3 Haiku",
"context_length": 200000,
"pricing": {},
"architecture": {"modality": "text+image->text", "output_modalities": ["text"]},
},
{
"id": "openai/dall-e-3",
"name": "DALL-E 3",
"context_length": None,
"pricing": {"image": "0.04"},
"architecture": {"modality": "text->image", "output_modalities": ["image"]},
},
{
"id": "openai/sora-2",
"name": "Sora 2",
"context_length": None,
"pricing": {"video": "0.10"},
"architecture": {"modality": "text->video", "output_modalities": ["video"]},
},
{
"id": "google/gemini-2.5-flash-image",
"name": "Gemini 2.5 Flash Image",
"context_length": None,
"pricing": {},
"architecture": {"output_modalities": ["image", "text"]},
},
]
@pytest.fixture(autouse=True)
def fresh_db():
db_module._conn = None
db_module.init_db(":memory:")
yield
db_module.close_db()
db_module._conn = None
@pytest_asyncio.fixture
async def client(fresh_db):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
async def _register_login(client, email, password, is_admin=False):
"""Register + login; optionally promote to admin directly in DB."""
await client.post("/auth/register", json={"email": email, "password": password})
if is_admin:
db_module.get_conn().execute(
"UPDATE users SET role = 'admin' WHERE email = ?", [email]
)
resp = await client.post("/auth/login", json={"email": email, "password": password})
return resp.json()["access_token"]
# ---------------------------------------------------------------------------
# Unit tests: _parse_modality
# ---------------------------------------------------------------------------
def test_parse_modality_text():
assert _parse_modality("text->text") == "text"
def test_parse_modality_multimodal_input_text_output():
assert _parse_modality("text+image->text") == "text"
def test_parse_modality_image():
assert _parse_modality("text->image") == "image"
def test_parse_modality_video():
assert _parse_modality("text->video") == "video"
def test_parse_modality_audio():
assert _parse_modality("text->audio") == "audio"
def test_parse_modality_no_arrow_fallback():
assert _parse_modality("text") == "text"
def test_normalize_embedding_alias():
assert _normalize_modality("embedding") == "embeddings"
def test_extract_output_modality_prefers_output_modalities():
model = {
"architecture": {
"modality": "text->text",
"output_modalities": ["image"],
}
}
assert _extract_output_modality(model) == "image"
def test_extract_output_modality_legacy_fallback():
model = {"architecture": {"modality": "text->audio"}}
assert _extract_output_modality(model) == "audio"
# ---------------------------------------------------------------------------
# Unit tests: is_cache_stale
# ---------------------------------------------------------------------------
def test_cache_stale_when_empty():
conn = db_module.get_conn()
assert is_cache_stale(conn) is True
def test_cache_not_stale_after_fresh_insert():
conn = db_module.get_conn()
now = datetime.now(timezone.utc).replace(tzinfo=None)
conn.execute(
"INSERT INTO models_cache (model_id, name, modality, fetched_at) VALUES (?, ?, ?, ?)",
["openai/gpt-4o", "GPT-4o", "text", now],
)
assert is_cache_stale(conn) is False
def test_cache_stale_after_ttl_exceeded():
conn = db_module.get_conn()
# Store naive UTC to match DuckDB TIMESTAMP behaviour
old_time = datetime.now(timezone.utc).replace(
tzinfo=None) - timedelta(hours=25)
conn.execute(
"INSERT INTO models_cache (model_id, name, modality, fetched_at) VALUES (?, ?, ?, ?)",
["openai/gpt-4o", "GPT-4o", "text", old_time],
)
assert is_cache_stale(conn) is True
# ---------------------------------------------------------------------------
# Unit tests: refresh_models_cache + get_cached_models
# ---------------------------------------------------------------------------
async def test_refresh_stores_models():
conn = db_module.get_conn()
with patch(
"app.services.models.openrouter.list_models",
new_callable=AsyncMock,
return_value=FAKE_MODELS_RAW,
):
count = await refresh_models_cache(conn)
assert count == 5
all_models = get_cached_models(conn)
assert len(all_models) == 5
async def test_refresh_replaces_old_cache():
conn = db_module.get_conn()
old_time = datetime.now(timezone.utc).replace(
tzinfo=None) - timedelta(hours=30)
conn.execute(
"INSERT INTO models_cache (model_id, name, modality, fetched_at) VALUES (?, ?, ?, ?)",
["old/model", "Old Model", "text", old_time],
)
with patch(
"app.services.models.openrouter.list_models",
new_callable=AsyncMock,
return_value=FAKE_MODELS_RAW,
):
await refresh_models_cache(conn)
ids = [m["id"] for m in get_cached_models(conn)]
assert "old/model" not in ids
assert "openai/gpt-4o" in ids
assert len(ids) == 5
def test_get_cached_models_filter_by_modality():
conn = db_module.get_conn()
now = datetime.now(timezone.utc).replace(tzinfo=None)
for m in FAKE_MODELS_RAW:
modality = _extract_output_modality(m)
conn.execute(
"INSERT INTO models_cache (model_id, name, modality, fetched_at) VALUES (?, ?, ?, ?)",
[m["id"], m["name"], modality, now],
)
text_models = get_cached_models(conn, modality="text")
# gpt-4o, claude-3-haiku (gemini has output_modalities=["image","text"] → classified as "image")
assert len(text_models) == 2
assert all(m["modality"] == "text" for m in text_models)
image_models = get_cached_models(conn, modality="image")
# dall-e-3 + gemini (output_modalities starts with image)
assert len(image_models) == 2
image_ids = [m["id"] for m in image_models]
assert "openai/dall-e-3" in image_ids
assert "google/gemini-2.5-flash-image" in image_ids
video_models = get_cached_models(conn, modality="video")
assert len(video_models) == 1
assert video_models[0]["id"] == "openai/sora-2"
# ---------------------------------------------------------------------------
# Integration tests: GET /models/
# ---------------------------------------------------------------------------
async def test_list_models_endpoint_auto_refreshes(client):
token = await _register_login(client, "user@example.com", "secret123")
with patch(
"app.services.models.openrouter.list_models",
new_callable=AsyncMock,
return_value=FAKE_MODELS_RAW,
) as mock_fetch:
resp = await client.get(
"/models/", headers={"Authorization": f"Bearer {token}"}
)
assert resp.status_code == 200
assert len(resp.json()) == 5
assert mock_fetch.await_count >= 1
async def test_list_models_endpoint_uses_cache(client):
token = await _register_login(client, "user@example.com", "secret123")
conn = db_module.get_conn()
now = datetime.now(timezone.utc).replace(tzinfo=None)
conn.execute(
"INSERT INTO models_cache (model_id, name, modality, fetched_at) VALUES (?, ?, ?, ?)",
["cached/model", "Cached Model", "text", now],
)
with patch(
"app.services.models.openrouter.list_models",
new_callable=AsyncMock,
) as mock_fetch:
resp = await client.get(
"/models/?modality=text", headers={"Authorization": f"Bearer {token}"}
)
assert resp.status_code == 200
assert resp.json()[0]["id"] == "cached/model"
mock_fetch.assert_not_awaited()
async def test_list_models_endpoint_requires_auth(client):
resp = await client.get("/models/")
assert resp.status_code == 401
async def test_list_models_filter_by_modality(client):
token = await _register_login(client, "user@example.com", "secret123")
with patch(
"app.services.models.openrouter.list_models",
new_callable=AsyncMock,
return_value=FAKE_MODELS_RAW,
):
resp = await client.get(
"/models/?modality=image", headers={"Authorization": f"Bearer {token}"}
)
assert resp.status_code == 200
data = resp.json()
assert len(data) == 2 # dall-e-3 + gemini-2.5-flash-image
image_ids = [m["id"] for m in data]
assert "openai/dall-e-3" in image_ids
assert "google/gemini-2.5-flash-image" in image_ids
# ---------------------------------------------------------------------------
# Integration tests: POST /models/refresh
# ---------------------------------------------------------------------------
async def test_refresh_endpoint_requires_admin(client):
token = await _register_login(client, "user@example.com", "secret123")
resp = await client.post(
"/models/refresh", headers={"Authorization": f"Bearer {token}"}
)
assert resp.status_code == 403
async def test_refresh_endpoint_admin_succeeds(client):
token = await _register_login(client, "admin@example.com", "secret123", is_admin=True)
with patch(
"app.services.models.openrouter.list_models",
new_callable=AsyncMock,
return_value=FAKE_MODELS_RAW,
):
resp = await client.post(
"/models/refresh", headers={"Authorization": f"Bearer {token}"}
)
assert resp.status_code == 200
assert resp.json()["refreshed"] == 5
async def test_refresh_endpoint_502_on_openrouter_error(client):
token = await _register_login(client, "admin@example.com", "secret123", is_admin=True)
with patch(
"app.services.models.openrouter.list_models",
new_callable=AsyncMock,
side_effect=RuntimeError("network error"),
):
resp = await client.post(
"/models/refresh", headers={"Authorization": f"Bearer {token}"}
)
assert resp.status_code == 502
# ---------------------------------------------------------------------------
# Unit tests: get_model_output_modalities
# ---------------------------------------------------------------------------
async def test_get_model_output_modalities_image_only():
conn = db_module.get_conn()
with patch(
"app.services.models.openrouter.list_models",
new_callable=AsyncMock,
return_value=FAKE_MODELS_RAW,
):
await refresh_models_cache(conn)
modalities = get_model_output_modalities(conn, "openai/dall-e-3")
assert modalities == ["image"]
async def test_get_model_output_modalities_image_text():
conn = db_module.get_conn()
with patch(
"app.services.models.openrouter.list_models",
new_callable=AsyncMock,
return_value=FAKE_MODELS_RAW,
):
await refresh_models_cache(conn)
modalities = get_model_output_modalities(
conn, "google/gemini-2.5-flash-image")
assert set(modalities) == {"image", "text"}
def test_get_model_output_modalities_unknown_model():
conn = db_module.get_conn()
result = get_model_output_modalities(conn, "unknown/model")
assert result == []
"""Tests for model cache service and router."""
import json
import os
from datetime import datetime, timedelta, timezone
from unittest.mock import AsyncMock, patch
import pytest
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
from app import db as db_module
from app.main import app
from app.services.models import (
_extract_output_modality,
_normalize_modality,
_parse_modality,
get_cached_models,
get_model_output_modalities,
is_cache_stale,
refresh_models_cache,
)
os.environ.setdefault("JWT_SECRET", "test-secret-key-for-testing-only")
os.environ.setdefault("OPENROUTER_API_KEY", "test-key")
FAKE_MODELS_RAW = [
{
"id": "openai/gpt-4o",
"name": "GPT-4o",
"context_length": 128000,
"pricing": {"prompt": "0.000005"},
"architecture": {"modality": "text->text", "output_modalities": ["text"]},
},
{
"id": "anthropic/claude-3-haiku",
"name": "Claude 3 Haiku",
"context_length": 200000,
"pricing": {},
"architecture": {"modality": "text+image->text", "output_modalities": ["text"]},
},
{
"id": "openai/dall-e-3",
"name": "DALL-E 3",
"context_length": None,
"pricing": {"image": "0.04"},
"architecture": {"modality": "text->image", "output_modalities": ["image"]},
},
{
"id": "openai/sora-2",
"name": "Sora 2",
"context_length": None,
"pricing": {"video": "0.10"},
"architecture": {"modality": "text->video", "output_modalities": ["video"]},
},
{
"id": "google/gemini-2.5-flash-image",
"name": "Gemini 2.5 Flash Image",
"context_length": None,
"pricing": {},
"architecture": {"output_modalities": ["image", "text"]},
},
]
@pytest.fixture(autouse=True)
def fresh_db():
db_module._conn = None
db_module.init_db(":memory:")
yield
db_module.close_db()
db_module._conn = None
@pytest_asyncio.fixture
async def client(fresh_db):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
async def _register_login(client, email, password, is_admin=False):
"""Register + login; optionally promote to admin directly in DB."""
await client.post("/auth/register", json={"email": email, "password": password})
if is_admin:
db_module.get_conn().execute(
"UPDATE users SET role = 'admin' WHERE email = ?", [email]
)
resp = await client.post("/auth/login", json={"email": email, "password": password})
return resp.json()["access_token"]
# ---------------------------------------------------------------------------
# Unit tests: _parse_modality
# ---------------------------------------------------------------------------
def test_parse_modality_text():
assert _parse_modality("text->text") == "text"
def test_parse_modality_multimodal_input_text_output():
assert _parse_modality("text+image->text") == "text"
def test_parse_modality_image():
assert _parse_modality("text->image") == "image"
def test_parse_modality_video():
assert _parse_modality("text->video") == "video"
def test_parse_modality_audio():
assert _parse_modality("text->audio") == "audio"
def test_parse_modality_no_arrow_fallback():
assert _parse_modality("text") == "text"
def test_normalize_embedding_alias():
assert _normalize_modality("embedding") == "embeddings"
def test_extract_output_modality_prefers_output_modalities():
model = {
"architecture": {
"modality": "text->text",
"output_modalities": ["image"],
}
}
assert _extract_output_modality(model) == "image"
def test_extract_output_modality_legacy_fallback():
model = {"architecture": {"modality": "text->audio"}}
assert _extract_output_modality(model) == "audio"
# ---------------------------------------------------------------------------
# Unit tests: is_cache_stale
# ---------------------------------------------------------------------------
def test_cache_stale_when_empty():
conn = db_module.get_conn()
assert is_cache_stale(conn) is True
def test_cache_not_stale_after_fresh_insert():
conn = db_module.get_conn()
now = datetime.now(timezone.utc).replace(tzinfo=None)
conn.execute(
"INSERT INTO models_cache (model_id, name, modality, fetched_at) VALUES (?, ?, ?, ?)",
["openai/gpt-4o", "GPT-4o", "text", now],
)
assert is_cache_stale(conn) is False
def test_cache_stale_after_ttl_exceeded():
conn = db_module.get_conn()
# Store naive UTC to match DuckDB TIMESTAMP behaviour
old_time = datetime.now(timezone.utc).replace(
tzinfo=None) - timedelta(hours=25)
conn.execute(
"INSERT INTO models_cache (model_id, name, modality, fetched_at) VALUES (?, ?, ?, ?)",
["openai/gpt-4o", "GPT-4o", "text", old_time],
)
assert is_cache_stale(conn) is True
# ---------------------------------------------------------------------------
# Unit tests: refresh_models_cache + get_cached_models
# ---------------------------------------------------------------------------
async def test_refresh_stores_models():
conn = db_module.get_conn()
with patch(
"app.services.models.openrouter.list_models",
new_callable=AsyncMock,
return_value=FAKE_MODELS_RAW,
):
count = await refresh_models_cache(conn)
assert count == 5
all_models = get_cached_models(conn)
assert len(all_models) == 5
async def test_refresh_replaces_old_cache():
conn = db_module.get_conn()
old_time = datetime.now(timezone.utc).replace(
tzinfo=None) - timedelta(hours=30)
conn.execute(
"INSERT INTO models_cache (model_id, name, modality, fetched_at) VALUES (?, ?, ?, ?)",
["old/model", "Old Model", "text", old_time],
)
with patch(
"app.services.models.openrouter.list_models",
new_callable=AsyncMock,
return_value=FAKE_MODELS_RAW,
):
await refresh_models_cache(conn)
ids = [m["id"] for m in get_cached_models(conn)]
assert "old/model" not in ids
assert "openai/gpt-4o" in ids
assert len(ids) == 5
def test_get_cached_models_filter_by_modality():
conn = db_module.get_conn()
now = datetime.now(timezone.utc).replace(tzinfo=None)
for m in FAKE_MODELS_RAW:
modality = _extract_output_modality(m)
conn.execute(
"INSERT INTO models_cache (model_id, name, modality, fetched_at) VALUES (?, ?, ?, ?)",
[m["id"], m["name"], modality, now],
)
text_models = get_cached_models(conn, modality="text")
# gpt-4o, claude-3-haiku (gemini has output_modalities=["image","text"] → classified as "image")
assert len(text_models) == 2
assert all(m["modality"] == "text" for m in text_models)
image_models = get_cached_models(conn, modality="image")
# dall-e-3 + gemini (output_modalities starts with image)
assert len(image_models) == 2
image_ids = [m["id"] for m in image_models]
assert "openai/dall-e-3" in image_ids
assert "google/gemini-2.5-flash-image" in image_ids
video_models = get_cached_models(conn, modality="video")
assert len(video_models) == 1
assert video_models[0]["id"] == "openai/sora-2"
# ---------------------------------------------------------------------------
# Integration tests: GET /models/
# ---------------------------------------------------------------------------
async def test_list_models_endpoint_auto_refreshes(client):
token = await _register_login(client, "user@example.com", "secret123")
with patch(
"app.services.models.openrouter.list_models",
new_callable=AsyncMock,
return_value=FAKE_MODELS_RAW,
) as mock_fetch:
resp = await client.get(
"/models/", headers={"Authorization": f"Bearer {token}"}
)
assert resp.status_code == 200
assert len(resp.json()) == 5
assert mock_fetch.await_count >= 1
async def test_list_models_endpoint_uses_cache(client):
token = await _register_login(client, "user@example.com", "secret123")
conn = db_module.get_conn()
now = datetime.now(timezone.utc).replace(tzinfo=None)
conn.execute(
"INSERT INTO models_cache (model_id, name, modality, fetched_at) VALUES (?, ?, ?, ?)",
["cached/model", "Cached Model", "text", now],
)
with patch(
"app.services.models.openrouter.list_models",
new_callable=AsyncMock,
) as mock_fetch:
resp = await client.get(
"/models/?modality=text", headers={"Authorization": f"Bearer {token}"}
)
assert resp.status_code == 200
assert resp.json()[0]["id"] == "cached/model"
mock_fetch.assert_not_awaited()
async def test_list_models_endpoint_requires_auth(client):
resp = await client.get("/models/")
assert resp.status_code == 401
async def test_list_models_filter_by_modality(client):
token = await _register_login(client, "user@example.com", "secret123")
with patch(
"app.services.models.openrouter.list_models",
new_callable=AsyncMock,
return_value=FAKE_MODELS_RAW,
):
resp = await client.get(
"/models/?modality=image", headers={"Authorization": f"Bearer {token}"}
)
assert resp.status_code == 200
data = resp.json()
assert len(data) == 2 # dall-e-3 + gemini-2.5-flash-image
image_ids = [m["id"] for m in data]
assert "openai/dall-e-3" in image_ids
assert "google/gemini-2.5-flash-image" in image_ids
# ---------------------------------------------------------------------------
# Integration tests: POST /models/refresh
# ---------------------------------------------------------------------------
async def test_refresh_endpoint_requires_admin(client):
token = await _register_login(client, "user@example.com", "secret123")
resp = await client.post(
"/models/refresh", headers={"Authorization": f"Bearer {token}"}
)
assert resp.status_code == 403
async def test_refresh_endpoint_admin_succeeds(client):
token = await _register_login(client, "admin@example.com", "secret123", is_admin=True)
with patch(
"app.services.models.openrouter.list_models",
new_callable=AsyncMock,
return_value=FAKE_MODELS_RAW,
):
resp = await client.post(
"/models/refresh", headers={"Authorization": f"Bearer {token}"}
)
assert resp.status_code == 200
assert resp.json()["refreshed"] == 5
async def test_refresh_endpoint_502_on_openrouter_error(client):
token = await _register_login(client, "admin@example.com", "secret123", is_admin=True)
with patch(
"app.services.models.openrouter.list_models",
new_callable=AsyncMock,
side_effect=RuntimeError("network error"),
):
resp = await client.post(
"/models/refresh", headers={"Authorization": f"Bearer {token}"}
)
assert resp.status_code == 502
# ---------------------------------------------------------------------------
# Unit tests: get_model_output_modalities
# ---------------------------------------------------------------------------
async def test_get_model_output_modalities_image_only():
conn = db_module.get_conn()
with patch(
"app.services.models.openrouter.list_models",
new_callable=AsyncMock,
return_value=FAKE_MODELS_RAW,
):
await refresh_models_cache(conn)
modalities = get_model_output_modalities(conn, "openai/dall-e-3")
assert modalities == ["image"]
async def test_get_model_output_modalities_image_text():
conn = db_module.get_conn()
with patch(
"app.services.models.openrouter.list_models",
new_callable=AsyncMock,
return_value=FAKE_MODELS_RAW,
):
await refresh_models_cache(conn)
modalities = get_model_output_modalities(
conn, "google/gemini-2.5-flash-image")
assert set(modalities) == {"image", "text"}
def test_get_model_output_modalities_unknown_model():
conn = db_module.get_conn()
result = get_model_output_modalities(conn, "unknown/model")
assert result == []
+208 -208
View File
@@ -1,208 +1,208 @@
"""Integration tests for user management endpoints."""
import os
import pytest
import pytest_asyncio
from httpx import AsyncClient, ASGITransport
from app.main import app
from app import db as db_module
os.environ.setdefault("JWT_SECRET", "test-secret-key-for-testing-only")
@pytest.fixture(autouse=True)
def fresh_db():
db_module._conn = None
db_module.init_db(":memory:")
yield
db_module.close_db()
db_module._conn = None
@pytest_asyncio.fixture
async def client(fresh_db):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
async def _register_and_login(client, email="user@example.com", password="secret123"):
await client.post("/auth/register", json={"email": email, "password": password})
resp = await client.post("/auth/login", json={"email": email, "password": password})
return resp.json()["access_token"]
async def _make_admin(user_id: str):
"""Directly set a user's role to admin in the DB."""
conn = db_module.get_conn()
conn.execute("UPDATE users SET role = 'admin' WHERE id = ?", [user_id])
# ---------------------------------------------------------------------------
# GET /users/me
# ---------------------------------------------------------------------------
async def test_get_me(client):
token = await _register_and_login(client)
resp = await client.get("/users/me", headers={"Authorization": f"Bearer {token}"})
assert resp.status_code == 200
data = resp.json()
assert data["email"] == "user@example.com"
assert data["role"] == "user"
assert "id" in data
async def test_get_me_unauthenticated(client):
resp = await client.get("/users/me")
assert resp.status_code == 401
# ---------------------------------------------------------------------------
# PUT /users/me
# ---------------------------------------------------------------------------
async def test_update_me_email(client):
token = await _register_and_login(client)
resp = await client.put(
"/users/me",
json={"email": "new@example.com"},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
assert resp.json()["email"] == "new@example.com"
async def test_update_me_password(client):
token = await _register_and_login(client)
resp = await client.put(
"/users/me",
json={"password": "newpassword123"},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
# Verify new password works for login
login = await client.post(
"/auth/login", json={"email": "user@example.com", "password": "newpassword123"}
)
assert login.status_code == 200
async def test_update_me_duplicate_email(client):
await _register_and_login(client, "other@example.com")
token = await _register_and_login(client, "user@example.com")
resp = await client.put(
"/users/me",
json={"email": "other@example.com"},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 409
# ---------------------------------------------------------------------------
# GET /users (admin only)
# ---------------------------------------------------------------------------
async def test_list_users_as_admin(client):
token = await _register_and_login(client)
me = await client.get("/users/me", headers={"Authorization": f"Bearer {token}"})
await _make_admin(me.json()["id"])
# Re-login to get a token with admin role
login = await client.post(
"/auth/login", json={"email": "user@example.com", "password": "secret123"}
)
admin_token = login.json()["access_token"]
resp = await client.get("/users", headers={"Authorization": f"Bearer {admin_token}"})
assert resp.status_code == 200
assert isinstance(resp.json(), list)
assert len(resp.json()) >= 1
emails = [u["email"] for u in resp.json()]
assert "user@example.com" in emails
async def test_list_users_as_regular_user(client):
token = await _register_and_login(client)
resp = await client.get("/users", headers={"Authorization": f"Bearer {token}"})
assert resp.status_code == 403
# ---------------------------------------------------------------------------
# DELETE /users/{id} (admin only)
# ---------------------------------------------------------------------------
async def test_delete_user_as_admin(client):
# Register target user
await client.post("/auth/register", json={"email": "target@example.com", "password": "secret123"})
target_resp = await client.post("/auth/login", json={"email": "target@example.com", "password": "secret123"})
target_token = target_resp.json()["access_token"]
target_me = await client.get("/users/me", headers={"Authorization": f"Bearer {target_token}"})
target_id = target_me.json()["id"]
# Register admin
token = await _register_and_login(client)
me = await client.get("/users/me", headers={"Authorization": f"Bearer {token}"})
await _make_admin(me.json()["id"])
login = await client.post("/auth/login", json={"email": "user@example.com", "password": "secret123"})
admin_token = login.json()["access_token"]
resp = await client.delete(f"/users/{target_id}", headers={"Authorization": f"Bearer {admin_token}"})
assert resp.status_code == 204
async def test_delete_own_account_forbidden(client):
token = await _register_and_login(client)
me = await client.get("/users/me", headers={"Authorization": f"Bearer {token}"})
user_id = me.json()["id"]
await _make_admin(user_id)
login = await client.post("/auth/login", json={"email": "user@example.com", "password": "secret123"})
admin_token = login.json()["access_token"]
resp = await client.delete(f"/users/{user_id}", headers={"Authorization": f"Bearer {admin_token}"})
assert resp.status_code == 400
# ---------------------------------------------------------------------------
# PUT /users/{id}/role (admin only)
# ---------------------------------------------------------------------------
async def test_set_role_as_admin(client):
# Register target
await client.post("/auth/register", json={"email": "target@example.com", "password": "secret123"})
target_resp = await client.post("/auth/login", json={"email": "target@example.com", "password": "secret123"})
target_me = await client.get("/users/me", headers={"Authorization": f"Bearer {target_resp.json()['access_token']}"})
target_id = target_me.json()["id"]
# Register admin
token = await _register_and_login(client)
me = await client.get("/users/me", headers={"Authorization": f"Bearer {token}"})
await _make_admin(me.json()["id"])
login = await client.post("/auth/login", json={"email": "user@example.com", "password": "secret123"})
admin_token = login.json()["access_token"]
resp = await client.put(
f"/users/{target_id}/role",
json={"role": "admin"},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert resp.status_code == 200
assert resp.json()["role"] == "admin"
async def test_set_invalid_role(client):
await client.post("/auth/register", json={"email": "target@example.com", "password": "secret123"})
target_resp = await client.post("/auth/login", json={"email": "target@example.com", "password": "secret123"})
target_me = await client.get("/users/me", headers={"Authorization": f"Bearer {target_resp.json()['access_token']}"})
target_id = target_me.json()["id"]
token = await _register_and_login(client)
me = await client.get("/users/me", headers={"Authorization": f"Bearer {token}"})
await _make_admin(me.json()["id"])
login = await client.post("/auth/login", json={"email": "user@example.com", "password": "secret123"})
admin_token = login.json()["access_token"]
resp = await client.put(
f"/users/{target_id}/role",
json={"role": "superuser"},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert resp.status_code == 422
"""Integration tests for user management endpoints."""
import os
import pytest
import pytest_asyncio
from httpx import AsyncClient, ASGITransport
from app.main import app
from app import db as db_module
os.environ.setdefault("JWT_SECRET", "test-secret-key-for-testing-only")
@pytest.fixture(autouse=True)
def fresh_db():
db_module._conn = None
db_module.init_db(":memory:")
yield
db_module.close_db()
db_module._conn = None
@pytest_asyncio.fixture
async def client(fresh_db):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
async def _register_and_login(client, email="user@example.com", password="secret123"):
await client.post("/auth/register", json={"email": email, "password": password})
resp = await client.post("/auth/login", json={"email": email, "password": password})
return resp.json()["access_token"]
async def _make_admin(user_id: str):
"""Directly set a user's role to admin in the DB."""
conn = db_module.get_conn()
conn.execute("UPDATE users SET role = 'admin' WHERE id = ?", [user_id])
# ---------------------------------------------------------------------------
# GET /users/me
# ---------------------------------------------------------------------------
async def test_get_me(client):
token = await _register_and_login(client)
resp = await client.get("/users/me", headers={"Authorization": f"Bearer {token}"})
assert resp.status_code == 200
data = resp.json()
assert data["email"] == "user@example.com"
assert data["role"] == "user"
assert "id" in data
async def test_get_me_unauthenticated(client):
resp = await client.get("/users/me")
assert resp.status_code == 401
# ---------------------------------------------------------------------------
# PUT /users/me
# ---------------------------------------------------------------------------
async def test_update_me_email(client):
token = await _register_and_login(client)
resp = await client.put(
"/users/me",
json={"email": "new@example.com"},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
assert resp.json()["email"] == "new@example.com"
async def test_update_me_password(client):
token = await _register_and_login(client)
resp = await client.put(
"/users/me",
json={"password": "newpassword123"},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
# Verify new password works for login
login = await client.post(
"/auth/login", json={"email": "user@example.com", "password": "newpassword123"}
)
assert login.status_code == 200
async def test_update_me_duplicate_email(client):
await _register_and_login(client, "other@example.com")
token = await _register_and_login(client, "user@example.com")
resp = await client.put(
"/users/me",
json={"email": "other@example.com"},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 409
# ---------------------------------------------------------------------------
# GET /users (admin only)
# ---------------------------------------------------------------------------
async def test_list_users_as_admin(client):
token = await _register_and_login(client)
me = await client.get("/users/me", headers={"Authorization": f"Bearer {token}"})
await _make_admin(me.json()["id"])
# Re-login to get a token with admin role
login = await client.post(
"/auth/login", json={"email": "user@example.com", "password": "secret123"}
)
admin_token = login.json()["access_token"]
resp = await client.get("/users", headers={"Authorization": f"Bearer {admin_token}"})
assert resp.status_code == 200
assert isinstance(resp.json(), list)
assert len(resp.json()) >= 1
emails = [u["email"] for u in resp.json()]
assert "user@example.com" in emails
async def test_list_users_as_regular_user(client):
token = await _register_and_login(client)
resp = await client.get("/users", headers={"Authorization": f"Bearer {token}"})
assert resp.status_code == 403
# ---------------------------------------------------------------------------
# DELETE /users/{id} (admin only)
# ---------------------------------------------------------------------------
async def test_delete_user_as_admin(client):
# Register target user
await client.post("/auth/register", json={"email": "target@example.com", "password": "secret123"})
target_resp = await client.post("/auth/login", json={"email": "target@example.com", "password": "secret123"})
target_token = target_resp.json()["access_token"]
target_me = await client.get("/users/me", headers={"Authorization": f"Bearer {target_token}"})
target_id = target_me.json()["id"]
# Register admin
token = await _register_and_login(client)
me = await client.get("/users/me", headers={"Authorization": f"Bearer {token}"})
await _make_admin(me.json()["id"])
login = await client.post("/auth/login", json={"email": "user@example.com", "password": "secret123"})
admin_token = login.json()["access_token"]
resp = await client.delete(f"/users/{target_id}", headers={"Authorization": f"Bearer {admin_token}"})
assert resp.status_code == 204
async def test_delete_own_account_forbidden(client):
token = await _register_and_login(client)
me = await client.get("/users/me", headers={"Authorization": f"Bearer {token}"})
user_id = me.json()["id"]
await _make_admin(user_id)
login = await client.post("/auth/login", json={"email": "user@example.com", "password": "secret123"})
admin_token = login.json()["access_token"]
resp = await client.delete(f"/users/{user_id}", headers={"Authorization": f"Bearer {admin_token}"})
assert resp.status_code == 400
# ---------------------------------------------------------------------------
# PUT /users/{id}/role (admin only)
# ---------------------------------------------------------------------------
async def test_set_role_as_admin(client):
# Register target
await client.post("/auth/register", json={"email": "target@example.com", "password": "secret123"})
target_resp = await client.post("/auth/login", json={"email": "target@example.com", "password": "secret123"})
target_me = await client.get("/users/me", headers={"Authorization": f"Bearer {target_resp.json()['access_token']}"})
target_id = target_me.json()["id"]
# Register admin
token = await _register_and_login(client)
me = await client.get("/users/me", headers={"Authorization": f"Bearer {token}"})
await _make_admin(me.json()["id"])
login = await client.post("/auth/login", json={"email": "user@example.com", "password": "secret123"})
admin_token = login.json()["access_token"]
resp = await client.put(
f"/users/{target_id}/role",
json={"role": "admin"},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert resp.status_code == 200
assert resp.json()["role"] == "admin"
async def test_set_invalid_role(client):
await client.post("/auth/register", json={"email": "target@example.com", "password": "secret123"})
target_resp = await client.post("/auth/login", json={"email": "target@example.com", "password": "secret123"})
target_me = await client.get("/users/me", headers={"Authorization": f"Bearer {target_resp.json()['access_token']}"})
target_id = target_me.json()["id"]
token = await _register_and_login(client)
me = await client.get("/users/me", headers={"Authorization": f"Bearer {token}"})
await _make_admin(me.json()["id"])
login = await client.post("/auth/login", json={"email": "user@example.com", "password": "secret123"})
admin_token = login.json()["access_token"]
resp = await client.put(
f"/users/{target_id}/role",
json={"role": "superuser"},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert resp.status_code == 422