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