mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-03 01:40:21 +08:00
feat: 新增 Antigravity/Kiro 账户禁用封禁状态监控与展示
- 前端:为 Antigravity 和 Kiro 上游元数据添加禁用/封禁状态字段 - 前端:在密钥详情抽屉中展示账户禁用/封禁警告信息,合并重复的时间格式化函数 - 后端:优化 Antigravity 和 Kiro 适配器的状态检测与上报逻辑 Co-authored-by: AAEE86 <ppk0227@hotmail.com> Closes #158
This commit is contained in:
@@ -355,6 +355,9 @@ export interface AntigravityModelQuota {
|
|||||||
export interface AntigravityUpstreamMetadata {
|
export interface AntigravityUpstreamMetadata {
|
||||||
updated_at?: number // Unix 时间戳(秒)
|
updated_at?: number // Unix 时间戳(秒)
|
||||||
quota_by_model?: Record<string, AntigravityModelQuota>
|
quota_by_model?: Record<string, AntigravityModelQuota>
|
||||||
|
is_forbidden?: boolean // 账户是否被禁止访问
|
||||||
|
forbidden_reason?: string // 禁止访问原因
|
||||||
|
forbidden_at?: number // 禁止时间(Unix 时间戳,秒)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Kiro 上游配额信息
|
// Kiro 上游配额信息
|
||||||
@@ -367,6 +370,9 @@ export interface KiroUpstreamMetadata {
|
|||||||
next_reset_at?: number // 下次重置时间(Unix 时间戳,毫秒)
|
next_reset_at?: number // 下次重置时间(Unix 时间戳,毫秒)
|
||||||
email?: string // 用户邮箱
|
email?: string // 用户邮箱
|
||||||
updated_at?: number // Unix 时间戳(秒)
|
updated_at?: number // Unix 时间戳(秒)
|
||||||
|
is_banned?: boolean // 账户是否被封禁
|
||||||
|
ban_reason?: string // 封禁原因
|
||||||
|
banned_at?: number // 封禁时间(Unix 时间戳,秒)
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpstreamMetadata {
|
export interface UpstreamMetadata {
|
||||||
|
|||||||
@@ -577,109 +577,167 @@
|
|||||||
</div>
|
</div>
|
||||||
<!-- Antigravity 上游额度摘要(按家族分组展示关键配额) -->
|
<!-- Antigravity 上游额度摘要(按家族分组展示关键配额) -->
|
||||||
<div
|
<div
|
||||||
v-if="provider.provider_type === 'antigravity' && key.upstream_metadata && hasAntigravityQuotaData(key.upstream_metadata)"
|
v-if="provider.provider_type === 'antigravity' && key.upstream_metadata && (hasAntigravityQuotaData(key.upstream_metadata) || isAntigravityForbidden(key.upstream_metadata))"
|
||||||
class="mt-2 p-2 bg-muted/30 rounded-md"
|
class="mt-2 p-2 rounded-md"
|
||||||
|
:class="isAntigravityForbidden(key.upstream_metadata) ? 'bg-destructive/10 border border-destructive/30' : 'bg-muted/30'"
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between mb-1">
|
<!-- 封禁状态显示 -->
|
||||||
<span class="text-[10px] text-muted-foreground">模型配额</span>
|
<div
|
||||||
<div class="flex items-center gap-1">
|
v-if="isAntigravityForbidden(key.upstream_metadata)"
|
||||||
<RefreshCw
|
class="flex items-center gap-2 text-destructive"
|
||||||
v-if="refreshingQuota"
|
>
|
||||||
class="w-3 h-3 text-muted-foreground/70 animate-spin"
|
<ShieldX class="w-4 h-4 shrink-0" />
|
||||||
/>
|
<div class="flex-1 min-w-0">
|
||||||
<span
|
<div class="text-[11px] font-medium">
|
||||||
v-if="key.upstream_metadata.antigravity?.updated_at"
|
账户访问被禁止
|
||||||
class="text-[9px] text-muted-foreground/70"
|
|
||||||
>
|
|
||||||
{{ formatAntigravityUpdatedAt(key.upstream_metadata.antigravity.updated_at) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="grid grid-cols-2 gap-3">
|
|
||||||
<div
|
|
||||||
v-for="group in getAntigravityQuotaSummary(key.upstream_metadata)"
|
|
||||||
:key="group.key"
|
|
||||||
>
|
|
||||||
<div class="flex items-center justify-between text-[10px] mb-0.5">
|
|
||||||
<span class="text-muted-foreground truncate mr-2 min-w-0 flex-1">
|
|
||||||
{{ group.label }}
|
|
||||||
</span>
|
|
||||||
<span :class="getQuotaRemainingClass(group.usedPercent)">
|
|
||||||
{{ group.remainingPercent.toFixed(1) }}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="relative w-full h-1.5 bg-border rounded-full overflow-hidden">
|
|
||||||
<div
|
|
||||||
class="absolute left-0 top-0 h-full transition-all duration-300"
|
|
||||||
:class="getQuotaRemainingBarColor(group.usedPercent)"
|
|
||||||
:style="{ width: `${Math.max(group.remainingPercent, 0)}%` }"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="group.resetSeconds !== null || group.usedPercent > 0"
|
v-if="key.upstream_metadata.antigravity?.forbidden_reason"
|
||||||
class="text-[9px] text-muted-foreground/70 mt-0.5"
|
class="text-[10px] text-destructive/80 truncate"
|
||||||
|
:title="key.upstream_metadata.antigravity?.forbidden_reason"
|
||||||
>
|
>
|
||||||
<template v-if="group.resetSeconds !== null && group.resetSeconds > 0">
|
{{ key.upstream_metadata.antigravity?.forbidden_reason }}
|
||||||
{{ formatResetTime(group.resetSeconds) }}后重置
|
|
||||||
</template>
|
|
||||||
<template v-else-if="group.resetSeconds !== null && group.resetSeconds <= 0">
|
|
||||||
已重置
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
重置时间未知
|
|
||||||
</template>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<span
|
||||||
|
v-if="key.upstream_metadata.antigravity?.forbidden_at"
|
||||||
|
class="text-[9px] text-destructive/60 shrink-0"
|
||||||
|
>
|
||||||
|
{{ formatBanTimestamp(key.upstream_metadata.antigravity?.forbidden_at) }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- 正常配额显示 -->
|
||||||
|
<template v-else>
|
||||||
|
<div class="flex items-center justify-between mb-1">
|
||||||
|
<span class="text-[10px] text-muted-foreground">模型配额</span>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<RefreshCw
|
||||||
|
v-if="refreshingQuota"
|
||||||
|
class="w-3 h-3 text-muted-foreground/70 animate-spin"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
v-if="key.upstream_metadata.antigravity?.updated_at"
|
||||||
|
class="text-[9px] text-muted-foreground/70"
|
||||||
|
>
|
||||||
|
{{ formatAntigravityUpdatedAt(key.upstream_metadata.antigravity.updated_at) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<div
|
||||||
|
v-for="group in getAntigravityQuotaSummary(key.upstream_metadata)"
|
||||||
|
:key="group.key"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between text-[10px] mb-0.5">
|
||||||
|
<span class="text-muted-foreground truncate mr-2 min-w-0 flex-1">
|
||||||
|
{{ group.label }}
|
||||||
|
</span>
|
||||||
|
<span :class="getQuotaRemainingClass(group.usedPercent)">
|
||||||
|
{{ group.remainingPercent.toFixed(1) }}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="relative w-full h-1.5 bg-border rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
class="absolute left-0 top-0 h-full transition-all duration-300"
|
||||||
|
:class="getQuotaRemainingBarColor(group.usedPercent)"
|
||||||
|
:style="{ width: `${Math.max(group.remainingPercent, 0)}%` }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="group.resetSeconds !== null || group.usedPercent > 0"
|
||||||
|
class="text-[9px] text-muted-foreground/70 mt-0.5"
|
||||||
|
>
|
||||||
|
<template v-if="group.resetSeconds !== null && group.resetSeconds > 0">
|
||||||
|
{{ formatResetTime(group.resetSeconds) }}后重置
|
||||||
|
</template>
|
||||||
|
<template v-else-if="group.resetSeconds !== null && group.resetSeconds <= 0">
|
||||||
|
已重置
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
重置时间未知
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<!-- Kiro 上游额度信息(仅当有元数据时显示) -->
|
<!-- Kiro 上游额度信息(仅当有元数据时显示) -->
|
||||||
<div
|
<div
|
||||||
v-if="provider.provider_type === 'kiro' && key.upstream_metadata && hasKiroQuotaData(key.upstream_metadata)"
|
v-if="provider.provider_type === 'kiro' && key.upstream_metadata && (hasKiroQuotaData(key.upstream_metadata) || isKiroBanned(key.upstream_metadata))"
|
||||||
class="mt-2 p-2 bg-muted/30 rounded-md"
|
class="mt-2 p-2 rounded-md"
|
||||||
|
:class="isKiroBanned(key.upstream_metadata) ? 'bg-destructive/10 border border-destructive/30' : 'bg-muted/30'"
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between mb-1">
|
<!-- 封禁状态显示 -->
|
||||||
<span class="text-[10px] text-muted-foreground">账号配额</span>
|
<div
|
||||||
<div class="flex items-center gap-1">
|
v-if="isKiroBanned(key.upstream_metadata)"
|
||||||
<RefreshCw
|
class="flex items-center gap-2 text-destructive"
|
||||||
v-if="refreshingQuota"
|
>
|
||||||
class="w-3 h-3 text-muted-foreground/70 animate-spin"
|
<ShieldX class="w-4 h-4 shrink-0" />
|
||||||
/>
|
<div class="flex-1 min-w-0">
|
||||||
<span
|
<div class="text-[11px] font-medium">
|
||||||
v-if="key.upstream_metadata.kiro?.updated_at"
|
账户已封禁
|
||||||
class="text-[9px] text-muted-foreground/70"
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="key.upstream_metadata.kiro?.ban_reason"
|
||||||
|
class="text-[10px] text-destructive/80 truncate"
|
||||||
|
:title="key.upstream_metadata.kiro?.ban_reason"
|
||||||
>
|
>
|
||||||
{{ formatKiroUpdatedAt(key.upstream_metadata.kiro?.updated_at) }}
|
{{ key.upstream_metadata.kiro?.ban_reason }}
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Kiro 额度显示:使用进度 -->
|
|
||||||
<div>
|
|
||||||
<!-- 使用额度进度条 -->
|
|
||||||
<div>
|
|
||||||
<div class="flex items-center justify-between text-[10px] mb-0.5">
|
|
||||||
<span class="text-muted-foreground">使用额度</span>
|
|
||||||
<span :class="getQuotaRemainingClass(key.upstream_metadata.kiro?.usage_percentage || 0)">
|
|
||||||
{{ (100 - (key.upstream_metadata.kiro?.usage_percentage || 0)).toFixed(1) }}%
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="relative w-full h-1.5 bg-border rounded-full overflow-hidden">
|
</div>
|
||||||
<div
|
<span
|
||||||
class="absolute left-0 top-0 h-full transition-all duration-300"
|
v-if="key.upstream_metadata.kiro?.banned_at"
|
||||||
:class="getQuotaRemainingBarColor(key.upstream_metadata.kiro?.usage_percentage || 0)"
|
class="text-[9px] text-destructive/60 shrink-0"
|
||||||
:style="{ width: `${Math.max(100 - (key.upstream_metadata.kiro?.usage_percentage || 0), 0)}%` }"
|
>
|
||||||
|
{{ formatBanTimestamp(key.upstream_metadata.kiro?.banned_at) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<!-- 正常配额显示 -->
|
||||||
|
<template v-else>
|
||||||
|
<div class="flex items-center justify-between mb-1">
|
||||||
|
<span class="text-[10px] text-muted-foreground">账号配额</span>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<RefreshCw
|
||||||
|
v-if="refreshingQuota"
|
||||||
|
class="w-3 h-3 text-muted-foreground/70 animate-spin"
|
||||||
/>
|
/>
|
||||||
</div>
|
<span
|
||||||
<div class="flex items-center justify-between text-[9px] text-muted-foreground/70 mt-0.5">
|
v-if="key.upstream_metadata.kiro?.updated_at"
|
||||||
<span>
|
class="text-[9px] text-muted-foreground/70"
|
||||||
{{ formatKiroUsage(key.upstream_metadata.kiro?.current_usage) }} /
|
>
|
||||||
{{ formatKiroUsage(key.upstream_metadata.kiro?.usage_limit) }}
|
{{ formatKiroUpdatedAt(key.upstream_metadata.kiro?.updated_at) }}
|
||||||
</span>
|
|
||||||
<span v-if="key.upstream_metadata.kiro?.next_reset_at">
|
|
||||||
{{ formatKiroResetTime(key.upstream_metadata.kiro?.next_reset_at) }}重置
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<!-- Kiro 额度显示:使用进度 -->
|
||||||
|
<div>
|
||||||
|
<!-- 使用额度进度条 -->
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center justify-between text-[10px] mb-0.5">
|
||||||
|
<span class="text-muted-foreground">使用额度</span>
|
||||||
|
<span :class="getQuotaRemainingClass(key.upstream_metadata.kiro?.usage_percentage || 0)">
|
||||||
|
{{ (100 - (key.upstream_metadata.kiro?.usage_percentage || 0)).toFixed(1) }}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="relative w-full h-1.5 bg-border rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
class="absolute left-0 top-0 h-full transition-all duration-300"
|
||||||
|
:class="getQuotaRemainingBarColor(key.upstream_metadata.kiro?.usage_percentage || 0)"
|
||||||
|
:style="{ width: `${Math.max(100 - (key.upstream_metadata.kiro?.usage_percentage || 0), 0)}%` }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between text-[9px] text-muted-foreground/70 mt-0.5">
|
||||||
|
<span>
|
||||||
|
{{ formatKiroUsage(key.upstream_metadata.kiro?.current_usage) }} /
|
||||||
|
{{ formatKiroUsage(key.upstream_metadata.kiro?.usage_limit) }}
|
||||||
|
</span>
|
||||||
|
<span v-if="key.upstream_metadata.kiro?.next_reset_at">
|
||||||
|
{{ formatKiroResetTime(key.upstream_metadata.kiro?.next_reset_at) }}重置
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<!-- 第二行:优先级 + API 格式(展开显示) + 统计信息 -->
|
<!-- 第二行:优先级 + API 格式(展开显示) + 统计信息 -->
|
||||||
<div class="flex items-center gap-1.5 mt-1 text-[11px] text-muted-foreground">
|
<div class="flex items-center gap-1.5 mt-1 text-[11px] text-muted-foreground">
|
||||||
@@ -1488,6 +1546,30 @@ function hasKiroQuotaData(meta: UpstreamMetadata | null | undefined): boolean {
|
|||||||
return meta.kiro.usage_percentage !== undefined || meta.kiro.usage_limit !== undefined
|
return meta.kiro.usage_percentage !== undefined || meta.kiro.usage_limit !== undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查 Kiro 账户是否被封禁
|
||||||
|
function isKiroBanned(meta: UpstreamMetadata | null | undefined): boolean {
|
||||||
|
if (!meta?.kiro) return false
|
||||||
|
return meta.kiro.is_banned === true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化封禁/禁止时间(后端返回秒级时间戳,Kiro/Antigravity 通用)
|
||||||
|
function formatBanTimestamp(timestamp: number | undefined): string {
|
||||||
|
if (!timestamp) return ''
|
||||||
|
const date = new Date(timestamp * 1000)
|
||||||
|
return date.toLocaleString('zh-CN', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查 Antigravity 账户是否被禁止访问
|
||||||
|
function isAntigravityForbidden(meta: UpstreamMetadata | null | undefined): boolean {
|
||||||
|
if (!meta?.antigravity) return false
|
||||||
|
return meta.antigravity.is_forbidden === true
|
||||||
|
}
|
||||||
|
|
||||||
// 格式化 Kiro 更新时间
|
// 格式化 Kiro 更新时间
|
||||||
const formatKiroUpdatedAt = formatUpdatedAt
|
const formatKiroUpdatedAt = formatUpdatedAt
|
||||||
|
|
||||||
|
|||||||
@@ -1419,6 +1419,9 @@ class AdminRefreshProviderQuotaAdapter(AdminApiAdapter):
|
|||||||
UpstreamModelsFetchContext,
|
UpstreamModelsFetchContext,
|
||||||
fetch_models_for_key,
|
fetch_models_for_key,
|
||||||
)
|
)
|
||||||
|
from src.services.provider.adapters.antigravity.client import (
|
||||||
|
AntigravityAccountForbiddenException,
|
||||||
|
)
|
||||||
|
|
||||||
fetch_ctx = UpstreamModelsFetchContext(
|
fetch_ctx = UpstreamModelsFetchContext(
|
||||||
provider_type="antigravity",
|
provider_type="antigravity",
|
||||||
@@ -1428,11 +1431,49 @@ class AdminRefreshProviderQuotaAdapter(AdminApiAdapter):
|
|||||||
proxy_config=getattr(provider, "proxy", None),
|
proxy_config=getattr(provider, "proxy", None),
|
||||||
auth_config=auth_info.decrypted_auth_config,
|
auth_config=auth_info.decrypted_auth_config,
|
||||||
)
|
)
|
||||||
_models, errors, ok, upstream_meta = await fetch_models_for_key(
|
|
||||||
fetch_ctx, timeout_seconds=10.0
|
try:
|
||||||
)
|
_models, errors, ok, upstream_meta = await fetch_models_for_key(
|
||||||
|
fetch_ctx, timeout_seconds=10.0
|
||||||
|
)
|
||||||
|
except AntigravityAccountForbiddenException as e:
|
||||||
|
# 对齐 AM:所有 403 一律标记 is_forbidden 并停用
|
||||||
|
key.is_active = False
|
||||||
|
key.oauth_invalid_at = datetime.now(timezone.utc)
|
||||||
|
key.oauth_invalid_reason = f"账户访问被禁止: {e.reason or e.message}"
|
||||||
|
# 更新 upstream_metadata 标记封禁状态
|
||||||
|
forbidden_metadata = {
|
||||||
|
"antigravity": {
|
||||||
|
"is_forbidden": True,
|
||||||
|
"forbidden_reason": e.reason or e.message,
|
||||||
|
"forbidden_at": int(time.time()),
|
||||||
|
"updated_at": int(time.time()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
key.upstream_metadata = merge_upstream_metadata(
|
||||||
|
key.upstream_metadata, forbidden_metadata
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
logger.warning(
|
||||||
|
"[ANTIGRAVITY_QUOTA] Key {} 账户访问被禁止,已自动停用: {}",
|
||||||
|
key.id,
|
||||||
|
e.reason or e.message,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"key_id": key.id,
|
||||||
|
"key_name": key.name,
|
||||||
|
"status": "forbidden",
|
||||||
|
"message": f"账户访问被禁止: {e.reason or e.message}",
|
||||||
|
"is_forbidden": True,
|
||||||
|
"auto_disabled": True,
|
||||||
|
}
|
||||||
|
|
||||||
if ok and upstream_meta:
|
if ok and upstream_meta:
|
||||||
|
# 刷新成功时清除之前的封禁标记(如果账户已恢复)
|
||||||
|
if "antigravity" in upstream_meta:
|
||||||
|
upstream_meta["antigravity"]["is_forbidden"] = False
|
||||||
|
upstream_meta["antigravity"]["forbidden_reason"] = None
|
||||||
|
upstream_meta["antigravity"]["forbidden_at"] = None
|
||||||
metadata_updates[key.id] = upstream_meta
|
metadata_updates[key.id] = upstream_meta
|
||||||
return {
|
return {
|
||||||
"key_id": key.id,
|
"key_id": key.id,
|
||||||
@@ -1451,26 +1492,6 @@ class AdminRefreshProviderQuotaAdapter(AdminApiAdapter):
|
|||||||
|
|
||||||
error_msg = "; ".join(errors) if errors else "fetchAvailableModels failed"
|
error_msg = "; ".join(errors) if errors else "fetchAvailableModels failed"
|
||||||
|
|
||||||
# 403 "verify your account" → 标记账号异常
|
|
||||||
if any(
|
|
||||||
"403" in e and ("verify" in e.lower() or "permission" in e.lower())
|
|
||||||
for e in errors
|
|
||||||
):
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
from src.services.provider.oauth_token import (
|
|
||||||
OAUTH_ACCOUNT_BLOCK_PREFIX,
|
|
||||||
)
|
|
||||||
|
|
||||||
key.oauth_invalid_at = datetime.now(timezone.utc)
|
|
||||||
key.oauth_invalid_reason = (
|
|
||||||
f"{OAUTH_ACCOUNT_BLOCK_PREFIX}Google 要求验证账号"
|
|
||||||
)
|
|
||||||
# 立即持久化异常标记,使负载均衡能尽快跳过此 Key;
|
|
||||||
# 后续 metadata 更新由外层统一 commit
|
|
||||||
db.commit()
|
|
||||||
logger.warning("[QUOTA_REFRESH] Key {} 因 403 verify 已标记为异常", key.id)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"key_id": key.id,
|
"key_id": key.id,
|
||||||
"key_name": key.name,
|
"key_name": key.name,
|
||||||
@@ -1479,6 +1500,9 @@ class AdminRefreshProviderQuotaAdapter(AdminApiAdapter):
|
|||||||
}
|
}
|
||||||
|
|
||||||
elif provider_type == ProviderType.KIRO:
|
elif provider_type == ProviderType.KIRO:
|
||||||
|
from src.services.provider.adapters.kiro.usage import (
|
||||||
|
KiroAccountBannedException,
|
||||||
|
)
|
||||||
from src.services.provider.adapters.kiro.usage import (
|
from src.services.provider.adapters.kiro.usage import (
|
||||||
fetch_kiro_usage_limits as _fetch_kiro_usage_limits,
|
fetch_kiro_usage_limits as _fetch_kiro_usage_limits,
|
||||||
)
|
)
|
||||||
@@ -1516,6 +1540,37 @@ class AdminRefreshProviderQuotaAdapter(AdminApiAdapter):
|
|||||||
auth_config=auth_config_data,
|
auth_config=auth_config_data,
|
||||||
proxy_config=proxy_config,
|
proxy_config=proxy_config,
|
||||||
)
|
)
|
||||||
|
except KiroAccountBannedException as e:
|
||||||
|
# 账户被封禁,自动停用并标记
|
||||||
|
key.is_active = False
|
||||||
|
key.oauth_invalid_at = datetime.now(timezone.utc)
|
||||||
|
key.oauth_invalid_reason = f"账户已封禁: {e.reason or e.message}"
|
||||||
|
# 更新 upstream_metadata 标记封禁状态
|
||||||
|
ban_metadata = {
|
||||||
|
"kiro": {
|
||||||
|
"is_banned": True,
|
||||||
|
"ban_reason": e.reason or e.message,
|
||||||
|
"banned_at": int(time.time()),
|
||||||
|
"updated_at": int(time.time()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
key.upstream_metadata = merge_upstream_metadata(
|
||||||
|
key.upstream_metadata, ban_metadata
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
logger.warning(
|
||||||
|
"[KIRO_QUOTA] Key {} 账户已封禁,已自动停用: {}",
|
||||||
|
key.id,
|
||||||
|
e.reason or e.message,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"key_id": key.id,
|
||||||
|
"key_name": key.name,
|
||||||
|
"status": "banned",
|
||||||
|
"message": f"账户已封禁: {e.reason or e.message}",
|
||||||
|
"is_banned": True,
|
||||||
|
"auto_disabled": True,
|
||||||
|
}
|
||||||
except RuntimeError as e:
|
except RuntimeError as e:
|
||||||
error_msg = str(e)
|
error_msg = str(e)
|
||||||
# 检查是否需要标记账号异常
|
# 检查是否需要标记账号异常
|
||||||
@@ -1538,6 +1593,10 @@ class AdminRefreshProviderQuotaAdapter(AdminApiAdapter):
|
|||||||
metadata = _parse_kiro_usage_response(usage_data)
|
metadata = _parse_kiro_usage_response(usage_data)
|
||||||
|
|
||||||
if metadata:
|
if metadata:
|
||||||
|
# 刷新成功时清除之前的封禁标记(如果账户已恢复)
|
||||||
|
metadata["is_banned"] = False
|
||||||
|
metadata["ban_reason"] = None
|
||||||
|
metadata["banned_at"] = None
|
||||||
# 收集元数据,稍后统一更新数据库(存储到 kiro 子对象)
|
# 收集元数据,稍后统一更新数据库(存储到 kiro 子对象)
|
||||||
metadata_updates[key.id] = {"kiro": metadata}
|
metadata_updates[key.id] = {"kiro": metadata}
|
||||||
|
|
||||||
|
|||||||
@@ -123,6 +123,56 @@ def _should_fallback_status(status_code: int) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 账户封禁异常(对齐 AM: is_forbidden 标志)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class AntigravityAccountForbiddenException(Exception):
|
||||||
|
"""Antigravity 账户被封禁/禁止访问异常。
|
||||||
|
|
||||||
|
当 API 返回 403 Forbidden 时抛出,表示账户权限被撤销。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
message: str = "账户访问被禁止",
|
||||||
|
status_code: int = 403,
|
||||||
|
reason: str | None = None,
|
||||||
|
):
|
||||||
|
super().__init__(message)
|
||||||
|
self.message = message
|
||||||
|
self.status_code = status_code
|
||||||
|
self.reason = reason
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_forbidden_reason(response_text: str) -> str | None:
|
||||||
|
"""从 403 响应体中提取封禁原因。
|
||||||
|
|
||||||
|
尝试解析 JSON 响应中的 error.message 字段。
|
||||||
|
"""
|
||||||
|
if not response_text:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
data = json.loads(response_text)
|
||||||
|
if isinstance(data, dict):
|
||||||
|
error = data.get("error")
|
||||||
|
if isinstance(error, dict):
|
||||||
|
message = error.get("message")
|
||||||
|
if isinstance(message, str) and message.strip():
|
||||||
|
return message.strip()
|
||||||
|
# 直接在顶层查找 message
|
||||||
|
message = data.get("message")
|
||||||
|
if isinstance(message, str) and message.strip():
|
||||||
|
return message.strip()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# 如果无法解析,返回原始文本的前 100 个字符
|
||||||
|
if len(response_text) > 100:
|
||||||
|
return response_text[:100] + "..."
|
||||||
|
return response_text if response_text.strip() else None
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 从 loadCodeAssist 响应中提取信息
|
# 从 loadCodeAssist 响应中提取信息
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -441,12 +491,25 @@ async def fetch_available_models(
|
|||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# 403 Forbidden:账户权限被禁止(对齐 AM is_forbidden 标志)
|
||||||
|
if resp.status_code == 403:
|
||||||
|
reason = _extract_forbidden_reason(resp.text)
|
||||||
|
logger.warning(
|
||||||
|
"[antigravity] fetchAvailableModels 403 Forbidden: {}",
|
||||||
|
reason or "unknown",
|
||||||
|
)
|
||||||
|
raise AntigravityAccountForbiddenException(
|
||||||
|
message="账户访问被禁止",
|
||||||
|
status_code=403,
|
||||||
|
reason=reason,
|
||||||
|
)
|
||||||
|
|
||||||
# 不可 fallback 的 4xx:直接抛出
|
# 不可 fallback 的 4xx:直接抛出
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"fetchAvailableModels failed: status={resp.status_code} base_url={base_url} "
|
f"fetchAvailableModels failed: status={resp.status_code} base_url={base_url} "
|
||||||
f"body={resp.text[:200] if resp.text else ''}"
|
f"body={resp.text[:200] if resp.text else ''}"
|
||||||
)
|
)
|
||||||
except RuntimeError:
|
except (RuntimeError, AntigravityAccountForbiddenException):
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
url_availability.mark_unavailable(base_url)
|
url_availability.mark_unavailable(base_url)
|
||||||
@@ -513,6 +576,7 @@ def generate_fallback_project_id() -> str:
|
|||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"AntigravityAccountForbiddenException",
|
||||||
"extract_project_id",
|
"extract_project_id",
|
||||||
"extract_tier_id",
|
"extract_tier_id",
|
||||||
"fetch_available_models",
|
"fetch_available_models",
|
||||||
|
|||||||
@@ -208,7 +208,10 @@ async def fetch_models_antigravity(
|
|||||||
调用 v1internal:fetchAvailableModels 获取可用模型列表,
|
调用 v1internal:fetchAvailableModels 获取可用模型列表,
|
||||||
解析配额信息,过滤黑名单模型。
|
解析配额信息,过滤黑名单模型。
|
||||||
"""
|
"""
|
||||||
from src.services.provider.adapters.antigravity.client import fetch_available_models
|
from src.services.provider.adapters.antigravity.client import (
|
||||||
|
AntigravityAccountForbiddenException,
|
||||||
|
fetch_available_models,
|
||||||
|
)
|
||||||
|
|
||||||
auth_config = ctx.auth_config or {}
|
auth_config = ctx.auth_config or {}
|
||||||
project_id = auth_config.get("project_id")
|
project_id = auth_config.get("project_id")
|
||||||
@@ -222,6 +225,9 @@ async def fetch_models_antigravity(
|
|||||||
proxy_config=ctx.proxy_config,
|
proxy_config=ctx.proxy_config,
|
||||||
timeout_seconds=timeout_seconds,
|
timeout_seconds=timeout_seconds,
|
||||||
)
|
)
|
||||||
|
except AntigravityAccountForbiddenException:
|
||||||
|
# 重新抛出,由上层(keys.py)处理自动停用逻辑
|
||||||
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return [], [f"antigravity: fetchAvailableModels error: {e}"], False, None
|
return [], [f"antigravity: fetchAvailableModels error: {e}"], False, None
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -27,6 +28,21 @@ from src.services.provider.adapters.kiro.token_manager import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class KiroAccountBannedException(Exception):
|
||||||
|
"""Kiro 账户被封禁异常"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
message: str = "账户已封禁",
|
||||||
|
status_code: int = 403,
|
||||||
|
reason: str | None = None,
|
||||||
|
):
|
||||||
|
super().__init__(message)
|
||||||
|
self.message = message
|
||||||
|
self.status_code = status_code
|
||||||
|
self.reason = reason
|
||||||
|
|
||||||
|
|
||||||
async def fetch_kiro_usage_limits(
|
async def fetch_kiro_usage_limits(
|
||||||
auth_config: dict[str, Any],
|
auth_config: dict[str, Any],
|
||||||
proxy_config: dict[str, Any] | None = None,
|
proxy_config: dict[str, Any] | None = None,
|
||||||
@@ -97,9 +113,36 @@ async def fetch_kiro_usage_limits(
|
|||||||
response = await client.get(url, headers=headers, timeout=httpx.Timeout(30.0))
|
response = await client.get(url, headers=headers, timeout=httpx.Timeout(30.0))
|
||||||
|
|
||||||
if response.status_code != 200:
|
if response.status_code != 200:
|
||||||
|
response_text = (response.text or "").strip()
|
||||||
|
|
||||||
|
# 检测账户封禁(403/423 + 特定错误信息)
|
||||||
|
is_banned = False
|
||||||
|
ban_reason = None
|
||||||
|
if response.status_code in (403, 423):
|
||||||
|
# 检测封禁相关错误(正则模式,通过 re.search 匹配)
|
||||||
|
banned_keywords = [
|
||||||
|
"AccountSuspendedException",
|
||||||
|
"account.*suspend",
|
||||||
|
"account.*banned",
|
||||||
|
"account.*disabled",
|
||||||
|
"account.*access.*denied",
|
||||||
|
]
|
||||||
|
for keyword in banned_keywords:
|
||||||
|
if re.search(keyword, response_text, re.IGNORECASE):
|
||||||
|
is_banned = True
|
||||||
|
ban_reason = (
|
||||||
|
response_text[:200] if response_text else f"HTTP {response.status_code}"
|
||||||
|
)
|
||||||
|
break
|
||||||
|
if not is_banned and response.status_code == 423:
|
||||||
|
# 423 Locked 通常表示账户被锁定/封禁
|
||||||
|
is_banned = True
|
||||||
|
ban_reason = response_text[:200] if response_text else "HTTP 423 Locked"
|
||||||
|
|
||||||
error_msg = {
|
error_msg = {
|
||||||
401: "认证失败,Token 无效或已过期",
|
401: "认证失败,Token 无效或已过期",
|
||||||
403: "权限不足,无法获取使用额度",
|
403: "账户已封禁" if is_banned else "权限不足,无法获取使用额度",
|
||||||
|
423: "账户已封禁",
|
||||||
429: "请求过于频繁,已被限流",
|
429: "请求过于频繁,已被限流",
|
||||||
}.get(response.status_code, "获取使用额度失败")
|
}.get(response.status_code, "获取使用额度失败")
|
||||||
if 500 <= response.status_code < 600:
|
if 500 <= response.status_code < 600:
|
||||||
@@ -107,8 +150,17 @@ async def fetch_kiro_usage_limits(
|
|||||||
logger.debug(
|
logger.debug(
|
||||||
"kiro usage API error: HTTP {} | {}",
|
"kiro usage API error: HTTP {} | {}",
|
||||||
response.status_code,
|
response.status_code,
|
||||||
(response.text or "").strip()[:200],
|
response_text[:200],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 如果检测到封禁,抛出带有封禁标记的异常
|
||||||
|
if is_banned:
|
||||||
|
raise KiroAccountBannedException(
|
||||||
|
message=error_msg,
|
||||||
|
status_code=response.status_code,
|
||||||
|
reason=ban_reason,
|
||||||
|
)
|
||||||
|
|
||||||
raise RuntimeError(f"{error_msg}: HTTP {response.status_code}")
|
raise RuntimeError(f"{error_msg}: HTTP {response.status_code}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -178,6 +230,7 @@ def parse_kiro_usage_response(data: dict) -> dict | None:
|
|||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"KiroAccountBannedException",
|
||||||
"fetch_kiro_usage_limits",
|
"fetch_kiro_usage_limits",
|
||||||
"parse_kiro_usage_response",
|
"parse_kiro_usage_response",
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user