Files
AgentLab/backend/app/api/settings.py
T
ThearchyHelios 964eeaa829 AgentLab:可视化 Agent 编排实验台
在画布上拖拽编排 agent 工作流,并实时看着它执行。

后端 FastAPI + LangGraph:把画布 JSON 编译成 StateGraph,15 种节点
(模型/Agent/多Agent、工具/沙箱、分支/循环/子图、记忆/检索/整形、
人工介入/结构校验)。选 LangGraph 是因为可视化编排真正难的是暂停、
恢复和回放,而 checkpointer + interrupt() 正好把这几件事做在了底层。

前端 React + React Flow:节点元数据驱动属性面板与连接桩,运行时
节点高亮、边流动画、卡片内流式 token 全部由同一份事件流驱动。

关键实现:
- Agent 内工具审批用 @task 缓存模型调用,interrupt 重放时不重复计费
- 沙箱保持只读根文件系统,代码经 exec+base64 写入(put_archive 会被
  只读容器拒绝),断网、非 root、内存/CPU/超时限额
- 检索为向量与 BM25 混合,本地哈希 embedding 零配置可用
- 分支条件走 AST 白名单求值器;HTTP 工具逐 IP 拦截内网与元数据地址
- Mock provider 让无 API Key 也能跑通完整链路

7 个内置模板全部冒烟通过,含真实模型端到端验证。
2026-09-13 17:27:00 +08:00

263 lines
8.3 KiB
Python

from __future__ import annotations
import os
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings as app_settings
from app.core.crypto import encrypt, mask
from app.db.base import get_session
from app.db.models import Provider, Setting
from app.providers import catalog
from app.providers.factory import ModelSpec, ProviderNotConfigured, build_chat_model
router = APIRouter(prefix="/api", tags=["settings"])
# --------------------------------------------------------------------------
# Provider
# --------------------------------------------------------------------------
class ProviderIn(BaseModel):
name: str = Field(min_length=1, max_length=100)
kind: str = "anthropic"
base_url: str | None = None
api_key: str | None = None
models: list[dict[str, Any]] = Field(default_factory=list)
default_model: str | None = None
enabled: bool = True
extra: dict[str, Any] = Field(default_factory=dict)
class ProviderPatch(BaseModel):
name: str | None = None
base_url: str | None = None
api_key: str | None = None # 传空字符串表示清空;不传表示不动
models: list[dict[str, Any]] | None = None
default_model: str | None = None
enabled: bool | None = None
extra: dict[str, Any] | None = None
class ProviderOut(BaseModel):
id: str
name: str
kind: str
base_url: str | None
default_model: str | None
models: list[dict[str, Any]]
enabled: bool
extra: dict[str, Any]
# 只回掩码,明文 key 永远不出后端
api_key_masked: str = ""
has_key: bool = False
def _to_out(row: Provider) -> ProviderOut:
return ProviderOut(
id=row.id,
name=row.name,
kind=row.kind,
base_url=row.base_url,
default_model=row.default_model,
models=row.models or [],
enabled=row.enabled,
extra={k: v for k, v in (row.extra or {}).items() if k != "headers"} | (
{"headers": {k: "***" for k in (row.extra or {}).get("headers", {})}}
if (row.extra or {}).get("headers")
else {}
),
api_key_masked=mask(row.api_key),
has_key=bool(row.api_key),
)
@router.get("/providers", response_model=list[ProviderOut])
async def list_providers(session: AsyncSession = Depends(get_session)) -> list[ProviderOut]:
rows = (await session.execute(select(Provider).order_by(Provider.created_at))).scalars()
return [_to_out(r) for r in rows]
@router.get("/providers/catalog")
async def provider_catalog() -> dict[str, Any]:
"""内置的 provider 预设和模型目录,前端用它渲染"添加 Provider"表单。"""
return {
"kinds": catalog.PROVIDER_KINDS,
"env_detected": {
"ANTHROPIC_API_KEY": bool(os.environ.get("ANTHROPIC_API_KEY")),
"ANTHROPIC_AUTH_TOKEN": bool(os.environ.get("ANTHROPIC_AUTH_TOKEN")),
"ANTHROPIC_BASE_URL": os.environ.get("ANTHROPIC_BASE_URL") or None,
"OPENAI_API_KEY": bool(os.environ.get("OPENAI_API_KEY")),
"TAVILY_API_KEY": bool(os.environ.get("TAVILY_API_KEY")),
},
}
@router.post("/providers", response_model=ProviderOut, status_code=201)
async def create_provider(
payload: ProviderIn, session: AsyncSession = Depends(get_session)
) -> ProviderOut:
exists = (
await session.execute(select(Provider).where(Provider.name == payload.name))
).scalar_one_or_none()
if exists:
raise HTTPException(409, f"已经有叫 {payload.name!r} 的 provider 了")
row = Provider(
name=payload.name,
kind=payload.kind,
base_url=payload.base_url or None,
api_key=encrypt(payload.api_key),
models=payload.models,
default_model=payload.default_model,
enabled=payload.enabled,
extra=payload.extra,
)
session.add(row)
await session.commit()
await session.refresh(row)
return _to_out(row)
@router.patch("/providers/{provider_id}", response_model=ProviderOut)
async def update_provider(
provider_id: str, payload: ProviderPatch, session: AsyncSession = Depends(get_session)
) -> ProviderOut:
row = await session.get(Provider, provider_id)
if not row:
raise HTTPException(404, "provider 不存在")
data = payload.model_dump(exclude_unset=True)
if "api_key" in data:
row.api_key = encrypt(data.pop("api_key")) if data["api_key"] else None
data.pop("api_key", None)
for key, value in data.items():
setattr(row, key, value)
await session.commit()
await session.refresh(row)
return _to_out(row)
@router.delete("/providers/{provider_id}", status_code=204)
async def delete_provider(
provider_id: str, session: AsyncSession = Depends(get_session)
) -> None:
row = await session.get(Provider, provider_id)
if not row:
raise HTTPException(404, "provider 不存在")
await session.delete(row)
await session.commit()
class TestIn(BaseModel):
model: str | None = None
prompt: str = "用一句话介绍你自己"
@router.post("/providers/{provider_id}/test")
async def test_provider(
provider_id: str, payload: TestIn, session: AsyncSession = Depends(get_session)
) -> dict[str, Any]:
"""真发一次请求验证配置。设置页的"测试连接"按钮调它。"""
row = await session.get(Provider, provider_id)
if not row:
raise HTTPException(404, "provider 不存在")
import time
from app.engine.state import message_text
try:
model = build_chat_model(
row, ModelSpec(model=payload.model or row.default_model, max_tokens=256)
)
except ProviderNotConfigured as e:
return {"ok": False, "error": str(e)}
started = time.perf_counter()
try:
reply = await model.ainvoke(payload.prompt)
except Exception as e: # noqa: BLE001
return {"ok": False, "error": f"{type(e).__name__}: {e}"}
usage = getattr(reply, "usage_metadata", None) or {}
return {
"ok": True,
"latency_ms": int((time.perf_counter() - started) * 1000),
"model": payload.model or row.default_model,
"reply": message_text(reply)[:500],
"usage": usage,
}
# --------------------------------------------------------------------------
# 用户设置
# --------------------------------------------------------------------------
DEFAULT_SETTINGS: dict[str, Any] = {
"ui": {"theme": "system", "language": "zh", "canvas_snap": True, "autosave": True},
"run": {
"default_memory_scope": "default",
"default_collection": "default",
"stream_tokens": True,
"confirm_dangerous_tools": True,
},
"limits": {
"max_graph_steps": app_settings.max_graph_steps,
"max_agent_steps": app_settings.max_agent_steps,
"max_run_seconds": app_settings.max_run_seconds,
},
}
@router.get("/settings")
async def get_settings(session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
rows = (await session.execute(select(Setting))).scalars()
stored = {r.key: r.value for r in rows}
merged = {k: {**v, **(stored.get(k) or {})} for k, v in DEFAULT_SETTINGS.items()}
for key, value in stored.items():
merged.setdefault(key, value)
return merged
class SettingsIn(BaseModel):
values: dict[str, Any]
@router.put("/settings")
async def put_settings(
payload: SettingsIn, session: AsyncSession = Depends(get_session)
) -> dict[str, Any]:
for key, value in payload.values.items():
row = await session.get(Setting, key)
if row:
row.value = value
else:
session.add(Setting(key=key, value=value))
await session.commit()
return await get_settings(session)
@router.get("/system")
async def system_info() -> dict[str, Any]:
"""给设置页顶部展示的运行环境概览。"""
import sys
from app.sandbox.manager import sandbox_manager
return {
"python": sys.version.split()[0],
"data_dir": str(app_settings.data_dir),
"sandbox": await sandbox_manager.health(),
"limits": {
"max_graph_steps": app_settings.max_graph_steps,
"max_agent_steps": app_settings.max_agent_steps,
"max_concurrent_runs": app_settings.max_concurrent_runs,
},
}