mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 09:20:22 +08:00
- 删除全部 Python 源码 (src/) 及 Alembic 迁移脚本,归档至 _deprecated_py_src/ - 重构 Rust gateway ai_pipeline: 拆分 planner/finalize 模块,新增 contracts/adaptation 层 - 重组 handlers 模块为 admin/public/proxy/internal/shared 子模块结构 - 新增 executor 模块,引入 Rust 原生数据库迁移 (aether-data/migrations) - 简化 CI/Docker 构建流程,移除 base image 二级构建,统一为单一 app image - 移除 Python 相关基础设施文件 (entrypoint.sh, gunicorn_conf.py, Dockerfile.base)
595 lines
18 KiB
Python
595 lines
18 KiB
Python
"""Pool account state helpers.
|
|
|
|
Provides a shared way to classify account-level hard-block states
|
|
from upstream metadata and OAuth invalid reasons.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
import time
|
|
from dataclasses import dataclass
|
|
from typing import Any
|
|
|
|
from src.services.provider_keys.quota_reader import get_quota_reader
|
|
|
|
OAUTH_ACCOUNT_BLOCK_PREFIX = "[ACCOUNT_BLOCK] "
|
|
OAUTH_REFRESH_FAILED_PREFIX = "[REFRESH_FAILED] "
|
|
OAUTH_EXPIRED_PREFIX = "[OAUTH_EXPIRED] "
|
|
OAUTH_REQUEST_FAILED_PREFIX = "[REQUEST_FAILED] "
|
|
|
|
# -- 按原因细分的关键词组 --
|
|
# 封禁类 (suspended / banned)
|
|
_KEYWORDS_SUSPENDED: tuple[str, ...] = (
|
|
"suspended",
|
|
"account_block",
|
|
"account blocked",
|
|
"封禁",
|
|
"封号",
|
|
"被封",
|
|
"账户已封禁",
|
|
"账号异常",
|
|
)
|
|
|
|
# 停用类 (disabled / deactivated)
|
|
_KEYWORDS_DISABLED: tuple[str, ...] = (
|
|
"account has been disabled",
|
|
"account disabled",
|
|
"account has been deactivated",
|
|
"account_deactivated",
|
|
"account deactivated",
|
|
"organization has been disabled",
|
|
"organization_disabled",
|
|
"deactivated_workspace",
|
|
"deactivated",
|
|
"访问被禁止",
|
|
"账户访问被禁止",
|
|
)
|
|
|
|
_TOKEN_INVALID_KEYWORDS: tuple[str, ...] = (
|
|
"authentication token has been invalidated",
|
|
"token has been invalidated",
|
|
"codex token 无效或已过期",
|
|
)
|
|
|
|
# 需要验证类
|
|
_KEYWORDS_VERIFICATION: tuple[str, ...] = (
|
|
"validation_required",
|
|
"verify your account",
|
|
"需要验证",
|
|
"验证账号",
|
|
"验证身份",
|
|
)
|
|
|
|
# 合并的完整列表(用于 is_account_level_block_reason 快速判断)
|
|
ACCOUNT_BLOCK_REASON_KEYWORDS: tuple[str, ...] = (
|
|
*_KEYWORDS_SUSPENDED,
|
|
*_KEYWORDS_DISABLED,
|
|
*_TOKEN_INVALID_KEYWORDS,
|
|
*_KEYWORDS_VERIFICATION,
|
|
)
|
|
|
|
AUTO_REMOVABLE_ACCOUNT_STATE_CODES: frozenset[str] = frozenset(
|
|
{
|
|
"account_banned",
|
|
"account_suspended",
|
|
"account_disabled",
|
|
"workspace_deactivated",
|
|
"account_forbidden",
|
|
}
|
|
)
|
|
|
|
|
|
def _classify_block_reason(text: str) -> tuple[str, str]:
|
|
"""Return (code, label) based on the oauth_invalid_reason text."""
|
|
lowered = text.lower()
|
|
if any(kw in lowered for kw in _TOKEN_INVALID_KEYWORDS):
|
|
return "oauth_expired", "Token 失效"
|
|
if any(kw in lowered for kw in _KEYWORDS_VERIFICATION):
|
|
return "account_verification", "需要验证"
|
|
if "deactivated_workspace" in lowered:
|
|
return "workspace_deactivated", "工作区停用"
|
|
if any(kw in lowered for kw in _KEYWORDS_DISABLED):
|
|
return "account_disabled", "账号停用"
|
|
if any(kw in lowered for kw in _KEYWORDS_SUSPENDED):
|
|
return "account_suspended", "账号封禁"
|
|
return "account_blocked", "账号异常"
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class PoolAccountState:
|
|
"""Resolved account-level state for one key."""
|
|
|
|
blocked: bool
|
|
code: str | None = None # account_banned / account_forbidden / account_blocked
|
|
label: str | None = None
|
|
reason: str | None = None
|
|
source: str | None = None # metadata / oauth_invalid / oauth_refresh / oauth_request
|
|
recoverable: bool = False
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class OAuthStatusSnapshot:
|
|
code: str = "none" # none / valid / expiring / expired / invalid / check_failed
|
|
label: str | None = None
|
|
reason: str | None = None
|
|
expires_at: int | None = None
|
|
invalid_at: int | None = None
|
|
source: str | None = None
|
|
requires_reauth: bool = False
|
|
expiring_soon: bool = False
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class AccountStatusSnapshot:
|
|
code: str = "ok"
|
|
label: str | None = None
|
|
reason: str | None = None
|
|
blocked: bool = False
|
|
source: str | None = None
|
|
recoverable: bool = False
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class QuotaStatusSnapshot:
|
|
code: str = "unknown" # unknown / ok / exhausted
|
|
label: str | None = None
|
|
reason: str | None = None
|
|
exhausted: bool = False
|
|
usage_ratio: float | None = None
|
|
updated_at: int | None = None
|
|
reset_seconds: float | None = None
|
|
plan_type: str | None = None
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class ProviderKeyStatusSnapshot:
|
|
oauth: OAuthStatusSnapshot
|
|
account: AccountStatusSnapshot
|
|
quota: QuotaStatusSnapshot
|
|
|
|
|
|
def _is_truthy_flag(value: Any) -> bool:
|
|
if isinstance(value, bool):
|
|
return value
|
|
if isinstance(value, (int, float)):
|
|
return value != 0
|
|
if isinstance(value, str):
|
|
normalized = value.strip().lower()
|
|
return normalized in {"1", "true", "yes", "y"}
|
|
return False
|
|
|
|
|
|
def _clean_text(value: Any) -> str | None:
|
|
if not isinstance(value, str):
|
|
return None
|
|
text = value.strip()
|
|
return text or None
|
|
|
|
|
|
def _extract_reason(source: dict[str, Any] | None, *fields: str) -> str | None:
|
|
if not isinstance(source, dict):
|
|
return None
|
|
for field in fields:
|
|
text = _clean_text(source.get(field))
|
|
if text:
|
|
return text
|
|
return None
|
|
|
|
|
|
def _is_workspace_deactivated_reason(reason: str | None) -> bool:
|
|
text = _clean_text(reason)
|
|
return bool(text and "deactivated_workspace" in text.lower())
|
|
|
|
|
|
_TAGGED_REASON_PATTERN = re.compile(
|
|
r"(?:^|\n)\[(?P<tag>[A-Z_]+)\]\s*(?P<detail>.*?)(?=\n\[[A-Z_]+\]|\Z)",
|
|
re.S,
|
|
)
|
|
|
|
|
|
def _extract_tagged_reason_sections(reason: str | None) -> dict[str, str]:
|
|
text = _clean_text(reason)
|
|
if not text:
|
|
return {}
|
|
sections: dict[str, str] = {}
|
|
for match in _TAGGED_REASON_PATTERN.finditer(text):
|
|
tag = str(match.group("tag") or "").strip().upper()
|
|
if not tag or tag in sections:
|
|
continue
|
|
detail = str(match.group("detail") or "").strip()
|
|
sections[tag] = detail
|
|
return sections
|
|
|
|
|
|
def _resolve_from_metadata(
|
|
provider_type: str | None,
|
|
upstream_metadata: Any,
|
|
) -> PoolAccountState | None:
|
|
if not isinstance(upstream_metadata, dict):
|
|
return None
|
|
|
|
normalized_provider = str(provider_type or "").strip().lower()
|
|
provider_bucket: dict[str, Any] | None = None
|
|
if normalized_provider:
|
|
maybe_bucket = upstream_metadata.get(normalized_provider)
|
|
if isinstance(maybe_bucket, dict):
|
|
provider_bucket = maybe_bucket
|
|
|
|
quota_block = get_quota_reader(normalized_provider, upstream_metadata).account_block()
|
|
if quota_block.blocked:
|
|
return PoolAccountState(
|
|
blocked=True,
|
|
code=quota_block.code,
|
|
label=quota_block.label,
|
|
reason=quota_block.reason,
|
|
source="metadata",
|
|
)
|
|
|
|
for source in (provider_bucket, upstream_metadata):
|
|
if not isinstance(source, dict):
|
|
continue
|
|
if _is_truthy_flag(source.get("is_banned")):
|
|
reason = _extract_reason(source, "ban_reason", "forbidden_reason", "reason", "message")
|
|
return PoolAccountState(
|
|
blocked=True,
|
|
code="account_banned",
|
|
label="账号封禁",
|
|
reason=reason or "账号已封禁",
|
|
source="metadata",
|
|
)
|
|
if _is_truthy_flag(source.get("is_forbidden")) or _is_truthy_flag(
|
|
source.get("account_disabled")
|
|
):
|
|
reason = _extract_reason(source, "forbidden_reason", "ban_reason", "reason", "message")
|
|
if _is_workspace_deactivated_reason(reason):
|
|
return PoolAccountState(
|
|
blocked=True,
|
|
code="workspace_deactivated",
|
|
label="工作区停用",
|
|
reason=reason or "工作区已停用",
|
|
source="metadata",
|
|
)
|
|
return PoolAccountState(
|
|
blocked=True,
|
|
code="account_forbidden",
|
|
label="访问受限",
|
|
reason=reason or "账号访问受限",
|
|
source="metadata",
|
|
)
|
|
|
|
return None
|
|
|
|
|
|
def _resolve_from_oauth_invalid_reason(reason: str | None) -> PoolAccountState | None:
|
|
text = _clean_text(reason)
|
|
if not text:
|
|
return None
|
|
|
|
if text.startswith(OAUTH_ACCOUNT_BLOCK_PREFIX):
|
|
cleaned = text[len(OAUTH_ACCOUNT_BLOCK_PREFIX) :].strip()
|
|
code, label = (
|
|
_classify_block_reason(cleaned) if cleaned else ("account_blocked", "账号异常")
|
|
)
|
|
return PoolAccountState(
|
|
blocked=True,
|
|
code=code,
|
|
label=label,
|
|
reason=cleaned or "账号异常",
|
|
source="oauth_invalid",
|
|
)
|
|
|
|
if text.startswith(OAUTH_EXPIRED_PREFIX):
|
|
cleaned = text[len(OAUTH_EXPIRED_PREFIX) :].strip()
|
|
return PoolAccountState(
|
|
blocked=True,
|
|
code="oauth_expired",
|
|
label="Token 失效",
|
|
reason=cleaned or "OAuth Token 已过期且无法续期",
|
|
source="oauth_invalid",
|
|
recoverable=True,
|
|
)
|
|
|
|
if text.startswith(OAUTH_REFRESH_FAILED_PREFIX):
|
|
cleaned = text[len(OAUTH_REFRESH_FAILED_PREFIX) :].strip()
|
|
return PoolAccountState(
|
|
blocked=False,
|
|
code="oauth_refresh_failed",
|
|
label="续期失败",
|
|
reason=cleaned or "OAuth Token 续期失败",
|
|
source="oauth_refresh",
|
|
recoverable=True,
|
|
)
|
|
|
|
if text.startswith(OAUTH_REQUEST_FAILED_PREFIX):
|
|
cleaned = text[len(OAUTH_REQUEST_FAILED_PREFIX) :].strip()
|
|
return PoolAccountState(
|
|
blocked=False,
|
|
code="oauth_request_failed",
|
|
label="请求失败",
|
|
reason=cleaned or "账号状态检查失败",
|
|
source="oauth_request",
|
|
recoverable=True,
|
|
)
|
|
|
|
if text.startswith("["):
|
|
return None
|
|
|
|
lowered = text.lower()
|
|
if any(keyword in lowered for keyword in ACCOUNT_BLOCK_REASON_KEYWORDS):
|
|
code, label = _classify_block_reason(text)
|
|
return PoolAccountState(
|
|
blocked=True,
|
|
code=code,
|
|
label=label,
|
|
reason=text,
|
|
source="oauth_invalid",
|
|
)
|
|
|
|
return None
|
|
|
|
|
|
def resolve_account_status_snapshot(
|
|
*,
|
|
provider_type: str | None,
|
|
upstream_metadata: Any,
|
|
oauth_invalid_reason: str | None,
|
|
) -> AccountStatusSnapshot:
|
|
from_metadata = _resolve_from_metadata(provider_type, upstream_metadata)
|
|
if from_metadata is not None:
|
|
return AccountStatusSnapshot(
|
|
code=from_metadata.code or "ok",
|
|
label=from_metadata.label,
|
|
reason=from_metadata.reason,
|
|
blocked=from_metadata.blocked,
|
|
source=from_metadata.source,
|
|
recoverable=from_metadata.recoverable,
|
|
)
|
|
|
|
text = _clean_text(oauth_invalid_reason)
|
|
if not text:
|
|
return AccountStatusSnapshot()
|
|
|
|
tagged_sections = _extract_tagged_reason_sections(text)
|
|
if "ACCOUNT_BLOCK" in tagged_sections:
|
|
cleaned = tagged_sections["ACCOUNT_BLOCK"]
|
|
code, label = (
|
|
_classify_block_reason(cleaned) if cleaned else ("account_blocked", "账号异常")
|
|
)
|
|
return AccountStatusSnapshot(
|
|
code=code,
|
|
label=label,
|
|
reason=cleaned or "账号异常",
|
|
blocked=True,
|
|
source="oauth_invalid",
|
|
)
|
|
|
|
if text.startswith("["):
|
|
return AccountStatusSnapshot()
|
|
|
|
lowered = text.lower()
|
|
if any(keyword in lowered for keyword in ACCOUNT_BLOCK_REASON_KEYWORDS):
|
|
code, label = _classify_block_reason(text)
|
|
return AccountStatusSnapshot(
|
|
code=code,
|
|
label=label,
|
|
reason=text,
|
|
blocked=True,
|
|
source="oauth_invalid",
|
|
)
|
|
|
|
return AccountStatusSnapshot()
|
|
|
|
|
|
def resolve_oauth_status_snapshot(
|
|
*,
|
|
auth_type: str | None,
|
|
oauth_expires_at: int | None,
|
|
oauth_invalid_at: int | None,
|
|
oauth_invalid_reason: str | None,
|
|
now_ts: int | None = None,
|
|
) -> OAuthStatusSnapshot:
|
|
if str(auth_type or "").strip().lower() != "oauth":
|
|
return OAuthStatusSnapshot()
|
|
|
|
now = int(now_ts if now_ts is not None else time.time())
|
|
invalid_at = int(oauth_invalid_at) if isinstance(oauth_invalid_at, int) else None
|
|
tagged_sections = _extract_tagged_reason_sections(oauth_invalid_reason)
|
|
raw_reason = _clean_text(oauth_invalid_reason)
|
|
|
|
expired_reason = tagged_sections.get("OAUTH_EXPIRED")
|
|
if expired_reason:
|
|
return OAuthStatusSnapshot(
|
|
code="invalid",
|
|
label="已失效",
|
|
reason=expired_reason,
|
|
invalid_at=invalid_at,
|
|
expires_at=oauth_expires_at,
|
|
source="oauth_invalid",
|
|
requires_reauth=True,
|
|
)
|
|
|
|
refresh_failed_reason = tagged_sections.get("REFRESH_FAILED")
|
|
if refresh_failed_reason:
|
|
return OAuthStatusSnapshot(
|
|
code="invalid",
|
|
label="已失效",
|
|
reason=refresh_failed_reason,
|
|
invalid_at=invalid_at,
|
|
expires_at=oauth_expires_at,
|
|
source="oauth_refresh",
|
|
requires_reauth=True,
|
|
)
|
|
|
|
request_failed_reason = tagged_sections.get("REQUEST_FAILED")
|
|
if request_failed_reason:
|
|
return OAuthStatusSnapshot(
|
|
code="check_failed",
|
|
label="检查失败",
|
|
reason=request_failed_reason,
|
|
expires_at=oauth_expires_at,
|
|
source="oauth_request",
|
|
)
|
|
|
|
account_snapshot = resolve_account_status_snapshot(
|
|
provider_type=None,
|
|
upstream_metadata=None,
|
|
oauth_invalid_reason=raw_reason,
|
|
)
|
|
if account_snapshot.blocked:
|
|
if oauth_expires_at is None:
|
|
return OAuthStatusSnapshot()
|
|
elif raw_reason or invalid_at is not None:
|
|
return OAuthStatusSnapshot(
|
|
code="invalid",
|
|
label="已失效",
|
|
reason=raw_reason,
|
|
invalid_at=invalid_at,
|
|
expires_at=oauth_expires_at,
|
|
source="oauth_invalid",
|
|
requires_reauth=True,
|
|
)
|
|
|
|
expires_at = int(oauth_expires_at) if isinstance(oauth_expires_at, int) else None
|
|
if expires_at is None:
|
|
return OAuthStatusSnapshot()
|
|
if expires_at <= now:
|
|
return OAuthStatusSnapshot(
|
|
code="expired",
|
|
label="已过期",
|
|
reason="Token 已过期,请重新授权",
|
|
expires_at=expires_at,
|
|
source="expires_at",
|
|
requires_reauth=True,
|
|
)
|
|
expiring_soon = (expires_at - now) < 24 * 3600
|
|
return OAuthStatusSnapshot(
|
|
code="expiring" if expiring_soon else "valid",
|
|
label="即将过期" if expiring_soon else "有效",
|
|
expires_at=expires_at,
|
|
source="expires_at",
|
|
expiring_soon=expiring_soon,
|
|
)
|
|
|
|
|
|
def resolve_quota_status_snapshot(
|
|
*,
|
|
provider_type: str | None,
|
|
upstream_metadata: Any,
|
|
) -> QuotaStatusSnapshot:
|
|
normalized_provider = str(provider_type or "").strip().lower()
|
|
reader = get_quota_reader(normalized_provider, upstream_metadata)
|
|
quota_state = reader.is_exhausted()
|
|
usage_ratio = reader.usage_ratio()
|
|
updated_at = reader.updated_at()
|
|
reset_seconds = reader.reset_seconds()
|
|
plan_type = reader.plan_type()
|
|
|
|
if quota_state.exhausted:
|
|
return QuotaStatusSnapshot(
|
|
code="exhausted",
|
|
label="额度耗尽",
|
|
reason=quota_state.reason,
|
|
exhausted=True,
|
|
usage_ratio=usage_ratio,
|
|
updated_at=updated_at,
|
|
reset_seconds=reset_seconds,
|
|
plan_type=plan_type,
|
|
)
|
|
|
|
if any(value is not None for value in (usage_ratio, updated_at, reset_seconds, plan_type)):
|
|
return QuotaStatusSnapshot(
|
|
code="ok",
|
|
exhausted=False,
|
|
usage_ratio=usage_ratio,
|
|
updated_at=updated_at,
|
|
reset_seconds=reset_seconds,
|
|
plan_type=plan_type,
|
|
)
|
|
|
|
return QuotaStatusSnapshot()
|
|
|
|
|
|
def build_provider_key_status_snapshot(
|
|
*,
|
|
auth_type: str | None,
|
|
oauth_expires_at: int | None,
|
|
oauth_invalid_at: int | None,
|
|
oauth_invalid_reason: str | None,
|
|
provider_type: str | None,
|
|
upstream_metadata: Any,
|
|
now_ts: int | None = None,
|
|
) -> ProviderKeyStatusSnapshot:
|
|
account = resolve_account_status_snapshot(
|
|
provider_type=provider_type,
|
|
upstream_metadata=upstream_metadata,
|
|
oauth_invalid_reason=oauth_invalid_reason,
|
|
)
|
|
oauth = resolve_oauth_status_snapshot(
|
|
auth_type=auth_type,
|
|
oauth_expires_at=oauth_expires_at,
|
|
oauth_invalid_at=oauth_invalid_at,
|
|
oauth_invalid_reason=oauth_invalid_reason,
|
|
now_ts=now_ts,
|
|
)
|
|
quota = resolve_quota_status_snapshot(
|
|
provider_type=provider_type,
|
|
upstream_metadata=upstream_metadata,
|
|
)
|
|
return ProviderKeyStatusSnapshot(oauth=oauth, account=account, quota=quota)
|
|
|
|
|
|
def resolve_pool_account_state(
|
|
*,
|
|
provider_type: str | None,
|
|
upstream_metadata: Any,
|
|
oauth_invalid_reason: str | None,
|
|
) -> PoolAccountState:
|
|
"""Resolve account-level hard-block state for pool scheduling."""
|
|
|
|
from_metadata = _resolve_from_metadata(provider_type, upstream_metadata)
|
|
if from_metadata is not None:
|
|
return from_metadata
|
|
|
|
from_oauth = _resolve_from_oauth_invalid_reason(oauth_invalid_reason)
|
|
if from_oauth is not None:
|
|
return from_oauth
|
|
|
|
return PoolAccountState(blocked=False)
|
|
|
|
|
|
def should_auto_remove_account_state(state: PoolAccountState) -> bool:
|
|
"""Whether a resolved account state is safe to auto-remove.
|
|
|
|
Auto-removal is limited to hard, non-recoverable account abnormalities.
|
|
Pure token failures (`oauth_expired`, `oauth_refresh_failed`) and
|
|
softer/manual-recoverable states like `account_verification` are excluded.
|
|
"""
|
|
|
|
return bool(
|
|
state.blocked
|
|
and not state.recoverable
|
|
and str(state.code or "").strip().lower() in AUTO_REMOVABLE_ACCOUNT_STATE_CODES
|
|
)
|
|
|
|
|
|
__all__ = [
|
|
"ACCOUNT_BLOCK_REASON_KEYWORDS",
|
|
"AUTO_REMOVABLE_ACCOUNT_STATE_CODES",
|
|
"AccountStatusSnapshot",
|
|
"OAUTH_ACCOUNT_BLOCK_PREFIX",
|
|
"OAUTH_EXPIRED_PREFIX",
|
|
"OAUTH_REFRESH_FAILED_PREFIX",
|
|
"OAUTH_REQUEST_FAILED_PREFIX",
|
|
"OAuthStatusSnapshot",
|
|
"PoolAccountState",
|
|
"ProviderKeyStatusSnapshot",
|
|
"QuotaStatusSnapshot",
|
|
"build_provider_key_status_snapshot",
|
|
"resolve_account_status_snapshot",
|
|
"resolve_oauth_status_snapshot",
|
|
"resolve_pool_account_state",
|
|
"resolve_quota_status_snapshot",
|
|
"should_auto_remove_account_state",
|
|
]
|