"""网关配置:从 config.toml 加载(pydantic.BaseModel + tomllib,无 pydantic-settings)。 ``config.toml`` 只放「与账号无关」的配置(网关 / 行为 / 上游 / 代理 / 日志 / 注册机); 每个账号凭据存 ``account/.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/.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()