anuma2api / app /config.py
li2895's picture
feat: 环境变量覆盖 admin/api key(HF 网页直接配,不落盘)
172312f
Raw
History Blame Contribute Delete
8.57 kB
"""网关配置:从 config.toml 加载(pydantic.BaseModel + tomllib,无 pydantic-settings)。
``config.toml`` 只放「与账号无关」的配置(网关 / 行为 / 上游 / 代理 / 日志 / 注册机);
每个账号凭据存 ``account/<name>.json``,由 :mod:`app.account` 管理。
"""
from __future__ import annotations
import os
import tomllib
from functools import lru_cache
from pathlib import Path
from pydantic import BaseModel
class Settings(BaseModel):
"""网关行为与端点配置(不含账号凭据)。"""
# 网关监听
host: str = "0.0.0.0"
port: int = 8088 # 可用环境变量 PORT 覆盖(HF Space 强制 $PORT)
gateway_api_key: str = "" # 客户端访问网关的 key;空则不校验(/v1 无认证)
# 上游通用行为参数(与具体网站无关)
request_timeout: float = 120.0 # 单次请求总超时(秒)
# 2026-08-08 实测:生图成功也主动冷却账号(用户方案),防"连续生图 → 上游限速 fence"。
# 谁刚出过图谁歇 5 分钟,账号轮换不连击,比撞上 fence 再等 600s 更治本。0=关闭。
image_success_cooldown: float = 300.0 # 生图成功后该号主动冷却秒数(0=关闭)
poll_interval: float = 1.2 # 轮询式上游的轮询间隔(秒)
token_refresh_margin: int = 300 # 凭据到期前多少秒主动刷新
tool_call_retries: int = 3 # prompt 模式被拒绝时换角度重试次数(0=不重试;需 refusal_detect)
tool_call_dup_limit: int = 2 # native 模式:同一工具连续调用 N 轮后注入"停止重复、立即干活"纠正(0=关闭)
tool_forge_limit: int = 8 # native 模式:某工具历史空刷 ≥N 次且从无真实推进 -> 本轮从 tools 列表物理剔除它(0=关闭)
tool_read_cap: int = 30 # native 模式:read 累计 ≥N 次且远超其它动作(贪读不收口) -> 注入"读够了立即给结论"软纠正(0=关闭)
# 可选对抗策略(默认关;copy_skeleton --with-soften-system / --with-refusal-detect 可写 true)
soften_system: bool = False # 客户端 system 软化包装
refusal_detect: bool = False # 拒绝/识破检测 + tool 变体重试 + 解析跳过
# 上游专属占位字段(目标网站的端点/参数在 config.toml.example 的 [upstream] 段扩展)
upstream_chat_url: str = "" # 上游「发送对话」端点
upstream_strategy: str = "prompt" # tool 策略:prompt(注入解析)/ native(上游原生直通)
# 代理([proxy] 段):空 = 直连
proxy_url: str = "" # 默认代理(网关 → 上游)
registrar_proxy_url: str = "" # 注册机代理;空则回退 proxy_url
# 日志([logging] 段)
log_enabled: bool = True # 是否写入 logs/ 文件;false = 仅控制台
log_dir: str = "logs"
log_filename: str = "gateway.log"
log_level: str = "INFO"
log_max_bytes: int = 10 * 1024 * 1024 # 单文件最大字节,超出轮转
log_backup_count: int = 5 # 轮转保留份数
log_request_body: bool = True # 是否记录请求 body(已脱敏)
log_response_body: bool = True # 是否记录响应 body(已脱敏)
log_max_body_chars: int = 4000 # body 日志最大字符
# 账号凭据目录(相对工作目录;account/<name>.json,gitignored)
account_dir: str = "account"
# 自动补足账号([registry] 段)
target_account_count: int = 0 # 0=关闭;>0 时服务启动后自动维持可用账号数
auto_register_interval: float = 300.0 # 检查间隔(秒)
auto_register_workers: int = 1 # 单次并发注册数
# 2026-08-08:注册机/手动写入的账号**热加载**进内存池,网关不重启也感知新号。
# 与 /admin/reload 等价,但自动周期扫磁盘;0=关闭(仅靠手动 /admin/reload)。
pool_watch_interval: float = 30.0 # 账号池热加载扫描间隔(秒)
# 可恢复失效的冷却配置([upstream] 段)
quota_exhausted_action: str = "cooldown" # "cooldown" 或 "disable";仅对 QUOTA_EXHAUSTED
cooldown_seconds: float = 600.0 # 默认冷却时长(秒)
cooldown_seconds_quota: float | None = None # QUOTA_EXHAUSTED 覆盖值
cooldown_seconds_cf: float | None = None # CF_CHALLENGE 覆盖值
# 管理后台鉴权(/admin/*);空=关闭 admin 端点(返回 404 隐藏存在)
admin_auth_key: str = ""
def effective_proxy(self) -> str | None:
"""网关上游请求用的代理;未配置返回 ``None``(直连)。"""
p = (self.proxy_url or "").strip()
return p or None
def effective_registrar_proxy(self) -> str | None:
"""注册机用代理:优先 registrar_proxy_url,否则回退 proxy_url;皆空则 ``None``。"""
p = (self.registrar_proxy_url or "").strip() or (self.proxy_url or "").strip()
return p or None
def _flatten_toml(data: dict) -> dict:
"""平铺 [gateway]/[upstream]/[registry]/[admin]/[proxy]/[logging];
忽略 [email]/[captcha](仅注册机用)。
toml 简短键名映射到 Settings 字段(``api_key`` → ``gateway_api_key``,
``auth_key`` → ``admin_auth_key``;``[proxy].url`` / ``registrar_url`` →
``proxy_url`` / ``registrar_proxy_url``;``[logging].*`` → ``log_*``)。
"""
flat: dict = {}
for section in ("gateway", "upstream", "registry", "admin"):
flat.update(data.get(section, {}))
if "api_key" in flat and "gateway_api_key" not in flat:
flat["gateway_api_key"] = flat.pop("api_key")
if "auth_key" in flat and "admin_auth_key" not in flat:
flat["admin_auth_key"] = flat.pop("auth_key")
# [upstream] 的 strategy 是短键名,映射到 Settings.upstream_strategy(否则被 pydantic 忽略)
if "strategy" in flat and "upstream_strategy" not in flat:
flat["upstream_strategy"] = flat.pop("strategy")
# [proxy] 单独映射,避免裸键 url 与其它段冲突
proxy = data.get("proxy") or {}
if isinstance(proxy, dict):
if "proxy_url" not in flat:
flat["proxy_url"] = str(proxy.get("url") or proxy.get("default") or "")
if "registrar_proxy_url" not in flat:
flat["registrar_proxy_url"] = str(
proxy.get("registrar_url") or proxy.get("registrar") or ""
)
# [logging] → log_* 字段
logging_sec = data.get("logging") or {}
if isinstance(logging_sec, dict):
key_map = {
"enabled": "log_enabled",
"dir": "log_dir",
"log_dir": "log_dir", # 兼容全名写法
"filename": "log_filename",
"level": "log_level",
"max_bytes": "log_max_bytes",
"backup_count": "log_backup_count",
"log_request_body": "log_request_body",
"log_response_body": "log_response_body",
"max_body_chars": "log_max_body_chars",
}
for src, dst in key_map.items():
if src in logging_sec and dst not in flat:
flat[dst] = logging_sec[src]
return flat
@lru_cache(maxsize=8)
def get_settings(path: str | None = None) -> Settings:
"""加载 config.toml 构造 Settings。
``path`` 默认 ``$TWOAPI_CONFIG`` 或 ``config.toml``。文件缺失时回退全默认值。
环境变量覆盖:``$PORT``(HF Space 强制)覆盖 gateway.port。
被 :func:`clear_settings_cache` 用于测试重读。
"""
p = path or os.getenv("TWOAPI_CONFIG", "config.toml")
fpath = Path(p)
if fpath.is_file():
with fpath.open("rb") as f:
data = tomllib.load(f)
settings = Settings(**_flatten_toml(data))
else:
settings = Settings()
env_port = os.getenv("PORT")
if env_port:
try:
settings.port = int(env_port)
except (TypeError, ValueError):
pass
# 环境变量覆盖敏感配置(不落盘,HF Space 上直接在网页配):
# ANUMA_ADMIN_KEY → admin.auth_key(面板登录)
# ANUMA_API_KEY → gateway.api_key(客户端 /v1 鉴权)
for env_name, attr in (("ANUMA_ADMIN_KEY", "admin_auth_key"),
("ANUMA_API_KEY", "gateway_api_key")):
val = os.getenv(env_name)
if val:
setattr(settings, attr, val)
return settings
def clear_settings_cache() -> None:
"""清空 get_settings 的 lru_cache,供测试重读配置。"""
get_settings.cache_clear()