diff --git a/app/control/account/quota_defaults.py b/app/control/account/quota_defaults.py index f5464806e..6ab8d861a 100644 --- a/app/control/account/quota_defaults.py +++ b/app/control/account/quota_defaults.py @@ -2,10 +2,10 @@ Canonical quota totals per pool type (from upstream rate-limits API): - auto fast expert heavy - basic 20 60 8 — window: 72000 / 36000 s - super 50 140 50 — window: 7200 s - heavy 150 400 150 20 window: 7200 s + auto fast expert heavy grok_4_3 + basic 20 60 8 — — window: 72000 / 36000 s + super 50 140 50 — 50 window: 7200 s + heavy 150 400 150 20 150 window: 7200 s Pool inference uses ``auto.total`` as the primary signal — the three values (20 / 50 / 150) are mutually exclusive across pool types. diff --git a/app/control/account/refresh.py b/app/control/account/refresh.py index c21238d45..203c27784 100644 --- a/app/control/account/refresh.py +++ b/app/control/account/refresh.py @@ -57,9 +57,9 @@ class AccountRefreshService: """Fetches real quota data from the upstream usage API and persists it. Triggers: - 1. Import — super accounts: fetch all 3 modes. - 2. Call — super accounts: fetch the called mode (async, non-blocking). - 3. Schedule — super: fetch all 3 modes; basic: static window reset check. + 1. Import — fetch all modes supported by the account's pool. + 2. Call — fetch the called mode only (async, non-blocking). + 3. Schedule — refresh one pool per loop using that pool's supported modes. """ def __init__(self, repository: "AccountRepository") -> None: @@ -75,7 +75,13 @@ def __init__(self, repository: "AccountRepository") -> None: async def _fetch_all_quotas( self, token: str, pool: str ) -> dict[int, QuotaWindow] | None: - """Fetch quota windows for modes supported by *pool*. Returns {mode_id: window}.""" + """Fetch quota windows for every mode supported by *pool*. + + Examples: + - basic -> auto / fast / expert + - super -> auto / fast / expert / grok_4_3 + - heavy -> auto / fast / expert / heavy / grok_4_3 + """ try: from app.dataplane.reverse.protocol.xai_usage import fetch_all_quotas @@ -142,7 +148,7 @@ async def refresh_on_import(self, tokens: list[str]) -> RefreshResult: return agg async def refresh_call_async(self, token: str, mode_id: int) -> None: - """Fire-and-forget quota sync after a successful call (all accounts).""" + """Fire-and-forget single-mode quota sync after a successful call.""" record = (await self._repo.get_accounts([token]) or [None])[0] if record is None or record.is_deleted(): return @@ -219,7 +225,7 @@ async def _refresh_one( *, apply_fallback: bool = False, ) -> RefreshResult: - """Fetch all 3 modes from the usage API and persist real quota data. + """Fetch all pool-supported modes from the usage API and persist them. apply_fallback=True — used by scheduled/import paths: when API fails, decrement REAL quotas or reset expired DEFAULT windows. diff --git a/app/control/account/runtime.py b/app/control/account/runtime.py index 00a5778c0..fbf5f7f3a 100644 --- a/app/control/account/runtime.py +++ b/app/control/account/runtime.py @@ -1,11 +1,19 @@ -"""Account runtime singletons exposed without importing app.main.""" +"""Account runtime singletons and hot-apply helpers. -from typing import TYPE_CHECKING +These helpers expose the process-local account refresh runtime without making +callers import ``app.main``. Admin handlers use them to reconcile strategy and +scheduler state after hot config updates. +""" + +from typing import TYPE_CHECKING, Literal if TYPE_CHECKING: from .refresh import AccountRefreshService + from .scheduler import AccountRefreshScheduler _refresh_service: "AccountRefreshService | None" = None +_refresh_scheduler: "AccountRefreshScheduler | None" = None +_refresh_scheduler_leader = False def set_refresh_service(service: "AccountRefreshService | None") -> None: @@ -19,4 +27,76 @@ def get_refresh_service() -> "AccountRefreshService | None": return _refresh_service -__all__ = ["get_refresh_service", "set_refresh_service"] +def set_refresh_scheduler(scheduler: "AccountRefreshScheduler | None") -> None: + """Register the process-global account refresh scheduler.""" + global _refresh_scheduler + _refresh_scheduler = scheduler + + +def get_refresh_scheduler() -> "AccountRefreshScheduler | None": + """Return the registered account refresh scheduler, if any.""" + return _refresh_scheduler + + +def set_refresh_scheduler_leader(is_leader: bool) -> None: + """Record whether this worker currently owns the refresh scheduler lock.""" + global _refresh_scheduler_leader + _refresh_scheduler_leader = bool(is_leader) + + +def is_refresh_scheduler_leader() -> bool: + """Return True when this worker is the active refresh-scheduler leader.""" + return _refresh_scheduler_leader + + +def reconcile_refresh_runtime( + enabled: bool | None = None, +) -> Literal["quota", "random"]: + """Hot-apply refresh strategy and scheduler state for the current worker.""" + from app.dataplane.account.selector import current_strategy, set_strategy + from app.platform.config.snapshot import config + from app.platform.logging.logger import logger + + refresh_enabled = ( + config.get_bool("account.refresh.enabled", False) + if enabled is None + else bool(enabled) + ) + target_strategy: Literal["quota", "random"] = ( + "quota" if refresh_enabled else "random" + ) + previous_strategy = current_strategy() + if previous_strategy != target_strategy: + set_strategy(target_strategy) + + scheduler_action = "unchanged" + scheduler = _refresh_scheduler + if scheduler is not None and _refresh_scheduler_leader: + if refresh_enabled: + if not scheduler.is_running(): + scheduler.start() + scheduler_action = "started" + elif scheduler.is_running(): + scheduler.stop() + scheduler_action = "stopped" + + if previous_strategy != target_strategy or scheduler_action != "unchanged": + logger.info( + "account refresh runtime reconciled: previous_strategy={} strategy={} leader={} scheduler_action={}", + previous_strategy, + target_strategy, + _refresh_scheduler_leader, + scheduler_action, + ) + return target_strategy + + +__all__ = [ + "get_refresh_service", + "set_refresh_service", + "get_refresh_scheduler", + "set_refresh_scheduler", + "is_refresh_scheduler_leader", + "set_refresh_scheduler_leader", + "reconcile_refresh_runtime", +] diff --git a/app/control/account/scheduler.py b/app/control/account/scheduler.py index 0fa265fae..8d384d219 100644 --- a/app/control/account/scheduler.py +++ b/app/control/account/scheduler.py @@ -39,8 +39,16 @@ def __init__(self, refresh_service: AccountRefreshService) -> None: self._tasks: list[asyncio.Task] = [] self._stop = asyncio.Event() + def bind_service(self, refresh_service: AccountRefreshService) -> None: + """Update the refresh service used by the singleton scheduler.""" + self._service = refresh_service + + def is_running(self) -> bool: + """Return True while any pool refresh loop is still active.""" + return any(not task.done() for task in self._tasks) + def start(self) -> None: - if self._tasks and not all(t.done() for t in self._tasks): + if self.is_running(): return self._stop.clear() self._tasks = [ @@ -54,11 +62,14 @@ def start(self) -> None: ) def stop(self) -> None: + was_running = self.is_running() self._stop.set() for t in self._tasks: if not t.done(): t.cancel() - logger.info("account refresh scheduler stopped") + self._tasks = [] + if was_running: + logger.info("account refresh scheduler stopped") async def _loop(self, pool: str) -> None: while not self._stop.is_set(): @@ -102,6 +113,8 @@ def get_account_refresh_scheduler( global _scheduler if _scheduler is None: _scheduler = AccountRefreshScheduler(refresh_service) + else: + _scheduler.bind_service(refresh_service) return _scheduler diff --git a/app/dataplane/account/__init__.py b/app/dataplane/account/__init__.py index b753edcb3..11c074f00 100644 --- a/app/dataplane/account/__init__.py +++ b/app/dataplane/account/__init__.py @@ -8,16 +8,17 @@ import asyncio from typing import TYPE_CHECKING +from app.platform.config.snapshot import get_config from app.platform.logging.logger import logger from app.platform.runtime.clock import now_s from app.control.account.repository import AccountRepository from app.control.account.enums import FeedbackKind from .table import AccountRuntimeTable from .lease import AccountLease, new_lease -from .selector import select, select_any +from .selector import current_strategy, select, select_any from .sync import bootstrap as _bootstrap, apply_changes from . import feedback as fb -from ..shared.enums import StatusId +from ..shared.enums import POOL_ID_TO_STR, StatusId if TYPE_CHECKING: pass @@ -250,12 +251,22 @@ async def feedback( ts = now_s_val if now_s_val is not None else now_s() + strategy = current_strategy() + async with self._lock: if kind == FeedbackKind.SUCCESS: - fb.apply_success(table, idx, mode_id) + if strategy == "random": + fb.apply_success_random(table, idx) + else: + fb.apply_success_quota(table, idx, mode_id) elif kind == FeedbackKind.RATE_LIMITED: - fb.apply_rate_limited(table, idx, mode_id) + if strategy == "random": + pool_id = int(table.pool_by_idx[idx]) + cooling_sec = _pool_cooling_sec(pool_id) + fb.apply_rate_limited_random(table, idx, cooling_sec=cooling_sec) + else: + fb.apply_rate_limited_quota(table, idx, mode_id) fb.update_last_fail(table, idx, ts) elif kind == FeedbackKind.UNAUTHORIZED: @@ -271,8 +282,9 @@ async def feedback( fb.apply_server_error(table, idx) fb.update_last_fail(table, idx, ts) - # Apply authoritative quota data from upstream response headers. - if remaining is not None and reset_at_ms is not None: + # Quota strategy may receive authoritative quota data from upstream + # response headers; the random strategy ignores this entirely. + if strategy == "quota" and remaining is not None and reset_at_ms is not None: reset_s = int(reset_at_ms // 1000) fb.apply_quota_update(table, idx, mode_id, remaining, reset_s) @@ -289,6 +301,27 @@ def revision(self) -> int: return self._table.revision if self._table else 0 +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +_POOL_INTERVAL_CONFIG: dict[str, tuple[str, int]] = { + "basic": ("account.refresh.basic_interval_sec", 36_000), + "super": ("account.refresh.super_interval_sec", 7_200), + "heavy": ("account.refresh.heavy_interval_sec", 7_200), +} + + +def _pool_cooling_sec(pool_id: int) -> int: + """Cooling seconds for a 429 on a given pool (random strategy only).""" + pool_str = POOL_ID_TO_STR.get(pool_id, "basic") + interval_key, default_interval = _POOL_INTERVAL_CONFIG.get( + pool_str, _POOL_INTERVAL_CONFIG["basic"] + ) + return max(0, int(get_config(interval_key, default_interval))) + + # --------------------------------------------------------------------------- # Module-level singleton # --------------------------------------------------------------------------- diff --git a/app/dataplane/account/feedback.py b/app/dataplane/account/feedback.py index b77942c1e..3372b56e3 100644 --- a/app/dataplane/account/feedback.py +++ b/app/dataplane/account/feedback.py @@ -1,39 +1,78 @@ """Apply feedback to the runtime table columns (in-place, lock-free inner ops). -The caller (AccountDirectory) is responsible for holding the state lock -before calling these functions. +The caller (AccountDirectory) is responsible for holding the state lock before +calling these functions. + +Strategy-split functions +------------------------ +``apply_success`` / ``apply_rate_limited`` are split into ``*_quota`` and +``*_random`` variants. The caller picks exactly one based on the active +selector strategy. No runtime ``if`` inside the apply helpers themselves. """ +from app.platform.runtime.clock import now_s from ..shared.enums import ALL_MODE_IDS, StatusId from .table import AccountRuntimeTable # Health adjustment constants. -_SUCCESS_STEP = 0.12 -_AUTH_FACTOR = 0.55 -_FORBIDDEN_FACTOR = 0.25 -_RATE_LIMIT_FACTOR = 0.45 +_SUCCESS_STEP = 0.12 +_AUTH_FACTOR = 0.55 +_FORBIDDEN_FACTOR = 0.25 +_RATE_LIMIT_FACTOR = 0.45 _SERVER_ERROR_FACTOR = 0.75 _MIN_HEALTH = 0.05 _MAX_HEALTH = 1.0 -def apply_success(table: AccountRuntimeTable, idx: int, mode_id: int) -> None: - """Record a successful call: decrement quota, improve health.""" +# --------------------------------------------------------------------------- +# apply_success — two independent implementations +# --------------------------------------------------------------------------- + + +def apply_success_quota(table: AccountRuntimeTable, idx: int, mode_id: int) -> None: + """Quota strategy: decrement per-mode quota and improve health.""" quota_col = table._quota_col(mode_id) - new_q = max(0, int(quota_col[idx]) - 1) - quota_col[idx] = new_q + quota_col[idx] = max(0, int(quota_col[idx]) - 1) + _bump_health(table, idx) + - # Recover health. - h = min(_MAX_HEALTH, float(table.health_by_idx[idx]) + _SUCCESS_STEP) - table.health_by_idx[idx] = h +def apply_success_random(table: AccountRuntimeTable, idx: int) -> None: + """Random strategy: only improve health; quota columns are ignored.""" + _bump_health(table, idx) -def apply_rate_limited(table: AccountRuntimeTable, idx: int, mode_id: int) -> None: - """Zero the mode quota and reduce health.""" +# --------------------------------------------------------------------------- +# apply_rate_limited — two independent implementations +# --------------------------------------------------------------------------- + + +def apply_rate_limited_quota( + table: AccountRuntimeTable, idx: int, mode_id: int +) -> None: + """Quota strategy: zero the mode quota and reduce health.""" table._quota_col(mode_id)[idx] = 0 _adjust_health(table, idx, _RATE_LIMIT_FACTOR) +def apply_rate_limited_random( + table: AccountRuntimeTable, idx: int, *, cooling_sec: int +) -> None: + """Random strategy: set per-account cooldown timestamp; do not touch quota. + + The account stays in ``mode_available``; ``_random_select`` filters it out + until ``cooling_until_s_by_idx`` has elapsed. + """ + ts = now_s() + max(0, cooling_sec) + cooling_col = table.cooling_until_s_by_idx + cooling_col[idx] = max(int(cooling_col[idx]), ts) + _adjust_health(table, idx, _RATE_LIMIT_FACTOR) + + +# --------------------------------------------------------------------------- +# Strategy-agnostic feedback helpers (shared) +# --------------------------------------------------------------------------- + + def apply_auth_failure(table: AccountRuntimeTable, idx: int) -> None: """Reduce health on 401; caller may mark account expired.""" _adjust_health(table, idx, _AUTH_FACTOR) @@ -62,13 +101,11 @@ def apply_status_change( table.status_by_idx[idx] = new_status_id if new_status_id != int(StatusId.ACTIVE): - # Remove from all mode availability buckets. for mode_id in ALL_MODE_IDS: bucket = table.mode_available.get((pool_id, mode_id)) if bucket: bucket.discard(idx) else: - # Re-add to mode buckets where quota > 0. for mode_id in ALL_MODE_IDS: if int(table._quota_col(mode_id)[idx]) > 0: table.mode_available.setdefault((pool_id, mode_id), set()).add(idx) @@ -81,7 +118,11 @@ def apply_quota_update( remaining: int, reset_s: int, ) -> None: - """Update quota and reset timestamp from upstream API data.""" + """Update quota and reset timestamp from upstream API data. + + Used exclusively by the quota strategy (refresh service). The random + strategy never calls this. + """ quota_col = table._quota_col(mode_id) reset_col = table._reset_col(mode_id) quota_col[idx] = max(0, min(remaining, 32767)) @@ -116,14 +157,23 @@ def update_last_fail(table: AccountRuntimeTable, idx: int, now_s: int) -> None: # --------------------------------------------------------------------------- +def _bump_health(table: AccountRuntimeTable, idx: int) -> None: + table.health_by_idx[idx] = min( + _MAX_HEALTH, float(table.health_by_idx[idx]) + _SUCCESS_STEP + ) + + def _adjust_health(table: AccountRuntimeTable, idx: int, factor: float) -> None: - h = max(_MIN_HEALTH, float(table.health_by_idx[idx]) * factor) - table.health_by_idx[idx] = h + table.health_by_idx[idx] = max( + _MIN_HEALTH, float(table.health_by_idx[idx]) * factor + ) __all__ = [ - "apply_success", - "apply_rate_limited", + "apply_success_quota", + "apply_success_random", + "apply_rate_limited_quota", + "apply_rate_limited_random", "apply_auth_failure", "apply_forbidden", "apply_server_error", diff --git a/app/dataplane/account/selector.py b/app/dataplane/account/selector.py index 814259049..c7d048cbe 100644 --- a/app/dataplane/account/selector.py +++ b/app/dataplane/account/selector.py @@ -1,22 +1,63 @@ -"""Hot-path account selector — pure function, no object allocation. +"""Hot-path account selector — pluggable strategies. -All scoring operates on typed ``array.array`` columns; no attribute lookup -overhead. The caller provides the frozen set of excluded indices and -pre-resolved tag index set. +Two fully independent strategies: + +* ``_quota_select`` — scores candidates by health / quota / inflight / fails. + Used when ``account.refresh.enabled=true``. Behaviour is the historical one, + kept unchanged. +* ``_random_select`` — uniform random choice among non-cooling candidates. + Used when ``account.refresh.enabled=false``. Ignores quota and health entirely. + +Strategy selection is process-global, registered once by the lifespan via +:func:`set_strategy`. Callers (``AccountDirectory``) invoke :func:`select` / +:func:`select_any` and are unaware of which strategy is active. """ +import array # noqa: F401 — used in forward-referenced type annotations +import random +from typing import Literal + +from app.platform.config.snapshot import get_config from ..shared.enums import PoolId from .table import AccountRuntimeTable -# Scoring weights — tuned for throughput/fairness balance. -_W_HEALTH = 100.0 -_W_QUOTA = 25.0 -_W_RECENT = 15.0 # penalty for recently used accounts +# Scoring weights used by the quota strategy. +_W_HEALTH = 100.0 +_W_QUOTA = 25.0 +_W_RECENT = 15.0 # penalty for recently used accounts _W_INFLIGHT = 20.0 -_W_FAIL = 4.0 +_W_FAIL = 4.0 _RECENT_WINDOW_S = 15 # seconds +# --------------------------------------------------------------------------- +# Strategy registry +# --------------------------------------------------------------------------- + +_StrategyName = Literal["quota", "random"] +_STRATEGY_NAME: _StrategyName = "random" + + +def set_strategy(name: _StrategyName) -> None: + """Register the process-global selection strategy. + + Called once by the lifespan after reading ``account.refresh.enabled``. + """ + global _STRATEGY_NAME + if name not in ("quota", "random"): + raise ValueError(f"unknown selection strategy: {name!r}") + _STRATEGY_NAME = name + + +def current_strategy() -> _StrategyName: + return _STRATEGY_NAME + + +# --------------------------------------------------------------------------- +# Public entry points — delegate to the active strategy +# --------------------------------------------------------------------------- + + def select( table: AccountRuntimeTable, pool_id: int, @@ -26,30 +67,79 @@ def select( prefer_tag_idxs: set[int] | None = None, now_s: int, ) -> int | None: - """Select the best available account slot index. + """Select an account slot for ``(pool_id, mode_id)``. + + Returns the slot index or ``None`` when no candidate is available. + Does not mutate the table — callers increment inflight separately. + """ + if _STRATEGY_NAME == "random": + return _random_select( + table, pool_id, + exclude_idxs=exclude_idxs, + prefer_tag_idxs=prefer_tag_idxs, + now_s=now_s, + ) + return _quota_select( + table, pool_id, mode_id, + exclude_idxs=exclude_idxs, + prefer_tag_idxs=prefer_tag_idxs, + now_s=now_s, + ) + + +def select_any( + table: AccountRuntimeTable, + pool_id: int, + *, + exclude_idxs: frozenset[int] | None = None, + prefer_tag_idxs: set[int] | None = None, + now_s: int, +) -> int | None: + """Select any active account in ``pool_id`` irrespective of per-mode quota. - Returns the slot index on success, ``None`` if no account is available. - Does **not** mutate any table state — callers must increment inflight. + Used by WebSocket-based products that manage their own rate limiting. """ + if _STRATEGY_NAME == "random": + return _random_select( + table, pool_id, + exclude_idxs=exclude_idxs, + prefer_tag_idxs=prefer_tag_idxs, + now_s=now_s, + ) + return _quota_select_any( + table, pool_id, + exclude_idxs=exclude_idxs, + prefer_tag_idxs=prefer_tag_idxs, + now_s=now_s, + ) + + +# --------------------------------------------------------------------------- +# Strategy: quota — score-based selection (unchanged behaviour) +# --------------------------------------------------------------------------- + + +def _quota_select( + table: AccountRuntimeTable, + pool_id: int, + mode_id: int, + *, + exclude_idxs: frozenset[int] | None, + prefer_tag_idxs: set[int] | None, + now_s: int, +) -> int | None: candidates: set[int] | None = table.mode_available.get((pool_id, mode_id)) if not candidates: return None - # Apply window-expiry resets for basic accounts inline. - reset_col = table._reset_col(mode_id) - quota_col = table._quota_col(mode_id) - total_col = table._total_col(mode_id) + reset_col = table._reset_col(mode_id) + quota_col = table._quota_col(mode_id) + total_col = table._total_col(mode_id) window_col = table._window_col(mode_id) _maybe_reset_windows( - table, - candidates, - mode_id, - reset_col, - quota_col, - total_col, - window_col, - pool_id, - now_s, + table, candidates, mode_id, + reset_col, quota_col, total_col, window_col, + pool_id, now_s, ) working: set[int] = candidates.copy() @@ -59,30 +149,50 @@ def select( if not working: return None - # Prefer tagged subset; fall back to full set if empty. if prefer_tag_idxs: preferred = working & prefer_tag_idxs working = preferred if preferred else working - return _best(table, working, mode_id, quota_col, now_s) + return _best(table, working, quota_col, now_s) + + +def _quota_select_any( + table: AccountRuntimeTable, + pool_id: int, + *, + exclude_idxs: frozenset[int] | None, + prefer_tag_idxs: set[int] | None, + now_s: int, +) -> int | None: + candidates: set[int] = _pool_union(table, pool_id) + if not candidates: + return None + + working = candidates.copy() + if exclude_idxs: + working -= exclude_idxs + if not working: + return None + + if prefer_tag_idxs: + preferred = working & prefer_tag_idxs + working = preferred if preferred else working + + return _best_no_quota(table, working, now_s) def _maybe_reset_windows( - table: AccountRuntimeTable, + table: AccountRuntimeTable, candidates: set[int], - mode_id: int, + mode_id: int, reset_col: "array.array", quota_col: "array.array", total_col: "array.array", window_col: "array.array", - pool_id: int, - now_s: int, + pool_id: int, + now_s: int, ) -> None: - """Reset expired windows for basic-pool accounts (no API call required). - - Only applies to the basic pool — super/heavy quotas are managed exclusively - by the periodic refresh service and must not be reset inline. - """ + """Reset expired windows for basic-pool accounts inline (no API call needed).""" if pool_id != int(PoolId.BASIC): return @@ -93,22 +203,19 @@ def _maybe_reset_windows( if int(table.pool_by_idx[idx]) != pool_id: continue new_total = int(total_col[idx]) - window_s = int(window_col[idx]) + window_s = int(window_col[idx]) if new_total <= 0 or window_s <= 0: continue - # Window expired — restore the last known total for this account/mode. quota_col[idx] = new_total reset_col[idx] = now_s + window_s def _best( - table: AccountRuntimeTable, - working: set[int], - mode_id: int, + table: AccountRuntimeTable, + working: set[int], quota_col: "array.array", - now_s: int, -) -> int: - """Return the index of the highest-scoring candidate.""" + now_s: int, +) -> int | None: best_idx = -1 best_score = -1e18 @@ -118,7 +225,7 @@ def _best( last_use_col = table.last_use_at_by_idx for idx in working: - quota = int(quota_col[idx]) + quota = int(quota_col[idx]) if quota <= 0: continue health = float(health_col[idx]) @@ -144,60 +251,23 @@ def _best( return best_idx if best_idx >= 0 else None -def select_any( - table: AccountRuntimeTable, - pool_id: int, - *, - exclude_idxs: frozenset[int] | None = None, - prefer_tag_idxs: set[int] | None = None, - now_s: int, -) -> int | None: - """Select any active account from *pool_id* without mode-specific quota checking. - - Used for WebSocket-based operations that manage their own upstream rate limiting - and do not consume tracked quota buckets. Candidates are the union of all - mode_available sets for the pool so that accounts are reachable even when one - particular mode bucket (e.g. AUTO) is exhausted. - """ - candidates: set[int] = set() - for (pid, _mid), accounts in table.mode_available.items(): - if pid == pool_id: - candidates |= accounts - - if not candidates: - return None - - working = candidates.copy() - if exclude_idxs: - working -= exclude_idxs - if not working: - return None - - if prefer_tag_idxs: - preferred = working & prefer_tag_idxs - working = preferred if preferred else working - - return _best_no_quota(table, working, now_s) - - def _best_no_quota( table: AccountRuntimeTable, working: set[int], now_s: int, ) -> int | None: - """Score candidates by health and inflight only — no quota weighting.""" - best_idx = -1 + best_idx = -1 best_score = -1e18 - health_col = table.health_by_idx + health_col = table.health_by_idx inflight_col = table.inflight_by_idx - fail_col = table.fail_count_by_idx + fail_col = table.fail_count_by_idx last_use_col = table.last_use_at_by_idx for idx in working: - health = float(health_col[idx]) + health = float(health_col[idx]) inflight = int(inflight_col[idx]) - fails = min(int(fail_col[idx]), 10) + fails = min(int(fail_col[idx]), 10) last_use = int(last_use_col[idx]) score = health * _W_HEALTH - inflight * _W_INFLIGHT - fails * _W_FAIL @@ -208,9 +278,62 @@ def _best_no_quota( if score > best_score: best_score = score - best_idx = idx + best_idx = idx return best_idx if best_idx >= 0 else None -__all__ = ["select", "select_any"] +# --------------------------------------------------------------------------- +# Strategy: random — uniform choice with cooling + inflight filter +# --------------------------------------------------------------------------- + + +def _random_select( + table: AccountRuntimeTable, + pool_id: int, + *, + exclude_idxs: frozenset[int] | None, + prefer_tag_idxs: set[int] | None, + now_s: int, +) -> int | None: + candidates: set[int] = _pool_union(table, pool_id) + if not candidates: + return None + + max_inflight = int(get_config("account.selection.max_inflight", 8)) + cooling_col = table.cooling_until_s_by_idx + inflight_col = table.inflight_by_idx + + working = candidates.copy() + if exclude_idxs: + working -= exclude_idxs + working = { + idx for idx in working + if int(cooling_col[idx]) <= now_s + and int(inflight_col[idx]) < max_inflight + } + if not working: + return None + + if prefer_tag_idxs: + preferred = working & prefer_tag_idxs + working = preferred if preferred else working + + return random.choice(tuple(working)) + + +# --------------------------------------------------------------------------- +# Shared helper +# --------------------------------------------------------------------------- + + +def _pool_union(table: AccountRuntimeTable, pool_id: int) -> set[int]: + """Union of all ``mode_available`` buckets for ``pool_id``.""" + out: set[int] = set() + for (pid, _mid), accounts in table.mode_available.items(): + if pid == pool_id: + out |= accounts + return out + + +__all__ = ["select", "select_any", "set_strategy", "current_strategy"] diff --git a/app/dataplane/account/table.py b/app/dataplane/account/table.py index 69ab143a8..16a5719e9 100644 --- a/app/dataplane/account/table.py +++ b/app/dataplane/account/table.py @@ -151,6 +151,11 @@ class AccountRuntimeTable: default_factory=lambda: array.array("L") ) + # --- Per-account cooldown (random strategy only; uint32 epoch-seconds; 0 = not cooling) --- + cooling_until_s_by_idx: "array.array[int]" = field( + default_factory=lambda: array.array("L") + ) + # --- Pre-computed selection indexes --- # (pool_id, mode_id) → set of idx with a supported quota window and status == ACTIVE mode_available: dict[tuple[int, int], set[int]] = field(default_factory=dict) @@ -300,6 +305,7 @@ def _append_slot( self.health_by_idx.append(health) self.last_use_at_by_idx.append(last_use_s) self.last_fail_at_by_idx.append(last_fail_s) + self.cooling_until_s_by_idx.append(0) self.size += 1 self._add_to_indexes(idx) self._add_to_tag_idx(idx, tags) diff --git a/app/dataplane/shared/enums.py b/app/dataplane/shared/enums.py index bdbe4a9ea..88b9ed66b 100644 --- a/app/dataplane/shared/enums.py +++ b/app/dataplane/shared/enums.py @@ -36,6 +36,8 @@ class StatusId(IntEnum): "heavy": int(PoolId.HEAVY), } +POOL_ID_TO_STR: dict[int, str] = {v: k for k, v in POOL_STR_TO_ID.items()} + STATUS_STR_TO_ID: dict[str, int] = { "active": int(StatusId.ACTIVE), "cooling": int(StatusId.COOLING), @@ -56,6 +58,7 @@ class StatusId(IntEnum): "PoolId", "StatusId", "POOL_STR_TO_ID", + "POOL_ID_TO_STR", "STATUS_STR_TO_ID", "ALL_MODE_IDS", ] diff --git a/app/main.py b/app/main.py index 9a5174928..e69498c8d 100644 --- a/app/main.py +++ b/app/main.py @@ -116,7 +116,12 @@ async def lifespan(app: FastAPI): create_repository, describe_repository_target, ) - from app.control.account.runtime import set_refresh_service + from app.control.account.runtime import ( + reconcile_refresh_runtime, + set_refresh_scheduler, + set_refresh_scheduler_leader, + set_refresh_service, + ) from app.control.account.scheduler import get_account_refresh_scheduler from app.dataplane.account import get_account_directory @@ -182,26 +187,47 @@ async def _sync_loop() -> None: # 4. Account refresh scheduler — only the leader worker. # Uses an advisory file lock so exactly one process runs the heavy # upstream quota-fetch loop regardless of worker count. + # + # ``account.refresh.enabled`` selects between two independent runtime + # strategies: + # - true → "quota" selector + scheduler running (default). + # - false → "random" selector + scheduler idle (no upstream probing). from app.control.account.refresh import AccountRefreshService + refresh_enabled = _config.get_bool("account.refresh.enabled", False) + refresh_svc = AccountRefreshService(repo) set_refresh_service(refresh_svc) app.state.refresh_service = refresh_svc is_leader = _try_acquire_scheduler_lock() scheduler = get_account_refresh_scheduler(refresh_svc) - if is_leader: - scheduler.start() + set_refresh_scheduler(scheduler) + set_refresh_scheduler_leader(is_leader) + app.state.account_refresh_scheduler = scheduler + app.state.account_refresh_is_leader = is_leader + + strategy_name = reconcile_refresh_runtime(refresh_enabled) + if is_leader and strategy_name == "quota": logger.info( - "scheduler leader: pid={} active_sync_s={} idle_sync_s={}", + "scheduler leader: pid={} strategy=quota active_sync_s={} idle_sync_s={}", + os.getpid(), + _SYNC_ACTIVE_INTERVAL, + _SYNC_IDLE_INTERVAL, + ) + elif is_leader: + logger.info( + "scheduler leader: pid={} strategy=random (scheduler idle) " + "active_sync_s={} idle_sync_s={}", os.getpid(), _SYNC_ACTIVE_INTERVAL, _SYNC_IDLE_INTERVAL, ) else: logger.info( - "scheduler follower: pid={} active_sync_s={} idle_sync_s={}", + "scheduler follower: pid={} strategy={} active_sync_s={} idle_sync_s={}", os.getpid(), + strategy_name, _SYNC_ACTIVE_INTERVAL, _SYNC_IDLE_INTERVAL, ) @@ -233,6 +259,8 @@ async def _sync_loop() -> None: proxy_scheduler.stop() _release_scheduler_lock() + set_refresh_scheduler(None) + set_refresh_scheduler_leader(False) set_refresh_service(None) await repo.close() logger.info("application shutdown completed") @@ -313,7 +341,10 @@ def create_app() -> FastAPI: # Ensure config is loaded on every request. @app.middleware("http") async def _ensure_config(request: Request, call_next): + from app.control.account.runtime import reconcile_refresh_runtime + await _config.load() + reconcile_refresh_runtime() return await call_next(request) # Global exception handler — converts AppError to JSON. diff --git a/app/products/_account_selection.py b/app/products/_account_selection.py index 056583e67..a2f9423d9 100644 --- a/app/products/_account_selection.py +++ b/app/products/_account_selection.py @@ -3,8 +3,26 @@ from app.control.model.enums import ModeId from app.control.model.spec import ModelSpec from app.control.account.runtime import get_refresh_service +from app.dataplane.account.selector import current_strategy from app.platform.config.snapshot import get_config +# Random strategy has no config key for retry count; it is pinned here so that +# every retry-driven call site (chat / images / video / anthropic) sees the same +# value without introducing scattered magic numbers. +_RANDOM_MAX_RETRIES = 5 + + +def selection_max_retries() -> int: + """Retry count for account-swap loops, aware of the active selection strategy. + + - ``random`` strategy: fixed at :data:`_RANDOM_MAX_RETRIES` (=5). + - ``quota`` strategy: reads ``retry.max_retries`` (default 1), preserving + the historical behaviour. + """ + if current_strategy() == "random": + return _RANDOM_MAX_RETRIES + return int(get_config("retry.max_retries", 1)) + def mode_candidates(spec: ModelSpec) -> tuple[int, ...]: """Return mode IDs to try for *spec* in priority order. @@ -32,7 +50,9 @@ async def reserve_account( ): """Reserve an account and return ``(lease, selected_mode_id)``. - Returns ``(None, original_mode_id)`` when no account is available. + Returns ``(None, original_mode_id)`` when no account is available. Under the + random strategy no on-demand refresh fallback is attempted — upstream quota + data is never probed. """ original_mode_id = int(spec.mode_id) @@ -52,6 +72,9 @@ async def _try_reserve(): if lease is not None: return lease, selected_mode_id + if current_strategy() == "random": + return None, original_mode_id + refresh_svc = get_refresh_service() if refresh_svc is not None: await refresh_svc.refresh_on_demand() diff --git a/app/products/anthropic/messages.py b/app/products/anthropic/messages.py index 31ef2f90e..42ba98a17 100644 --- a/app/products/anthropic/messages.py +++ b/app/products/anthropic/messages.py @@ -33,7 +33,7 @@ _quota_sync, _fail_sync, _parse_retry_codes, _feedback_kind, _log_task_exception, _configured_retry_codes, _should_retry_upstream, ) -from app.products._account_selection import reserve_account +from app.products._account_selection import reserve_account, selection_max_retries from app.products.openai._tool_sieve import ToolSieve @@ -310,7 +310,7 @@ async def create( raise RateLimitError("Account directory not initialised") directory = _acct_dir - max_retries = cfg.get_int("retry.max_retries", 1) + max_retries = selection_max_retries() retry_codes = _configured_retry_codes(cfg) timeout_s = cfg.get_float("chat.timeout", 120.0) msg_id = _make_msg_id() diff --git a/app/products/openai/chat.py b/app/products/openai/chat.py index 4eeab694a..0d45bb30c 100644 --- a/app/products/openai/chat.py +++ b/app/products/openai/chat.py @@ -22,6 +22,7 @@ from app.control.model.registry import resolve as resolve_model from app.control.model.enums import ModeId from app.control.account.enums import FeedbackKind +from app.dataplane.account.selector import current_strategy from app.dataplane.proxy.adapters.headers import build_http_headers from app.dataplane.proxy import get_proxy_runtime from app.dataplane.proxy.adapters.session import ( @@ -54,7 +55,7 @@ build_usage, ) from ._tool_sieve import ToolSieve -from app.products._account_selection import reserve_account +from app.products._account_selection import reserve_account, selection_max_retries def _to_chat_annotations(anns: list[dict]) -> list[dict]: @@ -106,6 +107,8 @@ def _transport_upstream_error(exc: BaseException, *, context: str) -> UpstreamEr async def _quota_sync(token: str, mode_id: int) -> None: """Fire-and-forget: fetch real quota after a successful call.""" try: + if current_strategy() != "quota": + return svc = get_refresh_service() if svc: await svc.refresh_call_async(token, mode_id) @@ -121,12 +124,20 @@ async def _quota_sync(token: str, mode_id: int) -> None: async def _fail_sync( token: str, mode_id: int, exc: BaseException | None = None ) -> None: - """Fire-and-forget: persist failure counter after a failed call.""" + """Fire-and-forget: persist failure metadata after a failed call. + + In random mode this helper must not trigger upstream quota probes. It still + records failures so 401 invalidation and local failure accounting continue + to work unchanged. + """ try: svc = get_refresh_service() if svc: await svc.record_failure_async(token, mode_id, exc) - if getattr(exc, "status", None) == 429: + if ( + current_strategy() == "quota" + and getattr(exc, "status", None) == 429 + ): result = await svc.refresh_on_demand() logger.info( "account on-demand refresh triggered: token={}... mode_id={} refreshed={} failed={} rate_limited={}", @@ -470,7 +481,7 @@ async def completions( raise RateLimitError("Account directory not initialised") directory = _acct_dir - max_retries = cfg.get_int("retry.max_retries", 1) + max_retries = selection_max_retries() retry_codes = _configured_retry_codes(cfg) response_id = make_response_id() timeout_s = cfg.get_float("chat.timeout", 120.0) diff --git a/app/products/openai/responses.py b/app/products/openai/responses.py index 95fdc3e7c..d816c7a92 100644 --- a/app/products/openai/responses.py +++ b/app/products/openai/responses.py @@ -18,7 +18,7 @@ from app.control.model.registry import resolve as resolve_model from app.control.account.enums import FeedbackKind from app.dataplane.reverse.protocol.xai_chat import classify_line, StreamAdapter -from app.products._account_selection import reserve_account +from app.products._account_selection import reserve_account, selection_max_retries from .chat import _stream_chat, _extract_message, _resolve_image, _quota_sync, _fail_sync, _parse_retry_codes, _feedback_kind, _log_task_exception, _upstream_body_excerpt from .chat import _configured_retry_codes, _should_retry_upstream @@ -247,7 +247,7 @@ async def create( raise RateLimitError("Account directory not initialised") directory = _acct_dir - max_retries = cfg.get_int("retry.max_retries", 1) + max_retries = selection_max_retries() retry_codes = _configured_retry_codes(cfg) response_id = make_resp_id("resp") reasoning_id = make_resp_id("rs") diff --git a/app/products/web/admin/__init__.py b/app/products/web/admin/__init__.py index 529452a4c..8b87c6d7d 100644 --- a/app/products/web/admin/__init__.py +++ b/app/products/web/admin/__init__.py @@ -184,6 +184,8 @@ async def get_config_endpoint(): @router.post("/config", tags=[_TAG_ADMIN_SYSTEM]) async def update_config(req: ConfigPatchRequest): + from app.control.account.runtime import reconcile_refresh_runtime + patch = _sanitize_proxy_config(req.root) _ensure_runtime_patch_allowed(patch) await config.update(patch) @@ -195,7 +197,12 @@ async def update_config(req: ConfigPatchRequest): file_level=config.get_str("logging.file_level", "") or None, max_files=config.get_int("logging.max_files", 7), ) - return {"status": "success", "message": "配置已更新"} + strategy_name = reconcile_refresh_runtime() + return { + "status": "success", + "message": "配置已更新", + "selection_strategy": strategy_name, + } @router.get("/storage", tags=[_TAG_ADMIN_SYSTEM]) @@ -205,6 +212,7 @@ async def get_storage_mode(): @router.get("/status", tags=[_TAG_ADMIN_SYSTEM]) async def runtime_status(): + from app.control.account.runtime import reconcile_refresh_runtime from app.dataplane.account import _directory if _directory is None: @@ -214,12 +222,14 @@ async def runtime_status(): code="directory_not_initialised", status=503, ) + strategy_name = reconcile_refresh_runtime() return Response( content=orjson.dumps( { "status": "ok", "size": _directory.size, "revision": _directory.revision, + "selection_strategy": strategy_name, } ), media_type="application/json", diff --git a/app/statics/admin/account.html b/app/statics/admin/account.html index f8983e3fa..d72e814c5 100644 --- a/app/statics/admin/account.html +++ b/app/statics/admin/account.html @@ -83,6 +83,9 @@ gap:12px; margin-bottom:20px; } + .stat-grid.mode-random { + grid-template-columns:repeat(4, minmax(0, 1fr)); + } .stat-cell { min-height:88px; padding:14px 16px; @@ -126,6 +129,16 @@ margin-top:auto; } .stat-muted { color:#a1a1aa !important; } + [data-random-only] { display:none } + .stat-grid.mode-random [data-random-only] { display:flex } + .stat-grid.mode-random [data-stat-card="active"] { order:1 } + .stat-grid.mode-random [data-stat-card="cooling"] { order:2 } + .stat-grid.mode-random [data-stat-card="invalid"] { order:3 } + .stat-grid.mode-random [data-stat-card="disabled"] { order:4 } + .stat-grid.mode-random [data-stat-card="total"] { order:5 } + .stat-grid.mode-random [data-stat-card="calls"] { order:6 } + .stat-grid.mode-random [data-stat-card="success"] { order:7 } + .stat-grid.mode-random [data-stat-card="rate"] { order:8 } /* Filters */ .filter-bar { @@ -726,9 +739,9 @@
账户概览
- -
-
+ +
+
账户总数
@@ -737,7 +750,7 @@
0
-
+
正常账户
@@ -746,7 +759,7 @@
0
-
+
限流账户
@@ -755,7 +768,7 @@
0
-
+
异常账户
@@ -764,7 +777,7 @@
0
-
+
禁用账户
@@ -773,7 +786,7 @@
0
-
+
调用总数
@@ -782,7 +795,25 @@
0
-
+
+
+
成功总数
+ + + +
+
0
+
+
+
+
成功率
+ + + +
+
0%
+
+
Auto 余额
@@ -791,7 +822,7 @@
0
-
+
Fast 余额
@@ -800,7 +831,7 @@
0
-
+
Expert 余额
@@ -809,7 +840,7 @@
0
-
+
Heavy 余额
@@ -934,7 +965,7 @@ Token 类型 状态 - + 额度 A @@ -1134,32 +1165,66 @@ return r.json(); } +// Selection strategy reported by /status — "quota" | "random". +// Controls whether quota columns / stats are rendered (hidden in random mode). +let selectionStrategy = 'quota'; + // ── Load ─────────────────────────────────────────────────────────────────── async function load() { try { - const data = await _api('GET', '/tokens'); + const [data, status] = await Promise.all([ + _api('GET', '/tokens'), + _api('GET', '/status').catch(() => null), + ]); + if (status && typeof status.selection_strategy === 'string') { + selectionStrategy = status.selection_strategy; + } allTokens = Array.isArray(data.tokens) ? data.tokens : Object.entries(data.tokens || {}).flatMap(([pool, items]) => Array.isArray(items) ? items.map(t => ({ ...t, pool: t.pool || pool })) : []); + applyStrategyUI(); invalidateTokenView(); render(); } catch (e) { showToast(`${tr('account.loadFailed', null, '加载失败')}: ${e.message}`, 'error'); } } +function applyStrategyUI() { + // Random strategy: hide quota-related stat cells and the table column. + const isRandom = selectionStrategy === 'random'; + const statsGrid = document.getElementById('overview-stats'); + if (statsGrid) statsGrid.classList.toggle('mode-random', isRandom); + document.querySelectorAll('[data-quota-only]').forEach(el => { + el.style.display = isRandom ? 'none' : ''; + }); +} + // ── Render ───────────────────────────────────────────────────────────────── function render() { const view = getTokenView(); renderStats(view); renderFilters(view); renderTable(view); + applyStrategyUI(); } function getTokenView() { const cacheKey = `${_tokenViewVersion}|${curStatus}|${curNsfw}|${curPool}`; if (_tokenViewCache && _tokenViewCacheKey === cacheKey) return _tokenViewCache; - const stats = { active: 0, cooling: 0, invalid: 0, disabled: 0, calls: 0, qa: 0, qf: 0, qe: 0, qh: 0 }; + const stats = { + active: 0, + cooling: 0, + invalid: 0, + disabled: 0, + calls: 0, + success: 0, + fail: 0, + qa: 0, + qf: 0, + qe: 0, + qh: 0, + }; const statusCounts = { all: 0, active: 0, cooling: 0, invalid: 0, disabled: 0 }; const nsfwCounts = { all: 0, enabled: 0, disabled: 0 }; const poolCounts = new Map([['all', 0]]); @@ -1178,7 +1243,11 @@ const matchesNsfw = curNsfw === 'all' || (curNsfw === 'enabled' ? nsfwEnabled : !nsfwEnabled); poolsSet.add(pool); - stats.calls += (token.use_count || 0) + (token.fail_count || 0); + const successCount = token.use_count || 0; + const failCount = token.fail_count || 0; + stats.success += successCount; + stats.fail += failCount; + stats.calls += successCount + failCount; stats.qa += quota.auto?.remaining || 0; stats.qf += quota.fast?.remaining || 0; stats.qe += quota.expert?.remaining || 0; @@ -1248,6 +1317,8 @@ $('s-invalid', stats.invalid); $('s-disabled', stats.disabled); $('s-calls', fmt(stats.calls)); + $('s-success', fmt(stats.success)); + $('s-rate', fmtRate(stats.success, stats.fail)); $('s-qa', fmt(stats.qa)); $('s-qf', fmt(stats.qf)); $('s-qe', fmt(stats.qe)); $('s-qh', fmt(stats.qh)); } @@ -1409,7 +1480,7 @@
${xe(poolLabel(t.pool || 'basic'))} ${statusBadge(t.status)} -
${qpills(q)}
+
${qpills(q)}
${success} ${fail} ${fmtRate(success, fail)} diff --git a/app/statics/admin/config.html b/app/statics/admin/config.html index eb8fd63b3..d6b6715d9 100644 --- a/app/statics/admin/config.html +++ b/app/statics/admin/config.html @@ -111,6 +111,67 @@ line-height:1.35; } .cfg-desc { max-width:520px; font-size:11px; color:#9a9a9a; line-height:1.45 } + .cfg-help { + position:relative; + width:16px; + height:16px; + display:inline-flex; + align-items:center; + justify-content:center; + padding:0; + border-radius:50%; + border:1px solid #e6e6e6; + background:#fafafa; + color:#8d8d8d; + font-size:11px; + line-height:1; + font-weight:700; + cursor:help; + flex-shrink:0; + } + .cfg-help:hover, + .cfg-help:focus-visible { + color:#222; + border-color:#d6d6d6; + background:#fff; + outline:none; + } + .cfg-help-tip { + position:fixed; + box-sizing:border-box; + max-width:min(320px, calc(100vw - 24px)); + padding:9px 10px; + border-radius:8px; + background:#171717; + color:#fff; + font-size:12px; + font-weight:500; + line-height:1.5; + text-align:left; + white-space:pre-line; + overflow-wrap:anywhere; + box-shadow:0 8px 24px rgba(0,0,0,.16); + opacity:0; + pointer-events:none; + transform:translateY(3px); + transition:opacity .15s, transform .15s; + z-index:1000; + } + .cfg-help-tip.visible { + opacity:1; + transform:translateY(0); + } + .cfg-help-tip::before { + content:''; + position:absolute; + left:var(--arrow-left, 50%); + width:8px; + height:8px; + background:#171717; + transform:translateX(-50%) rotate(45deg); + } + .cfg-help-tip[data-placement="top"]::before { bottom:-4px } + .cfg-help-tip[data-placement="bottom"]::before { top:-4px } .cfg-input-col { min-width:0; display:flex; align-items:center; justify-content:flex-start; gap:8px } .cfg-input-col.is-bool { justify-content:flex-end } .cfg-inline-action { @@ -434,13 +495,21 @@ title: '配额刷新调度', titleKey: 'config.schema.groups.refreshSchedule', section: 'account.refresh', fields: [ - { key: 'basic_interval_sec', label: 'Basic 刷新周期(秒)', labelKey: 'config.schema.fields.basicInterval.label', type: 'number', desc: 'basic 号池额度的定时刷新间隔,建议与配额窗口对齐(默认 36000 s / 10 h)。', descKey: 'config.schema.fields.basicInterval.desc' }, - { key: 'super_interval_sec', label: 'Super 刷新周期(秒)', labelKey: 'config.schema.fields.superInterval.label', type: 'number', desc: 'super 号池额度的定时刷新间隔,建议与配额窗口对齐(默认 7200 s / 2 h)。', descKey: 'config.schema.fields.superInterval.desc' }, - { key: 'heavy_interval_sec', label: 'Heavy 刷新周期(秒)', labelKey: 'config.schema.fields.heavyInterval.label', type: 'number', desc: 'heavy 号池额度的定时刷新间隔,建议与配额窗口对齐(默认 7200 s / 2 h)。', descKey: 'config.schema.fields.heavyInterval.desc' }, - { key: 'on_demand_min_interval_sec', label: '按需刷新最小间隔(秒)', labelKey: 'config.schema.fields.onDemandMinInterval.label', type: 'number', desc: '请求链路触发刷新(例如收到 429)时的节流间隔。N 秒内重复触发只执行一次,避免批量打爆配额接口。', descKey: 'config.schema.fields.onDemandMinInterval.desc' }, + { key: 'enabled', label: '启用配额刷新', labelKey: 'config.schema.fields.refreshEnabled.label', type: 'bool', desc: '开启后自动进入配额刷新模式,关闭后自动进入自动重试模式。', descKey: 'config.schema.fields.refreshEnabled.desc', help: '开启:配额刷新模式,scheduler 周期同步真实配额,选号按评分。\n关闭:自动重试模式,不主动探测 upstream,选号随机,出错自动换号重试最多 5 次。\n建议:万级以上账号关闭,避免主动探测触发 upstream 429。', helpKey: 'config.schema.fields.refreshEnabled.help' }, + { key: 'basic_interval_sec', label: 'Basic 周期(秒)', labelKey: 'config.schema.fields.basicInterval.label', type: 'number', desc: 'basic 号池共用周期:quota 模式下用于后台刷新,random 模式下用于 429 冷却。默认 36000s。', descKey: 'config.schema.fields.basicInterval.desc' }, + { key: 'super_interval_sec', label: 'Super 周期(秒)', labelKey: 'config.schema.fields.superInterval.label', type: 'number', desc: 'super 号池共用周期:quota 模式下用于后台刷新,random 模式下用于 429 冷却。默认 7200s。', descKey: 'config.schema.fields.superInterval.desc' }, + { key: 'heavy_interval_sec', label: 'Heavy 周期(秒)', labelKey: 'config.schema.fields.heavyInterval.label', type: 'number', desc: 'heavy 号池共用周期:quota 模式下用于后台刷新,random 模式下用于 429 冷却。默认 7200s。', descKey: 'config.schema.fields.heavyInterval.desc' }, + { key: 'on_demand_min_interval_sec', label: '按需刷新间隔(秒)', labelKey: 'config.schema.fields.onDemandMinInterval.label', type: 'number', desc: '请求链路触发刷新(例如收到 429)时的节流间隔。N 秒内重复触发只执行一次,避免批量打爆配额接口。', descKey: 'config.schema.fields.onDemandMinInterval.desc' }, { key: 'usage_concurrency', label: '刷新并发数', labelKey: 'config.schema.fields.usageConcurrency.label', type: 'number', desc: '并发调用 usage 接口刷新额度时允许的最大并行数。', descKey: 'config.schema.fields.usageConcurrency.desc' }, ] }, + { + title: '选号并发限制', titleKey: 'config.schema.groups.selection', + section: 'account.selection', + fields: [ + { key: 'max_inflight', label: '单号并发上限', labelKey: 'config.schema.fields.maxInflight.label', type: 'number', desc: '单个账号同时在执行的请求上限,超过则选号路径跳过该号(两种模式共用)。', descKey: 'config.schema.fields.maxInflight.desc' }, + ] + }, { title: '批量操作并发', titleKey: 'config.schema.groups.batchConcurrency', section: 'batch', @@ -722,6 +791,7 @@ ...field, label: field.labelKey ? tr(field.labelKey, null, field.label ?? field.key) : (field.label ?? field.key), desc: field.descKey ? tr(field.descKey, null, field.desc ?? '') : (field.desc ?? ''), + help: field.helpKey ? tr(field.helpKey, null, field.help ?? '') : (field.help ?? ''), options: field.options ? field.options.map(_localizeOption) : field.options, }; } @@ -824,6 +894,76 @@ return el; } +let _helpTipEl = null; +let _activeHelpButton = null; + +function _hideHelpTip() { + if (_activeHelpButton) _activeHelpButton.removeAttribute('aria-describedby'); + if (_helpTipEl) _helpTipEl.remove(); + _helpTipEl = null; + _activeHelpButton = null; +} + +function _positionHelpTip(button, tip) { + const margin = 12; + const rect = button.getBoundingClientRect(); + const viewportWidth = window.innerWidth || document.documentElement.clientWidth; + const viewportHeight = window.innerHeight || document.documentElement.clientHeight; + const maxWidth = Math.max(120, viewportWidth - margin * 2); + + tip.style.maxWidth = `${Math.min(320, maxWidth)}px`; + tip.style.left = '0px'; + tip.style.top = '0px'; + + const tipRect = tip.getBoundingClientRect(); + let left = rect.left + rect.width / 2 - tipRect.width / 2; + left = Math.max(margin, Math.min(left, viewportWidth - tipRect.width - margin)); + + let placement = 'top'; + let top = rect.top - tipRect.height - 8; + if (top < margin) { + placement = 'bottom'; + top = rect.bottom + 8; + } + if (top + tipRect.height > viewportHeight - margin) { + top = Math.max(margin, viewportHeight - tipRect.height - margin); + } + + const arrowLeft = Math.max(12, Math.min(rect.left + rect.width / 2 - left, tipRect.width - 12)); + tip.dataset.placement = placement; + tip.style.setProperty('--arrow-left', `${arrowLeft}px`); + tip.style.left = `${left}px`; + tip.style.top = `${top}px`; +} + +function _showHelpTip(button) { + const text = button.getAttribute('data-tip'); + if (!text) return; + _hideHelpTip(); + + const tip = _el('div', { + class: 'cfg-help-tip', + id: 'cfg-help-tip', + role: 'tooltip', + }, text); + document.body.appendChild(tip); + button.setAttribute('aria-describedby', 'cfg-help-tip'); + + _helpTipEl = tip; + _activeHelpButton = button; + _positionHelpTip(button, tip); + requestAnimationFrame(() => tip.classList.add('visible')); +} + +window.addEventListener('resize', _hideHelpTip); +window.addEventListener('scroll', _hideHelpTip, true); +document.addEventListener('keydown', (event) => { + if (event.key === 'Escape') _hideHelpTip(); +}); +document.addEventListener('click', (event) => { + if (!event.target.closest?.('.cfg-help')) _hideHelpTip(); +}); + function renderInput(field, section, value) { const fp = _fullPath(section, field.key); @@ -941,6 +1081,24 @@ labelChildren.push(btn); } + if (field.help) { + labelChildren.push(_el('button', { + class: 'cfg-help', + type: 'button', + 'aria-label': field.help, + 'data-tip': field.help, + onmouseenter: (event) => _showHelpTip(event.currentTarget), + onmouseleave: _hideHelpTip, + onfocus: (event) => _showHelpTip(event.currentTarget), + onblur: _hideHelpTip, + onclick: (event) => { + event.preventDefault(); + event.stopPropagation(); + _showHelpTip(event.currentTarget); + }, + }, '?')); + } + labelChildren.push(dot); const labelCol = _el('div', { class: 'cfg-label-col' }, diff --git a/app/statics/i18n/de.json b/app/statics/i18n/de.json index c9d974d92..b26d1b5aa 100644 --- a/app/statics/i18n/de.json +++ b/app/statics/i18n/de.json @@ -160,6 +160,7 @@ "statInvalid": "Fehlerhaft", "statDisabled": "Deaktiviert", "statCalls": "Aufrufe gesamt", + "statSuccessTotal": "Erfolgreiche Aufrufe", "statAuto": "Auto-Guthaben", "statFast": "Fast-Guthaben", "statExpert": "Expert-Guthaben", @@ -330,6 +331,7 @@ "features": "Kernfunktionen", "mediaFormat": "Medienausgabe", "refreshSchedule": "Quotenaktualisierung", + "selection": "Auswahl-Nebenläufigkeit", "batchConcurrency": "Batch-Konkurrenz", "egressProxy": "Ausgehender Proxy", "clearance": "Cloudflare Clearance", @@ -381,11 +383,13 @@ "customInstruction": { "label": "Globale Zusatzanweisung" }, "imageFormat": { "label": "Bildausgabeformat" }, "videoFormat": { "label": "Videoausgabeformat" }, - "basicInterval": { "label": "Basic-Aktualisierungsintervall (s)" }, - "superInterval": { "label": "Super-Aktualisierungsintervall (s)" }, - "heavyInterval": { "label": "Heavy-Aktualisierungsintervall (s)" }, - "usageConcurrency": { "label": "Aktualisierungs-Konkurrenz" }, - "onDemandMinInterval": { "label": "Minimales On-Demand-Intervall (s)" }, + "refreshEnabled": { "label": "Quotenaktualisierung aktivieren", "desc": "Wenn aktiviert, wechselt das System automatisch in den Quotenaktualisierungsmodus. Wenn deaktiviert, wechselt es automatisch in den Auto-Retry-Modus.", "help": "Ein: Quotenaktualisierung. Der Scheduler synchronisiert regelmäßig echte Quoten, die Auswahl erfolgt nach Bewertung.\nAus: Auto-Retry-Modus. Keine aktive upstream-Abfrage, zufällige Auswahl und automatischer Kontowechsel bei Fehlern mit bis zu 5 Wiederholungen.\nEmpfehlung: Bei mehr als 10.000 Konten ausschalten, um upstream 429 durch aktive Abfragen zu vermeiden." }, + "maxInflight": { "label": "Max. laufende Anfragen pro Konto", "desc": "Maximale Anzahl gleichzeitig laufender Anfragen pro Konto. Konten am Limit werden von der Auswahl übersprungen (für beide Modi gemeinsam)." }, + "basicInterval": { "label": "Basic-Zyklus (s)", "desc": "Gemeinsamer Zyklus für den Basic-Pool: wird im Quota-Modus für den Hintergrund-Refresh und im Random-Modus für die 429-Abkühlung verwendet. Standardwert: 36000s." }, + "superInterval": { "label": "Super-Zyklus (s)", "desc": "Gemeinsamer Zyklus für den Super-Pool: wird im Quota-Modus für den Hintergrund-Refresh und im Random-Modus für die 429-Abkühlung verwendet. Standardwert: 7200s." }, + "heavyInterval": { "label": "Heavy-Zyklus (s)", "desc": "Gemeinsamer Zyklus für den Heavy-Pool: wird im Quota-Modus für den Hintergrund-Refresh und im Random-Modus für die 429-Abkühlung verwendet. Standardwert: 7200s." }, + "usageConcurrency": { "label": "Aktualisierungs-Konkurrenz", "desc": "Maximale Anzahl paralleler usage-Aufrufe während der Quotenaktualisierung." }, + "onDemandMinInterval": { "label": "On-Demand-Aktualisierungsintervall (s)", "desc": "Drosselungsfenster für durch Requests ausgelöste Aktualisierungen, z. B. nach 429. Wiederholte Auslöser innerhalb des Intervalls werden zu einer Aktualisierung zusammengefasst." }, "nsfwConcurrency": { "label": "Konkurrenz für NSFW-Aktivierung" }, "refreshConcurrency": { "label": "Usage-Aktualisierungskonkurrenz" }, "assetUploadConcurrency": { "label": "Asset-Upload-Konkurrenz" }, diff --git a/app/statics/i18n/en.json b/app/statics/i18n/en.json index 0b8dd3f1f..c2b0c066c 100644 --- a/app/statics/i18n/en.json +++ b/app/statics/i18n/en.json @@ -161,6 +161,7 @@ "statInvalid": "Abnormal", "statDisabled": "Disabled", "statCalls": "Total Calls", + "statSuccessTotal": "Successful Calls", "statAuto": "Auto Balance", "statFast": "Fast Balance", "statExpert": "Expert Balance", @@ -331,6 +332,7 @@ "features": "Core Features", "mediaFormat": "Media Output", "refreshSchedule": "Quota Refresh", + "selection": "Selection Concurrency", "batchConcurrency": "Batch Concurrency", "egressProxy": "Egress Proxy", "clearance": "Cloudflare Clearance", @@ -433,24 +435,33 @@ "label": "Video Output Format", "desc": "local_* modes download video assets to the server and deliver them through the local proxy. Configure the APP Base URL first and account for outbound bandwidth usage." }, + "refreshEnabled": { + "label": "Enable Quota Refresh", + "desc": "When enabled, the system automatically switches to quota refresh mode. When disabled, it automatically switches to auto-retry mode.", + "help": "On: quota refresh mode. The scheduler periodically syncs real quota, and selection uses scoring.\nOff: auto-retry mode. No upstream probing, uniform random selection, and auto-swap on errors for up to 5 retries.\nRecommendation: turn this off for >10k accounts to avoid self-triggering upstream 429s." + }, + "maxInflight": { + "label": "Max Inflight per Account", + "desc": "Maximum concurrent in-flight requests per account. Accounts at this limit are skipped by the selector (shared by both modes)." + }, "basicInterval": { - "label": "Basic Refresh Interval (s)", - "desc": "Scheduled quota refresh interval for the basic pool. Align this with the quota window when possible (default 36000 s / 10 h)." + "label": "Basic Cycle (s)", + "desc": "Shared cycle for the basic pool: used for background refresh in quota mode and for 429 cooldown in random mode. Default 36000s." }, "superInterval": { - "label": "Super Refresh Interval (s)", - "desc": "Scheduled quota refresh interval for the super pool. Align this with the quota window when possible (default 7200 s / 2 h)." + "label": "Super Cycle (s)", + "desc": "Shared cycle for the super pool: used for background refresh in quota mode and for 429 cooldown in random mode. Default 7200s." }, "heavyInterval": { - "label": "Heavy Refresh Interval (s)", - "desc": "Scheduled quota refresh interval for the heavy pool. Align this with the quota window when possible (default 7200 s / 2 h)." + "label": "Heavy Cycle (s)", + "desc": "Shared cycle for the heavy pool: used for background refresh in quota mode and for 429 cooldown in random mode. Default 7200s." }, "usageConcurrency": { "label": "Refresh Concurrency", "desc": "Maximum number of parallel usage calls allowed during quota refresh." }, "onDemandMinInterval": { - "label": "Minimum On-Demand Refresh Interval (s)", + "label": "On-Demand Refresh Interval (s)", "desc": "Throttle window for request-triggered refreshes such as 429 handling. Repeated triggers within the interval are coalesced into a single refresh." }, "nsfwConcurrency": { diff --git a/app/statics/i18n/es.json b/app/statics/i18n/es.json index 3ec90709f..eb092769d 100644 --- a/app/statics/i18n/es.json +++ b/app/statics/i18n/es.json @@ -160,6 +160,7 @@ "statInvalid": "Anómalas", "statDisabled": "Desactivadas", "statCalls": "Llamadas totales", + "statSuccessTotal": "Éxitos totales", "statAuto": "Saldo Auto", "statFast": "Saldo Fast", "statExpert": "Saldo Expert", @@ -330,6 +331,7 @@ "features": "Funciones principales", "mediaFormat": "Salida multimedia", "refreshSchedule": "Actualización de cuota", + "selection": "Concurrencia de selección", "batchConcurrency": "Concurrencia por lotes", "egressProxy": "Proxy de salida", "clearance": "Cloudflare Clearance", @@ -381,11 +383,13 @@ "customInstruction": { "label": "Instrucción suplementaria global" }, "imageFormat": { "label": "Formato de salida de imagen" }, "videoFormat": { "label": "Formato de salida de video" }, - "basicInterval": { "label": "Intervalo de refresco Basic (s)" }, - "superInterval": { "label": "Intervalo de refresco Super (s)" }, - "heavyInterval": { "label": "Intervalo de refresco Heavy (s)" }, - "usageConcurrency": { "label": "Concurrencia de actualización" }, - "onDemandMinInterval": { "label": "Intervalo mínimo bajo demanda (s)" }, + "refreshEnabled": { "label": "Activar actualización de cuota", "desc": "Al activarlo, el sistema cambia automáticamente al modo de actualización de cuota. Al desactivarlo, cambia automáticamente al modo de reintento automático.", "help": "Activado: modo de actualización de cuota. El scheduler sincroniza periódicamente la cuota real y la selección usa puntuación.\nDesactivado: modo de reintento automático. No comprueba upstream activamente, usa selección aleatoria y cambia de cuenta al fallar hasta 5 reintentos.\nRecomendación: desactívalo con más de 10.000 cuentas para evitar provocar 429 de upstream con comprobaciones activas." }, + "maxInflight": { "label": "Máximo en curso por cuenta", "desc": "Máximo de solicitudes simultáneas en curso por cuenta. Las cuentas en ese límite se omiten en la selección (compartido por ambos modos)." }, + "basicInterval": { "label": "Ciclo Basic (s)", "desc": "Ciclo compartido del pool Basic: se usa para la actualización en segundo plano en modo quota y para el enfriamiento tras 429 en modo random. Valor por defecto: 36000s." }, + "superInterval": { "label": "Ciclo Super (s)", "desc": "Ciclo compartido del pool Super: se usa para la actualización en segundo plano en modo quota y para el enfriamiento tras 429 en modo random. Valor por defecto: 7200s." }, + "heavyInterval": { "label": "Ciclo Heavy (s)", "desc": "Ciclo compartido del pool Heavy: se usa para la actualización en segundo plano en modo quota y para el enfriamiento tras 429 en modo random. Valor por defecto: 7200s." }, + "usageConcurrency": { "label": "Concurrencia de actualización", "desc": "Número máximo de llamadas usage paralelas permitidas durante la actualización de cuota." }, + "onDemandMinInterval": { "label": "Intervalo de actualización bajo demanda (s)", "desc": "Ventana de limitación para actualizaciones activadas por solicitudes, como el manejo de 429. Los disparos repetidos dentro del intervalo se agrupan en una sola actualización." }, "nsfwConcurrency": { "label": "Concurrencia de activación NSFW" }, "refreshConcurrency": { "label": "Concurrencia de refresco de Usage" }, "assetUploadConcurrency": { "label": "Concurrencia de carga de Asset" }, diff --git a/app/statics/i18n/fr.json b/app/statics/i18n/fr.json index f947d7530..70246469d 100644 --- a/app/statics/i18n/fr.json +++ b/app/statics/i18n/fr.json @@ -160,6 +160,7 @@ "statInvalid": "Anormaux", "statDisabled": "Désactivés", "statCalls": "Appels totaux", + "statSuccessTotal": "Succès totaux", "statAuto": "Solde Auto", "statFast": "Solde Fast", "statExpert": "Solde Expert", @@ -330,6 +331,7 @@ "features": "Fonctionnalités principales", "mediaFormat": "Sortie multimédia", "refreshSchedule": "Actualisation du quota", + "selection": "Concurrence de sélection", "batchConcurrency": "Concurrence par lot", "egressProxy": "Proxy sortant", "clearance": "Cloudflare Clearance", @@ -381,11 +383,13 @@ "customInstruction": { "label": "Instruction globale supplémentaire" }, "imageFormat": { "label": "Format de sortie image" }, "videoFormat": { "label": "Format de sortie vidéo" }, - "basicInterval": { "label": "Intervalle d’actualisation Basic (s)" }, - "superInterval": { "label": "Intervalle d’actualisation Super (s)" }, - "heavyInterval": { "label": "Intervalle d’actualisation Heavy (s)" }, - "usageConcurrency": { "label": "Concurrence d’actualisation" }, - "onDemandMinInterval": { "label": "Intervalle minimal à la demande (s)" }, + "refreshEnabled": { "label": "Activer l’actualisation du quota", "desc": "Lorsqu’il est activé, le système passe automatiquement en mode d’actualisation du quota. Lorsqu’il est désactivé, il passe automatiquement en mode de relance automatique.", "help": "Activé : mode d’actualisation du quota. Le scheduler synchronise périodiquement le quota réel et la sélection utilise un score.\nDésactivé : mode de relance automatique. Aucune sonde upstream active, sélection aléatoire et changement automatique de compte en cas d’erreur jusqu’à 5 relances.\nRecommandation : désactivez-le au-delà de 10 000 comptes pour éviter de déclencher des 429 upstream par les sondes actives." }, + "maxInflight": { "label": "Maximum en cours par compte", "desc": "Nombre maximal de requêtes simultanées par compte. Les comptes à cette limite sont ignorés par la sélection (partagé par les deux modes)." }, + "basicInterval": { "label": "Cycle Basic (s)", "desc": "Cycle partagé du pool Basic : utilisé pour l’actualisation en arrière-plan en mode quota et pour le refroidissement après 429 en mode random. Valeur par défaut : 36000s." }, + "superInterval": { "label": "Cycle Super (s)", "desc": "Cycle partagé du pool Super : utilisé pour l’actualisation en arrière-plan en mode quota et pour le refroidissement après 429 en mode random. Valeur par défaut : 7200s." }, + "heavyInterval": { "label": "Cycle Heavy (s)", "desc": "Cycle partagé du pool Heavy : utilisé pour l’actualisation en arrière-plan en mode quota et pour le refroidissement après 429 en mode random. Valeur par défaut : 7200s." }, + "usageConcurrency": { "label": "Concurrence d’actualisation", "desc": "Nombre maximal d’appels usage parallèles autorisés pendant l’actualisation du quota." }, + "onDemandMinInterval": { "label": "Intervalle d’actualisation à la demande (s)", "desc": "Fenêtre de limitation pour les actualisations déclenchées par les requêtes, par exemple après un 429. Les déclenchements répétés dans l’intervalle sont regroupés en une seule actualisation." }, "nsfwConcurrency": { "label": "Concurrence d’activation NSFW" }, "refreshConcurrency": { "label": "Concurrence d’actualisation Usage" }, "assetUploadConcurrency": { "label": "Concurrence d’envoi Asset" }, diff --git a/app/statics/i18n/ja.json b/app/statics/i18n/ja.json index b5fb9ae29..512765dfe 100644 --- a/app/statics/i18n/ja.json +++ b/app/statics/i18n/ja.json @@ -160,6 +160,7 @@ "statInvalid": "異常", "statDisabled": "無効化", "statCalls": "総呼び出し数", + "statSuccessTotal": "成功総数", "statAuto": "Auto 残高", "statFast": "Fast 残高", "statExpert": "Expert 残高", @@ -330,6 +331,7 @@ "features": "基本機能", "mediaFormat": "メディア出力", "refreshSchedule": "クォータ更新", + "selection": "選択同時実行数", "batchConcurrency": "一括処理の並列数", "egressProxy": "送信プロキシ", "clearance": "Cloudflare Clearance", @@ -381,11 +383,13 @@ "customInstruction": { "label": "グローバル補助指示" }, "imageFormat": { "label": "画像出力形式" }, "videoFormat": { "label": "動画出力形式" }, - "basicInterval": { "label": "Basic 更新間隔(秒)" }, - "superInterval": { "label": "Super 更新間隔(秒)" }, - "heavyInterval": { "label": "Heavy 更新間隔(秒)" }, - "usageConcurrency": { "label": "更新並列数" }, - "onDemandMinInterval": { "label": "最小オンデマンド更新間隔(秒)" }, + "refreshEnabled": { "label": "クォータ更新を有効化", "desc": "オンにすると自動的にクォータ更新モードへ切り替わり、オフにすると自動的に自動再試行モードへ切り替わります。", "help": "オン:クォータ更新モード。scheduler が実クォータを定期同期し、スコアでアカウントを選択します。\nオフ:自動再試行モード。upstream を能動的に確認せず、ランダム選択し、エラー時は最大 5 回まで別アカウントへ自動切替します。\n推奨:1 万件以上のアカウントではオフにして、能動確認による upstream 429 を避けてください。" }, + "maxInflight": { "label": "アカウントごとの同時実行上限", "desc": "1 アカウントで同時に処理中にできるリクエスト数の上限です。上限に達したアカウントは選択時にスキップされます(両モード共通)。" }, + "basicInterval": { "label": "Basic 周期(秒)", "desc": "Basic プールの共通周期です。quota モードではバックグラウンド更新に、random モードでは 429 クールダウンに使われます。既定値は 36000s です。" }, + "superInterval": { "label": "Super 周期(秒)", "desc": "Super プールの共通周期です。quota モードではバックグラウンド更新に、random モードでは 429 クールダウンに使われます。既定値は 7200s です。" }, + "heavyInterval": { "label": "Heavy 周期(秒)", "desc": "Heavy プールの共通周期です。quota モードではバックグラウンド更新に、random モードでは 429 クールダウンに使われます。既定値は 7200s です。" }, + "usageConcurrency": { "label": "更新並列数", "desc": "クォータ更新中に許可する usage 呼び出しの最大並列数です。" }, + "onDemandMinInterval": { "label": "オンデマンド更新間隔(秒)", "desc": "429 処理など、リクエスト経路から発生する更新のスロットル間隔です。この間隔内の重複トリガーは 1 回の更新にまとめられます。" }, "nsfwConcurrency": { "label": "NSFW 有効化の並列数" }, "refreshConcurrency": { "label": "Usage 更新の並列数" }, "assetUploadConcurrency": { "label": "Asset アップロード並列数" }, diff --git a/app/statics/i18n/zh.json b/app/statics/i18n/zh.json index 6f88402b7..12c64b25a 100644 --- a/app/statics/i18n/zh.json +++ b/app/statics/i18n/zh.json @@ -161,6 +161,7 @@ "statInvalid": "异常账户", "statDisabled": "禁用账户", "statCalls": "调用总数", + "statSuccessTotal": "成功总数", "statAuto": "Auto 余额", "statFast": "Fast 余额", "statExpert": "Expert 余额", @@ -331,6 +332,7 @@ "features": "基本功能设置", "mediaFormat": "媒体返回格式", "refreshSchedule": "配额刷新调度", + "selection": "选号并发限制", "batchConcurrency": "批量操作并发", "egressProxy": "出站代理设置", "clearance": "Cloudflare Clearance", @@ -433,24 +435,33 @@ "label": "视频返回格式", "desc": "local_* 模式会先将视频下载至服务端,再通过本地 URL 代理分发;请确保已配置 APP 访问地址,并评估出站带宽消耗。" }, + "refreshEnabled": { + "label": "启用配额刷新", + "desc": "开启后自动进入配额刷新模式,关闭后自动进入自动重试模式。", + "help": "开启:配额刷新模式,scheduler 周期同步真实配额,选号按评分。\n关闭:自动重试模式,不主动探测 upstream,选号随机,出错自动换号重试最多 5 次。\n建议:万级以上账号关闭,避免主动探测触发 upstream 429。" + }, + "maxInflight": { + "label": "单号并发上限", + "desc": "单个账号同时在执行的请求上限,超过则选号路径跳过该号(两种模式共用)。" + }, "basicInterval": { - "label": "Basic 刷新周期(秒)", - "desc": "basic 号池额度的定时刷新间隔,建议与配额窗口对齐(默认 36000 s / 10 h)。" + "label": "Basic 周期(秒)", + "desc": "basic 号池共用周期:quota 模式下用于后台刷新,random 模式下用于 429 冷却。默认 36000s。" }, "superInterval": { - "label": "Super 刷新周期(秒)", - "desc": "super 号池额度的定时刷新间隔,建议与配额窗口对齐(默认 7200 s / 2 h)。" + "label": "Super 周期(秒)", + "desc": "super 号池共用周期:quota 模式下用于后台刷新,random 模式下用于 429 冷却。默认 7200s。" }, "heavyInterval": { - "label": "Heavy 刷新周期(秒)", - "desc": "heavy 号池额度的定时刷新间隔,建议与配额窗口对齐(默认 7200 s / 2 h)。" + "label": "Heavy 周期(秒)", + "desc": "heavy 号池共用周期:quota 模式下用于后台刷新,random 模式下用于 429 冷却。默认 7200s。" }, "usageConcurrency": { "label": "刷新并发数", "desc": "并发调用 usage 接口刷新额度时允许的最大并行数。" }, "onDemandMinInterval": { - "label": "按需刷新最小间隔(秒)", + "label": "按需刷新间隔(秒)", "desc": "请求链路触发刷新(例如收到 429)时的节流间隔。N 秒内重复触发只执行一次,避免批量打爆配额接口。" }, "nsfwConcurrency": { diff --git a/config.defaults.toml b/config.defaults.toml index 4a716fefe..363d164a1 100644 --- a/config.defaults.toml +++ b/config.defaults.toml @@ -103,13 +103,20 @@ on_codes = "429,401,503" [account.refresh] -basic_interval_sec = 36000 # basic 号池刷新周期(秒),默认 10 h -super_interval_sec = 7200 # super 号池刷新周期(秒),默认 2 h -heavy_interval_sec = 7200 # heavy 号池刷新周期(秒),默认 2 h +# 总开关:true=配额刷新模式(主动探测,选号评分);false=自动重试模式(随机选号,零探测) +enabled = true +basic_interval_sec = 36000 # basic 号池周期(秒):quota 模式用于后台刷新,random 模式用于 429 冷却;默认 36000s +super_interval_sec = 7200 # super 号池周期(秒):quota 模式用于后台刷新,random 模式用于 429 冷却;默认 7200s +heavy_interval_sec = 7200 # heavy 号池周期(秒):quota 模式用于后台刷新,random 模式用于 429 冷却;默认 7200s usage_concurrency = 50 on_demand_min_interval_sec = 300 +[account.selection] +# 单号并发上限(两模式共用) +max_inflight = 8 + + # ==================== 对话配置 ==================== [chat] timeout = 60