fischer-agentkit/src/agentkit/server/routes/admin.py

1339 lines
42 KiB
Python

"""Admin REST routes — department CRUD + skill/KB bindings (U2).
This module hosts the *department-scoped* admin endpoints under
``/api/v1/admin/departments/*``. It is a sibling of the existing
``admin_router`` in :mod:`agentkit.server.routes.auth` (which hosts
session-management endpoints). The two routers coexist independently:
``auth.admin_router`` keeps its session endpoints, this module adds
department endpoints, and ``app.py`` mounts both under ``/api/v1``.
All endpoints here require the ``USER_MANAGE`` permission (admin role),
enforced by the :func:`_require_admin` dependency. The dependency is
re-defined locally to avoid a hard dependency on ``routes.auth`` at
import time (keeps the module self-contained and test-friendly).
"""
from __future__ import annotations
import logging
from datetime import datetime
from pathlib import Path
from typing import Any, Literal
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import PlainTextResponse
from pydantic import BaseModel, ConfigDict, EmailStr, Field
from agentkit.server.admin.department_service import get_department_service
from agentkit.server.admin.kb_service import get_kb_service
from agentkit.server.admin.llm_config_service import (
LlmConfigService,
get_llm_config_service,
)
from agentkit.server.admin.quota_service import get_quota_service
from agentkit.server.admin.skill_service import get_skill_service
from agentkit.server.admin.usage_service import get_usage_service
from agentkit.server.admin.user_service import get_user_service
from agentkit.server.auth.dependencies import require_authenticated
from agentkit.server.auth.models import DEFAULT_AUTH_DB_PATH, init_auth_db
from agentkit.server.auth.permissions import Permission, has_permission
logger = logging.getLogger(__name__)
admin_router = APIRouter(prefix="/admin", tags=["admin"])
# ---------------------------------------------------------------------------
# Local helpers (mirror routes.auth — kept local to avoid import cycles)
# ---------------------------------------------------------------------------
async def _require_admin(
request: Request,
user: dict[str, Any] = Depends(require_authenticated),
) -> dict[str, Any]:
"""Require the ``USER_MANAGE`` permission (admin role)."""
if not has_permission(user, Permission.USER_MANAGE):
raise HTTPException(status_code=403, detail="Admin permission required")
return user
def _resolve_db_path(request: Request) -> Path:
"""Resolve the auth DB path from ``app.state`` (mirrors routes.auth)."""
path = getattr(request.app.state, "auth_db_path", None)
return Path(path) if path else DEFAULT_AUTH_DB_PATH
async def _ensure_db(request: Request) -> Path:
"""Ensure the auth DB schema is up to date; returns the resolved path.
Always calls ``init_auth_db`` (idempotent) to ensure all tables
exist and pending migrations are applied — even when the DB file
already exists. This fixes the issue where existing V2 DBs were
never migrated to V3/V4 because the ``if not db_path.exists()``
guard skipped ``init_auth_db`` (U5/R8).
"""
db_path = _resolve_db_path(request)
await init_auth_db(db_path)
return db_path
# ---------------------------------------------------------------------------
# Pydantic request bodies
# ---------------------------------------------------------------------------
class DepartmentCreateRequest(BaseModel):
"""Body for ``POST /admin/departments``."""
model_config = ConfigDict(extra="forbid")
name: str
description: str = ""
class DepartmentUpdateRequest(BaseModel):
"""Body for ``PATCH /admin/departments/{id}``."""
model_config = ConfigDict(extra="forbid")
name: str | None = None
description: str | None = None
# ---------------------------------------------------------------------------
# Department CRUD endpoints
# ---------------------------------------------------------------------------
@admin_router.post("/departments", status_code=201)
async def create_department(
payload: DepartmentCreateRequest,
request: Request,
admin: dict[str, Any] = Depends(_require_admin),
) -> dict[str, Any]:
"""Create a new department.
Returns 201 with the department dict on success, 409 if a department
with the same name already exists.
"""
db_path = await _ensure_db(request)
svc = get_department_service()
try:
return await svc.create_department(db_path, payload.name, payload.description)
except ValueError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
@admin_router.get("/departments")
async def list_departments(
request: Request,
include_inactive: bool = True,
admin: dict[str, Any] = Depends(_require_admin),
) -> list[dict[str, Any]]:
"""List all departments."""
db_path = await _ensure_db(request)
svc = get_department_service()
return await svc.list_departments(db_path, include_inactive=include_inactive)
@admin_router.get("/departments/{department_id}")
async def get_department(
department_id: str,
request: Request,
admin: dict[str, Any] = Depends(_require_admin),
) -> dict[str, Any]:
"""Return a single department by id. 404 if not found."""
db_path = await _ensure_db(request)
svc = get_department_service()
dept = await svc.get_department(db_path, department_id)
if dept is None:
raise HTTPException(status_code=404, detail="Department not found")
return dept
@admin_router.patch("/departments/{department_id}")
async def update_department(
department_id: str,
payload: DepartmentUpdateRequest,
request: Request,
admin: dict[str, Any] = Depends(_require_admin),
) -> dict[str, Any]:
"""Partially update a department.
Returns 200 with the updated dict, 404 if not found, 409 on duplicate
name.
"""
db_path = await _ensure_db(request)
svc = get_department_service()
try:
return await svc.update_department(
db_path,
department_id,
name=payload.name,
description=payload.description,
)
except ValueError as exc:
msg = str(exc)
if "not found" in msg:
raise HTTPException(status_code=404, detail=msg) from exc
raise HTTPException(status_code=409, detail=msg) from exc
@admin_router.post("/departments/{department_id}/disable")
async def disable_department(
department_id: str,
request: Request,
admin: dict[str, Any] = Depends(_require_admin),
) -> dict[str, Any]:
"""Set ``is_active=False`` on a department."""
db_path = await _ensure_db(request)
svc = get_department_service()
try:
return await svc.set_department_active(db_path, department_id, is_active=False)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@admin_router.post("/departments/{department_id}/enable")
async def enable_department(
department_id: str,
request: Request,
admin: dict[str, Any] = Depends(_require_admin),
) -> dict[str, Any]:
"""Set ``is_active=True`` on a department."""
db_path = await _ensure_db(request)
svc = get_department_service()
try:
return await svc.set_department_active(db_path, department_id, is_active=True)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@admin_router.delete("/departments/{department_id}")
async def delete_department(
department_id: str,
request: Request,
admin: dict[str, Any] = Depends(_require_admin),
) -> dict[str, Any]:
"""Delete a department.
Returns 200 ``{deleted: true}`` on success. Returns 400 if the
department still has users assigned (admin must remove them first).
Returns 404 if the department does not exist.
"""
db_path = await _ensure_db(request)
svc = get_department_service()
try:
deleted = await svc.delete_department(db_path, department_id)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if not deleted:
raise HTTPException(status_code=404, detail="Department not found")
return {"deleted": True}
# ---------------------------------------------------------------------------
# Department skill bindings
# ---------------------------------------------------------------------------
@admin_router.post(
"/departments/{department_id}/skills/{skill_name}",
status_code=201,
)
async def bind_skill(
department_id: str,
skill_name: str,
request: Request,
admin: dict[str, Any] = Depends(_require_admin),
) -> dict[str, Any]:
"""Bind a skill to a department. 409 on duplicate binding."""
db_path = await _ensure_db(request)
svc = get_department_service()
try:
return await svc.bind_skill(db_path, department_id, skill_name)
except ValueError as exc:
msg = str(exc)
if "not found" in msg:
raise HTTPException(status_code=404, detail=msg) from exc
raise HTTPException(status_code=409, detail=msg) from exc
@admin_router.delete(
"/departments/{department_id}/skills/{skill_name}",
)
async def unbind_skill(
department_id: str,
skill_name: str,
request: Request,
admin: dict[str, Any] = Depends(_require_admin),
) -> dict[str, Any]:
"""Remove a skill binding. Idempotent — returns 200 even if no row existed."""
db_path = await _ensure_db(request)
svc = get_department_service()
await svc.unbind_skill(db_path, department_id, skill_name)
return {"unbound": True}
@admin_router.get("/departments/{department_id}/skills")
async def list_department_skills(
department_id: str,
request: Request,
admin: dict[str, Any] = Depends(_require_admin),
) -> list[str]:
"""List skill names bound to the department."""
db_path = await _ensure_db(request)
svc = get_department_service()
return await svc.list_department_skills(db_path, department_id)
# ---------------------------------------------------------------------------
# Department KB bindings
# ---------------------------------------------------------------------------
@admin_router.post(
"/departments/{department_id}/kb/{kb_source_id}",
status_code=201,
)
async def bind_kb(
department_id: str,
kb_source_id: str,
request: Request,
admin: dict[str, Any] = Depends(_require_admin),
) -> dict[str, Any]:
"""Bind a KB source to a department. 409 on duplicate binding."""
db_path = await _ensure_db(request)
svc = get_department_service()
try:
return await svc.bind_kb(db_path, department_id, kb_source_id)
except ValueError as exc:
msg = str(exc)
if "not found" in msg:
raise HTTPException(status_code=404, detail=msg) from exc
raise HTTPException(status_code=409, detail=msg) from exc
@admin_router.delete(
"/departments/{department_id}/kb/{kb_source_id}",
)
async def unbind_kb(
department_id: str,
kb_source_id: str,
request: Request,
admin: dict[str, Any] = Depends(_require_admin),
) -> dict[str, Any]:
"""Remove a KB binding. Idempotent — returns 200 even if no row existed."""
db_path = await _ensure_db(request)
svc = get_department_service()
await svc.unbind_kb(db_path, department_id, kb_source_id)
return {"unbound": True}
@admin_router.get("/departments/{department_id}/kb")
async def list_department_kbs(
department_id: str,
request: Request,
admin: dict[str, Any] = Depends(_require_admin),
) -> list[str]:
"""List KB source ids bound to the department."""
db_path = await _ensure_db(request)
svc = get_department_service()
return await svc.list_department_kbs(db_path, department_id)
# ---------------------------------------------------------------------------
# User CRUD endpoints (U3)
# ---------------------------------------------------------------------------
class UserCreateRequest(BaseModel):
"""Body for ``POST /admin/users``."""
model_config = ConfigDict(extra="forbid")
username: str
email: EmailStr
password: str = Field(min_length=8)
role: Literal["member", "operator", "admin"] = "member"
department_ids: list[str] | None = None
class UserUpdateRequest(BaseModel):
"""Body for ``PATCH /admin/users/{user_id}``."""
model_config = ConfigDict(extra="forbid")
role: Literal["member", "operator", "admin"] | None = None
is_active: bool | None = None
is_terminal_authorized: bool | None = None
is_server_terminal_authorized: bool | None = None
class ResetPasswordRequest(BaseModel):
"""Body for ``POST /admin/users/{user_id}/reset-password``."""
model_config = ConfigDict(extra="forbid")
new_password: str = Field(min_length=8)
@admin_router.post("/users", status_code=201)
async def create_user(
payload: UserCreateRequest,
request: Request,
admin: dict[str, Any] = Depends(_require_admin),
) -> dict[str, Any]:
"""Create a new user.
Returns 201 with the user dict (including ``departments``) on
success. Returns 409 on duplicate username or email, or if any of
the ``department_ids`` is already assigned. Returns 404 if any of
the ``department_ids`` does not exist.
"""
db_path = await _ensure_db(request)
svc = get_user_service()
try:
return await svc.create_user(
db_path,
username=payload.username,
email=payload.email,
password=payload.password,
role=payload.role,
department_ids=payload.department_ids,
created_by=admin.get("user_id"),
)
except ValueError as exc:
msg = str(exc)
if "not found" in msg:
raise HTTPException(status_code=404, detail=msg) from exc
raise HTTPException(status_code=409, detail=msg) from exc
@admin_router.get("/users")
async def list_users(
request: Request,
department_id: str | None = None,
include_inactive: bool = True,
admin: dict[str, Any] = Depends(_require_admin),
) -> list[dict[str, Any]]:
"""List users, optionally filtered by department."""
db_path = await _ensure_db(request)
svc = get_user_service()
return await svc.list_users(
db_path,
department_id=department_id,
include_inactive=include_inactive,
)
@admin_router.get("/users/{user_id}")
async def get_user(
user_id: str,
request: Request,
admin: dict[str, Any] = Depends(_require_admin),
) -> dict[str, Any]:
"""Return a single user by id (including departments). 404 if not found."""
db_path = await _ensure_db(request)
svc = get_user_service()
user = await svc.get_user(db_path, user_id)
if user is None:
raise HTTPException(status_code=404, detail="User not found")
return user
@admin_router.patch("/users/{user_id}")
async def update_user(
user_id: str,
payload: UserUpdateRequest,
request: Request,
admin: dict[str, Any] = Depends(_require_admin),
) -> dict[str, Any]:
"""Partially update a user.
Returns 200 with the updated dict, 404 if the user does not exist.
"""
db_path = await _ensure_db(request)
svc = get_user_service()
try:
return await svc.update_user(
db_path,
user_id,
role=payload.role,
is_active=payload.is_active,
is_terminal_authorized=payload.is_terminal_authorized,
is_server_terminal_authorized=payload.is_server_terminal_authorized,
)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@admin_router.delete("/users/{user_id}")
async def delete_user(
user_id: str,
request: Request,
admin: dict[str, Any] = Depends(_require_admin),
) -> dict[str, Any]:
"""Soft-delete a user (set ``is_active=0``).
Returns 200 ``{deleted: true}`` on success, 404 if the user does
not exist or is already inactive.
"""
db_path = await _ensure_db(request)
svc = get_user_service()
deleted = await svc.delete_user(db_path, user_id)
if not deleted:
raise HTTPException(status_code=404, detail="User not found")
return {"deleted": True}
@admin_router.post("/users/{user_id}/reset-password")
async def reset_password(
user_id: str,
payload: ResetPasswordRequest,
request: Request,
admin: dict[str, Any] = Depends(_require_admin),
) -> dict[str, Any]:
"""Reset a user's password and revoke all their sessions.
Returns 200 ``{reset: true}`` on success, 404 if the user does not
exist.
"""
db_path = await _ensure_db(request)
svc = get_user_service()
reset = await svc.reset_password(db_path, user_id, payload.new_password)
if not reset:
raise HTTPException(status_code=404, detail="User not found")
return {"reset": True}
@admin_router.post(
"/users/{user_id}/departments/{department_id}",
status_code=201,
)
async def assign_department(
user_id: str,
department_id: str,
request: Request,
admin: dict[str, Any] = Depends(_require_admin),
) -> dict[str, Any]:
"""Assign a user to a department.
Returns 201 ``{assigned: true}`` on success. Returns 404 if the
department does not exist. Returns 409 if the user is already
assigned to this department.
"""
db_path = await _ensure_db(request)
svc = get_user_service()
try:
await svc.assign_department(db_path, user_id, department_id)
except ValueError as exc:
msg = str(exc)
if "not found" in msg:
raise HTTPException(status_code=404, detail=msg) from exc
raise HTTPException(status_code=409, detail=msg) from exc
return {"assigned": True}
@admin_router.delete("/users/{user_id}/departments/{department_id}")
async def remove_department(
user_id: str,
department_id: str,
request: Request,
admin: dict[str, Any] = Depends(_require_admin),
) -> dict[str, Any]:
"""Remove a user's department assignment.
Returns 200 ``{removed: true}`` on success, 404 if the assignment
does not exist.
"""
db_path = await _ensure_db(request)
svc = get_user_service()
removed = await svc.remove_department(db_path, user_id, department_id)
if not removed:
raise HTTPException(status_code=404, detail="Assignment not found")
return {"removed": True}
@admin_router.get("/users/{user_id}/departments")
async def list_user_departments(
user_id: str,
request: Request,
admin: dict[str, Any] = Depends(_require_admin),
) -> list[dict[str, Any]]:
"""List the departments a user is assigned to."""
db_path = await _ensure_db(request)
svc = get_user_service()
return await svc.list_user_departments(db_path, user_id)
# ---------------------------------------------------------------------------
# LLM config endpoints (U5) — provider CRUD + fallback chains
# ---------------------------------------------------------------------------
def _get_llm_config_service(request: Request) -> LlmConfigService:
"""Resolve the :class:`LlmConfigService` from ``app.state`` or the
module singleton.
The service needs the YAML config path, which is read from
``app.state.server_config._config_path`` (same source as the
existing ``GET/PUT /settings/llm`` endpoints). Falls back to the
module singleton (which tests can pre-populate via
:func:`set_llm_config_service`).
"""
server_config = getattr(request.app.state, "server_config", None)
if server_config is not None and getattr(server_config, "_config_path", None):
try:
return get_llm_config_service(server_config._config_path)
except ValueError:
# Singleton was reset between calls — re-initialize with the
# resolved path.
from agentkit.server.admin.llm_config_service import set_llm_config_service
svc = LlmConfigService(Path(server_config._config_path))
set_llm_config_service(svc)
return svc
# Fall back to the existing singleton (tests inject it directly).
return get_llm_config_service()
class ProviderCreateRequest(BaseModel):
"""Body for ``POST /admin/llm/providers``."""
model_config = ConfigDict(extra="forbid")
name: str
type: str = "openai"
api_key: str
base_url: str = ""
models: dict[str, Any] | None = None
max_tokens: int = 4096
timeout: float = 60.0
class ProviderUpdateRequest(BaseModel):
"""Body for ``PATCH /admin/llm/providers/{name}``."""
model_config = ConfigDict(extra="forbid")
type: str | None = None
api_key: str | None = None
base_url: str | None = None
models: dict[str, Any] | None = None
max_tokens: int | None = None
timeout: float | None = None
class ApiKeySetRequest(BaseModel):
"""Body for ``POST /admin/llm/providers/{name}/api-key``."""
model_config = ConfigDict(extra="forbid")
api_key: str
class FallbackSetRequest(BaseModel):
"""Body for ``PUT /admin/llm/fallbacks/{model}``."""
model_config = ConfigDict(extra="forbid")
chain: list[str]
@admin_router.get("/llm/providers")
async def list_llm_providers(
request: Request,
admin: dict[str, Any] = Depends(_require_admin),
) -> list[dict[str, Any]]:
"""List all LLM providers (API keys masked)."""
svc = _get_llm_config_service(request)
return svc.list_providers()
@admin_router.post("/llm/providers", status_code=201)
async def create_llm_provider(
payload: ProviderCreateRequest,
request: Request,
admin: dict[str, Any] = Depends(_require_admin),
) -> dict[str, Any]:
"""Create a new LLM provider.
Returns 201 with the provider dict (masked key) on success, 409 if
a provider with the same name already exists.
"""
svc = _get_llm_config_service(request)
try:
return svc.create_provider(
name=payload.name,
provider_type=payload.type,
api_key=payload.api_key,
base_url=payload.base_url,
models=payload.models,
max_tokens=payload.max_tokens,
timeout=payload.timeout,
)
except ValueError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
@admin_router.patch("/llm/providers/{name}")
async def update_llm_provider(
name: str,
payload: ProviderUpdateRequest,
request: Request,
admin: dict[str, Any] = Depends(_require_admin),
) -> dict[str, Any]:
"""Partially update an LLM provider.
Returns 200 with the updated provider dict (masked key), 404 if the
provider does not exist.
"""
svc = _get_llm_config_service(request)
try:
return svc.update_provider(
name=name,
provider_type=payload.type,
api_key=payload.api_key,
base_url=payload.base_url,
models=payload.models,
max_tokens=payload.max_tokens,
timeout=payload.timeout,
)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@admin_router.delete("/llm/providers/{name}")
async def delete_llm_provider(
name: str,
request: Request,
admin: dict[str, Any] = Depends(_require_admin),
) -> dict[str, Any]:
"""Delete an LLM provider.
Returns 200 ``{deleted: true}`` on success, 404 if the provider
does not exist, 400 if the provider is used in a fallback chain.
"""
svc = _get_llm_config_service(request)
try:
deleted = svc.delete_provider(name)
except ValueError as exc:
msg = str(exc)
if "not found" in msg:
raise HTTPException(status_code=404, detail=msg) from exc
raise HTTPException(status_code=400, detail=msg) from exc
if not deleted:
raise HTTPException(status_code=404, detail="Provider not found")
return {"deleted": True}
@admin_router.post("/llm/providers/{name}/api-key")
async def set_llm_provider_api_key(
name: str,
payload: ApiKeySetRequest,
request: Request,
admin: dict[str, Any] = Depends(_require_admin),
) -> dict[str, Any]:
"""Set the API key for a provider.
The plaintext key is written to ``.env`` (not the YAML file); the
YAML stores a ``${ENV_VAR}`` reference. Returns 200 with the
updated provider dict (masked key).
"""
svc = _get_llm_config_service(request)
return svc.set_api_key(name, payload.api_key)
@admin_router.get("/llm/fallbacks")
async def get_llm_fallbacks(
request: Request,
admin: dict[str, Any] = Depends(_require_admin),
) -> dict[str, list[str]]:
"""Return all fallback chains (model → list of provider/model refs)."""
svc = _get_llm_config_service(request)
return svc.get_fallbacks()
@admin_router.put("/llm/fallbacks/{model}")
async def set_llm_fallback(
model: str,
payload: FallbackSetRequest,
request: Request,
admin: dict[str, Any] = Depends(_require_admin),
) -> dict[str, Any]:
"""Set the fallback chain for a model."""
svc = _get_llm_config_service(request)
return svc.set_fallback(model, payload.chain)
@admin_router.delete("/llm/fallbacks/{model}")
async def delete_llm_fallback(
model: str,
request: Request,
admin: dict[str, Any] = Depends(_require_admin),
) -> dict[str, Any]:
"""Delete the fallback chain for a model.
Returns 200 ``{deleted: true}`` if a chain was deleted, 404 if no
chain existed for ``model``.
"""
svc = _get_llm_config_service(request)
deleted = svc.delete_fallback(model)
if not deleted:
raise HTTPException(status_code=404, detail="Fallback chain not found")
return {"deleted": True}
# ---------------------------------------------------------------------------
# Department quota endpoints (U5) — per-department LLM quotas
# ---------------------------------------------------------------------------
class QuotaSetRequest(BaseModel):
"""Body for ``PUT /admin/departments/{id}/quotas``."""
model_config = ConfigDict(extra="forbid")
quota_type: str
limit_value: int | list[str]
period: str = "daily"
@admin_router.get("/departments/{department_id}/quotas")
async def list_department_quotas(
department_id: str,
request: Request,
admin: dict[str, Any] = Depends(_require_admin),
) -> list[dict[str, Any]]:
"""List all quotas for a department."""
db_path = await _ensure_db(request)
svc = get_quota_service()
return await svc.list_department_quotas(db_path, department_id)
@admin_router.put("/departments/{department_id}/quotas")
async def set_department_quota(
department_id: str,
payload: QuotaSetRequest,
request: Request,
admin: dict[str, Any] = Depends(_require_admin),
) -> dict[str, Any]:
"""Set (upsert) a quota for a department.
Returns 200 with the upserted quota dict. Returns 400 if
``quota_type`` or ``period`` is invalid, or if ``limit_value``
doesn't match the quota type (int for token/cost limits, list for
model_whitelist).
"""
db_path = await _ensure_db(request)
svc = get_quota_service()
try:
return await svc.set_quota(
db_path,
department_id,
payload.quota_type,
payload.limit_value,
payload.period,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@admin_router.delete("/departments/{department_id}/quotas")
async def delete_department_quota(
department_id: str,
request: Request,
quota_type: str,
period: str = "daily",
admin: dict[str, Any] = Depends(_require_admin),
) -> dict[str, Any]:
"""Delete a quota for a department.
Query params: ``quota_type`` (required), ``period`` (default
``daily``). Returns 200 ``{deleted: true}`` if a quota was deleted,
404 if no quota matched, 400 if ``quota_type`` or ``period`` is
invalid.
"""
db_path = await _ensure_db(request)
svc = get_quota_service()
try:
deleted = await svc.delete_quota(db_path, department_id, quota_type, period)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if not deleted:
raise HTTPException(status_code=404, detail="Quota not found")
return {"deleted": True}
# ---------------------------------------------------------------------------
# Skill management endpoints (U6) — enable/disable + import/reload
# ---------------------------------------------------------------------------
def _get_skills_dir(request: Request) -> str:
"""Resolve the skills directory from ``app.state.server_config``.
Mirrors the logic in :func:`agentkit.server.routes.skills._get_skills_dir`
so admin endpoints install/reload skills into the same directory as
the existing ``/skills/install`` route.
"""
server_config = getattr(request.app.state, "server_config", None)
if server_config and getattr(server_config, "skill_paths", None):
first_path = Path(server_config.skill_paths[0])
if first_path.is_dir():
return str(first_path)
# Fallback: configs/skills/ relative to cwd (matches routes.skills).
import os
return os.path.join(os.getcwd(), "configs", "skills")
def _get_skill_registry(request: Request) -> Any:
"""Return the live :class:`SkillRegistry` from ``app.state``.
Raises HTTPException(500) if the registry is missing — admin skill
endpoints cannot function without it.
"""
registry = getattr(request.app.state, "skill_registry", None)
if registry is None:
raise HTTPException(
status_code=500,
detail="Skill registry not initialized on app.state",
)
return registry
class SkillImportRequest(BaseModel):
"""Body for ``POST /admin/skills/import``."""
model_config = ConfigDict(extra="forbid")
yaml_content: str
class SkillUpdateRequest(BaseModel):
"""Body for ``PATCH /admin/skills/{name}``."""
model_config = ConfigDict(extra="forbid")
config: dict[str, Any]
@admin_router.post("/skills/{name}/enable")
async def enable_skill(
name: str,
request: Request,
admin: dict[str, Any] = Depends(_require_admin),
) -> dict[str, Any]:
"""Enable a previously-disabled skill.
Returns 200 ``{enabled: true}`` on success. Idempotent — returns
200 even if the skill was not disabled.
"""
db_path = await _ensure_db(request)
svc = get_skill_service()
try:
enabled = await svc.enable_skill(db_path, name)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"enabled": enabled, "skill_name": name}
@admin_router.post("/skills/{name}/disable")
async def disable_skill(
name: str,
request: Request,
admin: dict[str, Any] = Depends(_require_admin),
) -> dict[str, Any]:
"""Disable a skill (hides it from ``GET /skills``).
Returns 200 with the disabled skill state dict.
"""
db_path = await _ensure_db(request)
svc = get_skill_service()
try:
return await svc.disable_skill(db_path, name, disabled_by=admin.get("user_id"))
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@admin_router.patch("/skills/{name}")
async def update_skill(
name: str,
payload: SkillUpdateRequest,
request: Request,
admin: dict[str, Any] = Depends(_require_admin),
) -> dict[str, Any]:
"""Update a skill's YAML config in-place and reload it.
Returns 200 with the updated skill info. Returns 404 if the skill
YAML file does not exist, 400 if the patched config is invalid.
"""
skills_dir = _get_skills_dir(request)
registry = _get_skill_registry(request)
svc = get_skill_service()
try:
return await svc.update_skill_config(
name,
payload.config,
skills_dir,
registry,
)
except ValueError as exc:
msg = str(exc)
if "not found" in msg:
raise HTTPException(status_code=404, detail=msg) from exc
raise HTTPException(status_code=400, detail=msg) from exc
@admin_router.post("/skills/import", status_code=201)
async def import_skill(
payload: SkillImportRequest,
request: Request,
admin: dict[str, Any] = Depends(_require_admin),
) -> dict[str, Any]:
"""Import a skill from YAML content.
Writes the YAML to ``{skills_dir}/{name}.yaml`` and registers it
in the live :class:`SkillRegistry`. Returns 200 with the imported
skill info. Returns 400 if the YAML is invalid or the skill name
is invalid.
"""
skills_dir = _get_skills_dir(request)
registry = _get_skill_registry(request)
svc = get_skill_service()
try:
return await svc.import_skill(
payload.yaml_content,
skills_dir,
skill_registry=registry,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@admin_router.post("/skills/{name}/reload")
async def reload_skill(
name: str,
request: Request,
admin: dict[str, Any] = Depends(_require_admin),
) -> dict[str, Any]:
"""Reload a skill from its YAML file.
Returns 200 with the reloaded skill info. Returns 404 if the YAML
file does not exist, 400 if the skill name is invalid.
"""
skills_dir = _get_skills_dir(request)
registry = _get_skill_registry(request)
svc = get_skill_service()
try:
return await svc.reload_skill(name, registry, skills_dir)
except ValueError as exc:
msg = str(exc)
if "not found" in msg:
raise HTTPException(status_code=404, detail=msg) from exc
raise HTTPException(status_code=400, detail=msg) from exc
# ---------------------------------------------------------------------------
# KB management endpoints (U6) — documents + sources
# ---------------------------------------------------------------------------
class KbDocumentUploadRequest(BaseModel):
"""Body for ``POST /admin/kb/documents``."""
model_config = ConfigDict(extra="forbid")
filename: str
content: str
source_id: str = ""
department_id: str | None = None
@admin_router.get("/kb/documents")
async def list_kb_documents(
request: Request,
source_id: str | None = None,
department_id: str | None = None,
admin: dict[str, Any] = Depends(_require_admin),
) -> dict[str, Any]:
"""List KB documents (admin sees all).
Query params: ``source_id`` (optional filter), ``department_id``
(optional filter — when set, only documents bound to that
department or global documents are returned).
"""
svc = get_kb_service()
# Admin sees everything. If department_id is provided as a query
# filter, narrow to that department + global docs.
if department_id is not None:
dept_ids = [department_id]
else:
dept_ids = None # admin: no filtering
documents = svc.list_documents(department_ids=dept_ids, source_id=source_id)
return {"documents": documents}
@admin_router.post("/kb/documents", status_code=201)
async def upload_kb_document(
payload: KbDocumentUploadRequest,
request: Request,
admin: dict[str, Any] = Depends(_require_admin),
) -> dict[str, Any]:
"""Upload a KB document with optional department binding.
Returns 201 with the uploaded document dict.
"""
svc = get_kb_service()
return svc.upload_document(
filename=payload.filename,
content=payload.content.encode("utf-8"),
source_id=payload.source_id,
department_id=payload.department_id,
)
@admin_router.delete("/kb/documents/{document_id}")
async def delete_kb_document(
document_id: str,
request: Request,
admin: dict[str, Any] = Depends(_require_admin),
) -> dict[str, Any]:
"""Delete a KB document by id.
Returns 200 ``{deleted: true}`` on success, 404 if not found.
"""
svc = get_kb_service()
deleted = svc.delete_document(document_id)
if not deleted:
raise HTTPException(status_code=404, detail="Document not found")
return {"deleted": True}
@admin_router.post("/kb/sources/{source_id}/sync")
async def sync_kb_source(
source_id: str,
request: Request,
admin: dict[str, Any] = Depends(_require_admin),
) -> dict[str, Any]:
"""Trigger a sync for a KB source.
Returns 200 with the sync status. Returns 404 if the source does
not exist.
"""
svc = get_kb_service()
try:
return svc.sync_source(source_id)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@admin_router.post("/kb/sources/{source_id}/rebuild")
async def rebuild_kb_source(
source_id: str,
request: Request,
admin: dict[str, Any] = Depends(_require_admin),
) -> dict[str, Any]:
"""Rebuild the index for a KB source.
Returns 200 with the rebuild status. Returns 404 if the source
does not exist.
"""
svc = get_kb_service()
try:
return svc.rebuild_index(source_id)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
# ---------------------------------------------------------------------------
# Usage dashboard endpoints (U7) — usage aggregations + export
# ---------------------------------------------------------------------------
def _get_usage_store(request: Request) -> Any:
"""Return the live :class:`UsageStore` from ``app.state.llm_gateway``.
Raises HTTPException(500) if the gateway or usage store is missing —
usage endpoints cannot function without it.
"""
gateway = getattr(request.app.state, "llm_gateway", None)
if gateway is None:
raise HTTPException(
status_code=500,
detail="LLM gateway not initialized on app.state",
)
try:
return gateway._usage_tracker.store # type: ignore[attr-defined]
except AttributeError as exc:
raise HTTPException(
status_code=500,
detail="Usage store not available on LLM gateway",
) from exc
def _parse_iso(value: str | None) -> datetime | None:
"""Parse an ISO 8601 string into a timezone-aware datetime."""
if value is None or value == "":
return None
try:
dt = datetime.fromisoformat(value)
except ValueError:
# Try the trailing-Z form.
if value.endswith("Z"):
try:
from datetime import timezone
dt = datetime.fromisoformat(value[:-1]).replace(tzinfo=timezone.utc)
except ValueError:
raise HTTPException(
status_code=400, detail=f"Invalid ISO 8601 timestamp: {value!r}"
)
else:
raise HTTPException(status_code=400, detail=f"Invalid ISO 8601 timestamp: {value!r}")
return dt
@admin_router.get("/usage/summary")
async def get_usage_summary(
request: Request,
department_id: str | None = None,
user_id: str | None = None,
start: str | None = None,
end: str | None = None,
admin: dict[str, Any] = Depends(_require_admin),
) -> dict[str, Any]:
"""Return an aggregated usage summary.
Query params: ``department_id``, ``user_id``, ``start``, ``end``
(ISO 8601). Admins see all data; non-admin callers are blocked by
``_require_admin`` (403).
"""
store = _get_usage_store(request)
svc = get_usage_service()
return await svc.get_usage_summary(
store,
department_id=department_id,
user_id=user_id,
start_time=_parse_iso(start),
end_time=_parse_iso(end),
)
@admin_router.get("/usage/timeseries")
async def get_usage_timeseries(
request: Request,
department_id: str | None = None,
user_id: str | None = None,
start: str | None = None,
end: str | None = None,
interval: str = "day",
admin: dict[str, Any] = Depends(_require_admin),
) -> list[dict[str, Any]]:
"""Return a time-bucketed usage series.
Query params: ``department_id``, ``user_id``, ``start``, ``end``
(ISO 8601), ``interval`` (``day`` or ``hour``, default ``day``).
"""
if interval not in ("day", "hour"):
raise HTTPException(status_code=400, detail="interval must be 'day' or 'hour'")
store = _get_usage_store(request)
svc = get_usage_service()
return await svc.get_usage_timeseries(
store,
department_id=department_id,
user_id=user_id,
start_time=_parse_iso(start),
end_time=_parse_iso(end),
interval=interval,
)
@admin_router.get("/usage/by-model")
async def get_usage_by_model(
request: Request,
department_id: str | None = None,
user_id: str | None = None,
start: str | None = None,
end: str | None = None,
admin: dict[str, Any] = Depends(_require_admin),
) -> list[dict[str, Any]]:
"""Return a per-model usage breakdown."""
store = _get_usage_store(request)
svc = get_usage_service()
return await svc.get_usage_by_model(
store,
department_id=department_id,
user_id=user_id,
start_time=_parse_iso(start),
end_time=_parse_iso(end),
)
@admin_router.get("/usage/top-users")
async def get_top_users(
request: Request,
department_id: str | None = None,
user_id: str | None = None,
start: str | None = None,
end: str | None = None,
limit: int = 10,
admin: dict[str, Any] = Depends(_require_admin),
) -> list[dict[str, Any]]:
"""Return the top-N users by total token usage.
Query params: ``department_id``, ``user_id``, ``start``, ``end``,
``limit`` (default 10, max 100).
"""
if limit < 1:
limit = 1
if limit > 100:
limit = 100
store = _get_usage_store(request)
svc = get_usage_service()
return await svc.get_top_users(
store,
department_id=department_id,
user_id=user_id,
start_time=_parse_iso(start),
end_time=_parse_iso(end),
limit=limit,
)
@admin_router.get("/usage/export")
async def export_usage(
request: Request,
department_id: str | None = None,
user_id: str | None = None,
start: str | None = None,
end: str | None = None,
format: str = "csv",
admin: dict[str, Any] = Depends(_require_admin),
) -> Any:
"""Export raw usage records as CSV or JSON.
Query params: ``department_id``, ``user_id``, ``start``, ``end``,
``format`` (``csv`` or ``json``, default ``csv``).
Returns ``text/csv`` for CSV or ``application/json`` for JSON.
"""
if format not in ("csv", "json"):
raise HTTPException(status_code=400, detail="format must be 'csv' or 'json'")
store = _get_usage_store(request)
svc = get_usage_service()
body = await svc.export_usage(
store,
department_id=department_id,
user_id=user_id,
start_time=_parse_iso(start),
end_time=_parse_iso(end),
format=format,
)
if format == "csv":
return PlainTextResponse(content=body, media_type="text/csv")
return PlainTextResponse(content=body, media_type="application/json")