mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 06:21:58 +00:00
5928cc052e
显示成生效却没生效,比明说没有更危险——它让人以为有东西在兜底。
1. max_run_seconds 只存在于配置和设置接口里,没有任何代码执行它:一个挂住的
模型或工具能把运行和它占着的并发名额一直拖着。现在 runner 用 asyncio.timeout
强制执行,按"一段执行"计(等人工审批时 _drive 已结束,不算);超时判 failed、
说清是哪道上限,可以接着跑。别处抛的 TimeoutError 不冒充这道上限(看
Timeout.expired())。
2. 模型调用从来没设过超时:ModelSpec.timeout 从没被赋值,那条传参分支一次都没
执行过,所有调用走 SDK 默认的 10 分钟外加重试。新增 model_timeout_seconds
(默认 300 秒)作为缺省,节点显式配的仍优先;已确认 ChatAnthropic / ChatOpenAI
都接这个参数。
3. manifest 哈希只写不验,而且是在 run.finished 落库之前算的——清单里恰恰少了
宣布"跑完了、成果是什么"的那条,改成果清单照样对得上。现在终态事件落库之后
再封存,并记下封到哪一条(manifest_seq,新列);新增 verify_manifest 与
GET /api/runs/{id}/verify,运行详情页加了核对按钮。封存之后追加的事件不算篡改。
老运行没有 manifest_seq,按当时的口径还原范围——开发库里 300 条带哈希的历史
运行全部核对通过(成功 / 失败 / 取消各类都有),没有一条误报。
设置页「运行环境」补上单次执行时限和模型调用超时两项,README 加了对应的常见问题。
顺带修了一个被 _seal 放大的老问题:_drive 的收尾被打断时(关停取消、库连接出错)
漏掉 _tasks.pop,留下的任务让下一次 shutdown 的 gather 在别的事件循环里抛
"future belongs to a different loop"——测试里表现为每八九轮一次、一出就是几十个
teardown error。pop 移进内层 finally,shutdown 只等未结束的任务并清空。修后全量
连跑 12 轮 12 轮全绿。
test_guardrails.py 8 例:超时中止(挂住的节点 1 秒后被停下)、缺省模型超时、封存覆盖
run.finished、改成果被戳穿、事后追加不误报、老运行按旧口径核、中断态未封存、接口
404。414 → 422 passed。
265 lines
8.4 KiB
Python
265 lines
8.4 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,
|
|
"max_run_seconds": app_settings.max_run_seconds,
|
|
"model_timeout_seconds": app_settings.model_timeout_seconds,
|
|
},
|
|
}
|