mirror of
https://github.com/MemeMeow-Studio/MemeMeow.git
synced 2026-10-17 04:41:01 +08:00
145 lines
6.1 KiB
Python
145 lines
6.1 KiB
Python
"""公共核心 Settings HTTP 边界。
|
|
|
|
该模块位于 FastAPI 路由模板与配置领域之间,集中负责后端 Settings 的请求模型、
|
|
脱敏状态投影、管理凭据校验、dotenv 并发更新编排和 canonical/legacy 路由注册。
|
|
它不依赖应用入口 ``api.py``,以保持公共核心路由与应用装配之间的单向依赖。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import secrets
|
|
from typing import Any
|
|
|
|
from fastapi import APIRouter, Header, HTTPException, Request
|
|
from pydantic import AliasChoices, BaseModel, ConfigDict, Field, StrictInt
|
|
|
|
from backend.config import Settings, update_dotenv_concurrency
|
|
from backend.scope import ScopeServices
|
|
|
|
|
|
class _SettingsRequestModel(BaseModel):
|
|
"""Settings HTTP 请求模型基类,拒绝客户端提交未定义字段。"""
|
|
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
|
|
class ConcurrencyUpdateRequest(_SettingsRequestModel):
|
|
"""后端设置页唯一允许持久化的安全参数。"""
|
|
|
|
# 公共请求模型只要求正整数;Agent 队列不设置有限容量门禁。
|
|
opencode_concurrency: StrictInt = Field(
|
|
ge=1,
|
|
validation_alias=AliasChoices("opencode_concurrency", "agent_concurrency", "concurrency", "value"),
|
|
)
|
|
|
|
|
|
def _error(status: int, code: str, message: str) -> HTTPException:
|
|
"""构造 Settings HTTP 使用的统一错误异常。"""
|
|
return HTTPException(status_code=status, detail={"error": code, "message": message})
|
|
|
|
|
|
def _backend_settings_status(request: Request) -> dict[str, object]:
|
|
"""构造设置页脱敏状态,运行时探针仅返回布尔和固定标识。"""
|
|
settings: Settings = request.app.state.settings
|
|
runner: Any = request.app.state.opencode
|
|
services = getattr(request.state, "services", None)
|
|
engine = services.search if isinstance(services, ScopeServices) else getattr(request.app.state, "search_engine", None)
|
|
status = settings.backend_status(
|
|
cache_ready=bool(engine and engine.has_cache()),
|
|
runtime_ready=bool(runner.runtime_probe().get("verified")),
|
|
)
|
|
visual_client = getattr(request.app.state, "visual_inference", None)
|
|
if visual_client is not None:
|
|
status.setdefault("readonly", {})["visual_available"] = bool(visual_client.health().get("available"))
|
|
status.setdefault("read_only", {})["visual_available"] = bool(visual_client.health().get("available"))
|
|
return status
|
|
|
|
|
|
def _settings_token(
|
|
x_settings_admin_token: str | None,
|
|
x_mememeow_settings_token: str | None,
|
|
authorization: str | None,
|
|
) -> str | None:
|
|
"""按既有优先级提取 Settings 管理 token,兼容旧 Header 和 Bearer。"""
|
|
token = x_settings_admin_token or x_mememeow_settings_token
|
|
if not token and authorization and authorization.lower().startswith("bearer "):
|
|
token = authorization[7:].strip()
|
|
return token
|
|
|
|
|
|
def _authorize_settings(request: Request, token: str | None) -> None:
|
|
"""验证设置管理凭据;未启用或错误凭据统一返回 403。"""
|
|
configured = request.app.state.settings.settings_admin_token
|
|
if not configured or not token or not secrets.compare_digest(str(token), str(configured)):
|
|
raise _error(403, "settings_forbidden", "后端设置管理未授权")
|
|
|
|
|
|
async def _update_backend_settings(request: Request, payload: ConcurrencyUpdateRequest, token: str | None) -> dict[str, object]:
|
|
"""授权后原子更新 dotenv 的并发字段,当前进程只返回待重启状态。"""
|
|
_authorize_settings(request, token)
|
|
settings: Settings = request.app.state.settings
|
|
if os.environ.get("MEMEMEOW_OPENCODE_CONCURRENCY") is not None:
|
|
raise _error(409, "settings_environment_override", "并发数量由进程环境变量覆盖,不能写入 .env")
|
|
try:
|
|
update_dotenv_concurrency(
|
|
settings.dotenv_path,
|
|
payload.opencode_concurrency,
|
|
)
|
|
except ValueError as exc:
|
|
raise _error(400, "settings_update_invalid", str(exc)) from exc
|
|
except OSError as exc:
|
|
raise _error(409, "settings_update_failed", "配置文件无法安全更新") from exc
|
|
result = _backend_settings_status(request)
|
|
result["saved"] = True
|
|
result["restart_required"] = payload.opencode_concurrency != settings.opencode_concurrency
|
|
result["pending"] = {"opencode_concurrency": payload.opencode_concurrency}
|
|
return result
|
|
|
|
|
|
settings_router = APIRouter()
|
|
|
|
|
|
@settings_router.get("/backend/settings", tags=["system"])
|
|
@settings_router.get("/settings", tags=["system"], include_in_schema=False)
|
|
async def backend_settings(request: Request) -> dict[str, object]:
|
|
"""返回后端设置三类字段及当前/待重启配置。"""
|
|
return _backend_settings_status(request)
|
|
|
|
|
|
@settings_router.patch("/backend/settings", tags=["system"])
|
|
@settings_router.patch("/settings", tags=["system"], include_in_schema=False)
|
|
async def update_backend_settings(
|
|
request: Request,
|
|
payload: ConcurrencyUpdateRequest,
|
|
x_settings_admin_token: str | None = Header(default=None),
|
|
x_mememeow_settings_token: str | None = Header(default=None),
|
|
authorization: str | None = Header(default=None),
|
|
) -> dict[str, object]:
|
|
"""受保护更新接口;只接受 Agent 并发数量。"""
|
|
token = _settings_token(x_settings_admin_token, x_mememeow_settings_token, authorization)
|
|
return await _update_backend_settings(request, payload, token)
|
|
|
|
|
|
@settings_router.post("/backend/settings", tags=["system"], include_in_schema=False)
|
|
@settings_router.post("/backend/settings/concurrency", tags=["system"])
|
|
async def update_backend_concurrency(
|
|
request: Request,
|
|
payload: ConcurrencyUpdateRequest,
|
|
x_settings_admin_token: str | None = Header(default=None),
|
|
x_mememeow_settings_token: str | None = Header(default=None),
|
|
authorization: str | None = Header(default=None),
|
|
) -> dict[str, object]:
|
|
"""兼容设置页显式并发路径的受保护更新接口。"""
|
|
token = _settings_token(x_settings_admin_token, x_mememeow_settings_token, authorization)
|
|
return await _update_backend_settings(request, payload, token)
|
|
|
|
|
|
__all__ = [
|
|
"ConcurrencyUpdateRequest",
|
|
"backend_settings",
|
|
"settings_router",
|
|
"update_backend_concurrency",
|
|
"update_backend_settings",
|
|
]
|