Admin-unlimited Dev Workspaces: an admin-owned workspace is now exempt from the max-workspace-count limit, the max-tunnel-count limit, and the whole idle-stop/idle-warn/retention-delete lifecycle. Resolved once in quota.resolve() as Limits.unlimited (owner uid checked against get_admin_uids()), consumed at the three enforcement points (provision.ensure, provision.publish_tunnel, WorkspaceService._advance_lifecycle). Also hardens get_admin_uids()/get_primary_admin_uid() against a partially-schemaed users table (uid/role column guard), which a fresh test/init_db() path could hit. AI gateway per-model automatic fallback: any gateway_models route (chat/embed/image) can now name a fallback_model, picked on /admin/gateway from a select box of other configured public model names of the same kind only (never an internal upstream model id). When a route fails after its own retries are exhausted, the gateway retries once, automatically, against the fallback's own provider/pricing/key, before any bytes reach the client (including for a streaming response). One hop only, no chains or cycles; self-reference and cross-kind fallbacks are rejected at write time. AI gateway real upstream streaming and thinking-default control: stream:true is now forwarded to the upstream and relayed to the client as real SSE chunks (measured TTFT/inter-token latency) instead of a simulated split response, and every chat/vision call explicitly disables model "thinking" by default (admin-overridable via gateway_thinking), with per-dialect handling for DeepSeek, OpenRouter, and Ollama. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TjdKTWgWpW2SMNW8SFqxz5
133 lines
3.9 KiB
Python
133 lines
3.9 KiB
Python
# retoor <retoor@molodetz.nl>
|
|
|
|
from .core import TTLCache, bump_cache_version, db, sync_local_cache
|
|
|
|
|
|
def get_users_by_uids(uids):
|
|
if not uids or "users" not in db.tables:
|
|
return {}
|
|
users = db["users"]
|
|
if "uid" not in users.columns:
|
|
return {}
|
|
seen = set()
|
|
unique = [u for u in uids if u not in seen and not seen.add(u)]
|
|
return {u["uid"]: u for u in users.find(users.table.columns.uid.in_(unique))}
|
|
|
|
|
|
_admins_cache = TTLCache(ttl=300, max_size=4)
|
|
PRIMARY_ADMIN_CANDIDATES = 50
|
|
|
|
|
|
def invalidate_admins_cache() -> None:
|
|
_admins_cache.clear()
|
|
bump_cache_version("admins")
|
|
|
|
|
|
def get_admin_uids():
|
|
sync_local_cache("admins", _admins_cache)
|
|
cached = _admins_cache.get("uids")
|
|
if cached is not None:
|
|
return list(cached)
|
|
if "users" not in db.tables:
|
|
return []
|
|
users = db["users"]
|
|
if "uid" not in users.columns or "role" not in users.columns:
|
|
return []
|
|
rows = db.query("SELECT uid FROM users WHERE role = 'Admin'")
|
|
uids = [row["uid"] for row in rows]
|
|
_admins_cache.set("uids", uids)
|
|
return list(uids)
|
|
|
|
|
|
def set_user_timezone(user_uid: str, tz_name: str) -> None:
|
|
if "users" not in db.tables or not user_uid or not tz_name:
|
|
return
|
|
users = db["users"]
|
|
if not users.has_column("timezone"):
|
|
users.create_column_by_example("timezone", "")
|
|
current = users.find_one(uid=user_uid)
|
|
if current and current.get("timezone") == tz_name:
|
|
return
|
|
users.update({"uid": user_uid, "timezone": tz_name}, ["uid"])
|
|
|
|
|
|
def set_last_seen(user_uid: str, iso: str) -> None:
|
|
if "users" not in db.tables or not user_uid or not iso:
|
|
return
|
|
users = db["users"]
|
|
if not users.has_column("last_seen"):
|
|
users.create_column_by_example("last_seen", "")
|
|
users.update({"uid": user_uid, "last_seen": iso}, ["uid"])
|
|
|
|
|
|
def get_online_users(cutoff_iso: str, limit: int = 30) -> list:
|
|
if "users" not in db.tables:
|
|
return []
|
|
users = db["users"]
|
|
if "last_seen" not in users.columns:
|
|
return []
|
|
return list(
|
|
users.find(
|
|
last_seen={">=": cutoff_iso},
|
|
order_by=["username"],
|
|
_limit=limit,
|
|
)
|
|
)
|
|
|
|
|
|
def is_account_active(row) -> bool:
|
|
is_active = (row or {}).get("is_active")
|
|
return is_active is None or bool(is_active)
|
|
|
|
|
|
def _can_hold_primary_admin(row, tracks_active):
|
|
if row.get("deleted_at"):
|
|
return False
|
|
return not tracks_active or is_account_active(row)
|
|
|
|
|
|
def get_primary_admin_uid():
|
|
sync_local_cache("admins", _admins_cache)
|
|
cached = _admins_cache.get("primary")
|
|
if cached is not None:
|
|
return cached or None
|
|
if "users" not in db.tables:
|
|
return None
|
|
users = db["users"]
|
|
if "uid" not in users.columns or "role" not in users.columns:
|
|
return None
|
|
rows = list(
|
|
db.query(
|
|
"SELECT * FROM users WHERE role = 'Admin' "
|
|
"ORDER BY (created_at IS NULL OR created_at = ''), created_at ASC, id ASC "
|
|
"LIMIT :cap",
|
|
cap=PRIMARY_ADMIN_CANDIDATES,
|
|
)
|
|
)
|
|
tracks_active = "is_active" in db["users"].columns
|
|
primary = next(
|
|
(row["uid"] for row in rows if _can_hold_primary_admin(row, tracks_active)),
|
|
None,
|
|
)
|
|
_admins_cache.set("primary", primary or "")
|
|
return primary
|
|
|
|
|
|
def search_users_by_username(q, *, exclude_uid=None, limit=10):
|
|
if not q or "users" not in db.tables:
|
|
return []
|
|
if exclude_uid is not None:
|
|
rows = db.query(
|
|
"SELECT uid, username FROM users WHERE username LIKE :q AND uid != :me LIMIT :limit",
|
|
q=f"%{q}%",
|
|
me=exclude_uid,
|
|
limit=limit,
|
|
)
|
|
else:
|
|
rows = db.query(
|
|
"SELECT uid, username FROM users WHERE username LIKE :q LIMIT :limit",
|
|
q=f"%{q}%",
|
|
limit=limit,
|
|
)
|
|
return [{"uid": r["uid"], "username": r["username"]} for r in rows]
|