2026-06-20 19:13:05 +03:00
|
|
|
"""Admin API — settings, llm logs, users, stats, test endpoints, icon upload."""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import os
|
|
|
|
|
import time
|
|
|
|
|
import uuid
|
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile, status
|
|
|
|
|
from sqlalchemy import func, select
|
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
|
|
|
|
|
|
from app.api.deps import require_admin
|
|
|
|
|
from app.config import get_settings
|
|
|
|
|
from app.core.embeddings import (
|
|
|
|
|
HashEmbedder,
|
|
|
|
|
OpenAIEmbedder,
|
|
|
|
|
build_hash_embedder,
|
|
|
|
|
build_openai_embedder,
|
|
|
|
|
)
|
|
|
|
|
from app.core.llm import LlmClient
|
|
|
|
|
from app.core.logging import get_logger
|
|
|
|
|
from app.core.qdrant_client import init_qdrant_collections
|
|
|
|
|
from app.core.rag import reset_embedder_cache
|
|
|
|
|
from app.core.settings_service import (
|
|
|
|
|
DEFAULT_SETTINGS,
|
|
|
|
|
SECRET_KEYS,
|
|
|
|
|
get_all_settings,
|
|
|
|
|
mask_secret,
|
|
|
|
|
set_setting,
|
|
|
|
|
)
|
|
|
|
|
from app.db import get_db
|
|
|
|
|
from app.models import LlmCallLog, User
|
|
|
|
|
from app.schemas import LlmLogDetail, LlmLogOut, SettingsPatchRequest
|
|
|
|
|
|
|
|
|
|
_logger = get_logger(__name__)
|
|
|
|
|
|
|
|
|
|
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
|
|
|
|
|
|
|
|
|
|
2026-06-21 06:12:28 +03:00
|
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
# Admin recovery — create a new admin when all existing admins lost access.
|
|
|
|
|
# This endpoint is NOT behind require_admin (it's for recovery). It requires
|
|
|
|
|
# the admin.setup_token from settings, which is printed on every startup.
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
@router.post("/recover", response_model=dict)
|
|
|
|
|
async def recover_admin(
|
|
|
|
|
body: dict,
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
) -> dict:
|
|
|
|
|
"""Create a new admin user using the admin setup token.
|
|
|
|
|
|
|
|
|
|
This endpoint is for disaster recovery when all existing admins have lost
|
|
|
|
|
access. It requires the `admin.setup_token` (printed on backend startup)
|
|
|
|
|
and creates a new admin user.
|
|
|
|
|
|
|
|
|
|
Body: {token, email, username, password}
|
|
|
|
|
"""
|
|
|
|
|
from app.core.security import hash_password, validate_password_strength
|
|
|
|
|
from app.core.settings_service import get_admin_setup_token
|
|
|
|
|
from app.models import User as UserModel
|
|
|
|
|
from sqlalchemy import or_
|
|
|
|
|
|
|
|
|
|
token = body.get("token", "")
|
|
|
|
|
expected_token = await get_admin_setup_token(db)
|
|
|
|
|
if token != expected_token:
|
|
|
|
|
raise HTTPException(403, "invalid_admin_token")
|
|
|
|
|
|
|
|
|
|
email = body.get("email", "").strip()
|
|
|
|
|
username = body.get("username", "").strip()
|
|
|
|
|
password = body.get("password", "")
|
|
|
|
|
|
|
|
|
|
if not email or not username or not password:
|
|
|
|
|
raise HTTPException(400, "email, username, and password are required")
|
|
|
|
|
|
|
|
|
|
errors = validate_password_strength(password)
|
|
|
|
|
if errors:
|
|
|
|
|
raise HTTPException(400, errors[0])
|
|
|
|
|
|
|
|
|
|
existing = (
|
|
|
|
|
await db.execute(
|
|
|
|
|
select(UserModel).where(
|
|
|
|
|
or_(UserModel.email == email, UserModel.username == username)
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
).scalar_one_or_none()
|
|
|
|
|
if existing is not None:
|
|
|
|
|
if existing.email == email:
|
|
|
|
|
raise HTTPException(400, "email_already_exists")
|
|
|
|
|
raise HTTPException(400, "username_already_exists")
|
|
|
|
|
|
|
|
|
|
user = UserModel(
|
|
|
|
|
email=email,
|
|
|
|
|
username=username,
|
|
|
|
|
password_hash=hash_password(password),
|
|
|
|
|
is_admin=True,
|
|
|
|
|
is_active=True,
|
|
|
|
|
)
|
|
|
|
|
db.add(user)
|
|
|
|
|
await db.commit()
|
|
|
|
|
await db.refresh(user)
|
|
|
|
|
return {
|
|
|
|
|
"ok": True,
|
|
|
|
|
"id": str(user.id),
|
|
|
|
|
"email": user.email,
|
|
|
|
|
"username": user.username,
|
|
|
|
|
"is_admin": user.is_admin,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-06-20 19:13:05 +03:00
|
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
# Settings
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
@router.get("/settings")
|
|
|
|
|
async def get_settings_endpoint(
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
_user: User = Depends(require_admin),
|
|
|
|
|
) -> dict:
|
|
|
|
|
settings = await get_all_settings(db)
|
|
|
|
|
# Mask secrets
|
|
|
|
|
out = {k: mask_secret(k, v) for k, v in settings.items()}
|
|
|
|
|
return {"settings": out, "descriptions": {k: s["description"] for k, s in DEFAULT_SETTINGS.items()}}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.patch("/settings")
|
|
|
|
|
async def patch_settings_endpoint(
|
|
|
|
|
body: SettingsPatchRequest,
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
_user: User = Depends(require_admin),
|
|
|
|
|
) -> dict:
|
|
|
|
|
updated = {}
|
|
|
|
|
for k, v in body.settings.items():
|
|
|
|
|
# Don't update secret keys if the masked value was sent back unchanged
|
|
|
|
|
if k in SECRET_KEYS and isinstance(v, str) and ("…" in v or v == "****"):
|
|
|
|
|
continue
|
|
|
|
|
await set_setting(db, k, v)
|
|
|
|
|
updated[k] = mask_secret(k, v)
|
|
|
|
|
# Clear embedder cache so new settings take effect
|
|
|
|
|
reset_embedder_cache()
|
|
|
|
|
return {"updated": updated}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
# LLM logs
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
@router.get("/llm-logs")
|
|
|
|
|
async def list_llm_logs(
|
|
|
|
|
world_id: uuid.UUID | None = None,
|
|
|
|
|
stage: str | None = None,
|
|
|
|
|
status_filter: str | None = None,
|
|
|
|
|
page: int = 1,
|
|
|
|
|
per_page: int = 50,
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
_user: User = Depends(require_admin),
|
|
|
|
|
) -> dict:
|
|
|
|
|
stmt = select(LlmCallLog)
|
|
|
|
|
if world_id:
|
|
|
|
|
stmt = stmt.where(LlmCallLog.world_id == world_id)
|
|
|
|
|
if stage:
|
|
|
|
|
stmt = stmt.where(LlmCallLog.stage == stage)
|
|
|
|
|
if status_filter:
|
|
|
|
|
stmt = stmt.where(LlmCallLog.status == status_filter)
|
|
|
|
|
total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar_one()
|
|
|
|
|
stmt = stmt.order_by(LlmCallLog.created_at.desc()).offset((page - 1) * per_page).limit(per_page)
|
|
|
|
|
rows = (await db.execute(stmt)).scalars().all()
|
2026-06-21 06:12:28 +03:00
|
|
|
items = []
|
|
|
|
|
for r in rows:
|
|
|
|
|
d = LlmLogOut.model_validate(r).model_dump(mode="json")
|
|
|
|
|
d["world_id"] = str(r.world_id) if r.world_id else None
|
|
|
|
|
items.append(d)
|
2026-06-20 19:13:05 +03:00
|
|
|
return {
|
2026-06-21 06:12:28 +03:00
|
|
|
"items": items,
|
2026-06-20 19:13:05 +03:00
|
|
|
"total": total, "page": page, "per_page": per_page,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/llm-logs/{log_id}", response_model=LlmLogDetail)
|
|
|
|
|
async def get_llm_log(
|
|
|
|
|
log_id: uuid.UUID,
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
_user: User = Depends(require_admin),
|
|
|
|
|
) -> LlmCallLog:
|
|
|
|
|
log = (
|
|
|
|
|
await db.execute(select(LlmCallLog).where(LlmCallLog.id == log_id))
|
|
|
|
|
).scalar_one_or_none()
|
|
|
|
|
if log is None:
|
|
|
|
|
raise HTTPException(404, "not_found")
|
|
|
|
|
return log
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
# Users
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
@router.get("/users")
|
|
|
|
|
async def list_users(
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
_user: User = Depends(require_admin),
|
|
|
|
|
) -> dict:
|
|
|
|
|
rows = (await db.execute(select(User).order_by(User.created_at.desc()))).scalars().all()
|
|
|
|
|
return {"items": [
|
|
|
|
|
{"id": str(u.id), "email": u.email, "username": u.username,
|
|
|
|
|
"is_admin": u.is_admin, "is_active": u.is_active,
|
|
|
|
|
"created_at": u.created_at.isoformat(), "last_login_at": u.last_login_at.isoformat() if u.last_login_at else None}
|
|
|
|
|
for u in rows
|
|
|
|
|
]}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.patch("/users/{user_id}")
|
|
|
|
|
async def patch_user(
|
|
|
|
|
user_id: uuid.UUID,
|
|
|
|
|
body: dict,
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
_user: User = Depends(require_admin),
|
|
|
|
|
) -> dict:
|
|
|
|
|
user = (
|
|
|
|
|
await db.execute(select(User).where(User.id == user_id))
|
|
|
|
|
).scalar_one_or_none()
|
|
|
|
|
if user is None:
|
|
|
|
|
raise HTTPException(404, "not_found")
|
|
|
|
|
if "is_admin" in body:
|
|
|
|
|
user.is_admin = bool(body["is_admin"])
|
|
|
|
|
if "is_active" in body:
|
|
|
|
|
user.is_active = bool(body["is_active"])
|
|
|
|
|
await db.commit()
|
|
|
|
|
return {"id": str(user.id), "is_admin": user.is_admin, "is_active": user.is_active}
|
|
|
|
|
|
|
|
|
|
|
2026-06-21 02:41:48 +03:00
|
|
|
@router.delete("/worlds/{world_id}", status_code=200)
|
|
|
|
|
async def hard_delete_world(
|
|
|
|
|
world_id: uuid.UUID,
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
_user: User = Depends(require_admin),
|
|
|
|
|
) -> dict:
|
|
|
|
|
"""Hard-delete a world (cascade deletes all related entities, steps, logs).
|
|
|
|
|
|
|
|
|
|
Also cleans up Qdrant points for the world (best-effort).
|
|
|
|
|
"""
|
|
|
|
|
from app.models import World
|
|
|
|
|
from app.core.qdrant_client import cleanup_world_points
|
|
|
|
|
|
|
|
|
|
world = (
|
|
|
|
|
await db.execute(select(World).where(World.id == world_id))
|
|
|
|
|
).scalar_one_or_none()
|
|
|
|
|
if world is None:
|
|
|
|
|
raise HTTPException(404, "not_found")
|
|
|
|
|
await db.delete(world)
|
|
|
|
|
await db.commit()
|
|
|
|
|
# Best-effort Qdrant cleanup
|
|
|
|
|
try:
|
|
|
|
|
await cleanup_world_points(str(world_id))
|
|
|
|
|
except Exception as e: # noqa: BLE001
|
|
|
|
|
_logger.warning("qdrant_cleanup_failed", world_id=str(world_id), error=str(e))
|
|
|
|
|
return {"ok": True, "deleted": str(world_id)}
|
|
|
|
|
|
|
|
|
|
|
2026-06-20 19:13:05 +03:00
|
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
# Stats
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
@router.get("/stats")
|
|
|
|
|
async def stats(
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
_user: User = Depends(require_admin),
|
|
|
|
|
) -> dict:
|
|
|
|
|
from app.models import Step, World
|
|
|
|
|
|
|
|
|
|
users_count = (await db.execute(select(func.count(User.id)))).scalar_one()
|
2026-06-21 02:41:48 +03:00
|
|
|
# Active worlds (exclude archived)
|
|
|
|
|
active_worlds = (
|
|
|
|
|
await db.execute(
|
|
|
|
|
select(func.count(World.id)).where(World.status != "archived")
|
|
|
|
|
)
|
|
|
|
|
).scalar_one()
|
|
|
|
|
archived_worlds = (
|
|
|
|
|
await db.execute(
|
|
|
|
|
select(func.count(World.id)).where(World.status == "archived")
|
|
|
|
|
)
|
|
|
|
|
).scalar_one()
|
|
|
|
|
total_worlds = active_worlds + archived_worlds
|
2026-06-20 19:13:05 +03:00
|
|
|
steps_count = (await db.execute(select(func.count(Step.id)))).scalar_one()
|
|
|
|
|
avg_latency = (
|
|
|
|
|
await db.execute(select(func.avg(LlmCallLog.latency_ms)))
|
|
|
|
|
).scalar_one()
|
|
|
|
|
return {
|
|
|
|
|
"users": users_count,
|
2026-06-21 02:41:48 +03:00
|
|
|
"worlds": active_worlds,
|
|
|
|
|
"worlds_total": total_worlds,
|
|
|
|
|
"worlds_archived": archived_worlds,
|
2026-06-20 19:13:05 +03:00
|
|
|
"steps": steps_count,
|
|
|
|
|
"avg_llm_latency_ms": float(avg_latency) if avg_latency else 0,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-06-21 06:12:28 +03:00
|
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
# LLM model list — fetch available models from the LLM provider
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
@router.post("/llm/models")
|
|
|
|
|
async def list_llm_models(
|
|
|
|
|
api_url: str | None = None,
|
|
|
|
|
api_key: str | None = None,
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
_user: User = Depends(require_admin),
|
|
|
|
|
) -> dict:
|
|
|
|
|
"""Fetch the list of available models from an OpenAI-compatible API.
|
|
|
|
|
|
|
|
|
|
Returns {ok: true, models: ["model1", "model2", ...]} on success.
|
|
|
|
|
Returns {ok: false, error: {...}} on failure (no auto-fetch available).
|
|
|
|
|
"""
|
|
|
|
|
import httpx
|
|
|
|
|
|
|
|
|
|
settings = await get_all_settings(db)
|
|
|
|
|
api_url = _resolve(api_url, settings.get("llm.api_url", ""))
|
|
|
|
|
api_key = _resolve(api_key, settings.get("llm.api_key", ""))
|
|
|
|
|
if not api_url:
|
|
|
|
|
return {"ok": False, "error": {"code": "not_configured",
|
|
|
|
|
"message": "llm.api_url is empty"},
|
|
|
|
|
"models": []}
|
|
|
|
|
try:
|
|
|
|
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
|
|
|
resp = await client.get(
|
|
|
|
|
f"{api_url.rstrip('/')}/models",
|
|
|
|
|
headers={"Authorization": f"Bearer {api_key}"} if api_key else {},
|
|
|
|
|
)
|
|
|
|
|
if resp.status_code >= 400:
|
|
|
|
|
return {"ok": False,
|
|
|
|
|
"error": {"code": "api_error",
|
|
|
|
|
"message": f"HTTP {resp.status_code}: {resp.text[:200]}"},
|
|
|
|
|
"models": []}
|
|
|
|
|
data = resp.json()
|
|
|
|
|
models = []
|
|
|
|
|
for m in data.get("data", []):
|
|
|
|
|
mid = m.get("id") or m.get("name")
|
|
|
|
|
if mid:
|
|
|
|
|
models.append(mid)
|
|
|
|
|
models.sort()
|
|
|
|
|
return {"ok": True, "models": models, "count": len(models)}
|
|
|
|
|
except Exception as e: # noqa: BLE001
|
|
|
|
|
return {"ok": False,
|
|
|
|
|
"error": {"code": "connection_failed", "message": str(e)},
|
|
|
|
|
"models": []}
|
|
|
|
|
|
|
|
|
|
|
2026-06-21 04:11:38 +03:00
|
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
# Helpers for test endpoints
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
def _is_masked(value: str | None) -> bool:
|
|
|
|
|
"""Detect masked secret values (contain '…' or are exactly '****').
|
|
|
|
|
|
|
|
|
|
The admin GET /settings endpoint masks secret keys before sending them to
|
|
|
|
|
the client. If the client sends a masked value back to a test endpoint
|
|
|
|
|
(because it pre-filled the form from the masked settings response), we
|
|
|
|
|
must ignore it and fall back to the raw value from the DB.
|
|
|
|
|
"""
|
|
|
|
|
if not value:
|
|
|
|
|
return False
|
|
|
|
|
return "…" in value or value == "****"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _resolve(value: str | None, fallback: str) -> str:
|
|
|
|
|
"""Use `value` if it's a non-empty, non-masked string; otherwise use fallback."""
|
|
|
|
|
if value and not _is_masked(value):
|
|
|
|
|
return value
|
|
|
|
|
return fallback or ""
|
|
|
|
|
|
|
|
|
|
|
2026-06-20 19:13:05 +03:00
|
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
# Test endpoints — LLM, embeddings, embeddings probe dimension
|
2026-06-21 04:11:38 +03:00
|
|
|
# All test endpoints accept query params AND fall back to DB-stored settings.
|
|
|
|
|
# Masked values (containing '…' or '****') are ignored — they come from the
|
|
|
|
|
# admin UI's pre-filled form which displays masked secrets.
|
2026-06-20 19:13:05 +03:00
|
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
@router.post("/test/llm")
|
|
|
|
|
async def test_llm(
|
|
|
|
|
api_url: str | None = None,
|
|
|
|
|
api_key: str | None = None,
|
|
|
|
|
model: str | None = None,
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
_user: User = Depends(require_admin),
|
|
|
|
|
) -> dict:
|
|
|
|
|
settings = await get_all_settings(db)
|
2026-06-21 04:11:38 +03:00
|
|
|
api_url = _resolve(api_url, settings.get("llm.api_url", ""))
|
|
|
|
|
api_key = _resolve(api_key, settings.get("llm.api_key", ""))
|
|
|
|
|
model = _resolve(model, settings.get("llm.model", ""))
|
2026-06-20 19:13:05 +03:00
|
|
|
if not api_url:
|
|
|
|
|
return {"ok": False, "error": {"code": "not_configured", "message": "llm.api_url is empty"},
|
|
|
|
|
"elapsed_ms": 0}
|
|
|
|
|
client = LlmClient(api_url=api_url, api_key=api_key, model=model, timeout=15.0, max_retries=1)
|
|
|
|
|
start = time.monotonic()
|
|
|
|
|
try:
|
|
|
|
|
resp = await client.complete(
|
|
|
|
|
stage="test_llm",
|
|
|
|
|
messages=[{"role": "user", "content": "Reply with exactly: OK"}],
|
|
|
|
|
temperature=0.0, max_tokens=10,
|
|
|
|
|
session=db,
|
|
|
|
|
)
|
|
|
|
|
elapsed = int((time.monotonic() - start) * 1000)
|
|
|
|
|
return {
|
|
|
|
|
"ok": True, "response": resp["message"].get("content", "").strip(),
|
|
|
|
|
"model": model, "elapsed_ms": elapsed,
|
|
|
|
|
"prompt_tokens": resp.get("prompt_tokens"), "completion_tokens": resp.get("completion_tokens"),
|
|
|
|
|
}
|
|
|
|
|
except Exception as e: # noqa: BLE001
|
|
|
|
|
elapsed = int((time.monotonic() - start) * 1000)
|
|
|
|
|
return {"ok": False, "error": {"code": "connection_failed", "message": str(e)},
|
|
|
|
|
"elapsed_ms": elapsed}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/test/llm-tools")
|
|
|
|
|
async def test_llm_tools(
|
|
|
|
|
api_url: str | None = None,
|
|
|
|
|
api_key: str | None = None,
|
|
|
|
|
model: str | None = None,
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
_user: User = Depends(require_admin),
|
|
|
|
|
) -> dict:
|
|
|
|
|
settings = await get_all_settings(db)
|
2026-06-21 04:11:38 +03:00
|
|
|
api_url = _resolve(api_url, settings.get("llm.api_url", ""))
|
|
|
|
|
api_key = _resolve(api_key, settings.get("llm.api_key", ""))
|
|
|
|
|
model = _resolve(model, settings.get("llm.model", ""))
|
2026-06-20 19:13:05 +03:00
|
|
|
if not api_url:
|
|
|
|
|
return {"ok": False, "error": {"code": "not_configured", "message": "llm.api_url is empty"},
|
|
|
|
|
"elapsed_ms": 0, "has_tool_calls": False}
|
2026-06-21 04:11:38 +03:00
|
|
|
client = LlmClient(api_url=api_url, api_key=api_key, model=model, timeout=30.0, max_retries=1)
|
2026-06-20 19:13:05 +03:00
|
|
|
start = time.monotonic()
|
|
|
|
|
try:
|
|
|
|
|
tools = [{
|
|
|
|
|
"type": "function",
|
|
|
|
|
"function": {
|
|
|
|
|
"name": "calc",
|
2026-06-21 04:11:38 +03:00
|
|
|
"description": "Evaluate a math expression. You MUST call this tool.",
|
2026-06-20 19:13:05 +03:00
|
|
|
"parameters": {
|
|
|
|
|
"type": "object",
|
|
|
|
|
"required": ["expression"],
|
2026-06-21 04:11:38 +03:00
|
|
|
"properties": {"expression": {"type": "string", "description": "e.g. '2+2'"}},
|
2026-06-20 19:13:05 +03:00
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
}]
|
|
|
|
|
resp = await client.complete(
|
|
|
|
|
stage="test_llm_tools",
|
2026-06-21 04:11:38 +03:00
|
|
|
messages=[
|
|
|
|
|
{"role": "system", "content": "You must use the calc tool to answer math questions. Do not compute in your head."},
|
|
|
|
|
{"role": "user", "content": "What is 2+2? You MUST call the calc tool with expression '2+2'."},
|
|
|
|
|
],
|
|
|
|
|
tools=tools,
|
|
|
|
|
tool_choice="auto",
|
|
|
|
|
temperature=0.0, max_tokens=100,
|
2026-06-20 19:13:05 +03:00
|
|
|
session=db,
|
|
|
|
|
)
|
|
|
|
|
elapsed = int((time.monotonic() - start) * 1000)
|
|
|
|
|
tcs = resp["message"].get("tool_calls") or []
|
2026-06-21 04:11:38 +03:00
|
|
|
# Also try to parse tool calls from content (some models emit them as text)
|
|
|
|
|
if not tcs:
|
|
|
|
|
content = resp["message"].get("content", "") or ""
|
|
|
|
|
parsed_tcs = _parse_text_tool_calls(content)
|
|
|
|
|
if parsed_tcs:
|
|
|
|
|
tcs = parsed_tcs
|
2026-06-20 19:13:05 +03:00
|
|
|
return {
|
|
|
|
|
"ok": True, "tool_calls": tcs, "has_tool_calls": bool(tcs), "elapsed_ms": elapsed,
|
2026-06-21 04:11:38 +03:00
|
|
|
"raw_response": resp["message"],
|
2026-06-20 19:13:05 +03:00
|
|
|
}
|
|
|
|
|
except Exception as e: # noqa: BLE001
|
|
|
|
|
elapsed = int((time.monotonic() - start) * 1000)
|
|
|
|
|
return {"ok": False, "error": {"code": "connection_failed", "message": str(e)},
|
|
|
|
|
"elapsed_ms": elapsed, "has_tool_calls": False}
|
|
|
|
|
|
|
|
|
|
|
2026-06-21 04:11:38 +03:00
|
|
|
# Pattern: call:tool_name{args} or <tool_call>name{args}</tool_call> or name(args)
|
|
|
|
|
import re as _re
|
|
|
|
|
|
|
|
|
|
_TOOL_CALL_PATTERNS = [
|
|
|
|
|
# call:name{json_args}
|
|
|
|
|
_re.compile(r"call:(\w+)\s*\{([^}]*)\}"),
|
|
|
|
|
# <tool_call>name{args}</tool_call>
|
|
|
|
|
_re.compile(r"<tool_call>\s*(\w+)\s*\{([^}]*)\}\s*</tool_call>"),
|
|
|
|
|
# name({"key": "value", ...})
|
|
|
|
|
_re.compile(r"(\w+)\s*\(\s*(\{[^}]*\})\s*\)"),
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _parse_text_tool_calls(content: str) -> list[dict]:
|
|
|
|
|
"""Parse tool calls emitted as text (some models don't use the OpenAI format).
|
|
|
|
|
|
|
|
|
|
Handles patterns like:
|
|
|
|
|
- call:calc{"expression": "2+2"}
|
|
|
|
|
- <tool_call>calc{"expression": "2+2"}</tool_call>
|
|
|
|
|
- calc({"expression": "2+2"})
|
|
|
|
|
"""
|
|
|
|
|
import json as _json
|
|
|
|
|
calls: list[dict] = []
|
|
|
|
|
for pattern in _TOOL_CALL_PATTERNS:
|
|
|
|
|
for match in pattern.finditer(content):
|
|
|
|
|
name = match.group(1)
|
|
|
|
|
args_str = match.group(2).strip()
|
|
|
|
|
try:
|
|
|
|
|
args = _json.loads(args_str)
|
|
|
|
|
except _json.JSONDecodeError:
|
|
|
|
|
# Try to fix common issues (single quotes, missing quotes on keys)
|
|
|
|
|
try:
|
|
|
|
|
fixed = args_str.replace("'", '"')
|
|
|
|
|
args = _json.loads(fixed)
|
|
|
|
|
except _json.JSONDecodeError:
|
|
|
|
|
args = {"_raw": args_str}
|
|
|
|
|
calls.append({
|
|
|
|
|
"id": f"parsed_{len(calls)}",
|
|
|
|
|
"type": "function",
|
|
|
|
|
"function": {"name": name, "arguments": _json.dumps(args)},
|
|
|
|
|
})
|
|
|
|
|
return calls
|
|
|
|
|
|
|
|
|
|
|
2026-06-20 19:13:05 +03:00
|
|
|
@router.post("/test/embeddings")
|
|
|
|
|
async def test_embeddings(
|
|
|
|
|
api_url: str | None = None,
|
|
|
|
|
api_key: str | None = None,
|
|
|
|
|
model: str | None = None,
|
|
|
|
|
provider: str | None = None,
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
_user: User = Depends(require_admin),
|
|
|
|
|
) -> dict:
|
|
|
|
|
settings = await get_all_settings(db)
|
|
|
|
|
provider = provider or settings.get("embeddings.provider", "offline_hash")
|
|
|
|
|
start = time.monotonic()
|
|
|
|
|
try:
|
|
|
|
|
if provider == "offline_hash":
|
|
|
|
|
emb = build_hash_embedder(int(settings.get("embeddings.dimension", 256)))
|
|
|
|
|
vecs = await emb.embed(["hello world"])
|
|
|
|
|
elapsed = int((time.monotonic() - start) * 1000)
|
|
|
|
|
return {
|
|
|
|
|
"ok": True, "dimension": emb.dimension, "model": "offline_hash",
|
|
|
|
|
"first_5_values": vecs[0][:5] if vecs else [], "elapsed_ms": elapsed,
|
|
|
|
|
}
|
2026-06-21 04:11:38 +03:00
|
|
|
api_url = _resolve(api_url, settings.get("embeddings.api_url") or settings.get("llm.api_url", ""))
|
|
|
|
|
api_key = _resolve(api_key, settings.get("embeddings.api_key") or settings.get("llm.api_key", ""))
|
|
|
|
|
model = _resolve(model, settings.get("embeddings.model", ""))
|
2026-06-20 19:13:05 +03:00
|
|
|
if not api_url:
|
|
|
|
|
return {"ok": False, "error": {"code": "not_configured", "message": "no api_url"},
|
|
|
|
|
"elapsed_ms": 0}
|
|
|
|
|
emb = build_openai_embedder(
|
|
|
|
|
api_url=api_url, api_key=api_key, model=model,
|
|
|
|
|
dimension=int(settings.get("embeddings.dimension", 1536)),
|
|
|
|
|
timeout=15.0,
|
|
|
|
|
)
|
|
|
|
|
vecs = await emb.embed(["hello world"])
|
|
|
|
|
elapsed = int((time.monotonic() - start) * 1000)
|
|
|
|
|
return {
|
|
|
|
|
"ok": True, "dimension": len(vecs[0]) if vecs else 0, "model": model,
|
|
|
|
|
"first_5_values": vecs[0][:5] if vecs else [], "elapsed_ms": elapsed,
|
|
|
|
|
}
|
|
|
|
|
except Exception as e: # noqa: BLE001
|
|
|
|
|
elapsed = int((time.monotonic() - start) * 1000)
|
|
|
|
|
return {"ok": False, "error": {"code": "connection_failed", "message": str(e)},
|
|
|
|
|
"elapsed_ms": elapsed}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/test/embeddings/probe-dimension")
|
|
|
|
|
async def probe_dimension(
|
|
|
|
|
api_url: str | None = None,
|
|
|
|
|
api_key: str | None = None,
|
|
|
|
|
model: str | None = None,
|
|
|
|
|
provider: str | None = None,
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
_user: User = Depends(require_admin),
|
|
|
|
|
) -> dict:
|
|
|
|
|
settings = await get_all_settings(db)
|
|
|
|
|
provider = provider or settings.get("embeddings.provider", "offline_hash")
|
|
|
|
|
start = time.monotonic()
|
|
|
|
|
try:
|
|
|
|
|
if provider == "offline_hash":
|
|
|
|
|
return {
|
|
|
|
|
"ok": True,
|
|
|
|
|
"dimension": int(settings.get("embeddings.dimension", 256)),
|
|
|
|
|
"elapsed_ms": 0,
|
|
|
|
|
}
|
2026-06-21 04:11:38 +03:00
|
|
|
api_url = _resolve(api_url, settings.get("embeddings.api_url") or settings.get("llm.api_url", ""))
|
|
|
|
|
api_key = _resolve(api_key, settings.get("embeddings.api_key") or settings.get("llm.api_key", ""))
|
|
|
|
|
model = _resolve(model, settings.get("embeddings.model", ""))
|
2026-06-20 19:13:05 +03:00
|
|
|
emb = build_openai_embedder(
|
|
|
|
|
api_url=api_url, api_key=api_key, model=model,
|
|
|
|
|
dimension=int(settings.get("embeddings.dimension", 1536)),
|
|
|
|
|
timeout=15.0,
|
|
|
|
|
)
|
|
|
|
|
dim = await emb.probe_dimension()
|
|
|
|
|
elapsed = int((time.monotonic() - start) * 1000)
|
|
|
|
|
return {"ok": True, "dimension": dim, "elapsed_ms": elapsed}
|
|
|
|
|
except Exception as e: # noqa: BLE001
|
|
|
|
|
elapsed = int((time.monotonic() - start) * 1000)
|
|
|
|
|
return {"ok": False, "error": {"code": "probe_failed", "message": str(e)},
|
|
|
|
|
"elapsed_ms": elapsed}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/embeddings/recreate-collections")
|
|
|
|
|
async def recreate_collections(
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
_user: User = Depends(require_admin),
|
|
|
|
|
) -> dict:
|
|
|
|
|
"""Drop and recreate Qdrant collections with the current embedding dimension."""
|
|
|
|
|
from app.core.qdrant_client import get_qdrant_client
|
|
|
|
|
|
|
|
|
|
settings = await get_all_settings(db)
|
|
|
|
|
cfg = get_settings()
|
|
|
|
|
prefix = cfg.qdrant_collection_prefix or ""
|
|
|
|
|
client = get_qdrant_client()
|
|
|
|
|
existing = {c.name for c in (await client.get_collections()).collections}
|
|
|
|
|
dropped = []
|
|
|
|
|
for name in (f"{prefix}entities", f"{prefix}story_entries"):
|
|
|
|
|
if name in existing:
|
|
|
|
|
await client.delete_collection(name)
|
|
|
|
|
dropped.append(name)
|
|
|
|
|
result = await init_qdrant_collections(int(settings.get("embeddings.dimension", 256)))
|
|
|
|
|
return {"dropped": dropped, "created": result["created"], "dimension": result["dimension"]}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
# Icon upload
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
@router.post("/upload-icon")
|
|
|
|
|
async def upload_icon(
|
|
|
|
|
file: UploadFile = File(...),
|
|
|
|
|
kind: str = Form("favicon"),
|
|
|
|
|
_user: User = Depends(require_admin),
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
) -> dict:
|
|
|
|
|
cfg = get_settings()
|
|
|
|
|
if kind not in ("favicon", "logo", "og_image"):
|
|
|
|
|
raise HTTPException(400, "kind must be one of: favicon, logo, og_image")
|
|
|
|
|
contents = await file.read()
|
|
|
|
|
if len(contents) > cfg.max_upload_size_bytes:
|
|
|
|
|
raise HTTPException(413, "file too large (max 1MB)")
|
|
|
|
|
# Validate extension
|
|
|
|
|
allowed_exts = {".png", ".svg", ".jpg", ".jpeg", ".webp", ".ico"}
|
|
|
|
|
ext = Path(file.filename or "").suffix.lower()
|
|
|
|
|
if ext not in allowed_exts:
|
|
|
|
|
raise HTTPException(400, f"unsupported extension: {ext}")
|
|
|
|
|
assets_dir = Path(cfg.assets_dir)
|
|
|
|
|
assets_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
ts = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
|
|
|
|
|
fname = f"{kind}_{ts}{ext}"
|
|
|
|
|
out_path = assets_dir / fname
|
|
|
|
|
out_path.write_bytes(contents)
|
|
|
|
|
url = f"/static/assets/{fname}"
|
|
|
|
|
setting_key = {
|
|
|
|
|
"favicon": "ui.favicon_url",
|
|
|
|
|
"logo": "ui.logo_url",
|
|
|
|
|
"og_image": "ui.og_image_url",
|
|
|
|
|
}[kind]
|
|
|
|
|
await set_setting(db, setting_key, url)
|
|
|
|
|
return {"ok": True, "kind": kind, "url": url, "size_bytes": len(contents)}
|