File size: 8,566 Bytes
fa1140b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
172312f
 
 
 
 
 
 
 
fa1140b
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
"""网关配置:从 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()