Files
AgentLab/backend/app/api/settings.py
T
ThearchyHelios eddeded2a4 全栈安全加固、性能提速与架构演进:连接池与沙箱止血、双路混合检索、多版本归档及前端分包治理
- 第一批(安全加固与生命周期止血):隔离测试宿主机密钥与 .env 泄漏;修复 SQLite 取消查询挂死、:memory: 连接池报错与 EngineCache 世代竞态;补齐运行与评测删除时的 Checkpointer 线程、Artifact 磁盘文件与无主附件回收;加固三套沙箱路径越界、进程组清理与 Seatbelt 数据目录隔离,封堵 SSRF IPv4-mapped IPv6 元数据绕过与响应体 OOM;修复推理模型多轮 reasoning_content、结构化输出 Token 漏计与缓存折扣计费
- 第二批(数据库/集群/问数提速与首屏分包):补齐核心表索引与 SQLite WAL 调优,列表接口按需 defer 大列,收敛启动扫描并打通跨进程 slot 槽位唤醒;优化跨源全局选表、哈希桶数字绑定、流式 CSV 导入及多方言分析算子;剥离首屏静态引入的 @xyflow/react 与全局样式(首屏主包降至 461.59 kB),补充流式 rAF 攒批、Store 精细订阅与表单无障碍绑定
- 第三批(架构演进与工程自动化闭环):记录库超挂接阈值历史版本自动增量归档至单库多表(解除 SQLite 10 库 ATTACH 硬上限);重构知识库为词面 BM25 与全集合归一化向量矩阵真·双路独立召回并支持游标分批重建;升级多方言感知 SQL 模板词法分析(E'...' / $$...$$ / q'[...]' / # / 嵌套注释)与 Decimal / bool / NaN 参数校验,消除 ledger 层对 api 路由层的反向依赖;拆分 3101 行 CaliberDrafts 页面为 7 个领域子模块,消除检查脚本本机路径硬编码并新增 GitHub Actions CI 门禁与三批回归测试套件
2026-11-13 14:51:36 +08:00

1298 lines
68 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import math
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.analysis import contract as analysis_contract
from app.api import health
from app.api.coded import PUBLIC_URL_ENV_LOCKED, CodedHTTPException
from app.core import public_url
from app.core.errors import explain, not_configured, raw
from app.core.config import settings as app_settings
from app.core.crypto import encrypt, mask
from app.data.draft_model import CATALOG_DRAFT_SETTING_KEY, setting_pair
from app.db.base import get_session
from app.db.models import Provider, Setting
from app.engine.judge import JUDGE_DEFAULTS, MAX_TOKENS as JUDGE_MAX_TOKENS
from app.engine.judge import SETTING_GROUP as JUDGE_GROUP, SETTING_LABEL as JUDGE_LABEL, SPEND_KEY as JUDGE_SPEND
from app.engine.judge import daily_spend, judge_model_spec, judge_settings
from app.engine.judge import sanitize_settings as judge_values, settings_problem as judge_problem
from app.engine.spend import SPEND_KEY as VERIFY_SPEND, daily_spend as verify_daily_spend
from app.ledger.settings import (
LEDGER_DEFAULTS,
LEDGER_GROUP,
LEDGER_RECALL_MAX,
LEDGER_SETTING_LABEL,
LEDGER_TIMEOUT_RANGE,
ledger_problem,
ledger_settings,
ledger_values,
)
from app.providers import catalog
from app.providers.factory import (
ModelSpec, ProviderNotConfigured, _resolve_key, assistant_unavailable, build_chat_model,
list_providers as all_providers, model_note, named_provider,
)
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
#: 最近一次测连接(见 app/api/health.py)。没测过、或者连接配置改过之后都是 None
last_checked_at: str | None = None
last_check_ok: bool | None = None
last_latency_ms: int | None = None
last_error: str | None = None
#: extra 里这一项是后端记的最近一次测连接结果,不是用户填的配置:不回显、
#: 不接受客户端写入,保存 extra 时原样留着
LAST_CHECK = "last_check"
def _user_extra(extra: dict[str, Any] | None) -> dict[str, Any]:
return {k: v for k, v in (extra or {}).items() if k != LAST_CHECK}
def _with_saved_headers(extra: dict[str, Any], saved: dict[str, Any] | None) -> dict[str, Any]:
"""请求头的值回显的是 ***:原样带回来的,换回存着的真值。
设置页保存时会把读到的 extra 整块带回来。以前照单全收,于是改个名字、
勾一下启用,自定义请求头就全变成了字面上的 ***,接口从此 401。
"""
headers = extra.get("headers")
if not isinstance(headers, dict):
return extra
old = (saved or {}).get("headers") or {}
return {**extra, "headers": {k: (old.get(k, v) if v == "***" else v) for k, v in headers.items()}}
def _connection(provider: Provider, model: str | None = None) -> str:
"""连接配置的指纹:类型、地址、实际用的钥匙、请求头这些、测的哪个模型。"""
return health.fingerprint(
"provider", provider.kind, (provider.base_url or "").strip(), _resolve_key(provider) or "",
_user_extra(provider.extra), model or provider.default_model or "",
)
def _set_last_check(row: Provider, last: dict[str, Any] | None) -> None:
row.extra = {**_user_extra(row.extra), **({LAST_CHECK: last} if last else {})}
def _to_out(row: Provider) -> ProviderOut:
extra = _user_extra(row.extra)
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 extra.items() if k != "headers"} | (
{"headers": {k: "***" for k in extra.get("headers", {})}}
if extra.get("headers")
else {}
),
api_key_masked=mask(row.api_key),
has_key=bool(row.api_key),
**health.fields((row.extra or {}).get(LAST_CHECK)),
)
@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, Provider.id))).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:
problem = _config_problem(payload.kind, payload.base_url)
if problem:
raise HTTPException(400, problem)
exists = (
await session.execute(select(Provider).where(Provider.name == payload.name))
).scalar_one_or_none()
if exists:
raise HTTPException(409, f"已存在名为「{payload.name}」的模型接入,请换一个名称")
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=_user_extra(payload.extra),
)
# 表单里刚测过这份配置:保存下来就带着那次结果
_set_last_check(row, health.draft_result(_connection(row)))
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, "模型接入不存在,可能已被删除")
data = payload.model_dump(exclude_unset=True)
before, last = _connection(row), (row.extra or {}).get(LAST_CHECK)
if "api_key" in data:
row.api_key = encrypt(data.pop("api_key")) if data["api_key"] else None
data.pop("api_key", None)
if data.get("extra") is not None:
data["extra"] = _with_saved_headers(_user_extra(data["extra"]), row.extra)
else:
data.pop("extra", None)
for key, value in data.items():
setattr(row, key, value)
after = _connection(row)
if after != before:
# 测过的已经不是这份配置了:旧结果作废,表单里测过这一份的话换成那一次
last = health.draft_result(after)
_set_last_check(row, last)
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, "模型接入不存在,可能已被删除")
await session.delete(row)
await session.commit()
class TestIn(BaseModel):
model: str | None = None
prompt: str = "用一句话介绍你自己"
def _config_problem(kind: str, base_url: str | None) -> str | None:
"""保存或测试前就能看出来的配置错误。"""
if kind not in {k["kind"] for k in catalog.PROVIDER_KINDS}:
return f"不支持「{kind}」接入类型"
if kind == "openai_compatible" and not (base_url or "").strip():
return "「OpenAI 兼容」必须填写 Base URL,例如 https://api.deepseek.com/v1"
return None
#: 测试连接最多等多久。真正跑节点用的是 model_timeout_seconds(默认 5 分钟),
#: 弹窗里转 5 分钟等于没有反馈
_TEST_TIMEOUT = 30
async def _try_model(provider: Provider, model: str | None, prompt: str) -> dict[str, Any]:
"""真发一次请求。失败也是正常结果,照实返回原因,不抛。"""
import asyncio
import time
from app.engine.state import message_text
model = model or provider.default_model
if not model:
return {"ok": False, "error": "没有可测试的模型",
"hint": "请先在「可选模型」中添加一个,或填写默认模型", "detail": ""}
try:
chat = build_chat_model(provider, ModelSpec(model=model, max_tokens=256,
timeout=_TEST_TIMEOUT))
except ProviderNotConfigured as e:
if e.code == "missing_key":
return {"ok": False, "error": "尚未填写 API Key",
"hint": "请填写 API Key 后再测试,也可在服务端的环境变量中配置", "detail": str(e)}
return {"ok": False, "error": not_configured(e, with_hint=False), "hint": "", "detail": str(e)}
started = time.perf_counter()
try:
async with asyncio.timeout(_TEST_TIMEOUT):
reply = await chat.ainvoke(prompt)
except Exception as e: # noqa: BLE001
reason, hint = explain(e)
return {"ok": False, "error": f"测试未通过:{reason}", "hint": hint, "detail": raw(e),
"model": model}
usage = getattr(reply, "usage_metadata", None) or {}
return {
"ok": True,
"latency_ms": int((time.perf_counter() - started) * 1000),
"model": model,
"reply": message_text(reply)[:500],
"usage": usage,
}
@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, "模型接入不存在,可能已被删除")
result = await _try_model(row, payload.model, payload.prompt)
# 只记默认模型的结果:拿别的模型测出 invalid_model,不等于这个接入连不上
if (payload.model or row.default_model) == row.default_model:
_set_last_check(row, _test_record(result))
await session.commit()
return result
def _test_record(result: dict[str, Any]) -> dict[str, Any]:
return health.record(result.get("ok"), result.get("latency_ms"), result.get("error"))
class ProviderDraftIn(BaseModel):
"""一份还没保存(或改了还没保存)的接入配置。带 id 表示在编辑已有的那个。"""
id: str | None = None
name: str = "草稿"
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
extra: dict[str, Any] = Field(default_factory=dict)
#: 这次测哪个模型,不填用 default_model
model: str | None = None
prompt: str = "用一句话介绍你自己"
def _draft_provider(payload: ProviderDraftIn, saved: Provider | None) -> Provider:
"""拼一个不进会话的临时接入点,只拿来试一下。
编辑时 Key 框留空表示不改、请求头回显的是 ***——测试都得换回存着的真值,
否则改个 Base URL 想测一下,只会得到一句"密钥不对"。
"""
extra = _user_extra(payload.extra)
if saved:
extra = _with_saved_headers(extra, saved.extra)
return Provider(
name=payload.name or (saved.name if saved else "草稿"),
kind=payload.kind,
base_url=(payload.base_url or "").strip() or None,
api_key=encrypt(payload.api_key) if payload.api_key else (saved.api_key if saved else None),
models=payload.models,
default_model=payload.default_model,
enabled=True,
extra=extra,
)
async def _draft(payload: ProviderDraftIn, session: AsyncSession) -> Provider:
saved = await session.get(Provider, payload.id) if payload.id else None
return _draft_provider(payload, saved)
@router.post("/providers/test")
@router.post("/settings/providers/test", include_in_schema=False)
async def test_provider_draft(
payload: ProviderDraftIn, session: AsyncSession = Depends(get_session)
) -> dict[str, Any]:
"""测一份没保存的配置,不落库。
以前只能测已保存的:Base URL 填错要保存→关弹窗→点测试→看提示→再打开改。
接内网网关这种填错一个字就连不上的东西,来回四五趟。
"""
problem = _config_problem(payload.kind, payload.base_url)
if problem:
return {"ok": False, "error": problem, "hint": "", "detail": ""}
saved = await session.get(Provider, payload.id) if payload.id else None
draft = _draft_provider(payload, saved)
result = await _try_model(draft, payload.model, payload.prompt)
last, key = _test_record(result), _connection(draft, payload.model)
health.remember_draft(key, last)
if saved is not None and key == _connection(saved):
# 编辑框里没改连接、只是再测一次:测的就是已保存的那份
_set_last_check(saved, last)
await session.commit()
return result
@router.post("/providers/models")
async def probe_provider_models(
payload: ProviderDraftIn, session: AsyncSession = Depends(get_session)
) -> dict[str, Any]:
"""问这个接入点有哪些模型,省得手敲模型 id。
知识库那边的探测不带鉴权,接需要 Key 的网关时必然 401,所以这里单独做一份:
Key、自定义请求头都按真正调用时的方式带上。
"""
import httpx
problem = _config_problem(payload.kind, payload.base_url)
if problem:
return {"ok": False, "error": problem, "hint": "", "detail": "", "models": []}
provider = await _draft(payload, session)
if provider.kind == "mock":
return {"ok": True, "models": [m["id"] for m in catalog.MOCK_MODELS], "url": ""}
extra = dict(provider.extra or {})
# 和真正调用时取同一把钥匙:只有官方 OpenAI / Anthropic 才退回环境变量。
# 兼容网关是别人家的服务,Key 框空着就不带——否则点一下「拉取模型」,
# 环境里那把 OpenAI 的钥匙就发给了表单上随便填的一个地址
key = _resolve_key(provider) or ""
headers: dict[str, str] = {}
if provider.kind == "anthropic":
base = provider.base_url or os.environ.get("ANTHROPIC_BASE_URL") or "https://api.anthropic.com"
url = base.rstrip("/") + "/v1/models"
headers["anthropic-version"] = "2023-06-01"
if key:
if extra.get("auth_style") == "bearer":
headers["Authorization"] = f"Bearer {key}"
else:
headers["x-api-key"] = key
else:
url = (provider.base_url or "https://api.openai.com/v1").rstrip("/") + "/models"
if key:
headers["Authorization"] = f"Bearer {key}"
headers.update({k: str(v) for k, v in (extra.get("headers") or {}).items()})
try:
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(url, headers=headers)
resp.raise_for_status()
data = resp.json()
except Exception as e: # noqa: BLE001
reason, hint = explain(e)
return {"ok": False, "error": f"未能获取模型列表:{reason}", "hint": hint,
"detail": raw(e), "models": [], "url": url}
models = [m.get("id", "") for m in (data.get("data") or []) if isinstance(m, dict) and m.get("id")]
return {"ok": True, "models": models, "url": url}
# --------------------------------------------------------------------------
# 核查:双盲复算、反方审稿、审批质询共用的一组设置
# --------------------------------------------------------------------------
#: 设置里 verify 这一组。三项都是让一个只读 agent 查库核对(engine/verify_agent.py),共用核查模型和每日金额上限;
#: 各自的开关和上限分开放。上限写 null 表示不限(和裁判一组的约定一样)
VERIFY_GROUP = "verify"
VERIFY_DEFAULTS: dict[str, Any] = {
"provider": None, # 核查模型:空着沿用证据裁判模型的取值链(verify_model_spec)
"model": None,
# 双盲复算:tolerance 是允许的相对误差,两边的值差在这个比例以内算一致
"dual_check": {"enabled": True, "tolerance": 1e-6, "max_queries": 6, "timeout_s": 120},
# 反方审稿:max_claims 是每份报告最多挑几句关键结论来审,查询次数、时长按每句算
"challenge": {"enabled": True, "max_claims": 5, "max_queries": 4, "timeout_s": 90},
# 审批质询:查询次数、时长按每次提问算
"inquiry": {"enabled": True, "max_queries": 6, "timeout_s": 120},
"daily_max_usd": 2.0, # 全局每日核查金额上限(按本地日期),和裁判的每日上限分开记账(engine/spend.py)
}
#: 三项各自那一组的键
_VERIFY_PARTS = ("dual_check", "challenge", "inquiry")
_VERIFY_COUNTS = frozenset({"max_queries", "max_claims"})
#: 设置页「核查」一节各项的叫法:报错时写它,不写键名。嵌套的项写成「组.项」
VERIFY_SETTING_LABEL = {
"provider": "核查模型 · 接入", "model": "核查模型 · 模型",
"daily_max_usd": "核查每日金额上限(美元)",
"dual_check": "双盲复算", "dual_check.enabled": "双盲复算 · 启用",
"dual_check.tolerance": "双盲复算 · 允许的相对误差", "dual_check.max_queries": "双盲复算 · 每项最多查询次数",
"dual_check.timeout_s": "双盲复算 · 每项时长上限(秒)",
"challenge": "反方审稿", "challenge.enabled": "反方审稿 · 启用",
"challenge.max_claims": "反方审稿 · 每份报告最多审几句", "challenge.max_queries": "反方审稿 · 每句最多查询次数",
"challenge.timeout_s": "反方审稿 · 每句时长上限(秒)",
"inquiry": "审批质询", "inquiry.enabled": "审批质询 · 启用",
"inquiry.max_queries": "审批质询 · 每次最多查询次数", "inquiry.timeout_s": "审批质询 · 每次时长上限(秒)",
}
def _positive(value: Any, *, whole: bool) -> bool:
"""上限:null(不限),或大于 0 的有限数;whole 时还得是整数(次数、句数)。True / False 不算数。"""
if value is None:
return True
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value) or value <= 0:
return False
return not (whole and int(value) != value)
def _tolerance_ok(value: Any) -> bool:
"""允许的相对误差:不小于 0 的有限数(0 表示必须完全一致)。不能是 null:没有「不限」的说法。"""
return not isinstance(value, bool) and isinstance(value, (int, float)) and math.isfinite(value) and value >= 0
def _verify_item_ok(key: str, value: Any) -> bool:
if key == "enabled":
return isinstance(value, bool)
if key == "tolerance":
return _tolerance_ok(value)
return _positive(value, whole=key in _VERIFY_COUNTS)
def _verify_item_problem(part: str, key: str, value: Any) -> str | None:
label = VERIFY_SETTING_LABEL[f"{part}.{key}"]
if _verify_item_ok(key, value):
return None
if key == "enabled":
return f"「{label}」需要选择开启或关闭,当前为「{value}」"
if key == "tolerance":
return f"「{label}」需要填写不小于 0 的数(例如 0.000001 表示百万分之一),当前为「{value}」"
words = "正整数(次数)" if key == "max_queries" else "正整数(句数)" if key == "max_claims" else "大于 0 的秒数"
return f"「{label}」需要填写{words},留空表示不限,当前为「{value}」"
def verify_problem(value: Any) -> str | None:
"""设置页写进来的 verify 这一组有什么问题,没问题返回 None。说明写设置页上的叫法。"""
if not isinstance(value, dict):
return f"「核查」设置格式有误,需要是一个对象,当前为「{value}」"
for key, item in value.items():
if key not in VERIFY_DEFAULTS:
return (f"「核查」设置中不支持「{key}」,可设置的项:"
+ "、".join(f"「{VERIFY_SETTING_LABEL[k]}」" for k in VERIFY_DEFAULTS))
label = VERIFY_SETTING_LABEL[key]
if key in ("provider", "model"):
if item is not None and not isinstance(item, str):
# 「跟随证据裁判模型」是设置页接入下拉框空选项的原话
return f"「{label}」需要填写模型接入的名称或模型 ID,留空表示跟随证据裁判模型,当前为「{item}」"
elif key == "daily_max_usd":
if not _positive(item, whole=False):
return f"「{label}」需要填写大于 0 的金额(美元),留空表示不限,当前为「{item}」"
elif not isinstance(item, dict):
return f"「{label}」设置格式有误,需要是一个对象,当前为「{item}」"
else:
allowed = VERIFY_DEFAULTS[key]
for sub, sub_value in item.items():
if sub not in allowed:
return (f"「{label}」设置中不支持「{sub}」,可设置的项:"
+ "、".join(f"「{VERIFY_SETTING_LABEL[f'{key}.{k}']}」" for k in allowed))
if problem := _verify_item_problem(key, sub, sub_value):
return problem
return None
def verify_values(stored: Any) -> dict[str, Any]:
"""库里存的 verify 一组 → 补齐默认的完整设置。存坏了的项(旧版本、手改)按默认,存了 null 的上限就是不限;
三项各自那一组逐项补,只存了一项的其余照默认。"""
stored = stored if isinstance(stored, dict) else {}
out: dict[str, Any] = {k: dict(v) if isinstance(v, dict) else v for k, v in VERIFY_DEFAULTS.items()}
for key in ("provider", "model"):
value = stored.get(key)
out[key] = value.strip() or None if isinstance(value, str) else None
if "daily_max_usd" in stored and _positive(stored["daily_max_usd"], whole=False):
out["daily_max_usd"] = stored["daily_max_usd"]
for part in _VERIFY_PARTS:
saved = stored.get(part)
if not isinstance(saved, dict):
continue
for key in VERIFY_DEFAULTS[part]:
if key in saved and _verify_item_ok(key, saved[key]):
out[part][key] = saved[key]
return out
async def verify_settings(session: AsyncSession) -> dict[str, Any]:
"""设置里的 verify 一组,没存过的键补默认(读法同 judge_settings)。节点、质询接口都从这里读。"""
row = await session.get(Setting, VERIFY_GROUP)
return verify_values(row.value if row else None)
def _model_text(value: Any) -> str | None:
return value.strip() or None if isinstance(value, str) else None
async def verify_model_spec(session: AsyncSession, node_config: dict[str, Any] | None = None, *,
settings: dict[str, Any] | None = None) -> ModelSpec:
"""核查用哪个模型:节点配置里的 provider / model > 设置里的核查模型 > 证据裁判模型的取值链(裁判设置 >
助手模型 > 默认接入,见 judge.judge_model_spec)。哪一级写了就整组用哪一级,不跨级拼接入和模型。
和裁判一样关掉 thinking:核查最后要按结构交结果(强制工具调用),和思考模式不兼容。额度也和裁判一样(judge.MAX_TOKENS,
16384):经 OpenAI 兼容接入的推理模型收不到 thinking=off,思考算在额度里,4096 常常想完就没额度查库、交结果了;
服务方嫌大时核查 agent 退回 judge.FALLBACK_MAX_TOKENS(engine/verify_agent.py)。
建议和被核查的模型不同,否则等于自己核自己;比较、提醒由调用方做(它知道被核查的是哪个模型)。
"""
node = node_config if isinstance(node_config, dict) else {}
provider, model = _model_text(node.get("provider")), _model_text(node.get("model"))
if not (provider or model):
chosen = settings if settings is not None else await verify_settings(session)
provider, model = _model_text(chosen.get("provider")), _model_text(chosen.get("model"))
if not (provider or model):
return await judge_model_spec(session, None)
return ModelSpec(provider=provider, model=model, max_tokens=JUDGE_MAX_TOKENS, thinking="off")
# --------------------------------------------------------------------------
# 起草模型:数据目录的「用模型起草」和按配方导入的 AI 起草用哪个模型
# --------------------------------------------------------------------------
#: 设置里 catalog_draft 这一组:{provider, model}。都留空时沿用助手的模型,再没有就是默认接入(取值顺序见
#: data/draft_model.py,两处起草共用)。只放接入和模型:额度、思考由两处起草各自定
CATALOG_DRAFT_GROUP = CATALOG_DRAFT_SETTING_KEY
CATALOG_DRAFT_DEFAULTS: dict[str, Any] = {"provider": None, "model": None}
#: 设置页「起草模型」一节各项的叫法:报错时写它,不写键名
CATALOG_DRAFT_LABEL = {"provider": "起草模型 · 接入", "model": "起草模型 · 模型"}
def catalog_draft_problem(value: Any) -> str | None:
"""设置页写进来的 catalog_draft 这一组有什么问题,没问题返回 None。点名的接入在不在由 _provider_problem 查。"""
if not isinstance(value, dict):
return f"「起草模型」设置格式有误,需要是一个对象,当前为「{value}」"
for key, item in value.items():
if key not in CATALOG_DRAFT_DEFAULTS:
return (f"「起草模型」设置中不支持「{key}」,可设置的项:"
+ "、".join(f"「{CATALOG_DRAFT_LABEL[k]}」" for k in CATALOG_DRAFT_DEFAULTS))
if item is not None and not isinstance(item, str):
# 「跟随助手的模型」是设置页接入下拉框空选项的原话
what = "模型接入的名称" if key == "provider" else "模型 ID"
return f"「{CATALOG_DRAFT_LABEL[key]}」需要填写{what},留空表示跟随助手的模型,当前为「{item}」"
return None
def catalog_draft_values(stored: Any) -> dict[str, Any]:
"""库里存的 catalog_draft 一组 → 补齐默认、规范化(去掉首尾空白,空串和存坏了的当没填)。"""
provider, model = setting_pair(stored)
return {"provider": provider, "model": model}
# --------------------------------------------------------------------------
# 对外访问地址:通知、Webhook 里的链接,单点登录的回调,来源核对(core/public_url.py)
# --------------------------------------------------------------------------
#: 设置里 notify 这一组。public_base_url 是别人打开 AgentLab 用的地址(https://agentlab.example.com),通知里的
#: 运行链接 = 它 + /runs/{运行 id}。服务端不知道自己在反向代理后面叫什么,只能由人填;留空的通知里不附链接。
#: 全站只认一个对外地址(core/public_url.py):环境变量 AGENTLAB_PUBLIC_URL 设了的话这一项不生效、不能改(PUT 回 409)。
#: 键名沿用 notify 一组,界面上在「设置 → 通用」
NOTIFY_GROUP = public_url.SETTING_GROUP
#: digest_at:每天几点给订阅了「每天的待办汇总」的个人渠道发汇总(HH:MM,按服务端所在时区;notify/digest.py,04 细案 B3)
NOTIFY_DEFAULTS: dict[str, Any] = {public_url.SETTING_KEY: None, "digest_at": "09:00"}
NOTIFY_SETTING_LABEL = {public_url.SETTING_KEY: "对外访问地址", "digest_at": "待办汇总的发送时间"}
#: GET /api/settings 另给的只读一组:{value: 生效的对外访问地址, env_locked: 是不是环境变量指定的}。设置页据此显示
#: 「由环境变量指定」;PUT 带上它也不落库(同 limits)
PUBLIC_URL_GROUP = "public_url"
def _base_url_problem(value: Any) -> str | None:
if value is None or (isinstance(value, str) and not value.strip()):
return None
label = NOTIFY_SETTING_LABEL[public_url.SETTING_KEY]
hint = (f"「{label}」需要是 http:// 或 https:// 开头的完整地址(例如 https://agentlab.example.com),"
f"留空表示通知里不附链接,当前为「{value}」")
if not isinstance(value, str):
return hint
if (problem := public_url.url_problem(value)) == "suffix":
return f"「{label}」不能带 ? 或 # 之后的部分,链接会接在地址后面,当前为「{value}」"
return hint if problem else None
def notify_problem(value: Any) -> str | None:
"""设置页写进来的 notify 这一组有什么问题,没问题返回 None。"""
if not isinstance(value, dict):
return f"「通知」设置格式有误,需要是一个对象,当前为「{value}」"
for key, item in value.items():
if key not in NOTIFY_DEFAULTS:
return (f"「通知」设置中不支持「{key}」,可设置的项:"
+ "、".join(f"「{NOTIFY_SETTING_LABEL[k]}」" for k in NOTIFY_DEFAULTS))
if key == "digest_at":
from app.notify.digest import time_ok
if not time_ok(item):
return f"「{NOTIFY_SETTING_LABEL[key]}」需要是 24 小时制的时间,例如 09:00(从 00:00 到 23:59),当前为「{item}」"
elif problem := _base_url_problem(item):
return problem
return None
def notify_values(stored: Any) -> dict[str, Any]:
"""库里存的(或刚写进来的)notify 一组 → 补齐默认、规范化:去掉首尾空白和末尾的 /,空串当没填;存坏了的按没填。
汇总时间存坏了按缺省。"""
from app.notify.digest import time_ok
stored = stored if isinstance(stored, dict) else {}
at = stored.get("digest_at")
return {public_url.SETTING_KEY: public_url.normalize(stored.get(public_url.SETTING_KEY)),
"digest_at": at.strip() if time_ok(at) else NOTIFY_DEFAULTS["digest_at"]}
async def notify_settings(session: AsyncSession) -> dict[str, Any]:
"""设置里存的 notify 一组,没存过的补默认。要拼链接的地方不读它:一律经 core/public_url(环境变量优先)。"""
row = await session.get(Setting, NOTIFY_GROUP)
return notify_values(row.value if row else None)
def public_url_values(stored_notify: Any) -> dict[str, Any]:
"""GET /api/settings 里只读的 public_url 一组:生效的那个地址(环境变量优先,其次库里存的这一项),是不是环境变量
指定的。按库里存的算,不读进程缓存:设置页看到的就是落库的样子。"""
locked = public_url.env_locked()
value = public_url.env_url() if locked else notify_values(stored_notify)[public_url.SETTING_KEY]
return {"value": value, "env_locked": locked}
# --------------------------------------------------------------------------
# 应用:按需视图的每日上限(D-j)、使用者能不能建个人应用(docs/design/normal-user-apps.md N7)
# --------------------------------------------------------------------------
#: 设置里 apps 这一组。on_demand_daily_limit:每个应用每天最多发起几次按需视图的正式运行(app/apps/resolve.py 的
#: daily_limit 读它),点「刷新」才运行、同一个键只运行一份之外,再兜一道花费的底。0 表示不再发起按需运行;个人应用的刷新
#: 共用这一项,按人计(blueprints/personal.py)。
#: user_personal_apps:开了登录时使用者能不能建个人应用、刷新、提交审核(默认关,同问数据的开关;搭建者及以上不受它管)。
#: 关登录时它照样生效:关着就没有个人应用的入口。只有管理员能改(整个设置接口的改动都要管理员)
APPS_GROUP = "apps"
#: share_links:能不能建分享链接(只读、不用登录就能看的视图页,api/shares.py,04 细案 C2、集成-I6)。缺省关;关掉时已有的
#: 分享链接一并打不开(管理员随时收得回来),撤销照常能做
APPS_DEFAULTS: dict[str, Any] = {"on_demand_daily_limit": 200, "user_personal_apps": False, "share_links": False}
APPS_SETTING_LABEL = {"on_demand_daily_limit": "按需视图每日上限", "user_personal_apps": "使用者可以建个人应用",
"share_links": "允许建分享链接"}
#: 上限的上限:再大就等于不限,要不限的不该靠这一项
APPS_DAILY_LIMIT_MAX = 100_000
def _daily_limit_ok(value: Any) -> bool:
return isinstance(value, int) and not isinstance(value, bool) and 0 <= value <= APPS_DAILY_LIMIT_MAX
def apps_problem(value: Any) -> str | None:
"""设置页写进来的 apps 这一组有什么问题,没问题返回 None。"""
if not isinstance(value, dict):
return f"「应用」设置格式有误,需要是一个对象,当前为「{value}」"
for key, item in value.items():
if key not in APPS_DEFAULTS:
return (f"「应用」设置中不支持「{key}」,可设置的项:"
+ "、".join(f"「{APPS_SETTING_LABEL[k]}」" for k in APPS_DEFAULTS))
if key in ("user_personal_apps", "share_links"):
if not isinstance(item, bool):
return f"「{APPS_SETTING_LABEL[key]}」只能是开或关,当前为「{item}」"
elif not _daily_limit_ok(item):
return (f"「{APPS_SETTING_LABEL[key]}」需要是 0 到 {APPS_DAILY_LIMIT_MAX:,} 之间的整数(0 表示不再发起按需运行),"
f"当前为「{item}」")
return None
def apps_values(stored: Any) -> dict[str, Any]:
"""库里存的(或刚写进来的)apps 一组 → 补齐默认;存坏了的项按默认(个人应用的开关存坏了按关)。"""
stored = stored if isinstance(stored, dict) else {}
limit = stored.get("on_demand_daily_limit")
personal = stored.get("user_personal_apps")
shares = stored.get("share_links")
return {"on_demand_daily_limit": limit if _daily_limit_ok(limit) else APPS_DEFAULTS["on_demand_daily_limit"],
"user_personal_apps": personal if isinstance(personal, bool) else APPS_DEFAULTS["user_personal_apps"],
"share_links": shares if isinstance(shares, bool) else APPS_DEFAULTS["share_links"]}
async def apps_settings(session: AsyncSession) -> dict[str, Any]:
"""设置里的 apps 一组,没存过的补默认。按需视图发起前从这里读每日上限。"""
row = await session.get(Setting, APPS_GROUP)
return apps_values(row.value if row else None)
# --------------------------------------------------------------------------
# 口径账本:默认项、范围与校验定义在 app/ledger/settings.py(此处直接透出供设置接口使用)
# --------------------------------------------------------------------------
# --------------------------------------------------------------------------
# 评测:并发、每题时限、单次和每日金额上限、保留几次、结论裁判的缺省(docs/design/next-round/01-效果度量与回归评测.md 做法 17)
# --------------------------------------------------------------------------
#: 设置里 eval 这一组(评测-D9 拍板的缺省:同时 2 题、每题 900 秒、单次评测 2 美元、评测每日 5 美元)。前四项是全站的预算
#: 缺省,题集上改过的项盖过它(app/eval/suites.effective_budget);评测每日的金额上限是评测那本账(engine/spend.eval_spend,
#: 单独记账,不挤占裁判、核查的额度)的上限,题集只能往下收。keep_runs:每个题集保留最近几次评测的明细(钉住的基线、A/B
#: 引用的那一对另外留着,总表永久保留,app/eval/cleanup.py)。judge:发起评测时变体里没写的话,结论裁判开不开(缺省关:
#: 裁判按句数花钱)。搭建者只读,管理员写(整个设置接口的规矩)
EVAL_GROUP = "eval"
EVAL_SPEND = "eval_spend"
EVAL_DEFAULTS: dict[str, Any] = {"max_parallel": 2, "per_case_timeout_s": 900, "per_run_max_usd": 2.0,
"daily_max_usd": 5.0, "keep_runs": 20, "judge": False}
EVAL_SETTING_LABEL = {"max_parallel": "评测 · 同时评几道题", "per_case_timeout_s": "评测 · 每题时限(秒)",
"per_run_max_usd": "评测 · 单次评测的金额上限(美元)", "daily_max_usd": "评测 · 每日金额上限(美元)",
"keep_runs": "评测 · 每个题集保留最近几次的明细", "judge": "评测 · 缺省打开结论裁判"}
#: 保留的次数:至少 1(最近一次总要能和下一次比),最多 1000
EVAL_KEEP_MAX = 1000
def _eval_item_problem(key: str, item: Any) -> str | None:
from app.eval.suites import BUDGET_RULES
label = EVAL_SETTING_LABEL[key]
if key == "judge":
return None if isinstance(item, bool) else f"「{label}」只能是开或关,当前为「{item}」"
if key == "keep_runs":
ok = isinstance(item, int) and not isinstance(item, bool) and 1 <= item <= EVAL_KEEP_MAX
return None if ok else f"「{label}」需要是 1 到 {EVAL_KEEP_MAX} 之间的整数,当前为「{item}」"
_, kind, low, high = BUDGET_RULES[key]
ok = not isinstance(item, bool) and isinstance(item, (int, float)) and math.isfinite(item) \
and (kind is float or isinstance(item, int)) and low <= item <= high
unit = "整数" if kind is int else "数"
return None if ok else f"「{label}」需要是 {low:g} 到 {high:g} 之间的{unit},当前为「{item}」"
def eval_problem(value: Any) -> str | None:
"""设置页写进来的 eval 这一组有什么问题,没问题返回 None。"""
if not isinstance(value, dict):
return f"「评测」设置格式有误,需要是一个对象,当前为「{value}」"
for key, item in value.items():
if key not in EVAL_DEFAULTS:
return (f"「评测」设置中不支持「{key}」,可设置的项:"
+ "、".join(f"「{EVAL_SETTING_LABEL[k]}」" for k in EVAL_DEFAULTS))
if problem := _eval_item_problem(key, item):
return problem
merged = eval_values(value)
if merged["per_run_max_usd"] > merged["daily_max_usd"]:
return (f"「{EVAL_SETTING_LABEL['per_run_max_usd']}」不能高于「{EVAL_SETTING_LABEL['daily_max_usd']}」,"
f"当前为 {merged['per_run_max_usd']:g} 和 {merged['daily_max_usd']:g}")
return None
def eval_values(stored: Any) -> dict[str, Any]:
"""库里存的(或刚写进来的)eval 一组 → 补齐默认;存坏了的项按默认。"""
stored = stored if isinstance(stored, dict) else {}
out = dict(EVAL_DEFAULTS)
for key in EVAL_DEFAULTS:
if key in stored and _eval_item_problem(key, stored[key]) is None:
value = stored[key]
out[key] = float(value) if key in ("per_run_max_usd", "daily_max_usd") else value
return out
async def eval_settings(session: AsyncSession) -> dict[str, Any]:
"""设置里的 eval 一组,没存过的补默认。发起评测、清理评测从这里读。"""
row = await session.get(Setting, EVAL_GROUP)
return eval_values(row.value if row else None)
# --------------------------------------------------------------------------
# 分析:判定用的阈值和次数上限(docs/design/analysis-round/01-算子与判定.md §10)。键、缺省、取值范围、叫法、校验都在
# app/analysis/contract.py 一处:设置页、发起时的快照(run.options["analysis"],engine/launch.py)、分析卡上的覆盖按同一张表。
# agent 改不了这些:分析工具的参数里没有阈值。搭建者只读,管理员写(整个设置接口的规矩)
# --------------------------------------------------------------------------
#: 设置里 analysis 这一组(contract.SETTINGS_KEY)
ANALYSIS_GROUP = analysis_contract.SETTINGS_KEY
#: 每一项的叫法(设置页、报错都用它,句子里不裸写键名)
ANALYSIS_SETTING_LABEL = analysis_contract.LABELS
def analysis_problem(value: Any) -> str | None:
"""设置页写进来的 analysis 这一组有什么问题(不认识的键、类型、范围、两两之间的大小),没问题返回 None。写进来的这一组
整个替换存着的(同别的组),缺的项按缺省,所以两两之间的大小也按缺省补齐了比。"""
if not isinstance(value, dict):
return f"「分析」设置格式有误,需要是一个对象,当前为「{value}」"
for key in value:
if key not in analysis_contract.DEFAULTS:
return (f"「分析」设置中不支持「{key}」,可设置的项:"
+ "、".join(f"「{ANALYSIS_SETTING_LABEL[k]}」" for k in analysis_contract.DEFAULTS))
return analysis_contract.settings_problem(value)
def _analysis_item_ok(key: str, value: Any) -> bool:
"""一项本身对不对(类型、范围),不看和别的项的大小:存着的一组逐项认,两两之间的另看。"""
kind, low, high = analysis_contract.RANGES[key]
if isinstance(value, bool) or not isinstance(value, (int, float)) or (kind is int and not isinstance(value, int)):
return False
return low <= value <= high
def analysis_values(stored: Any) -> dict[str, Any]:
"""库里存的(或刚写进来的)analysis 一组 → 补齐缺省;存坏了的项按缺省。两两之间的大小对不上的(「明显超出」不大于
「超出常规波动」、最多用几期历史小于至少几期、「集中」的合计占比小于「主要来源」的占比)那一对都按缺省:只换掉一项,可能
换出另一种对不上。"""
stored = stored if isinstance(stored, dict) else {}
out = dict(analysis_contract.DEFAULTS)
for key in analysis_contract.DEFAULTS:
if key in stored and _analysis_item_ok(key, stored[key]):
out[key] = float(stored[key]) if analysis_contract.RANGES[key][0] is float else stored[key]
for pair in (("k_warn", "k_alert"), ("min_history", "max_history"), ("main_share", "concentration")):
if analysis_contract.settings_problem({k: out[k] for k in pair}) is not None:
out.update({k: analysis_contract.DEFAULTS[k] for k in pair})
return out
async def analysis_settings(session: AsyncSession) -> dict[str, Any]:
"""设置里的 analysis 一组,没存过的补缺省。发起运行时快照这一份(engine/launch.py),没有快照的运行由分析工具、分析卡、
口径卡的可比性检查现读(app/tools/analysis.py)。"""
row = await session.get(Setting, ANALYSIS_GROUP)
return analysis_values(row.value if row else None)
# --------------------------------------------------------------------------
# 邮件服务(notify/mail.py,04 细案 B2):只有管理员能改(整个设置接口的改动都要管理员),口令加密存、读的时候只说配没配
# --------------------------------------------------------------------------
MAIL_GROUP = "mail"
#: 发测试邮件没有可发的账号(关登录)、账号没有邮箱时 POST /api/settings/mail/test 回 422 的机读码
MAIL_TEST_NO_ADDRESS = "mail_test_no_address"
# --------------------------------------------------------------------------
# 用户设置
# --------------------------------------------------------------------------
#: 不经 /api/settings 读写的组:登录设置只经 /api/auth/settings 改(D-l)——那一组里有 OIDC 的客户端密钥、管理员映射,
#: 而且改登录方式要在同一个请求里引导第一个管理员、刷新进程内的缓存;从这里写进去的那份既不校验也不生效,读出来的又
#: 会把密钥带给搭建者
def _hidden_groups() -> tuple[str, ...]:
from app.auth import SETTING_KEY as AUTH_KEY
return (AUTH_KEY,)
def _mail_public(stored: Any) -> dict[str, Any]:
from app.notify import mail
return mail.public_value(stored)
#: 运行并发一组(app/cluster/slots.py)
CONCURRENCY_GROUP = "concurrency"
def concurrency_values(stored: Any) -> dict[str, Any]:
from app.cluster import slots
return slots.values(stored)
def _internal(key: str) -> bool:
"""通知自己记的账(每天汇总的认领行、自动建过邮件渠道的人,notify/digest.STATE_PREFIX):不是用户设置,不列、不收。"""
from app.notify.digest import STATE_PREFIX
# cluster: 开头的是多进程协调自己记的(重建索引的进度之类,app/cluster),同样不是用户设置
return key.startswith(STATE_PREFIX) or key.startswith("cluster:")
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,
# 「始终允许 · 门控把关」的工具每次调用前问的那个模型(tools/trust.py)。
# 空着就用默认 provider 的默认模型
"tool_gate_provider": None,
"tool_gate_model": None,
# agent 护栏(engine/guards.py):兜底步数、每个节点的令牌 / 金额预算。预算为 null 表示不限
"agent_max_steps": 100,
"agent_budget_tokens": 2_000_000,
"agent_budget_usd": None,
# 问数简单查询撞上硬异常信号时的处置策略(P1):prompt(默认,正常交付并提示一键本体排查)/ auto(自动升级最多 1 轮)
"anomaly_escalation_mode": "prompt",
},
"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,
},
# 结论句裁判(engine/judge.py):证据裁判模型和各项上限,上限存 null 表示不限。不放进 limits:
# 那一组是环境变量定的、只读,这一组是用户自己定的
JUDGE_GROUP: dict(JUDGE_DEFAULTS),
# 核查(双盲复算、反方审稿、审批质询):核查模型、三项的开关和上限、每日金额上限。和裁判一组一样由用户定
VERIFY_GROUP: verify_values(None),
# 对外访问地址(存在 notify 一组):通知、Webhook 里的链接,单点登录的回调,来源核对。环境变量优先(core/public_url.py)
NOTIFY_GROUP: notify_values(None),
# 起草模型:数据目录起草、按配方导入的 AI 起草用的模型,留空沿用助手的模型
CATALOG_DRAFT_GROUP: catalog_draft_values(None),
# 应用:按需视图的每日上限、使用者能不能建个人应用、能不能建分享链接(管理员在设置页改)
APPS_GROUP: apps_values(None),
# 口径账本:自动按口径正式出具的开关、每日上限,口径比对的时限
LEDGER_GROUP: ledger_values(None),
# 评测:预算的全站缺省(题集上可以改)、保留几次明细、结论裁判的缺省开关
EVAL_GROUP: eval_values(None),
# 分析:判定用的阈值、Agent 每次执行最多分析几次、最多下钻几层(app/analysis/contract.py)
ANALYSIS_GROUP: analysis_values(None),
# 邮件服务(SMTP):通知渠道的邮件格式用它发信。缺省关
MAIL_GROUP: _mail_public(None),
# 运行并发(app/cluster/slots.py):全站同时运行的上限,交互、应用、定时、系统四类各自的上限和优先级。多进程时几个进程合起来算
CONCURRENCY_GROUP: concurrency_values(None),
}
ANOMALY_ESCALATION_MODES = ("prompt", "auto")
async def run_defaults(session: AsyncSession) -> dict[str, Any]:
"""设置里 run 这一组,没存过的键补内置默认。
发起运行和引擎都从这里读。各拼各的默认值,两边迟早会对「没存过」理解得
不一样——以前这三项就是只存不读,设置页上改了等于没改。
"""
row = await session.get(Setting, "run")
return {**DEFAULT_SETTINGS["run"], **((row.value if row else None) or {})}
async def anomaly_escalation_mode(session: AsyncSession) -> str:
"""问数简单查数命中硬异常时的本体排查升级策略:默认 prompt,明确设为 auto 时自动发起最多 1 轮诊断升级。"""
raw = (await run_defaults(session)).get("anomaly_escalation_mode")
return str(raw) if raw in ANOMALY_ESCALATION_MODES else "prompt"
def tool_approval_default(run: dict[str, Any]) -> str:
"""「危险工具默认需要人工确认」换算成节点 approval 的取值,节点自己配了的不看它。
只有明确存了 False 才关:值缺了、存坏了都按开着算。关掉审批得是一次明确的
选择,不能是某个字段没写上的副作用。
"""
return "never" if run.get("confirm_dangerous_tools") is False else "dangerous"
async def resolve_run_scope(
session: AsyncSession, memory_scope: str | None, collection: str | None
) -> tuple[str, str]:
"""请求没带的记忆域和知识库,取设置里的运行默认值。
空串等于没填:设置页把输入框清空再保存,存下来的就是空串,拿它当记忆域
写进去的东西谁也读不回来。
"""
defaults = await run_defaults(session)
def pick(explicit: str | None, key: str) -> str:
for value in (explicit, defaults.get(key)):
if isinstance(value, str) and value.strip():
return value.strip()
return "default"
return pick(memory_scope, "default_memory_scope"), pick(collection, "default_collection")
@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}
# limits 是服务端配置(环境变量),只读:库里可能存着旧版设置页写进去的一份
#(见过 max_agent_steps: 25),它从来不生效,却会盖住真实的上限,界面照着错的数许诺
stored.pop("limits", None)
stored.pop(PUBLIC_URL_GROUP, None)
# 裁判、核查的每日计数是引擎记的账,不是用户设置:设置页整组回写时带着一份旧的,会把今天的花费清零
stored.pop(JUDGE_SPEND, None)
stored.pop(VERIFY_SPEND, None)
stored.pop(EVAL_SPEND, None)
for key in _hidden_groups():
stored.pop(key, None)
stored = {k: v for k, v in stored.items() if not _internal(k)}
merged = {k: {**v, **(stored.get(k) or {})} for k, v in DEFAULT_SETTINGS.items()}
# 裁判、核查设置按引擎实际生效的样子给:存坏了的项显示默认,存了 null 的上限显示不限
merged[JUDGE_GROUP] = judge_values(stored.get(JUDGE_GROUP))
merged[VERIFY_GROUP] = verify_values(stored.get(VERIFY_GROUP))
merged[NOTIFY_GROUP] = notify_values(stored.get(NOTIFY_GROUP))
merged[PUBLIC_URL_GROUP] = public_url_values(stored.get(NOTIFY_GROUP))
merged[CATALOG_DRAFT_GROUP] = catalog_draft_values(stored.get(CATALOG_DRAFT_GROUP))
merged[APPS_GROUP] = apps_values(stored.get(APPS_GROUP))
merged[LEDGER_GROUP] = ledger_values(stored.get(LEDGER_GROUP))
merged[EVAL_GROUP] = eval_values(stored.get(EVAL_GROUP))
merged[ANALYSIS_GROUP] = analysis_values(stored.get(ANALYSIS_GROUP))
merged[CONCURRENCY_GROUP] = concurrency_values(stored.get(CONCURRENCY_GROUP))
# 口令不回(password 恒为 null),只说配没配(password_set)
merged[MAIL_GROUP] = _mail_public(stored.get(MAIL_GROUP))
for key, value in stored.items():
merged.setdefault(key, value)
return merged
class SettingsIn(BaseModel):
values: dict[str, Any]
#: 点名的模型接入不存在或已停用时 PUT /api/settings 回 422 的机读码,field 是出错的那一项(verify.provider……)
PROVIDER_INVALID = "settings_provider_invalid"
def _provider_fields() -> dict[tuple[str, str], tuple[str, str | None]]:
"""设置里点名模型接入的项:(组, 键) → (这一项在界面上的叫法, 同一组模型那一项的叫法)。门控、裁判、核查、起草模型的
叫法和设置页一致;助手的模型由「编排」页助手栏的「助手使用的模型」单独设定(PUT /api/copilot/model),只有这一个下拉框
(模型那一项为 None),这里只防经设置接口写进去的那一份。"""
from app.api.copilot import COPILOT_SETTING_KEY # 在这里导入:copilot 模块很大,设置模块被引擎各处导入
from app.providers.factory import ASSISTANT_MODEL_FIELD
return {
("run", "tool_gate_provider"): ("门控模型 · 接入", "门控模型 · 模型"),
(JUDGE_GROUP, "provider"): (JUDGE_LABEL["provider"], JUDGE_LABEL["model"]),
(VERIFY_GROUP, "provider"): (VERIFY_SETTING_LABEL["provider"], VERIFY_SETTING_LABEL["model"]),
(CATALOG_DRAFT_GROUP, "provider"): (CATALOG_DRAFT_LABEL["provider"], CATALOG_DRAFT_LABEL["model"]),
(COPILOT_SETTING_KEY, "provider"): (ASSISTANT_MODEL_FIELD, None),
}
async def _provider_problem(session: AsyncSession, values: dict[str, Any]) -> tuple[str, str] | None:
"""这次写进来的设置里点名的模型接入有没有问题:(字段, 说明),没问题返回 None。说明和运行时报的原因、提示同一句
(factory.named_provider),主语写这一项在设置页上的叫法;下拉框里选的,写「指定的」。助手那一项只有「编排」页
助手栏一个下拉框(只选模型,选了就清掉接入),没有「换一个接入」的地方:说法同助手取不到模型时那一句
(factory.assistant_unavailable),哪里报都一样。
以前点名的接入不存在时运行悄悄换成演示模型,名字写错一个字就由演示模型写报告、做核查,现在运行时照实报错;保存时
先拦一道。留空照旧表示沿用上一级——说明里不再讲留空是什么意思:设置页那一格的空选项写着(「默认接入」「跟随助手
的模型」「跟随证据裁判模型」……),这里另写一遍只会对不上。只查这次改了的:存着的值没改(写好之后接入才被删、
停用)不挡这次保存别的项——设置页在那一项下标着「不存在」「已停用」,运行时照实报错,同设置页「只校验改过的」。
点名的名字恰好是某个启用接入的模型时,补一句在这一组的哪一栏用它(factory.model_note)。
名字不是字符串(接口写进来的数字一类):核查、裁判两组自己的格式检查在前面先报;门控、助手两组没有自己的检查,
在这里报,免得存下去之后读它的地方(查助手当前用哪个模型)出错。"""
providers = None
for (group, key), (label, model_label) in _provider_fields().items():
sent = values.get(group)
if not isinstance(sent, dict):
continue
if (raw := sent.get(key)) is not None and not isinstance(raw, str):
return f"{group}.{key}", f"「{label}」需要填写模型接入的名称,当前为「{raw}」"
if (name := _model_text(raw)) is None:
continue
row = await session.get(Setting, group)
saved = row.value if row is not None and isinstance(row.value, dict) else {}
if _model_text(saved.get(key)) == name:
continue
if providers is None:
providers = await all_providers(session)
try:
named_provider(providers, name)
except ProviderNotConfigured as e:
if model_label is None:
return f"{group}.{key}", assistant_unavailable(e)
said = f"「{label}」指定的{e.reason}。{e.hint}"
if e.model_owner:
# 这几组的模型一栏是输入框:写「填写」
said += f"。{model_note(e.name, e.model_owner, f'「{model_label}」', f'「{label}」', '填写')}"
return f"{group}.{key}", said
return None
@router.put("/settings")
async def put_settings(
payload: SettingsIn, session: AsyncSession = Depends(get_session)
) -> dict[str, Any]:
from app.tools.trust import SETTING_KEY as TRUST_KEY
# 先查后写:一组里有一项不对就整个请求不落库,免得存下半组
if "run" in payload.values and isinstance(payload.values["run"], dict):
if "anomaly_escalation_mode" in payload.values["run"]:
mode = payload.values["run"]["anomaly_escalation_mode"]
if mode is not None and mode not in ANOMALY_ESCALATION_MODES:
raise HTTPException(
422,
f"异常升级策略(run.anomaly_escalation_mode)只能是 {' 或 '.join(ANOMALY_ESCALATION_MODES)},当前为「{mode}」",
)
if JUDGE_GROUP in payload.values and (problem := judge_problem(payload.values[JUDGE_GROUP])):
raise HTTPException(422, problem)
if VERIFY_GROUP in payload.values and (problem := verify_problem(payload.values[VERIFY_GROUP])):
raise HTTPException(422, problem)
if CATALOG_DRAFT_GROUP in payload.values and (
problem := catalog_draft_problem(payload.values[CATALOG_DRAFT_GROUP])):
raise HTTPException(422, problem)
if NOTIFY_GROUP in payload.values:
if problem := notify_problem(payload.values[NOTIFY_GROUP]):
raise HTTPException(422, problem)
# 存规范化之后的:末尾带 / 的地址拼链接会多出一个斜杠
# 只给了一部分的(只改汇总时间)补上存着的另几项,不把对外访问地址冲成没填
payload.values[NOTIFY_GROUP] = notify_values({**await notify_settings(session), **payload.values[NOTIFY_GROUP]})
current_url = (await notify_settings(session))[public_url.SETTING_KEY]
if public_url.env_locked() and payload.values[NOTIFY_GROUP][public_url.SETTING_KEY] != current_url:
# 环境变量指定了对外访问地址:改这一项不会生效,照实拒绝,不悄悄存一个用不上的值。原样写回(整组回写)不算改
raise CodedHTTPException(
409, f"对外访问地址由环境变量 {public_url.ENV_NAME} 指定({public_url.env_url()}),不能在设置里修改。"
"要改请改部署配置后重启服务", PUBLIC_URL_ENV_LOCKED, field=f"{NOTIFY_GROUP}.{public_url.SETTING_KEY}")
if APPS_GROUP in payload.values:
if problem := apps_problem(payload.values[APPS_GROUP]):
raise HTTPException(422, problem)
payload.values[APPS_GROUP] = apps_values(payload.values[APPS_GROUP])
if LEDGER_GROUP in payload.values:
if problem := ledger_problem(payload.values[LEDGER_GROUP]):
raise HTTPException(422, problem)
payload.values[LEDGER_GROUP] = ledger_values(payload.values[LEDGER_GROUP])
if EVAL_GROUP in payload.values:
if problem := eval_problem(payload.values[EVAL_GROUP]):
raise HTTPException(422, problem)
payload.values[EVAL_GROUP] = eval_values(payload.values[EVAL_GROUP])
if ANALYSIS_GROUP in payload.values:
if problem := analysis_problem(payload.values[ANALYSIS_GROUP]):
raise HTTPException(422, problem)
payload.values[ANALYSIS_GROUP] = analysis_values(payload.values[ANALYSIS_GROUP])
if CONCURRENCY_GROUP in payload.values:
from app.cluster import slots
if problem := slots.problem(payload.values[CONCURRENCY_GROUP]):
raise HTTPException(422, problem)
saved_row = await session.get(Setting, CONCURRENCY_GROUP)
payload.values[CONCURRENCY_GROUP] = slots.merged(saved_row.value if saved_row is not None else None,
payload.values[CONCURRENCY_GROUP])
if MAIL_GROUP in payload.values:
from app.notify import mail
saved_mail = await session.get(Setting, MAIL_GROUP)
saved_value = saved_mail.value if saved_mail is not None else None
if problem := mail.settings_problem(payload.values[MAIL_GROUP], saved_value):
raise HTTPException(422, problem)
payload.values[MAIL_GROUP] = mail.stored_value(payload.values[MAIL_GROUP], saved_value)
if found := await _provider_problem(session, payload.values):
raise CodedHTTPException(422, found[1], PROVIDER_INVALID, field=found[0])
for key, value in payload.values.items():
if key in (TRUST_KEY, "limits", PUBLIC_URL_GROUP, JUDGE_SPEND, VERIFY_SPEND, EVAL_SPEND, *_hidden_groups()) or _internal(key):
# 信任三档只走 PUT /api/tools/trust:设置页整组回写时带着一份旧的,
# 会把刚在工具页改的档位冲掉。limits 是环境变量定的、public_url 是算出来的,写进库也不生效。
# 裁判、核查的每日计数是引擎记的账,只有引擎自己写。登录设置只经 /api/auth/settings 改(_hidden_groups)
continue
row = await session.get(Setting, key)
if row:
row.value = value
else:
session.add(Setting(key=key, value=value))
await session.commit()
if CONCURRENCY_GROUP in payload.values:
# 本进程马上按新的发名额(别的进程最迟几秒,slots.POLICY_TTL_S)
from app.cluster import slots
slots.forget_policy()
if NOTIFY_GROUP in payload.values:
# 中间件、单点登录的回调按进程缓存取对外访问地址:写完库就刷新,不用重启
public_url.remember(payload.values[NOTIFY_GROUP][public_url.SETTING_KEY])
from app.cluster import caches
await caches.broadcast("public_url")
return await get_settings(session)
@router.post("/settings/mail/test")
async def test_mail(session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
"""按存着的邮件服务设置发一封测试邮件,发到管理员自己的账号邮箱(只发到认证过的账号,不收任意地址:不让这个接口成了
邮件中继)。没启用也能测(配好了先试一封再启用);不重试。{ok, message}:成功说「已交给邮件服务器」,是否送达以邮件服务器
为准(退信不追踪)。关登录时没有账号、账号没有邮箱:422(mail_test_no_address),说清怎么办。"""
from app.api.deps import current_principal
from app.db.models import User
from app.notify import mail
from app.notify.core import Message
principal = current_principal()
if principal.mode == "off" or not principal.user_id:
raise CodedHTTPException(422, "关闭登录时没有账号邮箱,测试邮件发不到人:请建一个全局的邮件渠道、填上收件地址,"
"用渠道的「发送测试消息」试", MAIL_TEST_NO_ADDRESS)
user = await session.get(User, principal.user_id)
to = (user.email or "").strip() if user is not None else ""
if not mail.address_ok(to):
raise CodedHTTPException(422, f"{mail.NO_EMAIL},再发测试邮件", MAIL_TEST_NO_ADDRESS)
row = await session.get(Setting, MAIL_GROUP)
cfg = mail.config_of(row.value if row is not None else None)
if not cfg.ready:
return {"ok": False, "message": f"请先填写并保存「{mail.LABEL['host']}」和「{mail.LABEL['from_addr']}」,再发测试邮件"}
msg = Message(title="邮件服务测试", ok=True, lines=[("说明", "这是一封测试邮件。收到即表示邮件服务配置正确")])
outcome = await mail.send(cfg, [to], msg, label="测试邮件", retry=False)
if outcome.ok:
return {"ok": True, "message": f"{mail.HANDED_OFF}(发到 {to}),是否送达以邮件服务器为准,请到收件箱确认"}
return {"ok": False, "message": outcome.error or "发送失败"}
@router.get("/settings/judge/spend")
async def get_judge_spend(session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
"""今天(本地日期)结论句裁判花了多少,设置页放在每日上限旁边。只读:计数只有裁判自己记。
{date, usd, calls, unpriced_calls, daily_max_usd}。unpriced_calls 是价格目录里没有的模型的调用次数,
这些调用估不出金额、没有算进 usd。daily_max_usd 为 null 表示不限。
"""
return {**await daily_spend(), "daily_max_usd": (await judge_settings(session))["daily_max_usd"]}
@router.get("/settings/verify/spend")
async def get_verify_spend(session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
"""今天(本地日期)核查(双盲复算、反方审稿、审批质询)花了多少,设置页放在核查的每日上限旁边。只读。
形状同 /settings/judge/spend:{date, usd, calls, unpriced_calls, daily_max_usd},daily_max_usd 为 null 表示不限。
"""
return {**await verify_daily_spend(), "daily_max_usd": (await verify_settings(session))["daily_max_usd"]}
@router.get("/settings/eval/spend")
async def get_eval_spend(session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
"""今天(本地日期)评测花了多少(评测那本账,engine/spend.eval_spend),放在评测的每日上限旁边。只读:计数只有评测
自己记。形状同 /settings/judge/spend:{date, usd, calls, unpriced_calls, daily_max_usd}。calls 是今天开评的题数(每题
开评前预留一次)。"""
from app.engine.spend import eval_spend
return {**await eval_spend.daily(), "daily_max_usd": (await eval_settings(session))["daily_max_usd"]}
@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,
},
# 多进程协调(app/cluster):登记着的工作进程、谁在调度当班、各类运行在跑 / 在等几个
"cluster": await _cluster_info(),
}
async def _cluster_info() -> dict[str, Any]:
from app.cluster import identity, leader, slots, workers
return {"me": identity.owner(), "database": app_settings.db_dialect, "workers": await workers.listing(),
"leader": await leader.current(), "runs": await slots.snapshot(),
"lease_s": workers.lease_s(), "heartbeat_s": workers.heartbeat_s()}