"""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")