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