feat(pool): 新增账号配额展示并清理 Codex 旧限额字段

- 后端池子 Key 列表新增 account_quota 字段,按 provider_type 生成配额摘要(codex/kiro/antigravity)
- 前端 PoolManagement 新增“配额”列与进度条展示,桌面端与移动端同步支持
- Provider 详情页移除 Codex code_review 限额展示,仅保留周限额与 5H 限额
- 清理 codex usage parser/realtime quota 中 code_review 相关解析与比较逻辑
- 同步更新调度与配额相关测试,改为忽略无关历史字段
This commit is contained in:
AAEE86
2026-03-01 00:32:14 +08:00
parent 6a8b5e6c8e
commit d7a8a89aa7
11 changed files with 349 additions and 68 deletions

View File

@@ -109,6 +109,144 @@ async def batch_import_keys(
ALLOWED_ACTIONS = {"enable", "disable", "delete", "clear_cooldown", "reset_cost"}
def _to_float(value: Any) -> float | None:
if isinstance(value, bool):
return None
if isinstance(value, (int, float)):
return float(value)
if isinstance(value, str):
raw = value.strip()
if not raw:
return None
try:
return float(raw)
except ValueError:
return None
return None
def _format_percent(value: float) -> str:
clamped = max(0.0, min(value, 100.0))
return f"{clamped:.1f}%"
def _format_quota_value(value: float) -> str:
rounded = round(value)
if abs(value - rounded) < 1e-6:
return str(rounded)
return f"{value:.1f}"
def _build_codex_account_quota(upstream_metadata: dict[str, Any]) -> str | None:
codex = upstream_metadata.get("codex")
if not isinstance(codex, dict):
return None
parts: list[str] = []
primary_used = _to_float(codex.get("primary_used_percent"))
if primary_used is not None:
parts.append(f"周剩余 {_format_percent(100.0 - primary_used)}")
secondary_used = _to_float(codex.get("secondary_used_percent"))
if secondary_used is not None:
parts.append(f"5H剩余 {_format_percent(100.0 - secondary_used)}")
if parts:
return " | ".join(parts)
has_credits = codex.get("has_credits")
credits_balance = _to_float(codex.get("credits_balance"))
if has_credits is True and credits_balance is not None:
return f"积分 {credits_balance:.2f}"
if has_credits is True:
return "有积分"
return None
def _build_kiro_account_quota(upstream_metadata: dict[str, Any]) -> str | None:
kiro = upstream_metadata.get("kiro")
if not isinstance(kiro, dict):
return None
if kiro.get("is_banned") is True:
return "账号已封禁"
usage_percentage = _to_float(kiro.get("usage_percentage"))
if usage_percentage is not None:
remaining = 100.0 - usage_percentage
current_usage = _to_float(kiro.get("current_usage"))
usage_limit = _to_float(kiro.get("usage_limit"))
if (
current_usage is not None
and usage_limit is not None
and usage_limit > 0
):
return (
f"剩余 {_format_percent(remaining)} "
f"({_format_quota_value(current_usage)}/{_format_quota_value(usage_limit)})"
)
return f"剩余 {_format_percent(remaining)}"
remaining = _to_float(kiro.get("remaining"))
usage_limit = _to_float(kiro.get("usage_limit"))
if remaining is not None and usage_limit is not None and usage_limit > 0:
return f"剩余 {_format_quota_value(remaining)}/{_format_quota_value(usage_limit)}"
return None
def _build_antigravity_account_quota(upstream_metadata: dict[str, Any]) -> str | None:
antigravity = upstream_metadata.get("antigravity")
if not isinstance(antigravity, dict):
return None
if antigravity.get("is_forbidden") is True:
return "访问受限"
quota_by_model = antigravity.get("quota_by_model")
if not isinstance(quota_by_model, dict) or not quota_by_model:
return None
remaining_list: list[float] = []
for raw_info in quota_by_model.values():
if not isinstance(raw_info, dict):
continue
used_percent = _to_float(raw_info.get("used_percent"))
if used_percent is None:
remaining_fraction = _to_float(raw_info.get("remaining_fraction"))
if remaining_fraction is not None:
used_percent = (1.0 - remaining_fraction) * 100.0
if used_percent is None:
continue
remaining = max(0.0, min(100.0 - used_percent, 100.0))
remaining_list.append(remaining)
if not remaining_list:
return None
min_remaining = min(remaining_list)
if len(remaining_list) == 1:
return f"剩余 {_format_percent(min_remaining)}"
return f"最低剩余 {_format_percent(min_remaining)} ({len(remaining_list)} 模型)"
def _build_account_quota(provider_type: str, upstream_metadata: Any) -> str | None:
if not isinstance(upstream_metadata, dict):
return None
normalized_type = provider_type.strip().lower()
if normalized_type == "codex":
return _build_codex_account_quota(upstream_metadata)
if normalized_type == "kiro":
return _build_kiro_account_quota(upstream_metadata)
if normalized_type == "antigravity":
return _build_antigravity_account_quota(upstream_metadata)
return None
@router.post("/{provider_id}/keys/batch-action", response_model=BatchActionResponse)
async def batch_action_keys(
provider_id: str,
@@ -192,6 +330,7 @@ class AdminListPoolKeysAdapter(AdminApiAdapter):
pcfg = parse_pool_config(getattr(provider, "config", None))
pid = str(provider.id)
provider_type = str(getattr(provider, "provider_type", "custom") or "custom")
# Base query
q = db.query(ProviderAPIKey).filter(ProviderAPIKey.provider_id == pid)
@@ -271,6 +410,10 @@ class AdminListPoolKeysAdapter(AdminApiAdapter):
key_name=k.name or "",
is_active=bool(k.is_active),
auth_type=str(getattr(k, "auth_type", "api_key") or "api_key"),
account_quota=_build_account_quota(
provider_type,
getattr(k, "upstream_metadata", None),
),
cooldown_reason=cd_reason,
cooldown_ttl_seconds=cd_ttl,
cost_window_usage=cost_totals.get(kid, 0),

View File

@@ -39,6 +39,7 @@ class PoolKeyDetail(BaseModel):
key_name: str
is_active: bool
auth_type: str = "api_key"
account_quota: str | None = None
cooldown_reason: str | None = None
cooldown_ttl_seconds: int | None = None
cost_window_usage: int = 0

View File

@@ -25,7 +25,6 @@ _COMPARE_IGNORE_FIELDS = frozenset(
"updated_at",
"primary_reset_seconds",
"secondary_reset_seconds",
"code_review_reset_seconds",
}
)
_CACHE_TTL_SECONDS = 30.0

View File

@@ -202,12 +202,10 @@ def parse_codex_wham_usage_response(data: dict[str, Any]) -> dict[str, Any] | No
Free 账号:
- rate_limit.primary_window: 周限额
- code_review_rate_limit.primary_window: 代码审查周限额
Team/Plus/Enterprise 账号:
- rate_limit.primary_window: 5H 限额
- rate_limit.secondary_window: 周限额
- code_review_rate_limit.primary_window: 代码审查周限额
"""
if data is None:
return None
@@ -261,18 +259,6 @@ def parse_codex_wham_usage_response(data: dict[str, Any]) -> dict[str, Any] | No
target_prefix="primary",
)
# 解析 code_review_rate_limit (代码审查限额)
code_review_limit = _as_dict(data.get("code_review_rate_limit"), "code_review_rate_limit")
code_review_primary = _as_dict(
code_review_limit.get("primary_window"), "code_review_rate_limit.primary_window"
)
_write_window(
result,
source=code_review_primary,
source_field="code_review_rate_limit.primary_window",
target_prefix="code_review",
)
# 解析 credits
credits = _as_dict(data.get("credits"), "credits")
has_credits = credits.get("has_credits")
@@ -333,16 +319,6 @@ def parse_codex_usage_headers(headers: Mapping[str, Any] | None) -> dict[str, An
window_minutes_key="x-codex-secondary-window-minutes",
source_field="headers.secondary_window",
)
# 兼容未来可能出现的 code review header当前反代可缺失
code_review_primary = _read_header_window(
headers=normalized_headers,
used_percent_key="x-codex-code-review-primary-used-percent",
reset_seconds_key="x-codex-code-review-primary-reset-after-seconds",
reset_at_key="x-codex-code-review-primary-reset-at",
window_minutes_key="x-codex-code-review-primary-window-minutes",
source_field="headers.code_review.primary_window",
)
# 与 wham/usage 解析保持一致:
# - metadata.primary_* 统一表示周限额
# - metadata.secondary_* 统一表示 5H 限额
@@ -368,13 +344,6 @@ def parse_codex_usage_headers(headers: Mapping[str, Any] | None) -> dict[str, An
target_prefix="primary",
)
_write_window(
result,
source=code_review_primary,
source_field="headers.code_review.primary_window",
target_prefix="code_review",
)
# 当前窗口挤占占比(有值才记录)
primary_over_secondary_limit = _coerce_optional_float(
normalized_headers.get("x-codex-primary-over-secondary-limit-percent"),

View File

@@ -33,7 +33,7 @@ def is_key_quota_exhausted(
Requirements:
- Kiro: account-level quota. When remaining == 0, skip this key; allow again when remaining > 0.
- Codex: only consider weekly quota + 5H quota (ignore code review quota).
- Codex: only consider weekly quota + 5H quota.
If either remaining is 0%, skip this key.
- Antigravity: quota is per-model; do not disable the account.
When the requested model's quota is 0%, skip this key.