diff --git a/.env.example b/.env.example index 22132c3..f30cefb 100644 --- a/.env.example +++ b/.env.example @@ -1,13 +1,13 @@ -# Copy this file to .env and fill in the values - -# openrouter.ai API key -OPENROUTER_API_KEY= - -# JWT secret for signing tokens (generate with: python -c "import secrets; print(secrets.token_hex(32))") -JWT_SECRET= - -# Path to DuckDB database file -DB_PATH=data/app.db - -# Log level (DEBUG, INFO, WARNING, ERROR) -LOG_LEVEL=INFO +# Copy this file to .env and fill in the values + +# openrouter.ai API key +OPENROUTER_API_KEY= + +# JWT secret for signing tokens (generate with: python -c "import secrets; print(secrets.token_hex(32))") +JWT_SECRET= + +# Path to DuckDB database file +DB_PATH=data/app.db + +# Log level (DEBUG, INFO, WARNING, ERROR) +LOG_LEVEL=INFO diff --git a/.gitignore b/.gitignore index 5ad90de..cf7f626 100644 --- a/.gitignore +++ b/.gitignore @@ -1,52 +1,52 @@ -# Python -__pycache__/ -*.py[cod] -*.pyo -*.pyd -.Python -*.egg -*.egg-info/ -dist/ -build/ -eggs/ -parts/ -var/ -sdist/ -wheels/ -pip-wheel-metadata/ - -# Virtual environments -.venv/ -venv/ -env/ -ENV/ - -# Environment variables -.env -.env.* -!.env.example - -# DuckDB data files -data/*.db -data/*.duckdb - -# pytest -.pytest_cache/ -.coverage -htmlcov/ - -# IDE -.vscode/ -.idea/ - -# OS -.DS_Store -Thumbs.db - -# instructions -.github/instructions/ - -# Logs and generated data -logs/ -data/ -backend/data/ +# Python +__pycache__/ +*.py[cod] +*.pyo +*.pyd +.Python +*.egg +*.egg-info/ +dist/ +build/ +eggs/ +parts/ +var/ +sdist/ +wheels/ +pip-wheel-metadata/ + +# Virtual environments +.venv/ +venv/ +env/ +ENV/ + +# Environment variables +.env +.env.* +!.env.example + +# DuckDB data files +data/*.db +data/*.duckdb + +# pytest +.pytest_cache/ +.coverage +htmlcov/ + +# IDE +.vscode/ +.idea/ + +# OS +.DS_Store +Thumbs.db + +# instructions +.github/instructions/ + +# Logs and generated data +logs/ +data/ +backend/data/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 83a66ec..5fc2743 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,36 +1,36 @@ -## [Unreleased] - 2026-05-31 - -### Added - -- Added admin video job management with a dedicated admin page, job table, status filters, cancel and purge actions, and backend endpoints for video job listing, cancellation, and cleanup. -- Added full gallery support for generated images, generated videos, and uploaded images with new routes, detail pages, templates, and thumbnail previews. -- Added backend endpoints for single-generated-image retrieval, single-video-job retrieval, and uploaded image retrieval. -- Added queued video generation workflow: backend job creation, background worker polling, OpenRouter job submission, status transitions from queued to processing to completed/failed, and timeout detection for stale jobs. -- Added model cache management with backend OpenRouter model retrieval, DuckDB caching, cache-status endpoints, manual refresh endpoint, and frontend admin UI for model inspection. -- Added frontend generation pages and forms for text, image, and video generation, including dropdown model selection by modality and support for image upload input on video generation flows. -- Added user-facing profile and admin sections in the Flask frontend, including profile update forms and admin user management actions. -- Added documentation updates and architecture content refresh across README and docs, plus Coolify deployment guidance. -- Added an initial admin user account `ai@allucanget.biz` with password `admin123` in the database initialization script. - -### Changed - -- Changed frontend API helper to support per-call timeout overrides. -- Changed dashboard behavior to separate pending video jobs from completed jobs and make pending workflows more visible. -- Changed gallery presentation to show cards with thumbnails and links instead of raw list output. -- Changed admin navigation and UI to expose video jobs and model management pages more directly. -- Changed backend video generation responses to include database IDs and redirect users to video detail pages after job creation. -- Changed documentation and architecture files to reflect recent backend, frontend, and deployment changes. - -### Fixed - -- Fixed gallery spinner logic so an empty gallery shows a "No images yet" message instead of a spinner. -- Fixed gallery image display issues so generated content is shown as cards with proper previews and detail links. -- Fixed video polling logic to handle cancelled jobs and avoid infinite polling loops. -- Fixed failed video job handling with user-safe error feedback in both backend and frontend. -- Fixed frontend image-generation timeout failures by catching `httpx.ReadTimeout` and surfacing a safe error message. -- Fixed frontend image generation route to handle backend timeout/request errors gracefully instead of returning 500 tracebacks. -- Fixed backend service behavior for actual video job processing and stale job timeout detection. - -### Removed - -- None. +## [Unreleased] - 2026-05-31 + +### Added + +- Added admin video job management with a dedicated admin page, job table, status filters, cancel and purge actions, and backend endpoints for video job listing, cancellation, and cleanup. +- Added full gallery support for generated images, generated videos, and uploaded images with new routes, detail pages, templates, and thumbnail previews. +- Added backend endpoints for single-generated-image retrieval, single-video-job retrieval, and uploaded image retrieval. +- Added queued video generation workflow: backend job creation, background worker polling, OpenRouter job submission, status transitions from queued to processing to completed/failed, and timeout detection for stale jobs. +- Added model cache management with backend OpenRouter model retrieval, DuckDB caching, cache-status endpoints, manual refresh endpoint, and frontend admin UI for model inspection. +- Added frontend generation pages and forms for text, image, and video generation, including dropdown model selection by modality and support for image upload input on video generation flows. +- Added user-facing profile and admin sections in the Flask frontend, including profile update forms and admin user management actions. +- Added documentation updates and architecture content refresh across README and docs, plus Coolify deployment guidance. +- Added an initial admin user account `ai@allucanget.biz` with password `admin123` in the database initialization script. + +### Changed + +- Changed frontend API helper to support per-call timeout overrides. +- Changed dashboard behavior to separate pending video jobs from completed jobs and make pending workflows more visible. +- Changed gallery presentation to show cards with thumbnails and links instead of raw list output. +- Changed admin navigation and UI to expose video jobs and model management pages more directly. +- Changed backend video generation responses to include database IDs and redirect users to video detail pages after job creation. +- Changed documentation and architecture files to reflect recent backend, frontend, and deployment changes. + +### Fixed + +- Fixed gallery spinner logic so an empty gallery shows a "No images yet" message instead of a spinner. +- Fixed gallery image display issues so generated content is shown as cards with proper previews and detail links. +- Fixed video polling logic to handle cancelled jobs and avoid infinite polling loops. +- Fixed failed video job handling with user-safe error feedback in both backend and frontend. +- Fixed frontend image-generation timeout failures by catching `httpx.ReadTimeout` and surfacing a safe error message. +- Fixed frontend image generation route to handle backend timeout/request errors gracefully instead of returning 500 tracebacks. +- Fixed backend service behavior for actual video job processing and stale job timeout detection. + +### Removed + +- None. diff --git a/README.md b/README.md index ea4ce9e..f817f5b 100644 --- a/README.md +++ b/README.md @@ -1,143 +1,143 @@ -# All You Can GET AI - -A multi-modal AI web application. Users can choose between different AI models for text generation, text-to-image, text-to-video, and image-to-video generation, powered by [openrouter.ai](https://openrouter.ai). - -Key features: - -- Multi-modal AI generation (text, images, videos) -- User authentication and role-based access control -- Admin dashboard for managing users, models, and video jobs -- Gallery for viewing generated images and videos -- Chat interface with message history -- Image upload and preview functionality - -## Components - -| Component | Technology | Description | -| --------- | ----------------- | -------------------------------------------------------- | -| Backend | FastAPI + uvicorn | REST API for auth, user management, and AI generation | -| Database | DuckDB | Lightweight embedded storage for users and session data | -| Frontend | Flask + Jinja2 | Server-rendered UI served at | - -## Getting Started - -### Prerequisites - -- Python 3.12+ -- An [openrouter.ai](https://openrouter.ai) API key - -### Setup - -```bash -# Clone the repo -git clone https://git.allucanget.biz/allucanget/ai.allucanget.biz.git -cd ai.allucanget.biz - -# Create and activate virtual environment -python -m venv .venv -# Windows -.venv\Scripts\activate -# Linux/macOS -source .venv/bin/activate - -# Install core dependencies -pip install -r requirements.txt - -# Install development dependencies -pip install -r backend/requirements-dev.txt -pip install -r frontend/requirements-dev.txt - -# Copy environment variables file -cp .env.example .env - -# Edit .env file and add your OpenRouter API key and configure other settings -nano .env -``` - -### Running the application locally - -#### Backend (FastAPI + Uvicorn) - -```bash -cd backend -uvicorn app.main:app --reload --port 12015 -``` - -#### Frontend (Flask) - -```bash -cd frontend -flask --app app.main run --port 12016 --debug -``` - -### Running tests - -```bash -# Run all tests -pytest - -# Run backend tests only -pytest backend/tests/ - -# Run frontend tests only -pytest frontend/tests/ -``` - -### Available Environment Variables - -| Variable | Description | Default | -| -------------------- | --------------------------- | ------------------- | -| `OPENROUTER_API_KEY` | Your OpenRouter API key | _Required_ | -| `ADMIN_EMAIL` | Default admin user email | `ai@allucanget.biz` | -| `ADMIN_PASSWORD` | Default admin user password | `admin123` | -| `DATABASE_URL` | DuckDB database path | `../data/app.db` | - -## Default admin user - -On first startup a default admin account is created: - -| Field | Value | -| -------- | ------------------- | -| Email | `ai@allucanget.biz` | -| Password | `admin123` | -| Role | `admin` | - -Override via environment variables `ADMIN_EMAIL` and `ADMIN_PASSWORD` before first run. - -## Deployment - -Deployed on [Coolify](https://coolify.io) using Nixpacks. See [docs/deployment/coolify.md](docs/deployment/coolify.md) for full instructions. - -## Project Structure - -```txt -backend/ FastAPI backend - app/ - __init__.py Package initialization - db.py Database connection and operations - dependencies.py Dependency injection - main.py FastAPI application entrypoint - models/ Pydantic and database models - routers/ API route handlers (auth, users, admin, generate, gallery) - services/ Business logic for AI generation, users, admin, etc. - tests/ Backend test suite -frontend/ Flask frontend - app/ - __init__.py App factory (create_app) - main.py Flask application entrypoint - config.py Configuration - filters.py Jinja2 template filters - helpers.py API client, auth decorators, model helpers - routes/ Blueprint route handlers (auth, dashboard, gallery, generate, admin, profile) - templates/ Jinja2 HTML templates - static/ CSS, JS, images - tests/ Frontend test suite -data/ DuckDB database files, uploaded media, and generated content -logs/ Application logs -docs/ Architecture documentation (arc42 template) -nginx/ Nginx configuration for Coolify deployment -``` - -## Documentation - -For the full architecture documentation see [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md), which links to all 12 arc42 sections. +# All You Can GET AI + +A multi-modal AI web application. Users can choose between different AI models for text generation, text-to-image, text-to-video, and image-to-video generation, powered by [openrouter.ai](https://openrouter.ai). + +Key features: + +- Multi-modal AI generation (text, images, videos) +- User authentication and role-based access control +- Admin dashboard for managing users, models, and video jobs +- Gallery for viewing generated images and videos +- Chat interface with message history +- Image upload and preview functionality + +## Components + +| Component | Technology | Description | +| --------- | ----------------- | -------------------------------------------------------- | +| Backend | FastAPI + uvicorn | REST API for auth, user management, and AI generation | +| Database | DuckDB | Lightweight embedded storage for users and session data | +| Frontend | Flask + Jinja2 | Server-rendered UI served at | + +## Getting Started + +### Prerequisites + +- Python 3.12+ +- An [openrouter.ai](https://openrouter.ai) API key + +### Setup + +```bash +# Clone the repo +git clone https://git.allucanget.biz/allucanget/ai.allucanget.biz.git +cd ai.allucanget.biz + +# Create and activate virtual environment +python -m venv .venv +# Windows +.venv\Scripts\activate +# Linux/macOS +source .venv/bin/activate + +# Install core dependencies +pip install -r requirements.txt + +# Install development dependencies +pip install -r backend/requirements-dev.txt +pip install -r frontend/requirements-dev.txt + +# Copy environment variables file +cp .env.example .env + +# Edit .env file and add your OpenRouter API key and configure other settings +nano .env +``` + +### Running the application locally + +#### Backend (FastAPI + Uvicorn) + +```bash +cd backend +uvicorn app.main:app --reload --port 12015 +``` + +#### Frontend (Flask) + +```bash +cd frontend +flask --app app.main run --port 12016 --debug +``` + +### Running tests + +```bash +# Run all tests +pytest + +# Run backend tests only +pytest backend/tests/ + +# Run frontend tests only +pytest frontend/tests/ +``` + +### Available Environment Variables + +| Variable | Description | Default | +| -------------------- | --------------------------- | ------------------- | +| `OPENROUTER_API_KEY` | Your OpenRouter API key | _Required_ | +| `ADMIN_EMAIL` | Default admin user email | `ai@allucanget.biz` | +| `ADMIN_PASSWORD` | Default admin user password | `admin123` | +| `DATABASE_URL` | DuckDB database path | `../data/app.db` | + +## Default admin user + +On first startup a default admin account is created: + +| Field | Value | +| -------- | ------------------- | +| Email | `ai@allucanget.biz` | +| Password | `admin123` | +| Role | `admin` | + +Override via environment variables `ADMIN_EMAIL` and `ADMIN_PASSWORD` before first run. + +## Deployment + +Deployed on [Coolify](https://coolify.io) using Nixpacks. See [docs/deployment/coolify.md](docs/deployment/coolify.md) for full instructions. + +## Project Structure + +```txt +backend/ FastAPI backend + app/ + __init__.py Package initialization + db.py Database connection and operations + dependencies.py Dependency injection + main.py FastAPI application entrypoint + models/ Pydantic and database models + routers/ API route handlers (auth, users, admin, generate, gallery) + services/ Business logic for AI generation, users, admin, etc. + tests/ Backend test suite +frontend/ Flask frontend + app/ + __init__.py App factory (create_app) + main.py Flask application entrypoint + config.py Configuration + filters.py Jinja2 template filters + helpers.py API client, auth decorators, model helpers + routes/ Blueprint route handlers (auth, dashboard, gallery, generate, admin, profile) + templates/ Jinja2 HTML templates + static/ CSS, JS, images + tests/ Frontend test suite +data/ DuckDB database files, uploaded media, and generated content +logs/ Application logs +docs/ Architecture documentation (arc42 template) +nginx/ Nginx configuration for Coolify deployment +``` + +## Documentation + +For the full architecture documentation see [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md), which links to all 12 arc42 sections. diff --git a/backend/Dockerfile b/backend/Dockerfile index eee0067..aac66af 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/README.md b/backend/README.md index 036d0de..295c00c 100644 --- a/backend/README.md +++ b/backend/README.md @@ -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//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//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). diff --git a/backend/app/db.py b/backend/app/db.py index 0256f81..dfb08af 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -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], + ) diff --git a/backend/app/dependencies.py b/backend/app/dependencies.py index 74419e6..c849b8d 100644 --- a/backend/app/dependencies.py +++ b/backend/app/dependencies.py @@ -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 diff --git a/backend/app/main.py b/backend/app/main.py index 3f5997e..093d14c 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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"} diff --git a/backend/app/models/ai.py b/backend/app/models/ai.py index ca4b943..8d72f35 100644 --- a/backend/app/models/ai.py +++ b/backend/app/models/ai.py @@ -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 diff --git a/backend/app/models/auth.py b/backend/app/models/auth.py index 9a6270c..aa4dacb 100644 --- a/backend/app/models/auth.py +++ b/backend/app/models/auth.py @@ -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 diff --git a/backend/app/models/users.py b/backend/app/models/users.py index a799371..d2a4368 100644 --- a/backend/app/models/users.py +++ b/backend/app/models/users.py @@ -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 diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index c5a7525..7a3ca61 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -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} diff --git a/backend/app/routers/ai.py b/backend/app/routers/ai.py index f887712..d045e70 100644 --- a/backend/app/routers/ai.py +++ b/backend/app/routers/ai.py @@ -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}", + ) diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 66b47f4..5f890cf 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -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 diff --git a/backend/app/routers/generate.py b/backend/app/routers/generate.py index c6c6594..8045e9d 100644 --- a/backend/app/routers/generate.py +++ b/backend/app/routers/generate.py @@ -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} diff --git a/backend/app/routers/images.py b/backend/app/routers/images.py index 0759eff..4f0ca11 100644 --- a/backend/app/routers/images.py +++ b/backend/app/routers/images.py @@ -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]) diff --git a/backend/app/routers/models.py b/backend/app/routers/models.py index 682844a..a868b41 100644 --- a/backend/app/routers/models.py +++ b/backend/app/routers/models.py @@ -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} diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py index 155fe53..d225c49 100644 --- a/backend/app/routers/users.py +++ b/backend/app/routers/users.py @@ -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) diff --git a/backend/app/services/auth.py b/backend/app/services/auth.py index c7e40e8..d8b8415 100644 --- a/backend/app/services/auth.py +++ b/backend/app/services/auth.py @@ -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 diff --git a/backend/app/services/models.py b/backend/app/services/models.py index 82b1fed..32f8a5f 100644 --- a/backend/app/services/models.py +++ b/backend/app/services/models.py @@ -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) diff --git a/backend/app/services/openrouter.py b/backend/app/services/openrouter.py index 08d7005..95b414a 100644 --- a/backend/app/services/openrouter.py +++ b/backend/app/services/openrouter.py @@ -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() diff --git a/backend/app/services/users.py b/backend/app/services/users.py index e88b9a2..f9a8a03 100644 --- a/backend/app/services/users.py +++ b/backend/app/services/users.py @@ -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 diff --git a/backend/app/services/video_worker.py b/backend/app/services/video_worker.py index cf9be4c..571c136 100644 --- a/backend/app/services/video_worker.py +++ b/backend/app/services/video_worker.py @@ -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) diff --git a/backend/requirements-dev.txt b/backend/requirements-dev.txt index 68a866a..45de4f6 100644 --- a/backend/requirements-dev.txt +++ b/backend/requirements-dev.txt @@ -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 + diff --git a/backend/requirements.txt b/backend/requirements.txt index 0bce6a7..e6f038f 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -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 \ No newline at end of file diff --git a/backend/tests/test_admin.py b/backend/tests/test_admin.py index 583fdca..292d660 100644 --- a/backend/tests/test_admin.py +++ b/backend/tests/test_admin.py @@ -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 diff --git a/backend/tests/test_ai.py b/backend/tests/test_ai.py index d2a4d16..cb65a5e 100644 --- a/backend/tests/test_ai.py +++ b/backend/tests/test_ai.py @@ -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 diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index 5051e5c..737d5cc 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -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 diff --git a/backend/tests/test_db.py b/backend/tests/test_db.py index f7a6d16..3e21943 100644 --- a/backend/tests/test_db.py +++ b/backend/tests/test_db.py @@ -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" diff --git a/backend/tests/test_generate.py b/backend/tests/test_generate.py index 275d078..7691d27 100644 --- a/backend/tests/test_generate.py +++ b/backend/tests/test_generate.py @@ -1,591 +1,591 @@ -"""Tests for generate endpoints — all OpenRouter calls 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_CHAT = { - "id": "gen-text-1", - "model": "openai/gpt-4o", - "choices": [{"message": {"role": "assistant", "content": "Once upon a time..."}}], - "usage": {"prompt_tokens": 5, "completion_tokens": 10, "total_tokens": 15}, -} - -FAKE_VIDEO = { - "id": "gen-vid-1", - "polling_url": "https://openrouter.ai/api/v1/videos/gen-vid-1", - "status": "queued", -} - -FAKE_VIDEO_DONE = { - "id": "gen-vid-2", - "polling_url": "https://openrouter.ai/api/v1/videos/gen-vid-2", - "status": "completed", - "unsigned_urls": ["https://example.com/video.mp4"], -} - - -@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"] - - -# --------------------------------------------------------------------------- -# POST /generate/text -# --------------------------------------------------------------------------- - -async def test_generate_text(client): - token = await _user_token(client) - with patch("app.routers.generate.openrouter.chat_completion", new_callable=AsyncMock, return_value=FAKE_CHAT): - resp = await client.post( - "/generate/text", - json={"model": "openai/gpt-4o", "prompt": "Tell me a story"}, - headers={"Authorization": f"Bearer {token}"}, - ) - assert resp.status_code == 200 - data = resp.json() - assert data["content"] == "Once upon a time..." - assert data["id"] == "gen-text-1" - assert data["usage"]["total_tokens"] == 15 - - -async def test_generate_text_with_system_prompt(client): - token = await _user_token(client) - mock = AsyncMock(return_value=FAKE_CHAT) - with patch("app.routers.generate.openrouter.chat_completion", mock): - await client.post( - "/generate/text", - json={"model": "openai/gpt-4o", "prompt": "Hello", - "system_prompt": "Be concise."}, - headers={"Authorization": f"Bearer {token}"}, - ) - call_messages = mock.call_args.kwargs["messages"] - assert call_messages[0] == {"role": "system", "content": "Be concise."} - assert call_messages[1] == {"role": "user", "content": "Hello"} - - -async def test_generate_text_with_messages_array(client): - """messages field takes precedence over prompt for multi-turn chat.""" - token = await _user_token(client) - mock = AsyncMock(return_value=FAKE_CHAT) - messages = [ - {"role": "user", "content": "First message"}, - {"role": "assistant", "content": "Reply"}, - {"role": "user", "content": "Follow up"}, - ] - with patch("app.routers.generate.openrouter.chat_completion", mock): - resp = await client.post( - "/generate/text", - json={"model": "openai/gpt-4o", "messages": messages}, - headers={"Authorization": f"Bearer {token}"}, - ) - assert resp.status_code == 200 - call_messages = mock.call_args.kwargs["messages"] - assert len(call_messages) == 3 - assert call_messages[2]["content"] == "Follow up" - - -async def test_generate_text_messages_with_system_prompt(client): - """system_prompt prepended when messages provided and no system msg present.""" - token = await _user_token(client) - mock = AsyncMock(return_value=FAKE_CHAT) - messages = [{"role": "user", "content": "Hi"}] - with patch("app.routers.generate.openrouter.chat_completion", mock): - await client.post( - "/generate/text", - json={"model": "openai/gpt-4o", "messages": messages, - "system_prompt": "Be brief."}, - headers={"Authorization": f"Bearer {token}"}, - ) - call_messages = mock.call_args.kwargs["messages"] - assert call_messages[0] == {"role": "system", "content": "Be brief."} - assert call_messages[1] == {"role": "user", "content": "Hi"} - - -async def test_generate_text_unauthenticated(client): - resp = await client.post("/generate/text", json={"model": "openai/gpt-4o", "prompt": "Hi"}) - assert resp.status_code == 401 - - -async def test_generate_text_upstream_error(client): - token = await _user_token(client) - with patch("app.routers.generate.openrouter.chat_completion", new_callable=AsyncMock, side_effect=Exception("timeout")): - resp = await client.post( - "/generate/text", - json={"model": "openai/gpt-4o", "prompt": "Hi"}, - headers={"Authorization": f"Bearer {token}"}, - ) - assert resp.status_code == 502 - - -# --------------------------------------------------------------------------- -# POST /generate/image -# --------------------------------------------------------------------------- - -FAKE_IMAGE_CHAT_FLUX = { - "id": "gen-img-chat-1", - "model": "black-forest-labs/flux.2-klein-4b", - "choices": [{ - "message": { - "role": "assistant", - "content": None, - "images": [{ - "type": "image_url", - "image_url": {"url": "data:image/png;base64,abc123"}, - }], - } - }], -} - -FAKE_IMAGE_CHAT_GPT5 = { - "id": "gen-img-chat-2", - "model": "openai/gpt-5-image-mini", - "choices": [{ - "message": { - "role": "assistant", - "content": "Generated image.", - "images": [{ - "type": "image_url", - "image_url": {"url": "data:image/png;base64,xyz789"}, - }], - } - }], -} - -FAKE_IMAGE_CHAT_GEMINI = { - "id": "gen-img-chat-3", - "model": "google/gemini-2.5-flash-image", - "choices": [{ - "message": { - "role": "assistant", - "content": "Here is your image.", - "images": [{ - "type": "image_url", - "image_url": {"url": "data:image/png;base64,gemini123"}, - }], - } - }], -} - - -async def test_generate_image(client): - """All models now use generate_image_chat (chat completions endpoint).""" - token = await _user_token(client) - with patch("app.routers.generate.openrouter.generate_image_chat", new_callable=AsyncMock, return_value=FAKE_IMAGE_CHAT_GEMINI): - resp = await client.post( - "/generate/image", - json={"model": "google/gemini-2.5-flash-image", - "prompt": "A cat on the moon"}, - headers={"Authorization": f"Bearer {token}"}, - ) - assert resp.status_code == 200 - data = resp.json() - assert data["id"] == "gen-img-chat-3" - assert len(data["images"]) == 1 - assert data["images"][0]["url"] == "data:image/png;base64,gemini123" - assert data["images"][0]["image_id"] is not None # stored in DB - - -async def test_generate_image_unauthenticated(client): - resp = await client.post("/generate/image", json={"model": "google/gemini-2.5-flash-image", "prompt": "Hi"}) - assert resp.status_code == 401 - - -async def test_generate_image_upstream_error(client): - token = await _user_token(client) - with patch("app.routers.generate.openrouter.generate_image_chat", new_callable=AsyncMock, side_effect=Exception("rate limit")): - resp = await client.post( - "/generate/image", - json={"model": "google/gemini-2.5-flash-image", "prompt": "Hi"}, - headers={"Authorization": f"Bearer {token}"}, - ) - assert resp.status_code == 502 - - -async def test_generate_image_with_image_config(client): - """Passes aspect_ratio + image_size through to generate_image_chat.""" - token = await _user_token(client) - mock = AsyncMock(return_value=FAKE_IMAGE_CHAT_GEMINI) - with patch("app.routers.generate.openrouter.generate_image_chat", mock): - await client.post( - "/generate/image", - json={ - "model": "google/gemini-2.5-flash-image", - "prompt": "A landscape", - "aspect_ratio": "16:9", - "image_size": "2K", - }, - headers={"Authorization": f"Bearer {token}"}, - ) - call_kwargs = mock.call_args.kwargs - assert call_kwargs["image_config"]["aspect_ratio"] == "16:9" - assert call_kwargs["image_config"]["image_size"] == "2K" - - -async def test_generate_image_default_modalities_image_text(client): - """Model not in cache → default modalities = ['image', 'text'].""" - token = await _user_token(client) - mock = AsyncMock(return_value=FAKE_IMAGE_CHAT_GEMINI) - with patch("app.routers.generate.openrouter.generate_image_chat", mock): - await client.post( - "/generate/image", - json={"model": "google/gemini-2.5-flash-image", "prompt": "Hi"}, - headers={"Authorization": f"Bearer {token}"}, - ) - assert mock.call_args.kwargs["modalities"] == ["image", "text"] - - -async def test_generate_image_image_only_modalities_from_cache(client): - """Model cached with image-only output_modalities → modalities = ['image'].""" - from app import db as db_module - from app.services.models import get_model_output_modalities - import json as _json - token = await _user_token(client) - - # Seed cache with image-only model - conn = db_module.get_conn() - from datetime import datetime, timezone - now = datetime.now(timezone.utc).replace(tzinfo=None) - conn.execute( - "DELETE FROM models_cache WHERE model_id = 'black-forest-labs/flux.2-pro'" - ) - conn.execute( - """INSERT INTO models_cache (model_id, name, modality, context_length, pricing, fetched_at, output_modalities) - VALUES (?, ?, ?, ?, ?, ?, ?)""", - ["black-forest-labs/flux.2-pro", "FLUX.2 Pro", "image", None, None, now, - _json.dumps(["image"])], - ) - - mock = AsyncMock(return_value=FAKE_IMAGE_CHAT_FLUX) - with patch("app.routers.generate.openrouter.generate_image_chat", mock): - resp = await client.post( - "/generate/image", - json={"model": "black-forest-labs/flux.2-pro", "prompt": "Sky"}, - headers={"Authorization": f"Bearer {token}"}, - ) - assert resp.status_code == 200 - assert mock.call_args.kwargs["modalities"] == ["image"] - - -async def test_generate_image_no_images_in_response(client): - """502 when model returns no images.""" - token = await _user_token(client) - empty_response = { - "id": "gen-empty", - "model": "google/gemini-2.5-flash-image", - "choices": [{"message": {"role": "assistant", "content": "ok", "images": []}}], - } - with patch("app.routers.generate.openrouter.generate_image_chat", - new_callable=AsyncMock, return_value=empty_response): - resp = await client.post( - "/generate/image", - json={"model": "google/gemini-2.5-flash-image", "prompt": "Hi"}, - headers={"Authorization": f"Bearer {token}"}, - ) - assert resp.status_code == 502 - assert "No images returned" in resp.json()["detail"] - - -async def test_generate_image_flux(client): - """Flux model works correctly via chat completions.""" - token = await _user_token(client) - with patch("app.routers.generate.openrouter.generate_image_chat", - new_callable=AsyncMock, return_value=FAKE_IMAGE_CHAT_FLUX): - resp = await client.post( - "/generate/image", - json={"model": "black-forest-labs/flux.2-klein-4b", - "prompt": "A sunset"}, - headers={"Authorization": f"Bearer {token}"}, - ) - assert resp.status_code == 200 - data = resp.json() - assert data["images"][0]["url"] == "data:image/png;base64,abc123" - - -async def test_generate_image_stored_in_db(client): - """Generated image row persists in generated_images table.""" - from app import db as db_module - token = await _user_token(client) - with patch("app.routers.generate.openrouter.generate_image_chat", - new_callable=AsyncMock, return_value=FAKE_IMAGE_CHAT_GEMINI): - resp = await client.post( - "/generate/image", - json={"model": "google/gemini-2.5-flash-image", - "prompt": "A mountain"}, - headers={"Authorization": f"Bearer {token}"}, - ) - assert resp.status_code == 200 - image_id = resp.json()["images"][0]["image_id"] - assert image_id is not None - - row = db_module.get_conn().execute( - "SELECT model_id, prompt, image_data FROM generated_images WHERE id = ?", - [image_id], - ).fetchone() - assert row is not None - assert row[0] == "google/gemini-2.5-flash-image" - assert row[1] == "A mountain" - assert row[2] == "data:image/png;base64,gemini123" - - -# --------------------------------------------------------------------------- -# POST /generate/video -# --------------------------------------------------------------------------- - -async def test_generate_video(client): - token = await _user_token(client) - with patch("app.routers.generate.openrouter.generate_video", new_callable=AsyncMock, return_value=FAKE_VIDEO): - resp = await client.post( - "/generate/video", - json={"model": "stability/stable-video", - "prompt": "Ocean waves at sunset"}, - headers={"Authorization": f"Bearer {token}"}, - ) - assert resp.status_code == 200 - data = resp.json() - assert data["id"] == "gen-vid-1" - assert data["status"] == "queued" - assert data["polling_url"] == "https://openrouter.ai/api/v1/videos/gen-vid-1" - assert data["video_url"] is None - - -async def test_generate_video_unauthenticated(client): - resp = await client.post("/generate/video", json={"model": "m", "prompt": "p"}) - assert resp.status_code == 401 - - -async def test_generate_video_upstream_error(client): - token = await _user_token(client) - with patch("app.routers.generate.openrouter.generate_video", new_callable=AsyncMock, side_effect=Exception("503")): - resp = await client.post( - "/generate/video", - json={"model": "stability/stable-video", "prompt": "Hi"}, - headers={"Authorization": f"Bearer {token}"}, - ) - assert resp.status_code == 502 - - -# --------------------------------------------------------------------------- -# POST /generate/video/from-image -# --------------------------------------------------------------------------- - -async def test_generate_video_from_image(client): - token = await _user_token(client) - with patch("app.routers.generate.openrouter.generate_video_from_image", new_callable=AsyncMock, return_value=FAKE_VIDEO_DONE): - resp = await client.post( - "/generate/video/from-image", - json={ - "model": "runway/gen-3", - "image_url": "https://example.com/cat.jpg", - "prompt": "Cat runs across the room", - }, - headers={"Authorization": f"Bearer {token}"}, - ) - assert resp.status_code == 200 - data = resp.json() - assert data["status"] == "completed" - assert data["video_url"] == "https://example.com/video.mp4" - assert data["video_urls"] == ["https://example.com/video.mp4"] - - -async def test_poll_video_status(client): - token = await _user_token(client) - mock_result = { - "id": "gen-vid-1", - "status": "completed", - "unsigned_urls": ["https://example.com/video.mp4"], - } - with patch("app.routers.generate.openrouter.poll_video_status", new_callable=AsyncMock, return_value=mock_result): - resp = await client.get( - "/generate/video/status", - params={"polling_url": "https://openrouter.ai/api/v1/videos/gen-vid-1"}, - headers={"Authorization": f"Bearer {token}"}, - ) - assert resp.status_code == 200 - data = resp.json() - assert data["status"] == "completed" - assert data["video_url"] == "https://example.com/video.mp4" - - -async def test_poll_video_status_unauthenticated(client): - resp = await client.get( - "/generate/video/status", - params={"polling_url": "https://openrouter.ai/api/v1/videos/gen-vid-1"}, - ) - assert resp.status_code == 401 - - -async def test_poll_video_status_upstream_error(client): - token = await _user_token(client) - with patch("app.routers.generate.openrouter.poll_video_status", new_callable=AsyncMock, side_effect=Exception("timeout")): - resp = await client.get( - "/generate/video/status", - params={"polling_url": "https://openrouter.ai/api/v1/videos/gen-vid-1"}, - headers={"Authorization": f"Bearer {token}"}, - ) - assert resp.status_code == 502 - - -async def test_generate_video_from_image_unauthenticated(client): - resp = await client.post( - "/generate/video/from-image", - json={"model": "m", "image_url": "https://example.com/img.jpg", "prompt": "p"}, - ) - assert resp.status_code == 401 - - -# --------------------------------------------------------------------------- -# Video job DB storage -# --------------------------------------------------------------------------- - -async def test_generate_video_stored_in_db(client): - """Submitting a video job inserts a row into generated_videos.""" - token = await _user_token(client) - with patch("app.routers.generate.openrouter.generate_video", new_callable=AsyncMock, return_value=FAKE_VIDEO): - resp = await client.post( - "/generate/video", - json={"model": "stability/stable-video", "prompt": "Ocean waves"}, - headers={"Authorization": f"Bearer {token}"}, - ) - assert resp.status_code == 200 - - row = db_module.get_conn().execute( - "SELECT job_id, model_id, prompt, status FROM generated_videos WHERE job_id = ?", - ["gen-vid-1"], - ).fetchone() - assert row is not None - assert row[0] == "gen-vid-1" - assert row[1] == "stability/stable-video" - assert row[2] == "Ocean waves" - assert row[3] == "queued" - - -async def test_generate_video_from_image_stored_in_db(client): - """Submitting a from-image job inserts a row into generated_videos.""" - token = await _user_token(client) - with patch("app.routers.generate.openrouter.generate_video_from_image", new_callable=AsyncMock, return_value=FAKE_VIDEO_DONE): - resp = await client.post( - "/generate/video/from-image", - json={ - "model": "runway/gen-3", - "image_url": "https://example.com/cat.jpg", - "prompt": "Cat runs", - }, - headers={"Authorization": f"Bearer {token}"}, - ) - assert resp.status_code == 200 - - row = db_module.get_conn().execute( - "SELECT job_id, model_id, prompt, status FROM generated_videos WHERE job_id = ?", - ["gen-vid-2"], - ).fetchone() - assert row is not None - assert row[1] == "runway/gen-3" - assert row[2] == "Cat runs" - - -async def test_poll_video_updates_db_on_completion(client): - """Polling a completed job updates the row status and video_url.""" - token = await _user_token(client) - # First submit a job - with patch("app.routers.generate.openrouter.generate_video", new_callable=AsyncMock, return_value=FAKE_VIDEO): - await client.post( - "/generate/video", - json={"model": "stability/stable-video", "prompt": "Test"}, - headers={"Authorization": f"Bearer {token}"}, - ) - - # Now poll and get completed status - mock_result = { - "id": "gen-vid-1", - "status": "completed", - "unsigned_urls": ["https://example.com/video.mp4"], - } - with patch("app.routers.generate.openrouter.poll_video_status", new_callable=AsyncMock, return_value=mock_result): - await client.get( - "/generate/video/status", - params={"polling_url": "https://openrouter.ai/api/v1/videos/gen-vid-1"}, - headers={"Authorization": f"Bearer {token}"}, - ) - - row = db_module.get_conn().execute( - "SELECT status, video_url FROM generated_videos WHERE job_id = ?", - ["gen-vid-1"], - ).fetchone() - assert row is not None - assert row[0] == "completed" - assert row[1] == "https://example.com/video.mp4" - - -async def test_list_generated_videos_empty(client): - """GET /generate/videos returns empty list initially.""" - token = await _user_token(client) - resp = await client.get( - "/generate/videos", - headers={"Authorization": f"Bearer {token}"}, - ) - assert resp.status_code == 200 - assert resp.json() == [] - - -async def test_list_generated_videos_returns_own_jobs(client): - """GET /generate/videos returns the current user's jobs only.""" - token = await _user_token(client) - with patch("app.routers.generate.openrouter.generate_video", new_callable=AsyncMock, return_value=FAKE_VIDEO): - await client.post( - "/generate/video", - json={"model": "stability/stable-video", "prompt": "Waves"}, - headers={"Authorization": f"Bearer {token}"}, - ) - - resp = await client.get( - "/generate/videos", - headers={"Authorization": f"Bearer {token}"}, - ) - assert resp.status_code == 200 - data = resp.json() - assert len(data) == 1 - assert data[0]["job_id"] == "gen-vid-1" - assert data[0]["prompt"] == "Waves" - assert data[0]["status"] == "queued" - - -async def test_list_generated_videos_unauthenticated(client): - resp = await client.get("/generate/videos") - assert resp.status_code == 401 - - -async def test_generate_video_from_image_upstream_error(client): - token = await _user_token(client) - with patch("app.routers.generate.openrouter.generate_video_from_image", new_callable=AsyncMock, side_effect=Exception("error")): - resp = await client.post( - "/generate/video/from-image", - json={"model": "runway/gen-3", - "image_url": "https://example.com/img.jpg", "prompt": "p"}, - headers={"Authorization": f"Bearer {token}"}, - ) - assert resp.status_code == 502 +"""Tests for generate endpoints — all OpenRouter calls 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_CHAT = { + "id": "gen-text-1", + "model": "openai/gpt-4o", + "choices": [{"message": {"role": "assistant", "content": "Once upon a time..."}}], + "usage": {"prompt_tokens": 5, "completion_tokens": 10, "total_tokens": 15}, +} + +FAKE_VIDEO = { + "id": "gen-vid-1", + "polling_url": "https://openrouter.ai/api/v1/videos/gen-vid-1", + "status": "queued", +} + +FAKE_VIDEO_DONE = { + "id": "gen-vid-2", + "polling_url": "https://openrouter.ai/api/v1/videos/gen-vid-2", + "status": "completed", + "unsigned_urls": ["https://example.com/video.mp4"], +} + + +@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"] + + +# --------------------------------------------------------------------------- +# POST /generate/text +# --------------------------------------------------------------------------- + +async def test_generate_text(client): + token = await _user_token(client) + with patch("app.routers.generate.openrouter.chat_completion", new_callable=AsyncMock, return_value=FAKE_CHAT): + resp = await client.post( + "/generate/text", + json={"model": "openai/gpt-4o", "prompt": "Tell me a story"}, + headers={"Authorization": f"Bearer {token}"}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["content"] == "Once upon a time..." + assert data["id"] == "gen-text-1" + assert data["usage"]["total_tokens"] == 15 + + +async def test_generate_text_with_system_prompt(client): + token = await _user_token(client) + mock = AsyncMock(return_value=FAKE_CHAT) + with patch("app.routers.generate.openrouter.chat_completion", mock): + await client.post( + "/generate/text", + json={"model": "openai/gpt-4o", "prompt": "Hello", + "system_prompt": "Be concise."}, + headers={"Authorization": f"Bearer {token}"}, + ) + call_messages = mock.call_args.kwargs["messages"] + assert call_messages[0] == {"role": "system", "content": "Be concise."} + assert call_messages[1] == {"role": "user", "content": "Hello"} + + +async def test_generate_text_with_messages_array(client): + """messages field takes precedence over prompt for multi-turn chat.""" + token = await _user_token(client) + mock = AsyncMock(return_value=FAKE_CHAT) + messages = [ + {"role": "user", "content": "First message"}, + {"role": "assistant", "content": "Reply"}, + {"role": "user", "content": "Follow up"}, + ] + with patch("app.routers.generate.openrouter.chat_completion", mock): + resp = await client.post( + "/generate/text", + json={"model": "openai/gpt-4o", "messages": messages}, + headers={"Authorization": f"Bearer {token}"}, + ) + assert resp.status_code == 200 + call_messages = mock.call_args.kwargs["messages"] + assert len(call_messages) == 3 + assert call_messages[2]["content"] == "Follow up" + + +async def test_generate_text_messages_with_system_prompt(client): + """system_prompt prepended when messages provided and no system msg present.""" + token = await _user_token(client) + mock = AsyncMock(return_value=FAKE_CHAT) + messages = [{"role": "user", "content": "Hi"}] + with patch("app.routers.generate.openrouter.chat_completion", mock): + await client.post( + "/generate/text", + json={"model": "openai/gpt-4o", "messages": messages, + "system_prompt": "Be brief."}, + headers={"Authorization": f"Bearer {token}"}, + ) + call_messages = mock.call_args.kwargs["messages"] + assert call_messages[0] == {"role": "system", "content": "Be brief."} + assert call_messages[1] == {"role": "user", "content": "Hi"} + + +async def test_generate_text_unauthenticated(client): + resp = await client.post("/generate/text", json={"model": "openai/gpt-4o", "prompt": "Hi"}) + assert resp.status_code == 401 + + +async def test_generate_text_upstream_error(client): + token = await _user_token(client) + with patch("app.routers.generate.openrouter.chat_completion", new_callable=AsyncMock, side_effect=Exception("timeout")): + resp = await client.post( + "/generate/text", + json={"model": "openai/gpt-4o", "prompt": "Hi"}, + headers={"Authorization": f"Bearer {token}"}, + ) + assert resp.status_code == 502 + + +# --------------------------------------------------------------------------- +# POST /generate/image +# --------------------------------------------------------------------------- + +FAKE_IMAGE_CHAT_FLUX = { + "id": "gen-img-chat-1", + "model": "black-forest-labs/flux.2-klein-4b", + "choices": [{ + "message": { + "role": "assistant", + "content": None, + "images": [{ + "type": "image_url", + "image_url": {"url": "data:image/png;base64,abc123"}, + }], + } + }], +} + +FAKE_IMAGE_CHAT_GPT5 = { + "id": "gen-img-chat-2", + "model": "openai/gpt-5-image-mini", + "choices": [{ + "message": { + "role": "assistant", + "content": "Generated image.", + "images": [{ + "type": "image_url", + "image_url": {"url": "data:image/png;base64,xyz789"}, + }], + } + }], +} + +FAKE_IMAGE_CHAT_GEMINI = { + "id": "gen-img-chat-3", + "model": "google/gemini-2.5-flash-image", + "choices": [{ + "message": { + "role": "assistant", + "content": "Here is your image.", + "images": [{ + "type": "image_url", + "image_url": {"url": "data:image/png;base64,gemini123"}, + }], + } + }], +} + + +async def test_generate_image(client): + """All models now use generate_image_chat (chat completions endpoint).""" + token = await _user_token(client) + with patch("app.routers.generate.openrouter.generate_image_chat", new_callable=AsyncMock, return_value=FAKE_IMAGE_CHAT_GEMINI): + resp = await client.post( + "/generate/image", + json={"model": "google/gemini-2.5-flash-image", + "prompt": "A cat on the moon"}, + headers={"Authorization": f"Bearer {token}"}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["id"] == "gen-img-chat-3" + assert len(data["images"]) == 1 + assert data["images"][0]["url"] == "data:image/png;base64,gemini123" + assert data["images"][0]["image_id"] is not None # stored in DB + + +async def test_generate_image_unauthenticated(client): + resp = await client.post("/generate/image", json={"model": "google/gemini-2.5-flash-image", "prompt": "Hi"}) + assert resp.status_code == 401 + + +async def test_generate_image_upstream_error(client): + token = await _user_token(client) + with patch("app.routers.generate.openrouter.generate_image_chat", new_callable=AsyncMock, side_effect=Exception("rate limit")): + resp = await client.post( + "/generate/image", + json={"model": "google/gemini-2.5-flash-image", "prompt": "Hi"}, + headers={"Authorization": f"Bearer {token}"}, + ) + assert resp.status_code == 502 + + +async def test_generate_image_with_image_config(client): + """Passes aspect_ratio + image_size through to generate_image_chat.""" + token = await _user_token(client) + mock = AsyncMock(return_value=FAKE_IMAGE_CHAT_GEMINI) + with patch("app.routers.generate.openrouter.generate_image_chat", mock): + await client.post( + "/generate/image", + json={ + "model": "google/gemini-2.5-flash-image", + "prompt": "A landscape", + "aspect_ratio": "16:9", + "image_size": "2K", + }, + headers={"Authorization": f"Bearer {token}"}, + ) + call_kwargs = mock.call_args.kwargs + assert call_kwargs["image_config"]["aspect_ratio"] == "16:9" + assert call_kwargs["image_config"]["image_size"] == "2K" + + +async def test_generate_image_default_modalities_image_text(client): + """Model not in cache → default modalities = ['image', 'text'].""" + token = await _user_token(client) + mock = AsyncMock(return_value=FAKE_IMAGE_CHAT_GEMINI) + with patch("app.routers.generate.openrouter.generate_image_chat", mock): + await client.post( + "/generate/image", + json={"model": "google/gemini-2.5-flash-image", "prompt": "Hi"}, + headers={"Authorization": f"Bearer {token}"}, + ) + assert mock.call_args.kwargs["modalities"] == ["image", "text"] + + +async def test_generate_image_image_only_modalities_from_cache(client): + """Model cached with image-only output_modalities → modalities = ['image'].""" + from app import db as db_module + from app.services.models import get_model_output_modalities + import json as _json + token = await _user_token(client) + + # Seed cache with image-only model + conn = db_module.get_conn() + from datetime import datetime, timezone + now = datetime.now(timezone.utc).replace(tzinfo=None) + conn.execute( + "DELETE FROM models_cache WHERE model_id = 'black-forest-labs/flux.2-pro'" + ) + conn.execute( + """INSERT INTO models_cache (model_id, name, modality, context_length, pricing, fetched_at, output_modalities) + VALUES (?, ?, ?, ?, ?, ?, ?)""", + ["black-forest-labs/flux.2-pro", "FLUX.2 Pro", "image", None, None, now, + _json.dumps(["image"])], + ) + + mock = AsyncMock(return_value=FAKE_IMAGE_CHAT_FLUX) + with patch("app.routers.generate.openrouter.generate_image_chat", mock): + resp = await client.post( + "/generate/image", + json={"model": "black-forest-labs/flux.2-pro", "prompt": "Sky"}, + headers={"Authorization": f"Bearer {token}"}, + ) + assert resp.status_code == 200 + assert mock.call_args.kwargs["modalities"] == ["image"] + + +async def test_generate_image_no_images_in_response(client): + """502 when model returns no images.""" + token = await _user_token(client) + empty_response = { + "id": "gen-empty", + "model": "google/gemini-2.5-flash-image", + "choices": [{"message": {"role": "assistant", "content": "ok", "images": []}}], + } + with patch("app.routers.generate.openrouter.generate_image_chat", + new_callable=AsyncMock, return_value=empty_response): + resp = await client.post( + "/generate/image", + json={"model": "google/gemini-2.5-flash-image", "prompt": "Hi"}, + headers={"Authorization": f"Bearer {token}"}, + ) + assert resp.status_code == 502 + assert "No images returned" in resp.json()["detail"] + + +async def test_generate_image_flux(client): + """Flux model works correctly via chat completions.""" + token = await _user_token(client) + with patch("app.routers.generate.openrouter.generate_image_chat", + new_callable=AsyncMock, return_value=FAKE_IMAGE_CHAT_FLUX): + resp = await client.post( + "/generate/image", + json={"model": "black-forest-labs/flux.2-klein-4b", + "prompt": "A sunset"}, + headers={"Authorization": f"Bearer {token}"}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["images"][0]["url"] == "data:image/png;base64,abc123" + + +async def test_generate_image_stored_in_db(client): + """Generated image row persists in generated_images table.""" + from app import db as db_module + token = await _user_token(client) + with patch("app.routers.generate.openrouter.generate_image_chat", + new_callable=AsyncMock, return_value=FAKE_IMAGE_CHAT_GEMINI): + resp = await client.post( + "/generate/image", + json={"model": "google/gemini-2.5-flash-image", + "prompt": "A mountain"}, + headers={"Authorization": f"Bearer {token}"}, + ) + assert resp.status_code == 200 + image_id = resp.json()["images"][0]["image_id"] + assert image_id is not None + + row = db_module.get_conn().execute( + "SELECT model_id, prompt, image_data FROM generated_images WHERE id = ?", + [image_id], + ).fetchone() + assert row is not None + assert row[0] == "google/gemini-2.5-flash-image" + assert row[1] == "A mountain" + assert row[2] == "data:image/png;base64,gemini123" + + +# --------------------------------------------------------------------------- +# POST /generate/video +# --------------------------------------------------------------------------- + +async def test_generate_video(client): + token = await _user_token(client) + with patch("app.routers.generate.openrouter.generate_video", new_callable=AsyncMock, return_value=FAKE_VIDEO): + resp = await client.post( + "/generate/video", + json={"model": "stability/stable-video", + "prompt": "Ocean waves at sunset"}, + headers={"Authorization": f"Bearer {token}"}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["id"] == "gen-vid-1" + assert data["status"] == "queued" + assert data["polling_url"] == "https://openrouter.ai/api/v1/videos/gen-vid-1" + assert data["video_url"] is None + + +async def test_generate_video_unauthenticated(client): + resp = await client.post("/generate/video", json={"model": "m", "prompt": "p"}) + assert resp.status_code == 401 + + +async def test_generate_video_upstream_error(client): + token = await _user_token(client) + with patch("app.routers.generate.openrouter.generate_video", new_callable=AsyncMock, side_effect=Exception("503")): + resp = await client.post( + "/generate/video", + json={"model": "stability/stable-video", "prompt": "Hi"}, + headers={"Authorization": f"Bearer {token}"}, + ) + assert resp.status_code == 502 + + +# --------------------------------------------------------------------------- +# POST /generate/video/from-image +# --------------------------------------------------------------------------- + +async def test_generate_video_from_image(client): + token = await _user_token(client) + with patch("app.routers.generate.openrouter.generate_video_from_image", new_callable=AsyncMock, return_value=FAKE_VIDEO_DONE): + resp = await client.post( + "/generate/video/from-image", + json={ + "model": "runway/gen-3", + "image_url": "https://example.com/cat.jpg", + "prompt": "Cat runs across the room", + }, + headers={"Authorization": f"Bearer {token}"}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["status"] == "completed" + assert data["video_url"] == "https://example.com/video.mp4" + assert data["video_urls"] == ["https://example.com/video.mp4"] + + +async def test_poll_video_status(client): + token = await _user_token(client) + mock_result = { + "id": "gen-vid-1", + "status": "completed", + "unsigned_urls": ["https://example.com/video.mp4"], + } + with patch("app.routers.generate.openrouter.poll_video_status", new_callable=AsyncMock, return_value=mock_result): + resp = await client.get( + "/generate/video/status", + params={"polling_url": "https://openrouter.ai/api/v1/videos/gen-vid-1"}, + headers={"Authorization": f"Bearer {token}"}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["status"] == "completed" + assert data["video_url"] == "https://example.com/video.mp4" + + +async def test_poll_video_status_unauthenticated(client): + resp = await client.get( + "/generate/video/status", + params={"polling_url": "https://openrouter.ai/api/v1/videos/gen-vid-1"}, + ) + assert resp.status_code == 401 + + +async def test_poll_video_status_upstream_error(client): + token = await _user_token(client) + with patch("app.routers.generate.openrouter.poll_video_status", new_callable=AsyncMock, side_effect=Exception("timeout")): + resp = await client.get( + "/generate/video/status", + params={"polling_url": "https://openrouter.ai/api/v1/videos/gen-vid-1"}, + headers={"Authorization": f"Bearer {token}"}, + ) + assert resp.status_code == 502 + + +async def test_generate_video_from_image_unauthenticated(client): + resp = await client.post( + "/generate/video/from-image", + json={"model": "m", "image_url": "https://example.com/img.jpg", "prompt": "p"}, + ) + assert resp.status_code == 401 + + +# --------------------------------------------------------------------------- +# Video job DB storage +# --------------------------------------------------------------------------- + +async def test_generate_video_stored_in_db(client): + """Submitting a video job inserts a row into generated_videos.""" + token = await _user_token(client) + with patch("app.routers.generate.openrouter.generate_video", new_callable=AsyncMock, return_value=FAKE_VIDEO): + resp = await client.post( + "/generate/video", + json={"model": "stability/stable-video", "prompt": "Ocean waves"}, + headers={"Authorization": f"Bearer {token}"}, + ) + assert resp.status_code == 200 + + row = db_module.get_conn().execute( + "SELECT job_id, model_id, prompt, status FROM generated_videos WHERE job_id = ?", + ["gen-vid-1"], + ).fetchone() + assert row is not None + assert row[0] == "gen-vid-1" + assert row[1] == "stability/stable-video" + assert row[2] == "Ocean waves" + assert row[3] == "queued" + + +async def test_generate_video_from_image_stored_in_db(client): + """Submitting a from-image job inserts a row into generated_videos.""" + token = await _user_token(client) + with patch("app.routers.generate.openrouter.generate_video_from_image", new_callable=AsyncMock, return_value=FAKE_VIDEO_DONE): + resp = await client.post( + "/generate/video/from-image", + json={ + "model": "runway/gen-3", + "image_url": "https://example.com/cat.jpg", + "prompt": "Cat runs", + }, + headers={"Authorization": f"Bearer {token}"}, + ) + assert resp.status_code == 200 + + row = db_module.get_conn().execute( + "SELECT job_id, model_id, prompt, status FROM generated_videos WHERE job_id = ?", + ["gen-vid-2"], + ).fetchone() + assert row is not None + assert row[1] == "runway/gen-3" + assert row[2] == "Cat runs" + + +async def test_poll_video_updates_db_on_completion(client): + """Polling a completed job updates the row status and video_url.""" + token = await _user_token(client) + # First submit a job + with patch("app.routers.generate.openrouter.generate_video", new_callable=AsyncMock, return_value=FAKE_VIDEO): + await client.post( + "/generate/video", + json={"model": "stability/stable-video", "prompt": "Test"}, + headers={"Authorization": f"Bearer {token}"}, + ) + + # Now poll and get completed status + mock_result = { + "id": "gen-vid-1", + "status": "completed", + "unsigned_urls": ["https://example.com/video.mp4"], + } + with patch("app.routers.generate.openrouter.poll_video_status", new_callable=AsyncMock, return_value=mock_result): + await client.get( + "/generate/video/status", + params={"polling_url": "https://openrouter.ai/api/v1/videos/gen-vid-1"}, + headers={"Authorization": f"Bearer {token}"}, + ) + + row = db_module.get_conn().execute( + "SELECT status, video_url FROM generated_videos WHERE job_id = ?", + ["gen-vid-1"], + ).fetchone() + assert row is not None + assert row[0] == "completed" + assert row[1] == "https://example.com/video.mp4" + + +async def test_list_generated_videos_empty(client): + """GET /generate/videos returns empty list initially.""" + token = await _user_token(client) + resp = await client.get( + "/generate/videos", + headers={"Authorization": f"Bearer {token}"}, + ) + assert resp.status_code == 200 + assert resp.json() == [] + + +async def test_list_generated_videos_returns_own_jobs(client): + """GET /generate/videos returns the current user's jobs only.""" + token = await _user_token(client) + with patch("app.routers.generate.openrouter.generate_video", new_callable=AsyncMock, return_value=FAKE_VIDEO): + await client.post( + "/generate/video", + json={"model": "stability/stable-video", "prompt": "Waves"}, + headers={"Authorization": f"Bearer {token}"}, + ) + + resp = await client.get( + "/generate/videos", + headers={"Authorization": f"Bearer {token}"}, + ) + assert resp.status_code == 200 + data = resp.json() + assert len(data) == 1 + assert data[0]["job_id"] == "gen-vid-1" + assert data[0]["prompt"] == "Waves" + assert data[0]["status"] == "queued" + + +async def test_list_generated_videos_unauthenticated(client): + resp = await client.get("/generate/videos") + assert resp.status_code == 401 + + +async def test_generate_video_from_image_upstream_error(client): + token = await _user_token(client) + with patch("app.routers.generate.openrouter.generate_video_from_image", new_callable=AsyncMock, side_effect=Exception("error")): + resp = await client.post( + "/generate/video/from-image", + json={"model": "runway/gen-3", + "image_url": "https://example.com/img.jpg", "prompt": "p"}, + headers={"Authorization": f"Bearer {token}"}, + ) + assert resp.status_code == 502 diff --git a/backend/tests/test_images.py b/backend/tests/test_images.py index 3b86b95..af543f7 100644 --- a/backend/tests/test_images.py +++ b/backend/tests/test_images.py @@ -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 diff --git a/backend/tests/test_models.py b/backend/tests/test_models.py index 1e3dc48..45a97b5 100644 --- a/backend/tests/test_models.py +++ b/backend/tests/test_models.py @@ -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 == [] diff --git a/backend/tests/test_users.py b/backend/tests/test_users.py index bd9792c..4043284 100644 --- a/backend/tests/test_users.py +++ b/backend/tests/test_users.py @@ -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 diff --git a/docs/01-introduction-and-goals.md b/docs/01-introduction-and-goals.md index bc40fb6..56e0fa4 100644 --- a/docs/01-introduction-and-goals.md +++ b/docs/01-introduction-and-goals.md @@ -1,34 +1,34 @@ -# 1. Introduction & Goals - -Describes the relevant requirements and the driving forces that software architects and the development team must consider. These include underlying business goals, essential features, essential functional requirements, quality goals for the architecture, and relevant stakeholders and their expectations. - -## Requirements Overview - -**Project name**: All You Can GET AI -**URL**: [https://ai.allucanget.biz](https://ai.allucanget.biz) -**Purpose**: Provide AI‑powered text, image, and video generation services via a web application. - -Users can choose between different AI models for: - -- Text generation -- Text‑to‑image generation -- Text‑to‑video generation -- Image‑to‑video generation - -Users can create accounts, log in, and view their generation history in a gallery. An admin dashboard allows managing users, models, and video generation jobs. - -## Quality Goals - -| Priority | Quality Goal | Scenario | -| -------- | ----------------- | --------------------------------------------- | -| 1 | High availability | Service is accessible 99.9% of the time | -| 2 | Low latency | Generation endpoints respond within 200 ms | -| 3 | Data privacy | User data is encrypted at rest and in transit | - -## Stakeholders - -| Role/Name | Contact | Expectations | -| ------------- | ---------- | --------------------------------------------- | -| Developer | Team | Clean APIs, testable code, good documentation | -| End User | Public | Fast, reliable AI generation features | -| Product Owner | Management | Feature completeness, uptime, cost control | +# 1. Introduction & Goals + +Describes the relevant requirements and the driving forces that software architects and the development team must consider. These include underlying business goals, essential features, essential functional requirements, quality goals for the architecture, and relevant stakeholders and their expectations. + +## Requirements Overview + +**Project name**: All You Can GET AI +**URL**: [https://ai.allucanget.biz](https://ai.allucanget.biz) +**Purpose**: Provide AI‑powered text, image, and video generation services via a web application. + +Users can choose between different AI models for: + +- Text generation +- Text‑to‑image generation +- Text‑to‑video generation +- Image‑to‑video generation + +Users can create accounts, log in, and view their generation history in a gallery. An admin dashboard allows managing users, models, and video generation jobs. + +## Quality Goals + +| Priority | Quality Goal | Scenario | +| -------- | ----------------- | --------------------------------------------- | +| 1 | High availability | Service is accessible 99.9% of the time | +| 2 | Low latency | Generation endpoints respond within 200 ms | +| 3 | Data privacy | User data is encrypted at rest and in transit | + +## Stakeholders + +| Role/Name | Contact | Expectations | +| ------------- | ---------- | --------------------------------------------- | +| Developer | Team | Clean APIs, testable code, good documentation | +| End User | Public | Fast, reliable AI generation features | +| Product Owner | Management | Feature completeness, uptime, cost control | diff --git a/docs/02-constraints.md b/docs/02-constraints.md index 0cb5305..47e7642 100644 --- a/docs/02-constraints.md +++ b/docs/02-constraints.md @@ -1,26 +1,26 @@ -# 2. Architecture Constraints - -Any requirement that constrains software architects in their freedom of design and implementation decisions or decision about the development process. These constraints sometimes go beyond individual systems and are valid for whole organizations and companies. - -## Technical Constraints - -| Constraint | Background / Motivation | -| ----------------------------- | ------------------------------------------- | -| Must run on Windows and Linux | Cross-platform developer environments | -| FastAPI for backend | Async performance, OpenAPI support | -| Flask for frontend | Lightweight UI serving | -| DuckDB as embedded database | Lightweight, no separate DB server required | -| External AI via openrouter.ai | Unified access to multiple AI models | - -## Organizational Constraints - -| Constraint | Background / Motivation | -| ----------------- | --------------------------------- | -| Open source stack | Cost reduction, community support | - -## Conventions - -| Convention | Background / Motivation | -| -------------------- | --------------------------------------------------- | -| Python 3.12+ | Modern language features, type hints | -| pytest for all tests | Consistent test tooling across backend and frontend | +# 2. Architecture Constraints + +Any requirement that constrains software architects in their freedom of design and implementation decisions or decision about the development process. These constraints sometimes go beyond individual systems and are valid for whole organizations and companies. + +## Technical Constraints + +| Constraint | Background / Motivation | +| ----------------------------- | ------------------------------------------- | +| Must run on Windows and Linux | Cross-platform developer environments | +| FastAPI for backend | Async performance, OpenAPI support | +| Flask for frontend | Lightweight UI serving | +| DuckDB as embedded database | Lightweight, no separate DB server required | +| External AI via openrouter.ai | Unified access to multiple AI models | + +## Organizational Constraints + +| Constraint | Background / Motivation | +| ----------------- | --------------------------------- | +| Open source stack | Cost reduction, community support | + +## Conventions + +| Convention | Background / Motivation | +| -------------------- | --------------------------------------------------- | +| Python 3.12+ | Modern language features, type hints | +| pytest for all tests | Consistent test tooling across backend and frontend | diff --git a/docs/03-context-and-scope.md b/docs/03-context-and-scope.md index a74c8c1..8084b1c 100644 --- a/docs/03-context-and-scope.md +++ b/docs/03-context-and-scope.md @@ -1,26 +1,26 @@ -# 3. Context and Scope - -Delimits your system (i.e. your scope) from all its communication partners (neighboring systems and users, i.e. the context of your system). It thereby specifies the external interfaces. - -## Business Context - -Specification of **all** communication partners (users, IT-systems, …) with explanations of domain specific inputs and outputs or interfaces. - -The system exposes REST APIs for authentication, user management, and AI generation. The Flask frontend consumes these APIs and serves pages at `http://ai.allucanget.biz`. - -| Communication Partner | Input | Output | -| --------------------- | ------------------------------ | ------------------------------ | -| End User (browser) | Form submissions, API requests | Generated text, images, videos | -| openrouter.ai API | Prompt + model selection | AI-generated content | -| DuckDB | SQL queries | User data, session data | - -## Technical Context - -| Channel | Protocol | Description | -| -------------------------------- | ---------- | ------------------ | -| User ↔ Flask frontend | HTTPS | Browser-based UI | -| Flask frontend ↔ FastAPI backend | HTTP REST | Internal API calls | -| FastAPI backend ↔ openrouter.ai | HTTPS REST | AI model inference | -| FastAPI backend ↔ DuckDB | In-process | Embedded DB access | - -**Mapping Input/Output to Channels:** All user-facing traffic flows through HTTPS. The backend communicates with openrouter.ai over HTTPS. DuckDB is accessed in-process with no network channel. +# 3. Context and Scope + +Delimits your system (i.e. your scope) from all its communication partners (neighboring systems and users, i.e. the context of your system). It thereby specifies the external interfaces. + +## Business Context + +Specification of **all** communication partners (users, IT-systems, …) with explanations of domain specific inputs and outputs or interfaces. + +The system exposes REST APIs for authentication, user management, and AI generation. The Flask frontend consumes these APIs and serves pages at `http://ai.allucanget.biz`. + +| Communication Partner | Input | Output | +| --------------------- | ------------------------------ | ------------------------------ | +| End User (browser) | Form submissions, API requests | Generated text, images, videos | +| openrouter.ai API | Prompt + model selection | AI-generated content | +| DuckDB | SQL queries | User data, session data | + +## Technical Context + +| Channel | Protocol | Description | +| -------------------------------- | ---------- | ------------------ | +| User ↔ Flask frontend | HTTPS | Browser-based UI | +| Flask frontend ↔ FastAPI backend | HTTP REST | Internal API calls | +| FastAPI backend ↔ openrouter.ai | HTTPS REST | AI model inference | +| FastAPI backend ↔ DuckDB | In-process | Embedded DB access | + +**Mapping Input/Output to Channels:** All user-facing traffic flows through HTTPS. The backend communicates with openrouter.ai over HTTPS. DuckDB is accessed in-process with no network channel. diff --git a/docs/04-solution-strategy.md b/docs/04-solution-strategy.md index 360a60d..d5a6f40 100644 --- a/docs/04-solution-strategy.md +++ b/docs/04-solution-strategy.md @@ -1,23 +1,23 @@ -# 4. Solution Strategy - -A short summary and explanation of the fundamental decisions and solution strategies that shape system architecture. It includes: - -- Technology decisions -- Decisions about the top-level decomposition of the system, e.g. usage of an architectural pattern or design pattern -- Decisions on how to achieve key quality goals -- Relevant organizational decisions, e.g. selecting a development process or delegating certain tasks to third parties - -Micro‑service style: a single FastAPI process handles all API endpoints; a separate Flask process serves the UI. - -| Quality Goal | Scenario | Solution Approach | -| ----------------- | ------------------------ | --------------------------------------------------------- | -| High availability | Service must be up 99.9% | Docker containers with health checks and restart policies | -| Low latency | Responses < 200 ms | FastAPI async endpoints; DuckDB in-process | -| Data privacy | Encrypted data | JWT auth, HTTPS everywhere, DuckDB file encryption | - -**Key technology decisions:** - -- **FastAPI** for async performance and automatic OpenAPI docs -- **DuckDB** for a lightweight embedded DB with no separate server -- **Flask** for a thin frontend layer with Jinja2 templates -- **openrouter.ai** for unified access to multiple AI model providers +# 4. Solution Strategy + +A short summary and explanation of the fundamental decisions and solution strategies that shape system architecture. It includes: + +- Technology decisions +- Decisions about the top-level decomposition of the system, e.g. usage of an architectural pattern or design pattern +- Decisions on how to achieve key quality goals +- Relevant organizational decisions, e.g. selecting a development process or delegating certain tasks to third parties + +Micro‑service style: a single FastAPI process handles all API endpoints; a separate Flask process serves the UI. + +| Quality Goal | Scenario | Solution Approach | +| ----------------- | ------------------------ | --------------------------------------------------------- | +| High availability | Service must be up 99.9% | Docker containers with health checks and restart policies | +| Low latency | Responses < 200 ms | FastAPI async endpoints; DuckDB in-process | +| Data privacy | Encrypted data | JWT auth, HTTPS everywhere, DuckDB file encryption | + +**Key technology decisions:** + +- **FastAPI** for async performance and automatic OpenAPI docs +- **DuckDB** for a lightweight embedded DB with no separate server +- **Flask** for a thin frontend layer with Jinja2 templates +- **openrouter.ai** for unified access to multiple AI model providers diff --git a/docs/05-building-block-view.md b/docs/05-building-block-view.md index 960f4cf..775ca53 100644 --- a/docs/05-building-block-view.md +++ b/docs/05-building-block-view.md @@ -1,110 +1,110 @@ -# 5. Building Block View - -Static decomposition of the system into building blocks (modules, components, subsystems, classes, interfaces, packages, libraries, frameworks, layers, partitions, tiers, functions, macros, operations, data structures, …) as well as their dependencies (relationships, associations, …). - -## Level 1 – Whitebox Overall System - -```text -┌────────────────────────┐ -│ Frontend (Flask) │ -└───────┬────────────────┘ - │ REST API calls -┌───────▼────────────────┐ -│ FastAPI Backend │ -│ ├─ Auth Service │ -│ ├─ User Service │ -│ ├─ AI Service │ -│ └─ DB Service (DuckDB)│ -└───────┬────────────────┘ - │ DB access -┌───────▼────────────────┐ -│ DuckDB Database │ -└────────────────────────┘ -``` - -**Motivation:** Separating the UI (Flask) from the API (FastAPI) allows independent scaling and testing of each layer. - -**Contained Building Blocks:** - -| Name | Responsibility | -| ---------------- | -------------------------------------------------- | -| Frontend (Flask) | Serves HTML/CSS/JS UI, proxies requests to FastAPI | -| FastAPI Backend | Handles auth, user management, AI generation API | -| Auth Service | JWT issuance and validation | -| User Service | CRUD operations for user accounts | -| AI Service | Sends prompts to openrouter.ai, returns results | -| DB Service | Reads/writes to DuckDB | -| DuckDB Database | Persistent storage for users and session data | - -## Level 2 – FastAPI Backend internals - -### White Box Auth Service (`/auth`) - -Handles JWT issuance, validation, and refresh token lifecycle. - -| Method | Path | Auth required | Description | -| ------ | ---------------- | :-----------: | ------------------------------------------------ | -| POST | `/auth/register` | — | Create a new user account | -| POST | `/auth/login` | — | Authenticate and receive access + refresh tokens | -| POST | `/auth/refresh` | — | Rotate refresh token, get a new access token | -| POST | `/auth/logout` | — | Revoke refresh token | - -### White Box User Service (`/users`) - -Self-service profile management and admin user CRUD. - -| Method | Path | Auth required | Admin only | Description | -| ------ | ------------------ | ------------- | ---------- | ---------------------------------------- | -| GET | `/users/me` | ✓ | — | Get current user profile | -| PUT | `/users/me` | ✓ | — | Update own email or password | -| GET | `/users` | ✓ | ✓ | List all users | -| DELETE | `/users/{id}` | ✓ | ✓ | Delete a user (cannot self-delete) | -| PUT | `/users/{id}/role` | ✓ | ✓ | Change a user's role (`user` \| `admin`) | - -### White Box Admin Service (`/admin`) - -Operational endpoints for application management. - -| Method | Path | Auth required | Admin only | Description | -| ------ | --------------------------- | ------------- | ---------- | ------------------------------------------ | -| GET | `/admin/stats` | ✓ | ✓ | User counts by role, token activity | -| GET | `/admin/health/db` | ✓ | ✓ | DuckDB connectivity check | -| POST | `/admin/tokens/purge` | ✓ | ✓ | Remove expired/revoked refresh tokens | -| GET | `/admin/videos` | ✓ | ✓ | List all video jobs with user emails | -| POST | `/admin/videos/{id}/cancel` | ✓ | ✓ | Cancel a queued/processing video job | -| POST | `/admin/videos/{id}/retry` | ✓ | ✓ | Retry a failed/cancelled video job | -| DELETE | `/admin/videos/{id}` | ✓ | ✓ | Permanently delete a video job | -| POST | `/admin/videos/purge` | ✓ | ✓ | Delete old completed/failed/cancelled jobs | -| POST | `/admin/videos/timed-out` | ✓ | ✓ | Mark stale processing jobs as failed | -| GET | `/admin/models` | ✓ | ✓ | List cached OpenRouter models | -| POST | `/admin/models/refresh` | ✓ | ✓ | Refresh model cache from OpenRouter | - -### White Box AI Service (`/ai`, `/generate`) - -Model listing and multi-modal generation via openrouter.ai. - -| Method | Path | Auth required | Description | -| ------ | ------------------------------ | ------------- | ------------------------------------------------------------------------------------------------------------------- | -| GET | `/ai/models` | ✓ | List available OpenRouter models | -| POST | `/ai/chat` | ✓ | Multi-turn chat completion | -| POST | `/generate/text` | ✓ | Single-prompt text generation (optional system prompt) | -| POST | `/generate/image` | ✓ | Text-to-image (DALL-E via `/images/generations` or FLUX/GPT-5 Image Mini via `/chat/completions` with `modalities`) | -| POST | `/generate/video` | ✓ | Text-to-video (Sora 2 Pro, Veo 3.1 Fast) — returns `polling_url` | -| POST | `/generate/video/from-image` | ✓ | Image-to-video — returns `polling_url` | -| GET | `/generate/video/status` | ✓ | Poll video generation status via `polling_url` | -| GET | `/generate/images` | ✓ | List current user's generated images | -| GET | `/generate/images/{id}` | ✓ | Get a single generated image | -| GET | `/generate/videos` | ✓ | List current user's video jobs | -| GET | `/generate/videos/{id}` | ✓ | Get a single video job | -| POST | `/generate/videos/{id}/cancel` | ✓ | Cancel a queued/processing video job | - -**Video generation flow:** The `/generate/video` and `/generate/video/from-image` endpoints queue a job in the local database and return immediately with `status: "queued"`. A background worker (`video_worker.py`) submits the job to OpenRouter's `/api/v1/videos` endpoint, receives a `polling_url`, and polls it periodically until the job reaches `"completed"` or `"failed"`. The frontend polls `GET /generate/video/{id}/status` every 5 seconds to show live status updates. - -**Image generation routing:** The router auto-detects the model type — models containing `"flux"` or `"gpt-5-image-mini"` are routed to `/chat/completions` with `modalities: ["image"]` (or `["image", "text"]` depending on cached output modalities), while others (e.g. DALL-E 3) use the legacy `/images/generations` endpoint. - -### White Box DB Service (`db.py`) - -- Singleton DuckDB connection, opened on FastAPI startup via lifespan hook. -- `asyncio.Lock` serialises all write operations within the single process. -- Schema migration runs automatically on first startup. -- Tables: `users`, `refresh_tokens` (see Section 8 for schema details). +# 5. Building Block View + +Static decomposition of the system into building blocks (modules, components, subsystems, classes, interfaces, packages, libraries, frameworks, layers, partitions, tiers, functions, macros, operations, data structures, …) as well as their dependencies (relationships, associations, …). + +## Level 1 – Whitebox Overall System + +```text +┌────────────────────────┐ +│ Frontend (Flask) │ +└───────┬────────────────┘ + │ REST API calls +┌───────▼────────────────┐ +│ FastAPI Backend │ +│ ├─ Auth Service │ +│ ├─ User Service │ +│ ├─ AI Service │ +│ └─ DB Service (DuckDB)│ +└───────┬────────────────┘ + │ DB access +┌───────▼────────────────┐ +│ DuckDB Database │ +└────────────────────────┘ +``` + +**Motivation:** Separating the UI (Flask) from the API (FastAPI) allows independent scaling and testing of each layer. + +**Contained Building Blocks:** + +| Name | Responsibility | +| ---------------- | -------------------------------------------------- | +| Frontend (Flask) | Serves HTML/CSS/JS UI, proxies requests to FastAPI | +| FastAPI Backend | Handles auth, user management, AI generation API | +| Auth Service | JWT issuance and validation | +| User Service | CRUD operations for user accounts | +| AI Service | Sends prompts to openrouter.ai, returns results | +| DB Service | Reads/writes to DuckDB | +| DuckDB Database | Persistent storage for users and session data | + +## Level 2 – FastAPI Backend internals + +### White Box Auth Service (`/auth`) + +Handles JWT issuance, validation, and refresh token lifecycle. + +| Method | Path | Auth required | Description | +| ------ | ---------------- | :-----------: | ------------------------------------------------ | +| POST | `/auth/register` | — | Create a new user account | +| POST | `/auth/login` | — | Authenticate and receive access + refresh tokens | +| POST | `/auth/refresh` | — | Rotate refresh token, get a new access token | +| POST | `/auth/logout` | — | Revoke refresh token | + +### White Box User Service (`/users`) + +Self-service profile management and admin user CRUD. + +| Method | Path | Auth required | Admin only | Description | +| ------ | ------------------ | ------------- | ---------- | ---------------------------------------- | +| GET | `/users/me` | ✓ | — | Get current user profile | +| PUT | `/users/me` | ✓ | — | Update own email or password | +| GET | `/users` | ✓ | ✓ | List all users | +| DELETE | `/users/{id}` | ✓ | ✓ | Delete a user (cannot self-delete) | +| PUT | `/users/{id}/role` | ✓ | ✓ | Change a user's role (`user` \| `admin`) | + +### White Box Admin Service (`/admin`) + +Operational endpoints for application management. + +| Method | Path | Auth required | Admin only | Description | +| ------ | --------------------------- | ------------- | ---------- | ------------------------------------------ | +| GET | `/admin/stats` | ✓ | ✓ | User counts by role, token activity | +| GET | `/admin/health/db` | ✓ | ✓ | DuckDB connectivity check | +| POST | `/admin/tokens/purge` | ✓ | ✓ | Remove expired/revoked refresh tokens | +| GET | `/admin/videos` | ✓ | ✓ | List all video jobs with user emails | +| POST | `/admin/videos/{id}/cancel` | ✓ | ✓ | Cancel a queued/processing video job | +| POST | `/admin/videos/{id}/retry` | ✓ | ✓ | Retry a failed/cancelled video job | +| DELETE | `/admin/videos/{id}` | ✓ | ✓ | Permanently delete a video job | +| POST | `/admin/videos/purge` | ✓ | ✓ | Delete old completed/failed/cancelled jobs | +| POST | `/admin/videos/timed-out` | ✓ | ✓ | Mark stale processing jobs as failed | +| GET | `/admin/models` | ✓ | ✓ | List cached OpenRouter models | +| POST | `/admin/models/refresh` | ✓ | ✓ | Refresh model cache from OpenRouter | + +### White Box AI Service (`/ai`, `/generate`) + +Model listing and multi-modal generation via openrouter.ai. + +| Method | Path | Auth required | Description | +| ------ | ------------------------------ | ------------- | ------------------------------------------------------------------------------------------------------------------- | +| GET | `/ai/models` | ✓ | List available OpenRouter models | +| POST | `/ai/chat` | ✓ | Multi-turn chat completion | +| POST | `/generate/text` | ✓ | Single-prompt text generation (optional system prompt) | +| POST | `/generate/image` | ✓ | Text-to-image (DALL-E via `/images/generations` or FLUX/GPT-5 Image Mini via `/chat/completions` with `modalities`) | +| POST | `/generate/video` | ✓ | Text-to-video (Sora 2 Pro, Veo 3.1 Fast) — returns `polling_url` | +| POST | `/generate/video/from-image` | ✓ | Image-to-video — returns `polling_url` | +| GET | `/generate/video/status` | ✓ | Poll video generation status via `polling_url` | +| GET | `/generate/images` | ✓ | List current user's generated images | +| GET | `/generate/images/{id}` | ✓ | Get a single generated image | +| GET | `/generate/videos` | ✓ | List current user's video jobs | +| GET | `/generate/videos/{id}` | ✓ | Get a single video job | +| POST | `/generate/videos/{id}/cancel` | ✓ | Cancel a queued/processing video job | + +**Video generation flow:** The `/generate/video` and `/generate/video/from-image` endpoints queue a job in the local database and return immediately with `status: "queued"`. A background worker (`video_worker.py`) submits the job to OpenRouter's `/api/v1/videos` endpoint, receives a `polling_url`, and polls it periodically until the job reaches `"completed"` or `"failed"`. The frontend polls `GET /generate/video/{id}/status` every 5 seconds to show live status updates. + +**Image generation routing:** The router auto-detects the model type — models containing `"flux"` or `"gpt-5-image-mini"` are routed to `/chat/completions` with `modalities: ["image"]` (or `["image", "text"]` depending on cached output modalities), while others (e.g. DALL-E 3) use the legacy `/images/generations` endpoint. + +### White Box DB Service (`db.py`) + +- Singleton DuckDB connection, opened on FastAPI startup via lifespan hook. +- `asyncio.Lock` serialises all write operations within the single process. +- Schema migration runs automatically on first startup. +- Tables: `users`, `refresh_tokens` (see Section 8 for schema details). diff --git a/docs/06-runtime-view.md b/docs/06-runtime-view.md index 8c8fff9..25ea73d 100644 --- a/docs/06-runtime-view.md +++ b/docs/06-runtime-view.md @@ -1,98 +1,98 @@ -# 6. Runtime View - -Describes concrete behavior and interactions of the system's building blocks in form of scenarios from the following areas: - -- Important use cases or features: how do building blocks execute them? -- Interactions at critical external interfaces: how do building blocks cooperate with users and neighboring systems? -- Operation and administration: launch, start-up, stop -- Error and exception scenarios - -## Scenario 1: User Authentication - -1. User submits login form in Flask frontend -2. Flask POSTs credentials to `POST /auth/login` -3. Auth Service validates credentials against DuckDB -4. Auth Service returns JWT token -5. Flask stores token in session cookie -6. User is redirected to dashboard - -## Scenario 2: AI Text Generation - -1. User fills in text generation form in Flask frontend -2. Flask POSTs prompt + model to `POST /generate/text` with JWT header -3. Auth Service validates JWT -4. AI Service sends prompt to openrouter.ai -5. openrouter.ai returns generated text -6. FastAPI returns result to Flask -7. Flask renders result page for user - -## Scenario 3: Image Generation - -1. User submits image generation form with prompt, model, size, aspect ratio, and resolution -2. Flask POSTs to `POST /generate/image` with JWT header -3. Router auto-detects model type: - - **FLUX / GPT-5 Image Mini**: calls `/chat/completions` with `modalities: ["image"]` and `image_config` - - **DALL-E 3**: calls `/images/generations` with `size` and `n` -4. Image URL (base64 data URL or hosted URL) returned to Flask -5. Flask renders page with generated image(s) - -## Scenario 3a: Image Generation with Aspect Ratio & Resolution - -1. User selects aspect ratio (e.g. `16:9`) and resolution (`2K`) on the image generation form -2. Flask POSTs `aspect_ratio` and `image_size` to `POST /generate/image` -3. Backend passes these as `image_config` to the chat completions endpoint (for FLUX/GPT-5 Image Mini) -4. Generated image respects the requested aspect ratio and resolution - -## Scenario 4: Video Generation (Text-to-Video) - -1. User submits video generation form with prompt, model, aspect ratio, resolution, and duration -2. Flask POSTs to `POST /generate/video` with JWT header -3. Auth Service validates JWT -4. Backend inserts a row into `generated_videos` with `status: "queued"` and returns the DB job ID -5. Flask renders result page with polling UI -6. Background worker (`video_worker.py`) picks up queued jobs every 15 seconds: - - Calls OpenRouter `POST /api/v1/videos` with model, prompt, and parameters - - Receives `{"id": "...", "polling_url": "..."}` and updates the DB row to `status: "processing"` - - Polls the `polling_url` every 15 seconds until `status` is `"completed"` or `"failed"` - - Updates the DB row with the final status and video URL -7. Frontend JavaScript polls `GET /generate/video/{db_id}/status` every 5 seconds -8. When `status` becomes `"completed"`, the response includes `video_url` — the video is displayed in a `