oclaw/interfaces/admin/models_api.py
oliver d14e9d3596 feat(persistence): PostgreSQL assistant store, chat persist fixes, gateway scripts
- Add SQLAlchemy Core repos, pg adapter/compat, assistant_store factory, Alembic bootstrap and migration/cutover scripts.

- Harden chat_message writes (NUL scrub for PG), turn_uuid on attempt failure, WS turn_runner fallbacks and gateway executed_turn_uuid init.

- start_gateway: log paths, PS7 stderr handling via cmd, background stdout/stderr redirect; runtime assistant_runtime_log_dir export.

- Ops: clear_all_chat_sessions with PG-only --postgresql and env-gated wipe; clear_postgres_chat_sessions.ps1.

- Tests: SA repos, pg compat, persist fallback, smoke env isolation; CI and docs touch-ups.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-14 09:33:50 +08:00

668 lines
28 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Admin API: LLM profiles, agent bindings, UI language, eval (parity with Streamlit settings)."""
from __future__ import annotations
import csv
import io
import json
import os
from collections.abc import Callable
from typing import Any
from fastapi import APIRouter, Body, Header, HTTPException, Query
from fastapi.responses import Response
from runtime.agents.factory import DEFAULT_OLLAMA_BASE_URL, DEFAULT_OLLAMA_MODEL
from runtime.agents.specialists import (
AGENT_PROFILE_BINDINGS_KEY,
agent_role_ids,
dump_agent_profile_bindings,
parse_agent_profile_bindings,
)
from svc.config.paths import db_path
from runtime.orchestration.evaluation import eval_summary
from svc.persistence.assistant_store import get_assistant_store
from svc.persistence.sqlite_store import (
LLM_BUILTIN_OLLAMA_PROFILE_ID,
SqliteStore,
active_llm_profile_setting_key,
agent_profile_bindings_setting_key,
is_administrator_model_pool,
)
_LLM_MODE_OPTIONS = frozenset({"openai", "openai_responses", "anthropic", "google", "ollama", "rule"})
_LLM_USER_CREATE_MODES = frozenset({"openai", "anthropic", "google", "ollama", "rule"})
_OPS_AI_ACTIVE_KEY = "ops_ai_active_llm_profile_id"
_OPS_AI_BINDINGS_KEY = "ops_ai_agent_profile_bindings"
_CHAT_MODEL_SELECTOR_VISIBLE_KEY = "AIA_CHAT_MODEL_SELECTOR_VISIBLE"
def _truthy(v: Any, *, default: bool = False) -> bool:
if v is None:
return bool(default)
s = str(v).strip().lower()
if not s:
return bool(default)
return s in {"1", "true", "yes", "on"}
def _require_permission(ctx: dict[str, Any], permission: str) -> None:
perms = set(str(x) for x in (ctx.get("permissions") or []))
if permission in perms:
return
if str(ctx.get("role") or "") == "owner":
return
raise HTTPException(status_code=403, detail=f"forbidden:{permission}")
def _require_models_mutate(ctx: dict[str, Any]) -> None:
"""administrator 编辑全局池需 tenant:write;其余用户编辑自己的复制池仅需 read。"""
uname = str(ctx.get("username") or "").strip()
if is_administrator_model_pool(uname):
_require_permission(ctx, "admin:tenant:write")
else:
_require_permission(ctx, "admin:read")
def _models_list_kwargs(ctx: dict[str, Any]) -> dict[str, Any]:
uid = str(ctx.get("user_id") or "").strip()
uname = str(ctx.get("username") or "").strip()
tid = str(ctx.get("tenant_id") or "").strip()
if not uid:
return {}
out: dict[str, Any] = {"viewer_user_id": uid, "viewer_username": uname or None}
if tid:
out["viewer_tenant_id"] = tid
return out
def _active_key(ctx: dict[str, Any]) -> str:
uid = str(ctx.get("user_id") or "").strip()
uname = str(ctx.get("username") or "").strip()
if not uid:
return "active_llm_profile_id"
return active_llm_profile_setting_key(uid, uname or None)
def _bindings_key(ctx: dict[str, Any]) -> str:
uid = str(ctx.get("user_id") or "").strip()
uname = str(ctx.get("username") or "").strip()
if not uid:
return AGENT_PROFILE_BINDINGS_KEY
return agent_profile_bindings_setting_key(uid, uname or None)
def _assert_profile_mutable(ctx: dict[str, Any], prof: dict[str, Any] | None) -> dict[str, Any]:
if not prof:
raise HTTPException(status_code=404, detail="profile_not_found")
if prof.get("is_builtin"):
return prof
own = str(prof.get("owner_user_id") or "").strip()
uid = str(ctx.get("user_id") or "").strip()
uname = str(ctx.get("username") or "").strip()
if is_administrator_model_pool(uname):
return prof
if own == uid:
return prof
raise HTTPException(status_code=403, detail="profile_forbidden")
def _can_manage_llm_grants(ctx: dict[str, Any]) -> bool:
uname = str(ctx.get("username") or "").strip()
if not is_administrator_model_pool(uname):
return False
perms = set(str(x) for x in (ctx.get("permissions") or []))
if "admin:tenant:write" in perms:
return True
return str(ctx.get("role") or "") == "owner"
def _require_grant_manager(ctx: dict[str, Any]) -> None:
if not _can_manage_llm_grants(ctx):
raise HTTPException(status_code=403, detail="grants_administrator_only")
def _profile_shareable_for_admin_grant(ctx: dict[str, Any], prof: dict[str, Any] | None) -> bool:
"""仅全局池(无 owner)或操作者本人名下的 profile 可被授权给团队/用户。"""
if not prof or prof.get("is_builtin"):
return False
own = str(prof.get("owner_user_id") or "").strip()
uid = str(ctx.get("user_id") or "").strip()
if not own:
return True
return bool(uid) and own == uid
def _normalize_active(
store: SqliteStore, profiles: list[dict[str, Any]], profile_ids: list[str], ctx: dict[str, Any]
) -> str:
if not profile_ids:
return ""
key = _active_key(ctx)
active_id = str(store.get_setting(key) or "").strip()
if active_id not in profile_ids:
store.set_setting(key, LLM_BUILTIN_OLLAMA_PROFILE_ID)
active_id = LLM_BUILTIN_OLLAMA_PROFILE_ID
if active_id not in profile_ids:
active_id = profile_ids[0]
store.set_setting(key, active_id)
return active_id
def _normalize_active_by_key(store: SqliteStore, *, key: str, profile_ids: list[str]) -> str:
if not profile_ids:
return ""
active_id = str(store.get_setting(key) or "").strip()
if active_id not in profile_ids:
active_id = LLM_BUILTIN_OLLAMA_PROFILE_ID if LLM_BUILTIN_OLLAMA_PROFILE_ID in profile_ids else profile_ids[0]
store.set_setting(key, active_id)
return active_id
def include_model_mgmt_routes(
router: APIRouter,
*,
resolve_auth: Callable[[SqliteStore, str | None], dict[str, Any]],
) -> None:
mg = APIRouter(prefix="/admin/api/models", tags=["models"])
@mg.get("")
def api_models_state(
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
store = get_assistant_store()
ctx = resolve_auth(store, authorization)
_require_permission(ctx, "admin:read")
uid = str(ctx.get("user_id") or "").strip()
uname = str(ctx.get("username") or "").strip()
lk = _models_list_kwargs(ctx)
profiles = store.list_llm_profiles(visible_only=True, **lk)
profile_ids = [str(p["id"]) for p in profiles]
active_id = _normalize_active(store, profiles, profile_ids, ctx)
bindings = parse_agent_profile_bindings(store.get_setting(_bindings_key(ctx)))
ui_lang = str(store.get_setting("ui_lang") or "zh").strip().lower()
if ui_lang not in ("zh", "en"):
ui_lang = "zh"
secret = ""
if active_id and active_id in profile_ids:
active_prof = next((p for p in profiles if str(p.get("id") or "") == active_id), None)
if is_administrator_model_pool(uname) or (active_prof and active_prof.get("mutable", True)):
secret = store.get_llm_profile_secret(active_id) or ""
out: dict[str, Any] = {
"ok": True,
"active_llm_profile_id": active_id,
"profiles": profiles,
"bindings": bindings,
"ui_lang": ui_lang,
"builtin_ollama_profile_id": LLM_BUILTIN_OLLAMA_PROFILE_ID,
"has_openai_api_key_env": bool((os.getenv("OPENAI_API_KEY") or "").strip()),
"role_ids": list(agent_role_ids()),
"profile_secret": secret,
"can_manage_llm_grants": _can_manage_llm_grants(ctx),
"chat_model_selector_visible": _truthy(store.get_setting(_CHAT_MODEL_SELECTOR_VISIBLE_KEY), default=True),
# 便于核对「浏览器连的是哪台网关、网关读的是哪个库文件」
"db_path": db_path(),
}
ops_ai_active_id = _normalize_active_by_key(store, key=_OPS_AI_ACTIVE_KEY, profile_ids=profile_ids)
raw_ops_bindings = parse_agent_profile_bindings(store.get_setting(_OPS_AI_BINDINGS_KEY))
ops_ai_bindings: dict[str, str] = {}
changed = False
for rid in agent_role_ids():
pid = str(raw_ops_bindings.get(rid) or "").strip()
if pid and pid not in profile_ids:
pid = ""
changed = True
ops_ai_bindings[rid] = pid
if changed:
store.set_setting(_OPS_AI_BINDINGS_KEY, dump_agent_profile_bindings(ops_ai_bindings))
out["ops_ai_active_llm_profile_id"] = ops_ai_active_id
out["ops_ai_bindings"] = ops_ai_bindings
return out
@mg.post("/active")
def api_models_set_active(
payload: dict[str, Any] | None = Body(default=None),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
payload = payload or {}
store = get_assistant_store()
ctx = resolve_auth(store, authorization)
# 与能进入控制台一致:切换「当前选用」不写密钥,仅需读权限即可。
_require_permission(ctx, "admin:read")
profiles = store.list_llm_profiles(visible_only=True, **_models_list_kwargs(ctx))
profile_ids = [str(p["id"]) for p in profiles]
pid = str(payload.get("profile_id") or "").strip()
if pid not in profile_ids:
raise HTTPException(status_code=400, detail="invalid_profile_id")
store.set_setting(_active_key(ctx), pid)
return {"ok": True, "active_llm_profile_id": pid}
@mg.post("/bindings")
def api_models_set_bindings(
payload: dict[str, Any] | None = Body(default=None),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
payload = payload or {}
store = get_assistant_store()
ctx = resolve_auth(store, authorization)
_require_models_mutate(ctx)
profiles = store.list_llm_profiles(visible_only=True, **_models_list_kwargs(ctx))
profile_ids = set(str(p["id"]) for p in profiles)
raw = payload.get("bindings")
if not isinstance(raw, dict):
raise HTTPException(status_code=400, detail="bindings_object_required")
cur = parse_agent_profile_bindings(store.get_setting(_bindings_key(ctx)))
for rid in agent_role_ids():
v = raw.get(rid)
if v is None:
continue
s = str(v).strip()
if s and s not in profile_ids:
raise HTTPException(status_code=400, detail=f"invalid_binding:{rid}")
cur[rid] = s
store.set_setting(_bindings_key(ctx), dump_agent_profile_bindings(cur))
return {"ok": True, "bindings": cur}
@mg.post("/ops-ai/active")
def api_models_set_ops_ai_active(
payload: dict[str, Any] | None = Body(default=None),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
payload = payload or {}
store = get_assistant_store()
ctx = resolve_auth(store, authorization)
_require_models_mutate(ctx)
profiles = store.list_llm_profiles(visible_only=True, **_models_list_kwargs(ctx))
profile_ids = [str(p["id"]) for p in profiles]
pid = str(payload.get("profile_id") or "").strip()
if pid not in profile_ids:
raise HTTPException(status_code=400, detail="invalid_profile_id")
store.set_setting(_OPS_AI_ACTIVE_KEY, pid)
return {"ok": True, "ops_ai_active_llm_profile_id": pid}
@mg.post("/ops-ai/bindings")
def api_models_set_ops_ai_bindings(
payload: dict[str, Any] | None = Body(default=None),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
payload = payload or {}
store = get_assistant_store()
ctx = resolve_auth(store, authorization)
_require_models_mutate(ctx)
profiles = store.list_llm_profiles(visible_only=True, **_models_list_kwargs(ctx))
profile_ids = set(str(p["id"]) for p in profiles)
raw = payload.get("bindings")
if not isinstance(raw, dict):
raise HTTPException(status_code=400, detail="bindings_object_required")
cur = parse_agent_profile_bindings(store.get_setting(_OPS_AI_BINDINGS_KEY))
for rid in agent_role_ids():
v = raw.get(rid)
if v is None:
continue
s = str(v).strip()
if s and s not in profile_ids:
raise HTTPException(status_code=400, detail=f"invalid_binding:{rid}")
cur[rid] = s
store.set_setting(_OPS_AI_BINDINGS_KEY, dump_agent_profile_bindings(cur))
return {"ok": True, "ops_ai_bindings": cur}
@mg.post("/chat-ui")
def api_models_set_chat_ui(
payload: dict[str, Any] | None = Body(default=None),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
payload = payload or {}
store = get_assistant_store()
ctx = resolve_auth(store, authorization)
_require_permission(ctx, "admin:tenant:write")
visible = _truthy(payload.get("chat_model_selector_visible"), default=True)
store.set_setting(_CHAT_MODEL_SELECTOR_VISIBLE_KEY, "1" if visible else "0")
return {"ok": True, "chat_model_selector_visible": visible}
@mg.post("/profiles")
def api_models_create_profile(
payload: dict[str, Any] | None = Body(default=None),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
payload = payload or {}
store = get_assistant_store()
ctx = resolve_auth(store, authorization)
_require_models_mutate(ctx)
name = str(payload.get("name") or "").strip() or "新配置"
mode = str(payload.get("mode") or "openai").strip().lower()
if mode not in _LLM_USER_CREATE_MODES:
raise HTTPException(status_code=400, detail="invalid_mode")
if mode == "openai":
new_model = "gpt-4o-mini"
new_bu = ""
else:
new_model = DEFAULT_OLLAMA_MODEL
new_bu = DEFAULT_OLLAMA_BASE_URL
own: str | None = None
uid = str(ctx.get("user_id") or "").strip()
uname = str(ctx.get("username") or "").strip()
if uid and not is_administrator_model_pool(uname):
own = uid
pid = store.create_llm_profile(name=name, mode=mode, model=new_model, base_url=new_bu or None, owner_user_id=own)
store.set_setting(_active_key(ctx), pid)
prof = store.get_llm_profile(pid)
return {"ok": True, "profile_id": pid, "profile": prof}
@mg.patch("/profiles/{profile_id}")
def api_models_patch_profile(
profile_id: str,
payload: dict[str, Any] | None = Body(default=None),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
payload = payload or {}
store = get_assistant_store()
ctx = resolve_auth(store, authorization)
_require_models_mutate(ctx)
pid = str(profile_id or "").strip()
prof = _assert_profile_mutable(ctx, store.get_llm_profile(pid))
name = str(payload.get("name") if payload.get("name") is not None else prof.get("name") or "").strip() or "未命名"
mode_raw = str(payload.get("mode") if payload.get("mode") is not None else prof.get("mode") or "openai").strip().lower()
if pid == LLM_BUILTIN_OLLAMA_PROFILE_ID:
mode_save = "ollama"
else:
if mode_raw not in _LLM_MODE_OPTIONS:
raise HTTPException(status_code=400, detail="invalid_mode")
mode_save = mode_raw
model = payload.get("model")
base_url = payload.get("base_url")
thinking_mode_enabled = payload.get("thinking_mode_enabled")
if thinking_mode_enabled is None:
think_enabled: bool | None = None
else:
think_enabled = bool(thinking_mode_enabled)
reasoning_effort_raw = payload.get("reasoning_effort")
reasoning_effort: str | None = None
if reasoning_effort_raw is not None:
reasoning_effort = str(reasoning_effort_raw or "").strip().lower()
if reasoning_effort not in ("", "low", "medium", "high"):
raise HTTPException(status_code=400, detail="invalid_reasoning_effort")
model_s = str(model).strip() if model is not None else str(prof.get("model") or "").strip()
bu_s = str(base_url).strip() if base_url is not None else str(prof.get("base_url") or "").strip()
store.update_llm_profile(
profile_id=pid,
name=name,
mode=mode_save,
model=model_s or None,
base_url=bu_s or None,
thinking_mode_enabled=think_enabled,
reasoning_effort=reasoning_effort,
)
store.set_setting(_active_key(ctx), pid)
return {"ok": True, "profile": store.get_llm_profile(pid)}
@mg.post("/profiles/{profile_id}/secret")
def api_models_profile_secret(
profile_id: str,
payload: dict[str, Any] | None = Body(default=None),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
payload = payload or {}
store = get_assistant_store()
ctx = resolve_auth(store, authorization)
_require_models_mutate(ctx)
pid = str(profile_id or "").strip()
prof = _assert_profile_mutable(ctx, store.get_llm_profile(pid))
remember = bool(payload.get("remember"))
key_text = str(payload.get("secret") or "").strip()
mode_save = str(prof.get("mode") or "openai").strip().lower()
if pid == LLM_BUILTIN_OLLAMA_PROFILE_ID:
mode_save = "ollama"
if mode_save in ("openai", "ollama"):
if remember:
if key_text:
store.set_llm_profile_secret(pid, key_text)
elif mode_save == "openai":
raise HTTPException(status_code=400, detail="remember_key_empty")
else:
store.clear_llm_profile_secret(pid)
else:
store.clear_llm_profile_secret(pid)
return {"ok": True, "profile": store.get_llm_profile(pid)}
@mg.delete("/profiles/{profile_id}")
def api_models_delete_profile(
profile_id: str,
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
store = get_assistant_store()
ctx = resolve_auth(store, authorization)
_require_models_mutate(ctx)
pid = str(profile_id or "").strip()
_assert_profile_mutable(ctx, store.get_llm_profile(pid))
try:
store.delete_llm_profile(pid)
except ValueError:
raise HTTPException(status_code=400, detail="cannot_delete_builtin")
remaining = store.list_llm_profiles(visible_only=True, **_models_list_kwargs(ctx))
new_active = remaining[0]["id"] if remaining else LLM_BUILTIN_OLLAMA_PROFILE_ID
store.set_setting(_active_key(ctx), new_active)
return {"ok": True, "active_llm_profile_id": new_active}
@mg.get("/members")
def api_models_members(
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
store = get_assistant_store()
ctx = resolve_auth(store, authorization)
_require_grant_manager(ctx)
tid = str(ctx.get("tenant_id") or "").strip()
if not tid:
raise HTTPException(status_code=400, detail="tenant_required")
users = store.list_users(tenant_id=tid, limit=500, offset=0, include_inactive=True)
members: list[dict[str, Any]] = []
for u in users:
mid = str(u.get("id") or "").strip()
un = str(u.get("username") or "").strip()
if not mid:
continue
profs = store.list_llm_profiles(
visible_only=True,
viewer_user_id=mid,
viewer_username=un or None,
viewer_tenant_id=tid,
)
members.append(
{
"user_id": mid,
"username": un,
"display_name": str(u.get("display_name") or "").strip(),
"role": str(u.get("role") or "").strip(),
"profiles": profs,
}
)
return {"ok": True, "members": members}
@mg.get("/grants/tenant")
def api_models_grants_tenant_get(
profile_id: str = Query(...),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
store = get_assistant_store()
ctx = resolve_auth(store, authorization)
_require_grant_manager(ctx)
tid = str(ctx.get("tenant_id") or "").strip()
pid = str(profile_id or "").strip()
if not tid or not pid:
raise HTTPException(status_code=400, detail="tenant_or_profile_required")
granted = store.tenant_has_llm_profile_grant(tid, pid)
return {"ok": True, "granted": granted}
@mg.post("/grants/tenant")
def api_models_grants_tenant_create(
payload: dict[str, Any] | None = Body(default=None),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
payload = payload or {}
store = get_assistant_store()
ctx = resolve_auth(store, authorization)
_require_grant_manager(ctx)
tid = str(ctx.get("tenant_id") or "").strip()
pid = str(payload.get("profile_id") or "").strip()
if not tid or not pid:
raise HTTPException(status_code=400, detail="profile_id_required")
prof = store.get_llm_profile(pid)
if not _profile_shareable_for_admin_grant(ctx, prof):
raise HTTPException(status_code=403, detail="profile_not_shareable")
actor = str(ctx.get("user_id") or "").strip() or None
try:
gid = store.grant_llm_profile_to_tenant(
tenant_id=tid, profile_id=pid, created_by_user_id=actor
)
except ValueError as e:
code = str(e)
if code == "profile_not_found":
raise HTTPException(status_code=404, detail=code) from e
raise HTTPException(status_code=400, detail=code) from e
return {"ok": True, "grant_id": gid}
@mg.delete("/grants/tenant")
def api_models_grants_tenant_revoke(
profile_id: str = Query(...),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
store = get_assistant_store()
ctx = resolve_auth(store, authorization)
_require_grant_manager(ctx)
tid = str(ctx.get("tenant_id") or "").strip()
pid = str(profile_id or "").strip()
if not tid or not pid:
raise HTTPException(status_code=400, detail="profile_id_required")
n = store.revoke_llm_profile_tenant_grant(tenant_id=tid, profile_id=pid)
return {"ok": True, "removed": int(n)}
@mg.get("/grants")
def api_models_grants_list(
profile_id: str = Query(..., description="llm_profile id"),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
store = get_assistant_store()
ctx = resolve_auth(store, authorization)
_require_grant_manager(ctx)
tid = str(ctx.get("tenant_id") or "").strip()
pid = str(profile_id or "").strip()
if not tid or not pid:
raise HTTPException(status_code=400, detail="tenant_or_profile_required")
rows = store.list_llm_profile_grants_for_profile(tid, pid)
return {"ok": True, "grants": rows}
@mg.post("/grants")
def api_models_grants_create(
payload: dict[str, Any] | None = Body(default=None),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
payload = payload or {}
store = get_assistant_store()
ctx = resolve_auth(store, authorization)
_require_grant_manager(ctx)
tid = str(ctx.get("tenant_id") or "").strip()
pid = str(payload.get("profile_id") or "").strip()
uid = str(payload.get("user_id") or "").strip()
if not tid or not pid or not uid:
raise HTTPException(status_code=400, detail="profile_id_and_user_id_required")
prof = store.get_llm_profile(pid)
if not _profile_shareable_for_admin_grant(ctx, prof):
raise HTTPException(status_code=403, detail="profile_not_shareable")
actor = str(ctx.get("user_id") or "").strip() or None
try:
gid = store.grant_llm_profile_to_user(
tenant_id=tid,
profile_id=pid,
user_id=uid,
created_by_user_id=actor,
)
except ValueError as e:
code = str(e)
if code == "profile_not_found":
raise HTTPException(status_code=404, detail=code) from e
if code == "user_not_found":
raise HTTPException(status_code=404, detail=code) from e
raise HTTPException(status_code=400, detail=code) from e
return {"ok": True, "grant_id": gid}
@mg.delete("/grants")
def api_models_grants_revoke(
profile_id: str = Query(...),
user_id: str = Query(...),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
store = get_assistant_store()
ctx = resolve_auth(store, authorization)
_require_grant_manager(ctx)
tid = str(ctx.get("tenant_id") or "").strip()
pid = str(profile_id or "").strip()
uid = str(user_id or "").strip()
if not tid or not pid or not uid:
raise HTTPException(status_code=400, detail="profile_id_and_user_id_required")
n = store.revoke_llm_profile_grant(tenant_id=tid, profile_id=pid, user_id=uid)
return {"ok": True, "removed": int(n)}
@mg.get("/eval")
def api_models_eval(
authorization: str | None = Header(default=None),
limit_logs: int = Query(default=100, ge=1, le=500),
limit_summary: int = Query(default=500, ge=1, le=5000),
) -> dict[str, Any]:
store = get_assistant_store()
ctx = resolve_auth(store, authorization)
_require_permission(ctx, "admin:read")
summary = eval_summary(store, limit=limit_summary)
logs = store.list_agent_eval_logs(limit=limit_logs)
return {"ok": True, "summary": summary, "logs": logs}
_EVAL_EXPORT_FIELDS = (
"timestamp",
"session_id",
"specialist",
"task_kind",
"success",
"latency_ms",
"cost_hint",
"notes",
)
@mg.get("/eval/export")
def api_models_eval_export(
authorization: str | None = Header(default=None),
format: str = Query(default="csv", description="csv or json"),
limit: int = Query(default=100_000, ge=1, le=200_000),
) -> Response:
store = get_assistant_store()
ctx = resolve_auth(store, authorization)
_require_permission(ctx, "admin:read")
fmt = str(format or "csv").strip().lower()
rows = store.list_agent_eval_logs(limit=limit)
if fmt == "json":
body = json.dumps(rows, ensure_ascii=False, indent=2)
return Response(
content=body.encode("utf-8"),
media_type="application/json; charset=utf-8",
headers={
"Content-Disposition": 'attachment; filename="agent_eval_logs.json"',
},
)
if fmt != "csv":
raise HTTPException(status_code=400, detail="invalid_format")
buf = io.StringIO()
w = csv.DictWriter(buf, fieldnames=list(_EVAL_EXPORT_FIELDS), extrasaction="ignore")
w.writeheader()
for r in rows:
row = {k: r.get(k) for k in _EVAL_EXPORT_FIELDS}
if "success" in row:
row["success"] = 1 if bool(row.get("success")) else 0
w.writerow(row)
payload = "\ufeff" + buf.getvalue()
return Response(
content=payload.encode("utf-8"),
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": 'attachment; filename="agent_eval_logs.csv"'},
)
router.include_router(mg)
__all__ = ["include_model_mgmt_routes"]