mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
feat(pool,trace): 账号封禁原因细分与请求追踪 attempted_only 过滤
- 将账号封禁原因从笼统的"账号异常"细分为封禁/停用/需要验证三类, 前后端关键词组同步拆分,号池管理页面展示对应分类标签 - trace API 新增 attempted_only 参数,支持仅返回实际尝试过的候选, 前端时间线组件默认启用过滤,排除 available/unused/skipped 记录
This commit is contained in:
@@ -55,8 +55,14 @@ export const requestTraceApi = {
|
|||||||
/**
|
/**
|
||||||
* 获取特定请求的完整追踪信息
|
* 获取特定请求的完整追踪信息
|
||||||
*/
|
*/
|
||||||
async getRequestTrace(requestId: string): Promise<RequestTrace> {
|
async getRequestTrace(
|
||||||
const response = await apiClient.get<RequestTrace>(`/api/admin/monitoring/trace/${requestId}`)
|
requestId: string,
|
||||||
|
options: { attemptedOnly?: boolean } = {},
|
||||||
|
): Promise<RequestTrace> {
|
||||||
|
const attemptedOnly = options.attemptedOnly ?? true
|
||||||
|
const response = await apiClient.get<RequestTrace>(`/api/admin/monitoring/trace/${requestId}`, {
|
||||||
|
params: { attempted_only: attemptedOnly },
|
||||||
|
})
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -632,6 +632,19 @@ const makeAttemptKey = (candidateIndex: number, retryIndex: number): string => {
|
|||||||
return `${candidateIndex}:${retryIndex}`
|
return `${candidateIndex}:${retryIndex}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const POOL_UNATTEMPTED_STATUS = new Set<CandidateRecord['status']>([
|
||||||
|
'available',
|
||||||
|
'unused',
|
||||||
|
'skipped',
|
||||||
|
])
|
||||||
|
|
||||||
|
const isPoolAttemptedCandidate = (candidate: CandidateRecord): boolean => {
|
||||||
|
if (POOL_UNATTEMPTED_STATUS.has(candidate.status)) return false
|
||||||
|
// pending 只有开始执行后才算真正进入号池内部尝试
|
||||||
|
if (candidate.status === 'pending' && !candidate.started_at) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
const normalizeTimelineStatus = (value: unknown): CandidateRecord['status'] => {
|
const normalizeTimelineStatus = (value: unknown): CandidateRecord['status'] => {
|
||||||
if (typeof value !== 'string') return 'failed'
|
if (typeof value !== 'string') return 'failed'
|
||||||
const normalized = value.trim().toLowerCase()
|
const normalized = value.trim().toLowerCase()
|
||||||
@@ -677,8 +690,11 @@ const schedulingAudit = computed<Record<string, unknown> | null>(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const poolAttemptCandidates = computed<CandidateRecord[]>(() => {
|
const poolAttemptCandidates = computed<CandidateRecord[]>(() => {
|
||||||
// 新链路:优先使用后端写入的 extra_data.pool_group_id。
|
// 新链路:优先使用后端写入的 extra_data.pool_group_id,
|
||||||
const fromTrace = rawTimeline.value.filter((candidate) => extractPoolGroupId(candidate) !== null)
|
// 但仅展示实际进入号池执行的 key(排除 available/unused/skipped)。
|
||||||
|
const fromTrace = rawTimeline.value.filter(
|
||||||
|
(candidate) => extractPoolGroupId(candidate) !== null && isPoolAttemptedCandidate(candidate),
|
||||||
|
)
|
||||||
if (fromTrace.length > 0) {
|
if (fromTrace.length > 0) {
|
||||||
return fromTrace
|
return fromTrace
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,19 @@
|
|||||||
// 账号级别封禁/异常的关键词匹配(用于判断 oauth_invalid_reason 是否属于账号封禁)
|
// -- 按原因细分的关键词组 --
|
||||||
const ACCOUNT_BLOCK_REASON_KEYWORDS = [
|
|
||||||
|
// 封禁类 (suspended / banned)
|
||||||
|
const KEYWORDS_SUSPENDED = [
|
||||||
|
'suspended',
|
||||||
'account_block',
|
'account_block',
|
||||||
'account blocked',
|
'account blocked',
|
||||||
|
'封禁',
|
||||||
|
'封号',
|
||||||
|
'被封',
|
||||||
|
'账户已封禁',
|
||||||
|
'账号异常',
|
||||||
|
]
|
||||||
|
|
||||||
|
// 停用类 (disabled / deactivated)
|
||||||
|
const KEYWORDS_DISABLED = [
|
||||||
'account has been disabled',
|
'account has been disabled',
|
||||||
'account disabled',
|
'account disabled',
|
||||||
'account has been deactivated',
|
'account has been deactivated',
|
||||||
@@ -9,19 +21,22 @@ const ACCOUNT_BLOCK_REASON_KEYWORDS = [
|
|||||||
'account deactivated',
|
'account deactivated',
|
||||||
'organization has been disabled',
|
'organization has been disabled',
|
||||||
'organization_disabled',
|
'organization_disabled',
|
||||||
|
'deactivated',
|
||||||
|
'访问被禁止',
|
||||||
|
'账户访问被禁止',
|
||||||
|
]
|
||||||
|
|
||||||
|
// 需要验证类
|
||||||
|
const KEYWORDS_VERIFICATION = [
|
||||||
'validation_required',
|
'validation_required',
|
||||||
'verify your account',
|
'verify your account',
|
||||||
'suspended',
|
]
|
||||||
'deactivated',
|
|
||||||
// Kiro quota refresher 写入的确切文本
|
// 合并的完整列表
|
||||||
'账户已封禁',
|
const ACCOUNT_BLOCK_REASON_KEYWORDS = [
|
||||||
// Antigravity quota refresher 写入的确切文本
|
...KEYWORDS_SUSPENDED,
|
||||||
'账户访问被禁止',
|
...KEYWORDS_DISABLED,
|
||||||
'封禁',
|
...KEYWORDS_VERIFICATION,
|
||||||
'封号',
|
|
||||||
'被封',
|
|
||||||
'访问被禁止',
|
|
||||||
'账号异常',
|
|
||||||
]
|
]
|
||||||
|
|
||||||
export function isAccountLevelBlockReason(reason: string | null | undefined): boolean {
|
export function isAccountLevelBlockReason(reason: string | null | undefined): boolean {
|
||||||
@@ -33,6 +48,14 @@ export function isAccountLevelBlockReason(reason: string | null | undefined): bo
|
|||||||
return ACCOUNT_BLOCK_REASON_KEYWORDS.some(keyword => lowered.includes(keyword))
|
return ACCOUNT_BLOCK_REASON_KEYWORDS.some(keyword => lowered.includes(keyword))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function classifyAccountBlockLabel(reason: string): string {
|
||||||
|
const lowered = reason.toLowerCase()
|
||||||
|
if (KEYWORDS_VERIFICATION.some(kw => lowered.includes(kw))) return '需要验证'
|
||||||
|
if (KEYWORDS_DISABLED.some(kw => lowered.includes(kw))) return '账号停用'
|
||||||
|
if (KEYWORDS_SUSPENDED.some(kw => lowered.includes(kw))) return '账号封禁'
|
||||||
|
return '账号异常'
|
||||||
|
}
|
||||||
|
|
||||||
export function cleanAccountBlockReason(reason: string): string {
|
export function cleanAccountBlockReason(reason: string): string {
|
||||||
return reason.replace(/^\[ACCOUNT_BLOCK\]\s*/i, '').trim()
|
return reason.replace(/^\[ACCOUNT_BLOCK\]\s*/i, '').trim()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1186,7 +1186,7 @@ import KeyFormDialog from '@/features/providers/components/KeyFormDialog.vue'
|
|||||||
import OAuthKeyEditDialog from '@/features/providers/components/OAuthKeyEditDialog.vue'
|
import OAuthKeyEditDialog from '@/features/providers/components/OAuthKeyEditDialog.vue'
|
||||||
import OAuthAccountDialog from '@/features/providers/components/OAuthAccountDialog.vue'
|
import OAuthAccountDialog from '@/features/providers/components/OAuthAccountDialog.vue'
|
||||||
import ProxyNodeSelect from '@/features/providers/components/ProxyNodeSelect.vue'
|
import ProxyNodeSelect from '@/features/providers/components/ProxyNodeSelect.vue'
|
||||||
import { isAccountLevelBlockReason, cleanAccountBlockReason } from '@/utils/accountBlock'
|
import { isAccountLevelBlockReason, classifyAccountBlockLabel, cleanAccountBlockReason } from '@/utils/accountBlock'
|
||||||
|
|
||||||
const { success, error: showError, warning: showWarning } = useToast()
|
const { success, error: showError, warning: showWarning } = useToast()
|
||||||
const { confirm } = useConfirm()
|
const { confirm } = useConfirm()
|
||||||
@@ -2339,7 +2339,11 @@ function getAccountAlertLabel(key: PoolKeyDetail): string | null {
|
|||||||
// 后端 _build_account_quota 返回的确切文本: "账号已封禁" / "访问受限"
|
// 后端 _build_account_quota 返回的确切文本: "账号已封禁" / "访问受限"
|
||||||
if (quotaText === '账号已封禁' || quotaText === '封禁') result = '账号封禁'
|
if (quotaText === '账号已封禁' || quotaText === '封禁') result = '账号封禁'
|
||||||
else if (quotaText === '访问受限') result = '访问受限'
|
else if (quotaText === '访问受限') result = '访问受限'
|
||||||
else if (isAccountLevelBlockReason(key.oauth_invalid_reason)) result = '账号异常'
|
else if (isAccountLevelBlockReason(key.oauth_invalid_reason)) {
|
||||||
|
const reason = String(key.oauth_invalid_reason || '').trim()
|
||||||
|
const cleaned = cleanAccountBlockReason(reason)
|
||||||
|
result = classifyAccountBlockLabel(cleaned || reason)
|
||||||
|
}
|
||||||
|
|
||||||
_accountAlertCache.set(key, result)
|
_accountAlertCache.set(key, result)
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ class RequestTraceResponse(BaseModel):
|
|||||||
async def get_request_trace(
|
async def get_request_trace(
|
||||||
request_id: str,
|
request_id: str,
|
||||||
request: Request,
|
request: Request,
|
||||||
|
attempted_only: bool = Query(False, description="仅返回实际尝试过的候选"),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""
|
"""
|
||||||
@@ -119,7 +120,7 @@ async def get_request_trace(
|
|||||||
- `finished_at`: 完成时间
|
- `finished_at`: 完成时间
|
||||||
"""
|
"""
|
||||||
|
|
||||||
adapter = AdminGetRequestTraceAdapter(request_id=request_id)
|
adapter = AdminGetRequestTraceAdapter(request_id=request_id, attempted_only=attempted_only)
|
||||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
@@ -159,6 +160,18 @@ async def get_provider_failure_rate(
|
|||||||
@dataclass
|
@dataclass
|
||||||
class AdminGetRequestTraceAdapter(AdminApiAdapter):
|
class AdminGetRequestTraceAdapter(AdminApiAdapter):
|
||||||
request_id: str
|
request_id: str
|
||||||
|
attempted_only: bool = False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_attempted_candidate(candidate: Any) -> bool:
|
||||||
|
status = str(getattr(candidate, "status", "") or "").strip().lower()
|
||||||
|
# pre-created / never executed rows should not be considered as attempted
|
||||||
|
if status in {"", "available", "unused", "skipped"}:
|
||||||
|
return False
|
||||||
|
# pending must have started_at to be considered truly entered execution
|
||||||
|
if status == "pending" and getattr(candidate, "started_at", None) is None:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||||
db = context.db
|
db = context.db
|
||||||
@@ -173,7 +186,11 @@ class AdminGetRequestTraceAdapter(AdminApiAdapter):
|
|||||||
if not all_candidates:
|
if not all_candidates:
|
||||||
raise HTTPException(status_code=404, detail="Request not found")
|
raise HTTPException(status_code=404, detail="Request not found")
|
||||||
|
|
||||||
candidates = all_candidates
|
candidates = (
|
||||||
|
[c for c in all_candidates if self._is_attempted_candidate(c)]
|
||||||
|
if self.attempted_only
|
||||||
|
else all_candidates
|
||||||
|
)
|
||||||
|
|
||||||
# 计算总延迟(只统计已完成的候选:success, failed, cancelled)
|
# 计算总延迟(只统计已完成的候选:success, failed, cancelled)
|
||||||
# 使用显式的 is not None 检查,避免过滤掉 0ms 的快速响应
|
# 使用显式的 is not None 检查,避免过滤掉 0ms 的快速响应
|
||||||
@@ -191,14 +208,15 @@ class AdminGetRequestTraceAdapter(AdminApiAdapter):
|
|||||||
# 3. status="streaming" 表示流式请求正在进行中
|
# 3. status="streaming" 表示流式请求正在进行中
|
||||||
# 4. status="pending" 表示请求尚未开始执行
|
# 4. status="pending" 表示请求尚未开始执行
|
||||||
# 5. status="cancelled" 表示客户端主动断开连接(不算失败)
|
# 5. status="cancelled" 表示客户端主动断开连接(不算失败)
|
||||||
|
final_status_source = all_candidates if self.attempted_only else candidates
|
||||||
has_success = any(
|
has_success = any(
|
||||||
c.status == "success" or (c.status_code is not None and 200 <= c.status_code < 300)
|
c.status == "success" or (c.status_code is not None and 200 <= c.status_code < 300)
|
||||||
for c in candidates
|
for c in final_status_source
|
||||||
)
|
)
|
||||||
has_streaming = any(c.status == "streaming" for c in candidates)
|
has_streaming = any(c.status == "streaming" for c in final_status_source)
|
||||||
has_pending = any(c.status == "pending" for c in candidates)
|
has_pending = any(c.status == "pending" for c in final_status_source)
|
||||||
has_cancelled = any(c.status == "cancelled" for c in candidates)
|
has_cancelled = any(c.status == "cancelled" for c in final_status_source)
|
||||||
has_failed = any(c.status == "failed" for c in candidates)
|
has_failed = any(c.status == "failed" for c in final_status_source)
|
||||||
|
|
||||||
if has_success:
|
if has_success:
|
||||||
final_status = "success"
|
final_status = "success"
|
||||||
@@ -385,6 +403,7 @@ class AdminGetRequestTraceAdapter(AdminApiAdapter):
|
|||||||
total_candidates=len(candidates),
|
total_candidates=len(candidates),
|
||||||
final_status=final_status,
|
final_status=final_status,
|
||||||
total_latency_ms=total_latency,
|
total_latency_ms=total_latency,
|
||||||
|
attempted_only=self.attempted_only,
|
||||||
)
|
)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|||||||
@@ -11,9 +11,21 @@ from typing import Any
|
|||||||
|
|
||||||
OAUTH_ACCOUNT_BLOCK_PREFIX = "[ACCOUNT_BLOCK] "
|
OAUTH_ACCOUNT_BLOCK_PREFIX = "[ACCOUNT_BLOCK] "
|
||||||
|
|
||||||
ACCOUNT_BLOCK_REASON_KEYWORDS: tuple[str, ...] = (
|
# -- 按原因细分的关键词组 --
|
||||||
|
# 封禁类 (suspended / banned)
|
||||||
|
_KEYWORDS_SUSPENDED: tuple[str, ...] = (
|
||||||
|
"suspended",
|
||||||
"account_block",
|
"account_block",
|
||||||
"account blocked",
|
"account blocked",
|
||||||
|
"封禁",
|
||||||
|
"封号",
|
||||||
|
"被封",
|
||||||
|
"账户已封禁",
|
||||||
|
"账号异常",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 停用类 (disabled / deactivated)
|
||||||
|
_KEYWORDS_DISABLED: tuple[str, ...] = (
|
||||||
"account has been disabled",
|
"account has been disabled",
|
||||||
"account disabled",
|
"account disabled",
|
||||||
"account has been deactivated",
|
"account has been deactivated",
|
||||||
@@ -21,21 +33,36 @@ ACCOUNT_BLOCK_REASON_KEYWORDS: tuple[str, ...] = (
|
|||||||
"account deactivated",
|
"account deactivated",
|
||||||
"organization has been disabled",
|
"organization has been disabled",
|
||||||
"organization_disabled",
|
"organization_disabled",
|
||||||
|
"deactivated",
|
||||||
|
"访问被禁止",
|
||||||
|
"账户访问被禁止",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 需要验证类
|
||||||
|
_KEYWORDS_VERIFICATION: tuple[str, ...] = (
|
||||||
"validation_required",
|
"validation_required",
|
||||||
"verify your account",
|
"verify your account",
|
||||||
"suspended",
|
|
||||||
"deactivated",
|
|
||||||
# Kiro quota refresher 写入的确切文本
|
|
||||||
"账户已封禁",
|
|
||||||
# Antigravity quota refresher 写入的确切文本
|
|
||||||
"账户访问被禁止",
|
|
||||||
"封禁",
|
|
||||||
"封号",
|
|
||||||
"被封",
|
|
||||||
"访问被禁止",
|
|
||||||
"账号异常",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 合并的完整列表(用于 is_account_level_block_reason 快速判断)
|
||||||
|
ACCOUNT_BLOCK_REASON_KEYWORDS: tuple[str, ...] = (
|
||||||
|
*_KEYWORDS_SUSPENDED,
|
||||||
|
*_KEYWORDS_DISABLED,
|
||||||
|
*_KEYWORDS_VERIFICATION,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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 _KEYWORDS_VERIFICATION):
|
||||||
|
return "account_verification", "需要验证"
|
||||||
|
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)
|
@dataclass(frozen=True, slots=True)
|
||||||
class PoolAccountState:
|
class PoolAccountState:
|
||||||
@@ -147,19 +174,23 @@ def _resolve_from_oauth_invalid_reason(reason: str | None) -> PoolAccountState |
|
|||||||
|
|
||||||
if text.startswith(OAUTH_ACCOUNT_BLOCK_PREFIX):
|
if text.startswith(OAUTH_ACCOUNT_BLOCK_PREFIX):
|
||||||
cleaned = text[len(OAUTH_ACCOUNT_BLOCK_PREFIX) :].strip()
|
cleaned = text[len(OAUTH_ACCOUNT_BLOCK_PREFIX) :].strip()
|
||||||
|
code, label = (
|
||||||
|
_classify_block_reason(cleaned) if cleaned else ("account_blocked", "账号异常")
|
||||||
|
)
|
||||||
return PoolAccountState(
|
return PoolAccountState(
|
||||||
blocked=True,
|
blocked=True,
|
||||||
code="account_blocked",
|
code=code,
|
||||||
label="账号异常",
|
label=label,
|
||||||
reason=cleaned or "账号异常",
|
reason=cleaned or "账号异常",
|
||||||
)
|
)
|
||||||
|
|
||||||
lowered = text.lower()
|
lowered = text.lower()
|
||||||
if any(keyword in lowered for keyword in ACCOUNT_BLOCK_REASON_KEYWORDS):
|
if any(keyword in lowered for keyword in ACCOUNT_BLOCK_REASON_KEYWORDS):
|
||||||
|
code, label = _classify_block_reason(text)
|
||||||
return PoolAccountState(
|
return PoolAccountState(
|
||||||
blocked=True,
|
blocked=True,
|
||||||
code="account_blocked",
|
code=code,
|
||||||
label="账号异常",
|
label=label,
|
||||||
reason=text,
|
reason=text,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ def test_resolve_from_antigravity_forbidden_metadata() -> None:
|
|||||||
assert state.reason == "403"
|
assert state.reason == "403"
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_from_structured_oauth_reason() -> None:
|
def test_resolve_from_structured_oauth_reason_verification() -> None:
|
||||||
state = resolve_pool_account_state(
|
state = resolve_pool_account_state(
|
||||||
provider_type="codex",
|
provider_type="codex",
|
||||||
upstream_metadata=None,
|
upstream_metadata=None,
|
||||||
@@ -41,15 +41,38 @@ def test_resolve_from_structured_oauth_reason() -> None:
|
|||||||
assert state.reason == "Google requires verification"
|
assert state.reason == "Google requires verification"
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_from_keyword_oauth_reason() -> None:
|
def test_resolve_from_structured_oauth_reason_suspended() -> None:
|
||||||
|
state = resolve_pool_account_state(
|
||||||
|
provider_type="codex",
|
||||||
|
upstream_metadata=None,
|
||||||
|
oauth_invalid_reason="[ACCOUNT_BLOCK] account suspended by admin",
|
||||||
|
)
|
||||||
|
assert state.blocked is True
|
||||||
|
assert state.code == "account_suspended"
|
||||||
|
assert state.label == "账号封禁"
|
||||||
|
assert state.reason == "account suspended by admin"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_from_keyword_oauth_reason_disabled() -> None:
|
||||||
state = resolve_pool_account_state(
|
state = resolve_pool_account_state(
|
||||||
provider_type=None,
|
provider_type=None,
|
||||||
upstream_metadata={},
|
upstream_metadata={},
|
||||||
oauth_invalid_reason="organization has been disabled by admin",
|
oauth_invalid_reason="organization has been disabled by admin",
|
||||||
)
|
)
|
||||||
assert state.blocked is True
|
assert state.blocked is True
|
||||||
assert state.code == "account_blocked"
|
assert state.code == "account_disabled"
|
||||||
assert state.label == "账号异常"
|
assert state.label == "账号停用"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_from_keyword_oauth_reason_verification() -> None:
|
||||||
|
state = resolve_pool_account_state(
|
||||||
|
provider_type=None,
|
||||||
|
upstream_metadata={},
|
||||||
|
oauth_invalid_reason="validation_required: please verify your identity",
|
||||||
|
)
|
||||||
|
assert state.blocked is True
|
||||||
|
assert state.code == "account_verification"
|
||||||
|
assert state.label == "需要验证"
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_healthy_state() -> None:
|
def test_resolve_healthy_state() -> None:
|
||||||
@@ -72,21 +95,23 @@ def test_bare_forbidden_not_treated_as_account_block() -> None:
|
|||||||
assert state.blocked is False
|
assert state.blocked is False
|
||||||
|
|
||||||
|
|
||||||
def test_kiro_oauth_reason_text_detected_as_block() -> None:
|
def test_kiro_oauth_reason_text_detected_as_suspended() -> None:
|
||||||
state = resolve_pool_account_state(
|
state = resolve_pool_account_state(
|
||||||
provider_type="kiro",
|
provider_type="kiro",
|
||||||
upstream_metadata={},
|
upstream_metadata={},
|
||||||
oauth_invalid_reason="账户已封禁: Terms of Service violation",
|
oauth_invalid_reason="账户已封禁: Terms of Service violation",
|
||||||
)
|
)
|
||||||
assert state.blocked is True
|
assert state.blocked is True
|
||||||
assert state.code == "account_blocked"
|
assert state.code == "account_suspended"
|
||||||
|
assert state.label == "账号封禁"
|
||||||
|
|
||||||
|
|
||||||
def test_antigravity_oauth_reason_text_detected_as_block() -> None:
|
def test_antigravity_oauth_reason_text_detected_as_disabled() -> None:
|
||||||
state = resolve_pool_account_state(
|
state = resolve_pool_account_state(
|
||||||
provider_type="antigravity",
|
provider_type="antigravity",
|
||||||
upstream_metadata={},
|
upstream_metadata={},
|
||||||
oauth_invalid_reason="账户访问被禁止: 403 Forbidden",
|
oauth_invalid_reason="账户访问被禁止: 403 Forbidden",
|
||||||
)
|
)
|
||||||
assert state.blocked is True
|
assert state.blocked is True
|
||||||
assert state.code == "account_blocked"
|
assert state.code == "account_disabled"
|
||||||
|
assert state.label == "账号停用"
|
||||||
|
|||||||
@@ -8,9 +8,16 @@ from unittest.mock import MagicMock
|
|||||||
from src.api.admin.monitoring.trace import AdminGetRequestTraceAdapter
|
from src.api.admin.monitoring.trace import AdminGetRequestTraceAdapter
|
||||||
from src.services.request.candidate import RequestCandidateService
|
from src.services.request.candidate import RequestCandidateService
|
||||||
|
|
||||||
|
_STARTED_STATUSES = {"pending", "streaming", "success", "failed", "cancelled"}
|
||||||
|
|
||||||
def _candidate(*, status: str, latency_ms: int | None = None) -> SimpleNamespace:
|
|
||||||
|
def _candidate(
|
||||||
|
*, status: str, latency_ms: int | None = None, started_at: datetime | None = ... # type: ignore[assignment]
|
||||||
|
) -> SimpleNamespace:
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
|
resolved_started_at = (
|
||||||
|
(now if status in _STARTED_STATUSES else None) if started_at is ... else started_at
|
||||||
|
)
|
||||||
return SimpleNamespace(
|
return SimpleNamespace(
|
||||||
id=f"cand-{status}",
|
id=f"cand-{status}",
|
||||||
request_id="req-1",
|
request_id="req-1",
|
||||||
@@ -30,7 +37,7 @@ def _candidate(*, status: str, latency_ms: int | None = None) -> SimpleNamespace
|
|||||||
concurrent_requests=None,
|
concurrent_requests=None,
|
||||||
extra_data=None,
|
extra_data=None,
|
||||||
created_at=now,
|
created_at=now,
|
||||||
started_at=None,
|
started_at=resolved_started_at,
|
||||||
finished_at=None,
|
finished_at=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -80,3 +87,46 @@ def test_trace_returns_unattempted_candidates(monkeypatch: object) -> None:
|
|||||||
assert response.total_candidates == 2
|
assert response.total_candidates == 2
|
||||||
assert len(response.candidates) == 2
|
assert len(response.candidates) == 2
|
||||||
assert {c.status for c in response.candidates} == {"available", "unused"}
|
assert {c.status for c in response.candidates} == {"available", "unused"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_trace_attempted_only_filters_unattempted_candidates(monkeypatch: object) -> None:
|
||||||
|
candidates = [
|
||||||
|
_candidate(status="available"),
|
||||||
|
_candidate(status="unused"),
|
||||||
|
_candidate(status="skipped"),
|
||||||
|
_candidate(status="failed", latency_ms=123),
|
||||||
|
_candidate(status="success", latency_ms=456),
|
||||||
|
]
|
||||||
|
monkeypatch.setattr(
|
||||||
|
RequestCandidateService,
|
||||||
|
"get_candidates_by_request_id",
|
||||||
|
lambda _db, _request_id: candidates,
|
||||||
|
)
|
||||||
|
|
||||||
|
adapter = AdminGetRequestTraceAdapter(request_id="req-1", attempted_only=True)
|
||||||
|
response = asyncio.run(adapter.handle(_context()))
|
||||||
|
|
||||||
|
assert response.total_candidates == 2
|
||||||
|
assert len(response.candidates) == 2
|
||||||
|
assert {c.status for c in response.candidates} == {"failed", "success"}
|
||||||
|
assert response.total_latency_ms == 579
|
||||||
|
|
||||||
|
|
||||||
|
def test_trace_attempted_only_excludes_pending_without_started_at(monkeypatch: object) -> None:
|
||||||
|
candidates = [
|
||||||
|
_candidate(status="available"),
|
||||||
|
_candidate(status="pending", started_at=None),
|
||||||
|
_candidate(status="pending"),
|
||||||
|
]
|
||||||
|
monkeypatch.setattr(
|
||||||
|
RequestCandidateService,
|
||||||
|
"get_candidates_by_request_id",
|
||||||
|
lambda _db, _request_id: candidates,
|
||||||
|
)
|
||||||
|
|
||||||
|
adapter = AdminGetRequestTraceAdapter(request_id="req-1", attempted_only=True)
|
||||||
|
response = asyncio.run(adapter.handle(_context()))
|
||||||
|
|
||||||
|
assert response.total_candidates == 1
|
||||||
|
assert len(response.candidates) == 1
|
||||||
|
assert response.candidates[0].status == "pending"
|
||||||
|
|||||||
Reference in New Issue
Block a user