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
+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)
|
||||
Reference in new issue
Block a user