9904854cc1
Co-Authored-By: Claude <noreply@anthropic.com>
543 lines
21 KiB
Python
543 lines
21 KiB
Python
"""能力探测 + CapabilitySet 持久化(§5.3)。
|
|
|
|
探测策略:逐 capability 用最小代价请求试探。
|
|
- 成功 → 记录可用,优先取响应头 X-RateLimit-* 否则用 tiers.yaml 默认
|
|
- 抛权限错 → 不可用
|
|
- 抛其他错 → 不可用(谨慎,保留日志)
|
|
|
|
Tier Label 算法见 §5.3:基线档 + 补丁能力。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import time
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import yaml
|
|
|
|
from app.config import settings
|
|
from app import secrets_store
|
|
|
|
from .capabilities import Cap, CapabilityLimits, CapabilitySet
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_CAPSET_CACHE_FILE = "capabilities.json"
|
|
|
|
# 缓存 schema 版本。capabilities 模型有结构性变更时 bump(如新增/拆分 Cap),
|
|
# 旧缓存(无此字段或版本更低)会被判定过期,触发重新探测。
|
|
# v2: 拆分 depth5 → depth5(单只) + depth5.batch(批量)
|
|
# v3: 探测补全 quote.batch(此前 tiers.yaml 声明了但 _probe_real 漏探测)
|
|
# v5: Free 档补充付费服务器 quote.by_symbol(10rpm/5标的),用于自选股实时监控。
|
|
_CACHE_SCHEMA_VERSION = 5
|
|
|
|
# 探测用最小代价请求:挑流通性最好的 1 只标的试
|
|
_PROBE_SYMBOL = "600000.SH" # 浦发银行,长期不会退市
|
|
|
|
|
|
def _load_tiers_yaml() -> dict[str, dict[str, dict[str, Any]]]:
|
|
for path in [settings.tiers_yaml, Path("/app/tiers.yaml"), Path("../tiers.yaml")]:
|
|
if path.exists():
|
|
with path.open(encoding="utf-8") as f:
|
|
return yaml.safe_load(f)
|
|
raise FileNotFoundError("tiers.yaml not found")
|
|
|
|
|
|
def _tier_to_capset(tier_def: dict[str, dict[str, Any]]) -> CapabilitySet:
|
|
caps: dict[Cap, CapabilityLimits] = {}
|
|
for cap_name, limits_dict in tier_def.items():
|
|
try:
|
|
cap = Cap(cap_name)
|
|
except ValueError:
|
|
logger.warning("unknown cap in tiers.yaml: %s", cap_name)
|
|
continue
|
|
caps[cap] = CapabilityLimits(
|
|
rpm=limits_dict.get("rpm"),
|
|
batch=limits_dict.get("batch"),
|
|
subscribe=limits_dict.get("subscribe"),
|
|
)
|
|
return CapabilitySet(caps)
|
|
|
|
|
|
def _is_transient(e: Exception) -> bool:
|
|
"""是否为"可重试的瞬时错误"——网络抖动 / 限流 / 服务端 5xx。
|
|
|
|
与权限/参数错误(403/401/400/404)区分:后者重试也无用,不重试。
|
|
用类名匹配而非 import SDK 异常,避免探测期对 SDK 内部耦合。
|
|
"""
|
|
cls = e.__class__.__name__
|
|
if cls in {
|
|
"RateLimitError", "InternalServerError", "APIError",
|
|
"ConnectionError", "TimeoutError", "ConnectError",
|
|
"ConnectTimeout", "ReadTimeout", "RemoteProtocolError",
|
|
"httpx.ConnectError", "httpx.TimeoutException",
|
|
}:
|
|
return True
|
|
# APIError 体系下,status_code 5xx/429 视为瞬时
|
|
status = getattr(e, "status_code", None)
|
|
if isinstance(status, int) and (status == 429 or status >= 500):
|
|
return True
|
|
return False
|
|
|
|
|
|
def _call_with_retry(fn, attempts: int = 3, backoff: float = 0.6) -> None:
|
|
"""调用 fn();对瞬时错误退避重试,权限/参数错误立即抛出。
|
|
|
|
attempts=总尝试次数(含首次)。返回 None,异常由调用方分类。
|
|
"""
|
|
last_exc: Exception | None = None
|
|
for i in range(attempts):
|
|
try:
|
|
fn()
|
|
return
|
|
except Exception as e: # noqa: BLE001
|
|
last_exc = e
|
|
# 权限/参数类错误:重试无意义,立即抛出交给 try_call 归类
|
|
if not _is_transient(e):
|
|
raise
|
|
# 瞬时错误:最后一轮不再 sleep
|
|
if i < attempts - 1:
|
|
time.sleep(backoff * (i + 1))
|
|
# 重试耗尽,抛出最后一次异常
|
|
assert last_exc is not None
|
|
raise last_exc
|
|
|
|
|
|
def _probe_real(tiers: dict) -> tuple[CapabilitySet, list[str]]:
|
|
"""逐 capability 试探。需要 API key。
|
|
|
|
**关键**:探测始终在付费端点(api.tickflow.org)上进行,用 key 鉴权验证有效性。
|
|
绝不能读旧 capabilities 缓存的档位来选服务器 —— 否则首次保存 key 时,
|
|
旧缓存是 none 档 → get_client() 返回 free 服务器 → free 服务器忽略 key →
|
|
乱填 key 也能拿到日K → 误判成 free 档(鸡生蛋蛋生鸡的循环依赖 bug)。
|
|
|
|
返回 (capset, probe_log)。
|
|
"""
|
|
from tickflow import TickFlow
|
|
from .client import _base_url, PAID_ENDPOINT
|
|
|
|
key = secrets_store.get_tickflow_key()
|
|
# 探测专用客户端:强制走付费端点验证 key。
|
|
# base_url 用用户自定义端点(若已配置测速切换),否则默认 api.tickflow.org。
|
|
probe_base = _base_url() or PAID_ENDPOINT
|
|
tf = TickFlow(api_key=key, base_url=probe_base)
|
|
available: dict[Cap, CapabilityLimits] = {}
|
|
log: list[str] = []
|
|
|
|
def try_call(cap: Cap, fn, default_limits: dict[str, Any]) -> None:
|
|
try:
|
|
_call_with_retry(fn)
|
|
available[cap] = CapabilityLimits(
|
|
rpm=default_limits.get("rpm"),
|
|
batch=default_limits.get("batch"),
|
|
subscribe=default_limits.get("subscribe"),
|
|
)
|
|
log.append(f"✓ {cap}")
|
|
except Exception as e: # noqa: BLE001
|
|
msg = str(e).lower()
|
|
cls = e.__class__.__name__
|
|
# PermissionError 类名 / HTTP 403 / 中英文权限关键词都算"明确无权限"
|
|
is_perm_denied = (
|
|
cls in {"PermissionError", "AuthorizationError"}
|
|
or "permission" in msg or "unauthorized" in msg
|
|
or "403" in msg or "forbidden" in msg
|
|
or "套餐" in msg or "权限" in msg or "需要" in msg
|
|
)
|
|
if is_perm_denied:
|
|
log.append(f"✗ {cap}(无权限)")
|
|
else:
|
|
# 重试耗尽仍失败的瞬时错误 — 标记为疑似,而非直接判定"无此能力"
|
|
log.append(f"? {cap} ({cls}: {e})")
|
|
|
|
# 用各档默认上限作为占位(无 X-RateLimit-* 头时)
|
|
# 取所有档的并集,逐 cap 试探
|
|
all_caps_defaults: dict[str, dict[str, Any]] = {}
|
|
for tier in ("free", "starter", "pro", "expert"):
|
|
for cap_name, lim in tiers.get(tier, {}).items():
|
|
all_caps_defaults.setdefault(cap_name, lim)
|
|
|
|
def defaults(cap: Cap) -> dict[str, Any]:
|
|
return all_caps_defaults.get(str(cap), {})
|
|
|
|
# 全部用 keyword-only 形式调用,符合 SDK 真实签名
|
|
# quote.by_symbol
|
|
try_call(Cap.QUOTE_BY_SYMBOL,
|
|
lambda: tf.quotes.get(symbols=[_PROBE_SYMBOL], as_dataframe=False),
|
|
defaults(Cap.QUOTE_BY_SYMBOL))
|
|
|
|
# quote.batch — 批量行情(POST /v1/quotes)。用 get_by_symbols 试探。
|
|
try_call(Cap.QUOTE_BATCH,
|
|
lambda: tf.quotes.get_by_symbols([_PROBE_SYMBOL], as_dataframe=False),
|
|
defaults(Cap.QUOTE_BATCH))
|
|
|
|
# quote.pool — 用一个真实存在的 universe id 试探。
|
|
# universes.list() 在 Free 也开放,先拿任意一个 universe id 再用 get_by_universes 试。
|
|
def _probe_pool():
|
|
unis = tf.universes.list()
|
|
if not unis:
|
|
raise RuntimeError("no universes available")
|
|
first_id = unis[0]["id"] if isinstance(unis[0], dict) else getattr(unis[0], "id")
|
|
return tf.quotes.get_by_universes([first_id], as_dataframe=False)
|
|
|
|
try_call(Cap.QUOTE_POOL, _probe_pool, defaults(Cap.QUOTE_POOL))
|
|
|
|
# kline.daily.by_symbol — Free 也有
|
|
try_call(Cap.KLINE_DAILY_BY_SYMBOL,
|
|
lambda: tf.klines.get(_PROBE_SYMBOL, period="1d", count=1, as_dataframe=False),
|
|
defaults(Cap.KLINE_DAILY_BY_SYMBOL))
|
|
|
|
# kline.daily.batch
|
|
try_call(Cap.KLINE_DAILY_BATCH,
|
|
lambda: tf.klines.batch([_PROBE_SYMBOL], period="1d", count=1, as_dataframe=False),
|
|
defaults(Cap.KLINE_DAILY_BATCH))
|
|
|
|
# kline.minute.by_symbol
|
|
try_call(Cap.KLINE_MINUTE_BY_SYMBOL,
|
|
lambda: tf.klines.get(_PROBE_SYMBOL, period="1m", count=1, as_dataframe=False),
|
|
defaults(Cap.KLINE_MINUTE_BY_SYMBOL))
|
|
|
|
# kline.minute.batch
|
|
try_call(Cap.KLINE_MINUTE_BATCH,
|
|
lambda: tf.klines.batch([_PROBE_SYMBOL], period="1m", count=1, as_dataframe=False),
|
|
defaults(Cap.KLINE_MINUTE_BATCH))
|
|
|
|
# intraday
|
|
try_call(Cap.INTRADAY,
|
|
lambda: tf.klines.intraday(_PROBE_SYMBOL, count=1, as_dataframe=False),
|
|
defaults(Cap.INTRADAY))
|
|
|
|
# intraday.batch
|
|
try_call(Cap.INTRADAY_BATCH,
|
|
lambda: tf.klines.intraday_batch([_PROBE_SYMBOL], count=1, as_dataframe=False),
|
|
defaults(Cap.INTRADAY_BATCH))
|
|
|
|
# depth5 — 按标的查(单只)
|
|
try_call(Cap.DEPTH5,
|
|
lambda: tf.depth.get(_PROBE_SYMBOL),
|
|
defaults(Cap.DEPTH5))
|
|
|
|
# depth5.batch — 批量查(SDK 0.1.23+ 提供 depth.batch,对应官方 /v1/depth/batch 端点)
|
|
try_call(Cap.DEPTH5_BATCH,
|
|
lambda: tf.depth.batch([_PROBE_SYMBOL]),
|
|
defaults(Cap.DEPTH5_BATCH))
|
|
|
|
# financial — SDK 提供 income / balance_sheet / cash_flow / metrics / shares
|
|
# 用 metrics 探测(单据最小)
|
|
try_call(Cap.FINANCIAL,
|
|
lambda: tf.financials.metrics([_PROBE_SYMBOL], latest=True, as_dataframe=False),
|
|
defaults(Cap.FINANCIAL))
|
|
|
|
# adj_factor — 实际在 klines.ex_factors
|
|
try_call(Cap.ADJ_FACTOR,
|
|
lambda: tf.klines.ex_factors([_PROBE_SYMBOL], as_dataframe=False),
|
|
defaults(Cap.ADJ_FACTOR))
|
|
|
|
# websocket 不在探测期试连接(成本太高且阻塞),按档位默认推断
|
|
|
|
return CapabilitySet(available), log
|
|
|
|
|
|
def detect_capabilities(force: bool = False) -> CapabilitySet:
|
|
"""探测当前 API Key 的能力集。"""
|
|
cache_path = settings.data_dir / _CAPSET_CACHE_FILE
|
|
if not force and cache_path.exists():
|
|
with cache_path.open(encoding="utf-8") as f:
|
|
cached = json.load(f)
|
|
# schema 版本校验:旧缓存或缺版本号 → 过期,丢弃后重新探测
|
|
if cached.get("schema_version") == _CACHE_SCHEMA_VERSION:
|
|
return _capset_from_json(cached)
|
|
logger.info("capabilities 缓存 schema 版本过期(缓存=%s, 当前=%d), 重新探测",
|
|
cached.get("schema_version"), _CACHE_SCHEMA_VERSION)
|
|
|
|
tiers = _load_tiers_yaml()
|
|
if settings.use_free_mode:
|
|
# 无 key —— 归 none 档(走 free-api 服务器,仅历史日K)
|
|
capset = _tier_to_capset(tiers["none"])
|
|
_persist(capset, "None", log=["无 API Key(无档 · free-api 服务器)"], missing=[], extras=[])
|
|
return capset
|
|
|
|
# 有 API key — 真实探测
|
|
try:
|
|
capset, probe_log = _probe_real(tiers)
|
|
# 判定档位:无效 key → none,免费 key → free,付费 → starter/pro/expert
|
|
classified = _classify_tier(capset, tiers)
|
|
if classified.is_invalid:
|
|
# 无效 key(连单只日K都拿不到):归 none 档,标记要求清除 key
|
|
capset = _tier_to_capset(tiers["none"])
|
|
probe_log.append("⚠ Key 无效(单只日K也无法获取),判定为无档")
|
|
_persist(capset, "None", log=probe_log, missing=[], extras=[], invalid_key=True)
|
|
return capset
|
|
if classified.is_free:
|
|
# 免费有效 key:按 free 档能力持久化(日K free-api + 按标的实时)。
|
|
capset = _tier_to_capset(tiers["free"])
|
|
_persist(capset, "Free", log=probe_log + ["✓ 免费有效 key(运行时走 free-api 服务器)"], missing=[], extras=[])
|
|
return capset
|
|
# 付费档(starter+) — 探测出的能力即为真实可用
|
|
label, missing, extras = _compute_label_and_missing(capset, tiers)
|
|
capset = _override_limits_with_detected_tier(capset, label, tiers)
|
|
_persist(capset, label, log=probe_log, missing=missing, extras=extras)
|
|
return capset
|
|
except Exception as e:
|
|
logger.exception("detect_capabilities failed; using none baseline: %s", e)
|
|
capset = _tier_to_capset(tiers["none"])
|
|
_persist(capset, "None(探测失败)", log=[f"探测失败:{e}"], missing=[], extras=[])
|
|
return capset
|
|
|
|
|
|
# ===== Tier 代表性 capability(signature caps)=====
|
|
# 拥有**任意一个**即认作该档及以上。自上而下匹配。
|
|
# 这套设计的好处:单个 capability 探测的 transient 失败不会把整体档位"误降"。
|
|
TIER_SIGNATURES: dict[str, set[Cap]] = {
|
|
"expert": {Cap.FINANCIAL, Cap.INTRADAY_BATCH},
|
|
"pro": {Cap.KLINE_MINUTE_BATCH, Cap.KLINE_MINUTE_BY_SYMBOL,
|
|
Cap.INTRADAY, Cap.DEPTH5, Cap.DEPTH5_BATCH},
|
|
"starter": {Cap.QUOTE_BATCH, Cap.KLINE_DAILY_BATCH,
|
|
Cap.ADJ_FACTOR, Cap.QUOTE_POOL},
|
|
# free / none 不需 signature — 由 _classify_tier 的分水岭逻辑判定
|
|
}
|
|
|
|
|
|
@dataclass(slots=True, frozen=True)
|
|
class TierClassification:
|
|
"""档位判定结果。
|
|
|
|
判定依据是"复权因子分水岭":
|
|
- 连单只日K都没有 → 无效 key(is_invalid),归 none 档
|
|
- 有单只日K、无复权因子 → 免费 key(is_free)
|
|
- 有复权因子 → 付费档(starter+),具体档位由 signature 决定
|
|
"""
|
|
|
|
tier: str # "none" / "free" / "starter" / "pro" / "expert"
|
|
is_invalid: bool # 无效 key(连单只日K都拿不到)
|
|
is_free: bool # 免费有效 key(有日K、无复权因子)
|
|
|
|
|
|
def _classify_tier(capset: CapabilitySet, tiers: dict) -> TierClassification:
|
|
"""根据探测出的能力集判定档位。
|
|
|
|
分水岭是 KLINE_DAILY_BY_SYMBOL(单只日K)与 ADJ_FACTOR(复权因子):
|
|
- 无单只日K → none(无效 key)
|
|
- 有日K无复权 → free(免费 key)
|
|
- 有复权因子 → 走 signature 判定 starter/pro/expert
|
|
"""
|
|
held = set(capset.all().keys())
|
|
|
|
# 1) 连单只日K都没有 → 无效 key
|
|
if Cap.KLINE_DAILY_BY_SYMBOL not in held:
|
|
return TierClassification(tier="none", is_invalid=True, is_free=False)
|
|
|
|
# 2) 有日K但无复权因子 → 免费 key
|
|
if Cap.ADJ_FACTOR not in held:
|
|
return TierClassification(tier="free", is_invalid=False, is_free=True)
|
|
|
|
# 3) 有复权因子 → 付费档,按 signature 自上而下判定
|
|
if held & TIER_SIGNATURES["expert"]:
|
|
base = "expert"
|
|
elif held & TIER_SIGNATURES["pro"]:
|
|
base = "pro"
|
|
elif held & TIER_SIGNATURES["starter"]:
|
|
base = "starter"
|
|
else:
|
|
# 有复权因子但无任何代表能力 — 兜底为 starter(复权本身是 starter 特征)
|
|
base = "starter"
|
|
return TierClassification(tier=base, is_invalid=False, is_free=False)
|
|
|
|
# 补丁友好命名(label 后缀用)
|
|
_CAP_ALIASES: dict[Cap, str] = {
|
|
Cap.KLINE_MINUTE_BATCH: "分钟K",
|
|
Cap.KLINE_MINUTE_BY_SYMBOL: "分钟K",
|
|
Cap.INTRADAY: "分时",
|
|
Cap.INTRADAY_BATCH: "批量分时",
|
|
Cap.DEPTH5: "五档",
|
|
Cap.DEPTH5_BATCH: "批量五档",
|
|
Cap.FINANCIAL: "财务",
|
|
Cap.ADJ_FACTOR: "复权",
|
|
Cap.QUOTE_BATCH: "批量行情",
|
|
Cap.QUOTE_POOL: "标的池",
|
|
Cap.KLINE_DAILY_BATCH: "日K批量",
|
|
}
|
|
|
|
|
|
def _override_limits_with_detected_tier(
|
|
capset: CapabilitySet, label: str, tiers: dict,
|
|
) -> CapabilitySet:
|
|
"""探测完成后,用判档对应的 limits 覆盖每个 cap 的速率/批量。
|
|
|
|
判档前每个 cap 用的是"所有档默认值的并集"(为了不漏数据),
|
|
判档后才知道用户真实档位,limits 用该档的实际值更准。
|
|
label 可能是 "Pro" / "Pro + 分钟K" / "Pro+" 等组合形式 — 取第一个词当作基线档名。
|
|
"""
|
|
base_name = label.split()[0].split("+")[0].strip().lower() # "Pro + 分钟K" → "pro"
|
|
tier_limits = tiers.get(base_name, {})
|
|
new_caps: dict[Cap, CapabilityLimits] = {}
|
|
for cap, _old_lim in capset.all().items():
|
|
spec = tier_limits.get(cap.value)
|
|
if spec:
|
|
new_caps[cap] = CapabilityLimits(
|
|
rpm=spec.get("rpm"),
|
|
batch=spec.get("batch"),
|
|
subscribe=spec.get("subscribe"),
|
|
)
|
|
else:
|
|
# 不在该档定义里(extras),用 expert 档兜底(最宽松)
|
|
expert_spec = tiers.get("expert", {}).get(cap.value, {})
|
|
new_caps[cap] = CapabilityLimits(
|
|
rpm=expert_spec.get("rpm"),
|
|
batch=expert_spec.get("batch"),
|
|
subscribe=expert_spec.get("subscribe"),
|
|
)
|
|
return CapabilitySet(new_caps)
|
|
|
|
|
|
def _tier_caps_set(tiers: dict, tier_name: str) -> set[Cap]:
|
|
"""读 tiers.yaml 的某档定义,转为 Cap 集合。"""
|
|
return {Cap(c) for c in tiers.get(tier_name, {}).keys() if c in {x.value for x in Cap}}
|
|
|
|
|
|
def _compute_label_and_missing(
|
|
capset: CapabilitySet, tiers: dict,
|
|
) -> tuple[str, list[str], list[str]]:
|
|
"""返回 (label, missing_caps, extra_caps)。
|
|
|
|
label:档位标签。
|
|
missing_caps:本档**应有但未探测到**的 capability(用于诊断:可能是探测 bug 或权限丢失)。
|
|
extra_caps:超出本档的额外 capability(自定义组合)。
|
|
"""
|
|
held = set(capset.all().keys())
|
|
|
|
# 1) 完全匹配 — 干净命中某档
|
|
for tier_name in ["free", "starter", "pro", "expert"]:
|
|
if held == _tier_caps_set(tiers, tier_name):
|
|
return tier_name.capitalize(), [], []
|
|
|
|
# 2) 按 signature 自上而下判档
|
|
if held & TIER_SIGNATURES["expert"]:
|
|
base = "expert"
|
|
elif held & TIER_SIGNATURES["pro"]:
|
|
base = "pro"
|
|
elif held & TIER_SIGNATURES["starter"]:
|
|
base = "starter"
|
|
else:
|
|
base = "free"
|
|
|
|
base_caps = _tier_caps_set(tiers, base)
|
|
missing = sorted(c.value for c in (base_caps - held))
|
|
extras = base_caps and (held - base_caps) or set() # extras 是超出该档的部分
|
|
|
|
# 实际超出 = held 中"既不属于本档、也不属于本档下方任何档"的 cap
|
|
# 简化:extras = held - base_caps
|
|
extras_set = held - base_caps
|
|
|
|
# 3) 拼 label
|
|
if not extras_set:
|
|
# 完全在本档内(可能缺一两项 — 由 missing 反映)
|
|
return base.capitalize(), missing, []
|
|
|
|
# 补丁过多 → 用 "≈" 形式
|
|
if len(extras_set) > 3:
|
|
return f"{base.capitalize()}+", missing, sorted(c.value for c in extras_set)
|
|
|
|
suffix = sorted({_CAP_ALIASES.get(e, str(e)) for e in extras_set})
|
|
return f"{base.capitalize()} + " + " + ".join(suffix), missing, sorted(c.value for c in extras_set)
|
|
|
|
|
|
def _compute_label(capset: CapabilitySet, tiers: dict) -> str:
|
|
"""对外简化签名 — 只要 label。"""
|
|
label, _missing, _extras = _compute_label_and_missing(capset, tiers)
|
|
return label
|
|
|
|
|
|
def _persist(
|
|
capset: CapabilitySet,
|
|
label: str,
|
|
log: list[str] | None = None,
|
|
missing: list[str] | None = None,
|
|
extras: list[str] | None = None,
|
|
invalid_key: bool = False,
|
|
) -> None:
|
|
settings.data_dir.mkdir(parents=True, exist_ok=True)
|
|
cache_path = settings.data_dir / _CAPSET_CACHE_FILE
|
|
payload = {
|
|
"schema_version": _CACHE_SCHEMA_VERSION,
|
|
"label": label,
|
|
"capabilities": capset.to_dict(),
|
|
"probe_log": log or [],
|
|
"missing_caps": missing or [], # 本档应有但未探测到
|
|
"extras_caps": extras or [], # 超出本档的额外能力
|
|
"invalid_key": invalid_key, # 探测出的 key 无效(连单只日K都拿不到)
|
|
}
|
|
with cache_path.open("w", encoding="utf-8") as f:
|
|
json.dump(payload, f, ensure_ascii=False, indent=2)
|
|
|
|
|
|
def _capset_from_json(data: dict[str, Any]) -> CapabilitySet:
|
|
caps: dict[Cap, CapabilityLimits] = {}
|
|
for cap_name, lim in data.get("capabilities", {}).items():
|
|
try:
|
|
cap = Cap(cap_name)
|
|
except ValueError:
|
|
continue
|
|
caps[cap] = CapabilityLimits(
|
|
rpm=lim.get("rpm"),
|
|
batch=lim.get("batch"),
|
|
subscribe=lim.get("subscribe"),
|
|
)
|
|
return CapabilitySet(caps)
|
|
|
|
|
|
def tier_label() -> str:
|
|
cache_path = settings.data_dir / _CAPSET_CACHE_FILE
|
|
if cache_path.exists():
|
|
with cache_path.open(encoding="utf-8") as f:
|
|
return json.load(f).get("label", "Unknown")
|
|
return "Unknown"
|
|
|
|
|
|
def probe_log() -> list[str]:
|
|
cache_path = settings.data_dir / _CAPSET_CACHE_FILE
|
|
if cache_path.exists():
|
|
with cache_path.open(encoding="utf-8") as f:
|
|
return json.load(f).get("probe_log", [])
|
|
return []
|
|
|
|
|
|
def missing_caps() -> list[str]:
|
|
"""本档应有但未探测到的 capability — 通常意味着探测有 bug 或权限边界。"""
|
|
cache_path = settings.data_dir / _CAPSET_CACHE_FILE
|
|
if cache_path.exists():
|
|
with cache_path.open(encoding="utf-8") as f:
|
|
return json.load(f).get("missing_caps", [])
|
|
return []
|
|
|
|
|
|
def extras_caps() -> list[str]:
|
|
cache_path = settings.data_dir / _CAPSET_CACHE_FILE
|
|
if cache_path.exists():
|
|
with cache_path.open(encoding="utf-8") as f:
|
|
return json.load(f).get("extras_caps", [])
|
|
return []
|
|
|
|
|
|
def is_invalid_key() -> bool:
|
|
"""最近一次探测是否判定 key 无效(连单只日K都拿不到)。
|
|
|
|
settings 层据此清除已存的 key,避免乱填的 key 被持久化。
|
|
"""
|
|
cache_path = settings.data_dir / _CAPSET_CACHE_FILE
|
|
if cache_path.exists():
|
|
with cache_path.open(encoding="utf-8") as f:
|
|
return bool(json.load(f).get("invalid_key", False))
|
|
return False
|
|
|
|
|
|
def base_tier_name() -> str:
|
|
"""当前档位的基础名(小写): none / free / starter / pro / expert。
|
|
|
|
供 client 层判断"是否走 free-api 服务器"(none/free → free 服务器)。
|
|
"""
|
|
label = tier_label()
|
|
return label.split()[0].split("+")[0].strip().lower()
|