oclaw/interfaces/admin/models_api.py
oliver 3d27a01879 补充并收敛管理台与运行时改动,完成本轮代码提交。
包含模型配置/thinking 透传、MCP 运行时与安装体验增强、以及会话渲染与图片显示链路修复,确保流式与历史展示行为一致。

Made-with: Cursor
2026-04-28 03:38:39 +08:00

574 lines
24 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 oclaw.runtime.agents.factory import DEFAULT_OLLAMA_BASE_URL, DEFAULT_OLLAMA_MODEL
from oclaw.runtime.agents.specialists import (
AGENT_PROFILE_BINDINGS_KEY,
agent_role_ids,
dump_agent_profile_bindings,
parse_agent_profile_bindings,
)
from oclaw.platform.config.paths import db_path
from oclaw.runtime.orchestration.evaluation import eval_summary
from oclaw.platform.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"})
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 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 = SqliteStore(db_path())
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),
# 便于核对「浏览器连的是哪台网关、网关读的是哪个库文件」
"db_path": db_path(),
}
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 = SqliteStore(db_path())
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 = SqliteStore(db_path())
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("/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 = SqliteStore(db_path())
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 = SqliteStore(db_path())
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 = SqliteStore(db_path())
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 = SqliteStore(db_path())
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 = SqliteStore(db_path())
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 = SqliteStore(db_path())
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 = SqliteStore(db_path())
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 = SqliteStore(db_path())
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 = SqliteStore(db_path())
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 = SqliteStore(db_path())
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 = SqliteStore(db_path())
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 = SqliteStore(db_path())
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 = SqliteStore(db_path())
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"]