Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in / Register
Toggle navigation
Menu
Open sidebar
陈曦
sub2api
Commits
fc00a4e3
Unverified
Commit
fc00a4e3
authored
Mar 14, 2026
by
Wesley Liddick
Committed by
GitHub
Mar 14, 2026
Browse files
Merge pull request #959 from touwaeriol/feat/antigravity-403-detection
feat(antigravity): add 403 forbidden status detection and display
parents
db1f6ded
45456fa2
Changes
14
Show whitespace changes
Inline
Side-by-side
backend/internal/pkg/antigravity/client.go
View file @
fc00a4e3
...
...
@@ -19,6 +19,16 @@ import (
"github.com/Wei-Shaw/sub2api/internal/pkg/proxyutil"
)
// ForbiddenError 表示上游返回 403 Forbidden
type
ForbiddenError
struct
{
StatusCode
int
Body
string
}
func
(
e
*
ForbiddenError
)
Error
()
string
{
return
fmt
.
Sprintf
(
"fetchAvailableModels 失败 (HTTP %d): %s"
,
e
.
StatusCode
,
e
.
Body
)
}
// NewAPIRequestWithURL 使用指定的 base URL 创建 Antigravity API 请求(v1internal 端点)
func
NewAPIRequestWithURL
(
ctx
context
.
Context
,
baseURL
,
action
,
accessToken
string
,
body
[]
byte
)
(
*
http
.
Request
,
error
)
{
// 构建 URL,流式请求添加 ?alt=sse 参数
...
...
@@ -515,6 +525,19 @@ type ModelQuotaInfo struct {
// ModelInfo 模型信息
type
ModelInfo
struct
{
QuotaInfo
*
ModelQuotaInfo
`json:"quotaInfo,omitempty"`
DisplayName
string
`json:"displayName,omitempty"`
SupportsImages
*
bool
`json:"supportsImages,omitempty"`
SupportsThinking
*
bool
`json:"supportsThinking,omitempty"`
ThinkingBudget
*
int
`json:"thinkingBudget,omitempty"`
Recommended
*
bool
`json:"recommended,omitempty"`
MaxTokens
*
int
`json:"maxTokens,omitempty"`
MaxOutputTokens
*
int
`json:"maxOutputTokens,omitempty"`
SupportedMimeTypes
map
[
string
]
bool
`json:"supportedMimeTypes,omitempty"`
}
// DeprecatedModelInfo 废弃模型转发信息
type
DeprecatedModelInfo
struct
{
NewModelID
string
`json:"newModelId"`
}
// FetchAvailableModelsRequest fetchAvailableModels 请求
...
...
@@ -525,6 +548,7 @@ type FetchAvailableModelsRequest struct {
// FetchAvailableModelsResponse fetchAvailableModels 响应
type
FetchAvailableModelsResponse
struct
{
Models
map
[
string
]
ModelInfo
`json:"models"`
DeprecatedModelIDs
map
[
string
]
DeprecatedModelInfo
`json:"deprecatedModelIds,omitempty"`
}
// FetchAvailableModels 获取可用模型和配额信息,返回解析后的结构体和原始 JSON
...
...
@@ -573,6 +597,13 @@ func (c *Client) FetchAvailableModels(ctx context.Context, accessToken, projectI
continue
}
if
resp
.
StatusCode
==
http
.
StatusForbidden
{
return
nil
,
nil
,
&
ForbiddenError
{
StatusCode
:
resp
.
StatusCode
,
Body
:
string
(
respBodyBytes
),
}
}
if
resp
.
StatusCode
!=
http
.
StatusOK
{
return
nil
,
nil
,
fmt
.
Errorf
(
"fetchAvailableModels 失败 (HTTP %d): %s"
,
resp
.
StatusCode
,
string
(
respBodyBytes
))
}
...
...
backend/internal/service/account_usage_service.go
View file @
fc00a4e3
...
...
@@ -6,6 +6,7 @@ import (
"encoding/json"
"fmt"
"log"
"log/slog"
"math/rand/v2"
"net/http"
"strings"
...
...
@@ -100,6 +101,7 @@ type antigravityUsageCache struct {
const
(
apiCacheTTL
=
3
*
time
.
Minute
apiErrorCacheTTL
=
1
*
time
.
Minute
// 负缓存 TTL:429 等错误缓存 1 分钟
antigravityErrorTTL
=
1
*
time
.
Minute
// Antigravity 错误缓存 TTL(可恢复错误)
apiQueryMaxJitter
=
800
*
time
.
Millisecond
// 用量查询最大随机延迟
windowStatsCacheTTL
=
1
*
time
.
Minute
openAIProbeCacheTTL
=
10
*
time
.
Minute
...
...
@@ -111,7 +113,8 @@ type UsageCache struct {
apiCache
sync
.
Map
// accountID -> *apiUsageCache
windowStatsCache
sync
.
Map
// accountID -> *windowStatsCache
antigravityCache
sync
.
Map
// accountID -> *antigravityUsageCache
apiFlight
singleflight
.
Group
// 防止同一账号的并发请求击穿缓存
apiFlight
singleflight
.
Group
// 防止同一账号的并发请求击穿缓存(Anthropic)
antigravityFlight
singleflight
.
Group
// 防止同一 Antigravity 账号的并发请求击穿缓存
openAIProbeCache
sync
.
Map
// accountID -> time.Time
}
...
...
@@ -149,6 +152,18 @@ type AntigravityModelQuota struct {
ResetTime
string
`json:"reset_time"`
// 重置时间 ISO8601
}
// AntigravityModelDetail Antigravity 单个模型的详细能力信息
type
AntigravityModelDetail
struct
{
DisplayName
string
`json:"display_name,omitempty"`
SupportsImages
*
bool
`json:"supports_images,omitempty"`
SupportsThinking
*
bool
`json:"supports_thinking,omitempty"`
ThinkingBudget
*
int
`json:"thinking_budget,omitempty"`
Recommended
*
bool
`json:"recommended,omitempty"`
MaxTokens
*
int
`json:"max_tokens,omitempty"`
MaxOutputTokens
*
int
`json:"max_output_tokens,omitempty"`
SupportedMimeTypes
map
[
string
]
bool
`json:"supported_mime_types,omitempty"`
}
// UsageInfo 账号使用量信息
type
UsageInfo
struct
{
UpdatedAt
*
time
.
Time
`json:"updated_at,omitempty"`
// 更新时间
...
...
@@ -164,6 +179,33 @@ type UsageInfo struct {
// Antigravity 多模型配额
AntigravityQuota
map
[
string
]
*
AntigravityModelQuota
`json:"antigravity_quota,omitempty"`
// Antigravity 账号级信息
SubscriptionTier
string
`json:"subscription_tier,omitempty"`
// 归一化订阅等级: FREE/PRO/ULTRA/UNKNOWN
SubscriptionTierRaw
string
`json:"subscription_tier_raw,omitempty"`
// 上游原始订阅等级名称
// Antigravity 模型详细能力信息(与 antigravity_quota 同 key)
AntigravityQuotaDetails
map
[
string
]
*
AntigravityModelDetail
`json:"antigravity_quota_details,omitempty"`
// Antigravity 废弃模型转发规则 (old_model_id -> new_model_id)
ModelForwardingRules
map
[
string
]
string
`json:"model_forwarding_rules,omitempty"`
// Antigravity 账号是否被上游禁止 (HTTP 403)
IsForbidden
bool
`json:"is_forbidden,omitempty"`
ForbiddenReason
string
`json:"forbidden_reason,omitempty"`
ForbiddenType
string
`json:"forbidden_type,omitempty"`
// "validation" / "violation" / "forbidden"
ValidationURL
string
`json:"validation_url,omitempty"`
// 验证/申诉链接
// 状态标记(从 ForbiddenType / HTTP 错误码推导)
NeedsVerify
bool
`json:"needs_verify,omitempty"`
// 需要人工验证(forbidden_type=validation)
IsBanned
bool
`json:"is_banned,omitempty"`
// 账号被封(forbidden_type=violation)
NeedsReauth
bool
`json:"needs_reauth,omitempty"`
// token 失效需重新授权(401)
// 错误码(机器可读):forbidden / unauthenticated / rate_limited / network_error
ErrorCode
string
`json:"error_code,omitempty"`
// 获取 usage 时的错误信息(降级返回,而非 500)
Error
string
`json:"error,omitempty"`
}
// ClaudeUsageResponse Anthropic API返回的usage结构
...
...
@@ -648,10 +690,11 @@ func (s *AccountUsageService) getAntigravityUsage(ctx context.Context, account *
return
&
UsageInfo
{
UpdatedAt
:
&
now
},
nil
}
// 1. 检查缓存
(10 分钟)
// 1. 检查缓存
if
cached
,
ok
:=
s
.
cache
.
antigravityCache
.
Load
(
account
.
ID
);
ok
{
if
cache
,
ok
:=
cached
.
(
*
antigravityUsageCache
);
ok
&&
time
.
Since
(
cache
.
timestamp
)
<
apiCacheTTL
{
// 重新计算 RemainingSeconds
if
cache
,
ok
:=
cached
.
(
*
antigravityUsageCache
);
ok
{
ttl
:=
antigravityCacheTTL
(
cache
.
usageInfo
)
if
time
.
Since
(
cache
.
timestamp
)
<
ttl
{
usage
:=
cache
.
usageInfo
if
usage
.
FiveHour
!=
nil
&&
usage
.
FiveHour
.
ResetsAt
!=
nil
{
usage
.
FiveHour
.
RemainingSeconds
=
int
(
time
.
Until
(
*
usage
.
FiveHour
.
ResetsAt
)
.
Seconds
())
...
...
@@ -659,23 +702,145 @@ func (s *AccountUsageService) getAntigravityUsage(ctx context.Context, account *
return
usage
,
nil
}
}
}
// 2. singleflight 防止并发击穿
flightKey
:=
fmt
.
Sprintf
(
"ag-usage:%d"
,
account
.
ID
)
result
,
flightErr
,
_
:=
s
.
cache
.
antigravityFlight
.
Do
(
flightKey
,
func
()
(
any
,
error
)
{
// 再次检查缓存(等待期间可能已被填充)
if
cached
,
ok
:=
s
.
cache
.
antigravityCache
.
Load
(
account
.
ID
);
ok
{
if
cache
,
ok
:=
cached
.
(
*
antigravityUsageCache
);
ok
{
ttl
:=
antigravityCacheTTL
(
cache
.
usageInfo
)
if
time
.
Since
(
cache
.
timestamp
)
<
ttl
{
usage
:=
cache
.
usageInfo
// 重新计算 RemainingSeconds,避免返回过时的剩余秒数
recalcAntigravityRemainingSeconds
(
usage
)
return
usage
,
nil
}
}
}
// 2. 获取代理 URL
proxyURL
:=
s
.
antigravityQuotaFetcher
.
GetProxyURL
(
ctx
,
account
)
// 使用独立 context,避免调用方 cancel 导致所有共享 flight 的请求失败
fetchCtx
,
fetchCancel
:=
context
.
WithTimeout
(
context
.
Background
(),
30
*
time
.
Second
)
defer
fetchCancel
()
// 3. 调用 API 获取额度
r
esult
,
err
:=
s
.
antigravityQuotaFetcher
.
FetchQuota
(
c
tx
,
account
,
proxyURL
)
proxyURL
:=
s
.
antigravityQuotaFetcher
.
GetProxyURL
(
fetchCtx
,
account
)
fetchR
esult
,
err
:=
s
.
antigravityQuotaFetcher
.
FetchQuota
(
fetchC
tx
,
account
,
proxyURL
)
if
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"fetch antigravity quota failed: %w"
,
err
)
degraded
:=
buildAntigravityDegradedUsage
(
err
)
enrichUsageWithAccountError
(
degraded
,
account
)
s
.
cache
.
antigravityCache
.
Store
(
account
.
ID
,
&
antigravityUsageCache
{
usageInfo
:
degraded
,
timestamp
:
time
.
Now
(),
})
return
degraded
,
nil
}
// 4. 缓存结果
enrichUsageWithAccountError
(
fetchResult
.
UsageInfo
,
account
)
s
.
cache
.
antigravityCache
.
Store
(
account
.
ID
,
&
antigravityUsageCache
{
usageInfo
:
r
esult
.
UsageInfo
,
usageInfo
:
fetchR
esult
.
UsageInfo
,
timestamp
:
time
.
Now
(),
})
return
fetchResult
.
UsageInfo
,
nil
})
if
flightErr
!=
nil
{
return
nil
,
flightErr
}
usage
,
ok
:=
result
.
(
*
UsageInfo
)
if
!
ok
||
usage
==
nil
{
now
:=
time
.
Now
()
return
&
UsageInfo
{
UpdatedAt
:
&
now
},
nil
}
return
usage
,
nil
}
return
result
.
UsageInfo
,
nil
// recalcAntigravityRemainingSeconds 重新计算 Antigravity UsageInfo 中各窗口的 RemainingSeconds
// 用于从缓存取出时更新倒计时,避免返回过时的剩余秒数
func
recalcAntigravityRemainingSeconds
(
info
*
UsageInfo
)
{
if
info
==
nil
{
return
}
if
info
.
FiveHour
!=
nil
&&
info
.
FiveHour
.
ResetsAt
!=
nil
{
remaining
:=
int
(
time
.
Until
(
*
info
.
FiveHour
.
ResetsAt
)
.
Seconds
())
if
remaining
<
0
{
remaining
=
0
}
info
.
FiveHour
.
RemainingSeconds
=
remaining
}
}
// antigravityCacheTTL 根据 UsageInfo 内容决定缓存 TTL
// 403 forbidden 状态稳定,缓存与成功相同(3 分钟);
// 其他错误(401/网络)可能快速恢复,缓存 1 分钟。
func
antigravityCacheTTL
(
info
*
UsageInfo
)
time
.
Duration
{
if
info
==
nil
{
return
antigravityErrorTTL
}
if
info
.
IsForbidden
{
return
apiCacheTTL
// 封号/验证状态不会很快变
}
if
info
.
ErrorCode
!=
""
||
info
.
Error
!=
""
{
return
antigravityErrorTTL
}
return
apiCacheTTL
}
// buildAntigravityDegradedUsage 从 FetchQuota 错误构建降级 UsageInfo
func
buildAntigravityDegradedUsage
(
err
error
)
*
UsageInfo
{
now
:=
time
.
Now
()
errMsg
:=
fmt
.
Sprintf
(
"usage API error: %v"
,
err
)
slog
.
Warn
(
"antigravity usage fetch failed, returning degraded response"
,
"error"
,
err
)
info
:=
&
UsageInfo
{
UpdatedAt
:
&
now
,
Error
:
errMsg
,
}
// 从错误信息推断 error_code 和状态标记
// 错误格式来自 antigravity/client.go: "fetchAvailableModels 失败 (HTTP %d): ..."
errStr
:=
err
.
Error
()
switch
{
case
strings
.
Contains
(
errStr
,
"HTTP 401"
)
||
strings
.
Contains
(
errStr
,
"UNAUTHENTICATED"
)
||
strings
.
Contains
(
errStr
,
"invalid_grant"
)
:
info
.
ErrorCode
=
errorCodeUnauthenticated
info
.
NeedsReauth
=
true
case
strings
.
Contains
(
errStr
,
"HTTP 429"
)
:
info
.
ErrorCode
=
errorCodeRateLimited
default
:
info
.
ErrorCode
=
errorCodeNetworkError
}
return
info
}
// enrichUsageWithAccountError 结合账号错误状态修正 UsageInfo
// 场景 1(成功路径):FetchAvailableModels 正常返回,但账号已因 403 被标记为 error,
//
// 需要在正常 usage 数据上附加 forbidden/validation 信息。
//
// 场景 2(降级路径):被封号的账号 OAuth token 失效,FetchAvailableModels 返回 401,
//
// 降级逻辑设置了 needs_reauth,但账号实际是 403 封号/需验证,需覆盖为正确状态。
func
enrichUsageWithAccountError
(
info
*
UsageInfo
,
account
*
Account
)
{
if
info
==
nil
||
account
==
nil
||
account
.
Status
!=
StatusError
{
return
}
msg
:=
strings
.
ToLower
(
account
.
ErrorMessage
)
if
!
strings
.
Contains
(
msg
,
"403"
)
&&
!
strings
.
Contains
(
msg
,
"forbidden"
)
&&
!
strings
.
Contains
(
msg
,
"violation"
)
&&
!
strings
.
Contains
(
msg
,
"validation"
)
{
return
}
fbType
:=
classifyForbiddenType
(
account
.
ErrorMessage
)
info
.
IsForbidden
=
true
info
.
ForbiddenType
=
fbType
info
.
ForbiddenReason
=
account
.
ErrorMessage
info
.
NeedsVerify
=
fbType
==
forbiddenTypeValidation
info
.
IsBanned
=
fbType
==
forbiddenTypeViolation
info
.
ValidationURL
=
extractValidationURL
(
account
.
ErrorMessage
)
info
.
ErrorCode
=
errorCodeForbidden
info
.
NeedsReauth
=
false
}
// addWindowStats 为 usage 数据添加窗口期统计
...
...
backend/internal/service/antigravity_quota_fetcher.go
View file @
fc00a4e3
...
...
@@ -2,12 +2,29 @@ package service
import
(
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"regexp"
"strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
)
const
(
forbiddenTypeValidation
=
"validation"
forbiddenTypeViolation
=
"violation"
forbiddenTypeForbidden
=
"forbidden"
// 机器可读的错误码
errorCodeForbidden
=
"forbidden"
errorCodeUnauthenticated
=
"unauthenticated"
errorCodeRateLimited
=
"rate_limited"
errorCodeNetworkError
=
"network_error"
)
// AntigravityQuotaFetcher 从 Antigravity API 获取额度
type
AntigravityQuotaFetcher
struct
{
proxyRepo
ProxyRepository
...
...
@@ -40,11 +57,32 @@ func (f *AntigravityQuotaFetcher) FetchQuota(ctx context.Context, account *Accou
// 调用 API 获取配额
modelsResp
,
modelsRaw
,
err
:=
client
.
FetchAvailableModels
(
ctx
,
accessToken
,
projectID
)
if
err
!=
nil
{
// 403 Forbidden: 不报错,返回 is_forbidden 标记
var
forbiddenErr
*
antigravity
.
ForbiddenError
if
errors
.
As
(
err
,
&
forbiddenErr
)
{
now
:=
time
.
Now
()
fbType
:=
classifyForbiddenType
(
forbiddenErr
.
Body
)
return
&
QuotaResult
{
UsageInfo
:
&
UsageInfo
{
UpdatedAt
:
&
now
,
IsForbidden
:
true
,
ForbiddenReason
:
forbiddenErr
.
Body
,
ForbiddenType
:
fbType
,
ValidationURL
:
extractValidationURL
(
forbiddenErr
.
Body
),
NeedsVerify
:
fbType
==
forbiddenTypeValidation
,
IsBanned
:
fbType
==
forbiddenTypeViolation
,
ErrorCode
:
errorCodeForbidden
,
},
},
nil
}
return
nil
,
err
}
// 调用 LoadCodeAssist 获取订阅等级(非关键路径,失败不影响主流程)
tierRaw
,
tierNormalized
:=
f
.
fetchSubscriptionTier
(
ctx
,
client
,
accessToken
)
// 转换为 UsageInfo
usageInfo
:=
f
.
buildUsageInfo
(
modelsResp
)
usageInfo
:=
f
.
buildUsageInfo
(
modelsResp
,
tierRaw
,
tierNormalized
)
return
&
QuotaResult
{
UsageInfo
:
usageInfo
,
...
...
@@ -52,15 +90,52 @@ func (f *AntigravityQuotaFetcher) FetchQuota(ctx context.Context, account *Accou
},
nil
}
// fetchSubscriptionTier 获取账号订阅等级,失败返回空字符串
func
(
f
*
AntigravityQuotaFetcher
)
fetchSubscriptionTier
(
ctx
context
.
Context
,
client
*
antigravity
.
Client
,
accessToken
string
)
(
raw
,
normalized
string
)
{
loadResp
,
_
,
err
:=
client
.
LoadCodeAssist
(
ctx
,
accessToken
)
if
err
!=
nil
{
slog
.
Warn
(
"failed to fetch subscription tier"
,
"error"
,
err
)
return
""
,
""
}
if
loadResp
==
nil
{
return
""
,
""
}
raw
=
loadResp
.
GetTier
()
// 已有方法:paidTier > currentTier
normalized
=
normalizeTier
(
raw
)
return
raw
,
normalized
}
// normalizeTier 将原始 tier 字符串归一化为 FREE/PRO/ULTRA/UNKNOWN
func
normalizeTier
(
raw
string
)
string
{
if
raw
==
""
{
return
""
}
lower
:=
strings
.
ToLower
(
raw
)
switch
{
case
strings
.
Contains
(
lower
,
"ultra"
)
:
return
"ULTRA"
case
strings
.
Contains
(
lower
,
"pro"
)
:
return
"PRO"
case
strings
.
Contains
(
lower
,
"free"
)
:
return
"FREE"
default
:
return
"UNKNOWN"
}
}
// buildUsageInfo 将 API 响应转换为 UsageInfo
func
(
f
*
AntigravityQuotaFetcher
)
buildUsageInfo
(
modelsResp
*
antigravity
.
FetchAvailableModelsResponse
)
*
UsageInfo
{
func
(
f
*
AntigravityQuotaFetcher
)
buildUsageInfo
(
modelsResp
*
antigravity
.
FetchAvailableModelsResponse
,
tierRaw
,
tierNormalized
string
)
*
UsageInfo
{
now
:=
time
.
Now
()
info
:=
&
UsageInfo
{
UpdatedAt
:
&
now
,
AntigravityQuota
:
make
(
map
[
string
]
*
AntigravityModelQuota
),
AntigravityQuotaDetails
:
make
(
map
[
string
]
*
AntigravityModelDetail
),
SubscriptionTier
:
tierNormalized
,
SubscriptionTierRaw
:
tierRaw
,
}
// 遍历所有模型,填充 AntigravityQuota
// 遍历所有模型,填充 AntigravityQuota
和 AntigravityQuotaDetails
for
modelName
,
modelInfo
:=
range
modelsResp
.
Models
{
if
modelInfo
.
QuotaInfo
==
nil
{
continue
...
...
@@ -73,6 +148,27 @@ func (f *AntigravityQuotaFetcher) buildUsageInfo(modelsResp *antigravity.FetchAv
Utilization
:
utilization
,
ResetTime
:
modelInfo
.
QuotaInfo
.
ResetTime
,
}
// 填充模型详细能力信息
detail
:=
&
AntigravityModelDetail
{
DisplayName
:
modelInfo
.
DisplayName
,
SupportsImages
:
modelInfo
.
SupportsImages
,
SupportsThinking
:
modelInfo
.
SupportsThinking
,
ThinkingBudget
:
modelInfo
.
ThinkingBudget
,
Recommended
:
modelInfo
.
Recommended
,
MaxTokens
:
modelInfo
.
MaxTokens
,
MaxOutputTokens
:
modelInfo
.
MaxOutputTokens
,
SupportedMimeTypes
:
modelInfo
.
SupportedMimeTypes
,
}
info
.
AntigravityQuotaDetails
[
modelName
]
=
detail
}
// 废弃模型转发规则
if
len
(
modelsResp
.
DeprecatedModelIDs
)
>
0
{
info
.
ModelForwardingRules
=
make
(
map
[
string
]
string
,
len
(
modelsResp
.
DeprecatedModelIDs
))
for
oldID
,
deprecated
:=
range
modelsResp
.
DeprecatedModelIDs
{
info
.
ModelForwardingRules
[
oldID
]
=
deprecated
.
NewModelID
}
}
// 同时设置 FiveHour 用于兼容展示(取主要模型)
...
...
@@ -108,3 +204,58 @@ func (f *AntigravityQuotaFetcher) GetProxyURL(ctx context.Context, account *Acco
}
return
proxy
.
URL
()
}
// classifyForbiddenType 根据 403 响应体判断禁止类型
func
classifyForbiddenType
(
body
string
)
string
{
lower
:=
strings
.
ToLower
(
body
)
switch
{
case
strings
.
Contains
(
lower
,
"validation_required"
)
||
strings
.
Contains
(
lower
,
"verify your account"
)
||
strings
.
Contains
(
lower
,
"validation_url"
)
:
return
forbiddenTypeValidation
case
strings
.
Contains
(
lower
,
"terms of service"
)
||
strings
.
Contains
(
lower
,
"violation"
)
:
return
forbiddenTypeViolation
default
:
return
forbiddenTypeForbidden
}
}
// urlPattern 用于从 403 响应体中提取 URL(降级方案)
var
urlPattern
=
regexp
.
MustCompile
(
`https://[^\s"'\\]+`
)
// extractValidationURL 从 403 响应 JSON 中提取验证/申诉链接
func
extractValidationURL
(
body
string
)
string
{
// 1. 尝试结构化 JSON 提取: /error/details[*]/metadata/validation_url 或 appeal_url
var
parsed
struct
{
Error
struct
{
Details
[]
struct
{
Metadata
map
[
string
]
string
`json:"metadata"`
}
`json:"details"`
}
`json:"error"`
}
if
json
.
Unmarshal
([]
byte
(
body
),
&
parsed
)
==
nil
{
for
_
,
detail
:=
range
parsed
.
Error
.
Details
{
if
u
:=
detail
.
Metadata
[
"validation_url"
];
u
!=
""
{
return
u
}
if
u
:=
detail
.
Metadata
[
"appeal_url"
];
u
!=
""
{
return
u
}
}
}
// 2. 降级:正则匹配 URL
lower
:=
strings
.
ToLower
(
body
)
if
!
strings
.
Contains
(
lower
,
"validation"
)
&&
!
strings
.
Contains
(
lower
,
"verify"
)
&&
!
strings
.
Contains
(
lower
,
"appeal"
)
{
return
""
}
// 先解码常见转义再匹配
normalized
:=
strings
.
ReplaceAll
(
body
,
`\u0026`
,
"&"
)
if
m
:=
urlPattern
.
FindString
(
normalized
);
m
!=
""
{
return
m
}
return
""
}
backend/internal/service/antigravity_quota_fetcher_test.go
0 → 100644
View file @
fc00a4e3
//go:build unit
package
service
import
(
"errors"
"testing"
"github.com/stretchr/testify/require"
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
)
// ---------------------------------------------------------------------------
// normalizeTier
// ---------------------------------------------------------------------------
func
TestNormalizeTier
(
t
*
testing
.
T
)
{
tests
:=
[]
struct
{
name
string
raw
string
expected
string
}{
{
name
:
"empty string"
,
raw
:
""
,
expected
:
""
},
{
name
:
"free-tier"
,
raw
:
"free-tier"
,
expected
:
"FREE"
},
{
name
:
"g1-pro-tier"
,
raw
:
"g1-pro-tier"
,
expected
:
"PRO"
},
{
name
:
"g1-ultra-tier"
,
raw
:
"g1-ultra-tier"
,
expected
:
"ULTRA"
},
{
name
:
"unknown-something"
,
raw
:
"unknown-something"
,
expected
:
"UNKNOWN"
},
{
name
:
"Google AI Pro contains pro keyword"
,
raw
:
"Google AI Pro"
,
expected
:
"PRO"
},
{
name
:
"case insensitive FREE"
,
raw
:
"FREE-TIER"
,
expected
:
"FREE"
},
{
name
:
"case insensitive Ultra"
,
raw
:
"Ultra Plan"
,
expected
:
"ULTRA"
},
{
name
:
"arbitrary unrecognized string"
,
raw
:
"enterprise-custom"
,
expected
:
"UNKNOWN"
},
}
for
_
,
tt
:=
range
tests
{
t
.
Run
(
tt
.
name
,
func
(
t
*
testing
.
T
)
{
got
:=
normalizeTier
(
tt
.
raw
)
require
.
Equal
(
t
,
tt
.
expected
,
got
,
"normalizeTier(%q)"
,
tt
.
raw
)
})
}
}
// ---------------------------------------------------------------------------
// buildUsageInfo
// ---------------------------------------------------------------------------
func
aqfBoolPtr
(
v
bool
)
*
bool
{
return
&
v
}
func
aqfIntPtr
(
v
int
)
*
int
{
return
&
v
}
func
TestBuildUsageInfo_BasicModels
(
t
*
testing
.
T
)
{
fetcher
:=
&
AntigravityQuotaFetcher
{}
modelsResp
:=
&
antigravity
.
FetchAvailableModelsResponse
{
Models
:
map
[
string
]
antigravity
.
ModelInfo
{
"claude-sonnet-4-20250514"
:
{
QuotaInfo
:
&
antigravity
.
ModelQuotaInfo
{
RemainingFraction
:
0.75
,
ResetTime
:
"2026-03-08T12:00:00Z"
,
},
DisplayName
:
"Claude Sonnet 4"
,
SupportsImages
:
aqfBoolPtr
(
true
),
SupportsThinking
:
aqfBoolPtr
(
false
),
ThinkingBudget
:
aqfIntPtr
(
0
),
Recommended
:
aqfBoolPtr
(
true
),
MaxTokens
:
aqfIntPtr
(
200000
),
MaxOutputTokens
:
aqfIntPtr
(
16384
),
SupportedMimeTypes
:
map
[
string
]
bool
{
"image/png"
:
true
,
"image/jpeg"
:
true
,
},
},
"gemini-2.5-pro"
:
{
QuotaInfo
:
&
antigravity
.
ModelQuotaInfo
{
RemainingFraction
:
0.50
,
ResetTime
:
"2026-03-08T15:00:00Z"
,
},
DisplayName
:
"Gemini 2.5 Pro"
,
MaxTokens
:
aqfIntPtr
(
1000000
),
MaxOutputTokens
:
aqfIntPtr
(
65536
),
},
},
}
info
:=
fetcher
.
buildUsageInfo
(
modelsResp
,
"g1-pro-tier"
,
"PRO"
)
// 基本字段
require
.
NotNil
(
t
,
info
.
UpdatedAt
,
"UpdatedAt should be set"
)
require
.
Equal
(
t
,
"PRO"
,
info
.
SubscriptionTier
)
require
.
Equal
(
t
,
"g1-pro-tier"
,
info
.
SubscriptionTierRaw
)
// AntigravityQuota
require
.
Len
(
t
,
info
.
AntigravityQuota
,
2
)
sonnetQuota
:=
info
.
AntigravityQuota
[
"claude-sonnet-4-20250514"
]
require
.
NotNil
(
t
,
sonnetQuota
)
require
.
Equal
(
t
,
25
,
sonnetQuota
.
Utilization
)
// (1 - 0.75) * 100 = 25
require
.
Equal
(
t
,
"2026-03-08T12:00:00Z"
,
sonnetQuota
.
ResetTime
)
geminiQuota
:=
info
.
AntigravityQuota
[
"gemini-2.5-pro"
]
require
.
NotNil
(
t
,
geminiQuota
)
require
.
Equal
(
t
,
50
,
geminiQuota
.
Utilization
)
// (1 - 0.50) * 100 = 50
require
.
Equal
(
t
,
"2026-03-08T15:00:00Z"
,
geminiQuota
.
ResetTime
)
// AntigravityQuotaDetails
require
.
Len
(
t
,
info
.
AntigravityQuotaDetails
,
2
)
sonnetDetail
:=
info
.
AntigravityQuotaDetails
[
"claude-sonnet-4-20250514"
]
require
.
NotNil
(
t
,
sonnetDetail
)
require
.
Equal
(
t
,
"Claude Sonnet 4"
,
sonnetDetail
.
DisplayName
)
require
.
Equal
(
t
,
aqfBoolPtr
(
true
),
sonnetDetail
.
SupportsImages
)
require
.
Equal
(
t
,
aqfBoolPtr
(
false
),
sonnetDetail
.
SupportsThinking
)
require
.
Equal
(
t
,
aqfIntPtr
(
0
),
sonnetDetail
.
ThinkingBudget
)
require
.
Equal
(
t
,
aqfBoolPtr
(
true
),
sonnetDetail
.
Recommended
)
require
.
Equal
(
t
,
aqfIntPtr
(
200000
),
sonnetDetail
.
MaxTokens
)
require
.
Equal
(
t
,
aqfIntPtr
(
16384
),
sonnetDetail
.
MaxOutputTokens
)
require
.
Equal
(
t
,
map
[
string
]
bool
{
"image/png"
:
true
,
"image/jpeg"
:
true
},
sonnetDetail
.
SupportedMimeTypes
)
geminiDetail
:=
info
.
AntigravityQuotaDetails
[
"gemini-2.5-pro"
]
require
.
NotNil
(
t
,
geminiDetail
)
require
.
Equal
(
t
,
"Gemini 2.5 Pro"
,
geminiDetail
.
DisplayName
)
require
.
Nil
(
t
,
geminiDetail
.
SupportsImages
)
require
.
Nil
(
t
,
geminiDetail
.
SupportsThinking
)
require
.
Equal
(
t
,
aqfIntPtr
(
1000000
),
geminiDetail
.
MaxTokens
)
require
.
Equal
(
t
,
aqfIntPtr
(
65536
),
geminiDetail
.
MaxOutputTokens
)
}
func
TestBuildUsageInfo_DeprecatedModels
(
t
*
testing
.
T
)
{
fetcher
:=
&
AntigravityQuotaFetcher
{}
modelsResp
:=
&
antigravity
.
FetchAvailableModelsResponse
{
Models
:
map
[
string
]
antigravity
.
ModelInfo
{
"claude-sonnet-4-20250514"
:
{
QuotaInfo
:
&
antigravity
.
ModelQuotaInfo
{
RemainingFraction
:
1.0
,
},
},
},
DeprecatedModelIDs
:
map
[
string
]
antigravity
.
DeprecatedModelInfo
{
"claude-3-sonnet-20240229"
:
{
NewModelID
:
"claude-sonnet-4-20250514"
},
"claude-3-haiku-20240307"
:
{
NewModelID
:
"claude-haiku-3.5-latest"
},
},
}
info
:=
fetcher
.
buildUsageInfo
(
modelsResp
,
""
,
""
)
require
.
Len
(
t
,
info
.
ModelForwardingRules
,
2
)
require
.
Equal
(
t
,
"claude-sonnet-4-20250514"
,
info
.
ModelForwardingRules
[
"claude-3-sonnet-20240229"
])
require
.
Equal
(
t
,
"claude-haiku-3.5-latest"
,
info
.
ModelForwardingRules
[
"claude-3-haiku-20240307"
])
}
func
TestBuildUsageInfo_NoDeprecatedModels
(
t
*
testing
.
T
)
{
fetcher
:=
&
AntigravityQuotaFetcher
{}
modelsResp
:=
&
antigravity
.
FetchAvailableModelsResponse
{
Models
:
map
[
string
]
antigravity
.
ModelInfo
{
"some-model"
:
{
QuotaInfo
:
&
antigravity
.
ModelQuotaInfo
{
RemainingFraction
:
0.9
},
},
},
}
info
:=
fetcher
.
buildUsageInfo
(
modelsResp
,
""
,
""
)
require
.
Nil
(
t
,
info
.
ModelForwardingRules
,
"ModelForwardingRules should be nil when no deprecated models"
)
}
func
TestBuildUsageInfo_EmptyModels
(
t
*
testing
.
T
)
{
fetcher
:=
&
AntigravityQuotaFetcher
{}
modelsResp
:=
&
antigravity
.
FetchAvailableModelsResponse
{
Models
:
map
[
string
]
antigravity
.
ModelInfo
{},
}
info
:=
fetcher
.
buildUsageInfo
(
modelsResp
,
""
,
""
)
require
.
NotNil
(
t
,
info
)
require
.
NotNil
(
t
,
info
.
AntigravityQuota
)
require
.
Empty
(
t
,
info
.
AntigravityQuota
)
require
.
NotNil
(
t
,
info
.
AntigravityQuotaDetails
)
require
.
Empty
(
t
,
info
.
AntigravityQuotaDetails
)
require
.
Nil
(
t
,
info
.
FiveHour
,
"FiveHour should be nil when no priority model exists"
)
}
func
TestBuildUsageInfo_ModelWithNilQuotaInfo
(
t
*
testing
.
T
)
{
fetcher
:=
&
AntigravityQuotaFetcher
{}
modelsResp
:=
&
antigravity
.
FetchAvailableModelsResponse
{
Models
:
map
[
string
]
antigravity
.
ModelInfo
{
"model-without-quota"
:
{
DisplayName
:
"No Quota Model"
,
// QuotaInfo is nil
},
},
}
info
:=
fetcher
.
buildUsageInfo
(
modelsResp
,
""
,
""
)
require
.
NotNil
(
t
,
info
)
require
.
Empty
(
t
,
info
.
AntigravityQuota
,
"models with nil QuotaInfo should be skipped"
)
require
.
Empty
(
t
,
info
.
AntigravityQuotaDetails
,
"models with nil QuotaInfo should be skipped from details too"
)
}
func
TestBuildUsageInfo_FiveHourPriorityOrder
(
t
*
testing
.
T
)
{
fetcher
:=
&
AntigravityQuotaFetcher
{}
// priorityModels = ["claude-sonnet-4-20250514", "claude-sonnet-4", "gemini-2.5-pro"]
// When the first priority model exists, it should be used for FiveHour
modelsResp
:=
&
antigravity
.
FetchAvailableModelsResponse
{
Models
:
map
[
string
]
antigravity
.
ModelInfo
{
"gemini-2.5-pro"
:
{
QuotaInfo
:
&
antigravity
.
ModelQuotaInfo
{
RemainingFraction
:
0.40
,
ResetTime
:
"2026-03-08T18:00:00Z"
,
},
},
"claude-sonnet-4-20250514"
:
{
QuotaInfo
:
&
antigravity
.
ModelQuotaInfo
{
RemainingFraction
:
0.80
,
ResetTime
:
"2026-03-08T12:00:00Z"
,
},
},
},
}
info
:=
fetcher
.
buildUsageInfo
(
modelsResp
,
""
,
""
)
require
.
NotNil
(
t
,
info
.
FiveHour
,
"FiveHour should be set when a priority model exists"
)
// claude-sonnet-4-20250514 is first in priority list, so it should be used
expectedUtilization
:=
(
1.0
-
0.80
)
*
100
// 20
require
.
InDelta
(
t
,
expectedUtilization
,
info
.
FiveHour
.
Utilization
,
0.01
)
require
.
NotNil
(
t
,
info
.
FiveHour
.
ResetsAt
,
"ResetsAt should be parsed from ResetTime"
)
}
func
TestBuildUsageInfo_FiveHourFallbackToClaude4
(
t
*
testing
.
T
)
{
fetcher
:=
&
AntigravityQuotaFetcher
{}
// Only claude-sonnet-4 exists (second in priority list), not claude-sonnet-4-20250514
modelsResp
:=
&
antigravity
.
FetchAvailableModelsResponse
{
Models
:
map
[
string
]
antigravity
.
ModelInfo
{
"claude-sonnet-4"
:
{
QuotaInfo
:
&
antigravity
.
ModelQuotaInfo
{
RemainingFraction
:
0.60
,
ResetTime
:
"2026-03-08T14:00:00Z"
,
},
},
"gemini-2.5-pro"
:
{
QuotaInfo
:
&
antigravity
.
ModelQuotaInfo
{
RemainingFraction
:
0.30
,
},
},
},
}
info
:=
fetcher
.
buildUsageInfo
(
modelsResp
,
""
,
""
)
require
.
NotNil
(
t
,
info
.
FiveHour
)
expectedUtilization
:=
(
1.0
-
0.60
)
*
100
// 40
require
.
InDelta
(
t
,
expectedUtilization
,
info
.
FiveHour
.
Utilization
,
0.01
)
}
func
TestBuildUsageInfo_FiveHourFallbackToGemini
(
t
*
testing
.
T
)
{
fetcher
:=
&
AntigravityQuotaFetcher
{}
// Only gemini-2.5-pro exists (third in priority list)
modelsResp
:=
&
antigravity
.
FetchAvailableModelsResponse
{
Models
:
map
[
string
]
antigravity
.
ModelInfo
{
"gemini-2.5-pro"
:
{
QuotaInfo
:
&
antigravity
.
ModelQuotaInfo
{
RemainingFraction
:
0.30
,
},
},
"other-model"
:
{
QuotaInfo
:
&
antigravity
.
ModelQuotaInfo
{
RemainingFraction
:
0.90
,
},
},
},
}
info
:=
fetcher
.
buildUsageInfo
(
modelsResp
,
""
,
""
)
require
.
NotNil
(
t
,
info
.
FiveHour
)
expectedUtilization
:=
(
1.0
-
0.30
)
*
100
// 70
require
.
InDelta
(
t
,
expectedUtilization
,
info
.
FiveHour
.
Utilization
,
0.01
)
}
func
TestBuildUsageInfo_FiveHourNoPriorityModel
(
t
*
testing
.
T
)
{
fetcher
:=
&
AntigravityQuotaFetcher
{}
// None of the priority models exist
modelsResp
:=
&
antigravity
.
FetchAvailableModelsResponse
{
Models
:
map
[
string
]
antigravity
.
ModelInfo
{
"some-other-model"
:
{
QuotaInfo
:
&
antigravity
.
ModelQuotaInfo
{
RemainingFraction
:
0.50
,
},
},
},
}
info
:=
fetcher
.
buildUsageInfo
(
modelsResp
,
""
,
""
)
require
.
Nil
(
t
,
info
.
FiveHour
,
"FiveHour should be nil when no priority model exists"
)
}
func
TestBuildUsageInfo_FiveHourWithEmptyResetTime
(
t
*
testing
.
T
)
{
fetcher
:=
&
AntigravityQuotaFetcher
{}
modelsResp
:=
&
antigravity
.
FetchAvailableModelsResponse
{
Models
:
map
[
string
]
antigravity
.
ModelInfo
{
"claude-sonnet-4-20250514"
:
{
QuotaInfo
:
&
antigravity
.
ModelQuotaInfo
{
RemainingFraction
:
0.50
,
ResetTime
:
""
,
// empty reset time
},
},
},
}
info
:=
fetcher
.
buildUsageInfo
(
modelsResp
,
""
,
""
)
require
.
NotNil
(
t
,
info
.
FiveHour
)
require
.
Nil
(
t
,
info
.
FiveHour
.
ResetsAt
,
"ResetsAt should be nil when ResetTime is empty"
)
require
.
Equal
(
t
,
0
,
info
.
FiveHour
.
RemainingSeconds
)
}
func
TestBuildUsageInfo_FullUtilization
(
t
*
testing
.
T
)
{
fetcher
:=
&
AntigravityQuotaFetcher
{}
modelsResp
:=
&
antigravity
.
FetchAvailableModelsResponse
{
Models
:
map
[
string
]
antigravity
.
ModelInfo
{
"claude-sonnet-4-20250514"
:
{
QuotaInfo
:
&
antigravity
.
ModelQuotaInfo
{
RemainingFraction
:
0.0
,
// fully used
ResetTime
:
"2026-03-08T12:00:00Z"
,
},
},
},
}
info
:=
fetcher
.
buildUsageInfo
(
modelsResp
,
""
,
""
)
quota
:=
info
.
AntigravityQuota
[
"claude-sonnet-4-20250514"
]
require
.
NotNil
(
t
,
quota
)
require
.
Equal
(
t
,
100
,
quota
.
Utilization
)
}
func
TestBuildUsageInfo_ZeroUtilization
(
t
*
testing
.
T
)
{
fetcher
:=
&
AntigravityQuotaFetcher
{}
modelsResp
:=
&
antigravity
.
FetchAvailableModelsResponse
{
Models
:
map
[
string
]
antigravity
.
ModelInfo
{
"claude-sonnet-4-20250514"
:
{
QuotaInfo
:
&
antigravity
.
ModelQuotaInfo
{
RemainingFraction
:
1.0
,
// fully available
},
},
},
}
info
:=
fetcher
.
buildUsageInfo
(
modelsResp
,
""
,
""
)
quota
:=
info
.
AntigravityQuota
[
"claude-sonnet-4-20250514"
]
require
.
NotNil
(
t
,
quota
)
require
.
Equal
(
t
,
0
,
quota
.
Utilization
)
}
func
TestFetchQuota_ForbiddenReturnsIsForbidden
(
t
*
testing
.
T
)
{
// 模拟 FetchQuota 遇到 403 时的行为:
// FetchAvailableModels 返回 ForbiddenError → FetchQuota 应返回 is_forbidden=true
forbiddenErr
:=
&
antigravity
.
ForbiddenError
{
StatusCode
:
403
,
Body
:
"Access denied"
,
}
// 验证 ForbiddenError 满足 errors.As
var
target
*
antigravity
.
ForbiddenError
require
.
True
(
t
,
errors
.
As
(
forbiddenErr
,
&
target
))
require
.
Equal
(
t
,
403
,
target
.
StatusCode
)
require
.
Equal
(
t
,
"Access denied"
,
target
.
Body
)
require
.
Contains
(
t
,
forbiddenErr
.
Error
(),
"403"
)
}
// ---------------------------------------------------------------------------
// classifyForbiddenType
// ---------------------------------------------------------------------------
func
TestClassifyForbiddenType
(
t
*
testing
.
T
)
{
tests
:=
[]
struct
{
name
string
body
string
expected
string
}{
{
name
:
"VALIDATION_REQUIRED keyword"
,
body
:
`{"error":{"message":"VALIDATION_REQUIRED"}}`
,
expected
:
"validation"
,
},
{
name
:
"verify your account"
,
body
:
`Please verify your account to continue`
,
expected
:
"validation"
,
},
{
name
:
"contains validation_url field"
,
body
:
`{"error":{"details":[{"metadata":{"validation_url":"https://..."}}]}}`
,
expected
:
"validation"
,
},
{
name
:
"terms of service violation"
,
body
:
`Your account has been suspended for Terms of Service violation`
,
expected
:
"violation"
,
},
{
name
:
"violation keyword"
,
body
:
`Account suspended due to policy violation`
,
expected
:
"violation"
,
},
{
name
:
"generic 403"
,
body
:
`Access denied`
,
expected
:
"forbidden"
,
},
{
name
:
"empty body"
,
body
:
""
,
expected
:
"forbidden"
,
},
}
for
_
,
tt
:=
range
tests
{
t
.
Run
(
tt
.
name
,
func
(
t
*
testing
.
T
)
{
got
:=
classifyForbiddenType
(
tt
.
body
)
require
.
Equal
(
t
,
tt
.
expected
,
got
)
})
}
}
// ---------------------------------------------------------------------------
// extractValidationURL
// ---------------------------------------------------------------------------
func
TestExtractValidationURL
(
t
*
testing
.
T
)
{
tests
:=
[]
struct
{
name
string
body
string
expected
string
}{
{
name
:
"structured validation_url"
,
body
:
`{"error":{"details":[{"metadata":{"validation_url":"https://accounts.google.com/verify?token=abc"}}]}}`
,
expected
:
"https://accounts.google.com/verify?token=abc"
,
},
{
name
:
"structured appeal_url"
,
body
:
`{"error":{"details":[{"metadata":{"appeal_url":"https://support.google.com/appeal/123"}}]}}`
,
expected
:
"https://support.google.com/appeal/123"
,
},
{
name
:
"validation_url takes priority over appeal_url"
,
body
:
`{"error":{"details":[{"metadata":{"validation_url":"https://v.com","appeal_url":"https://a.com"}}]}}`
,
expected
:
"https://v.com"
,
},
{
name
:
"fallback regex with verify keyword"
,
body
:
`Please verify your account at https://accounts.google.com/verify`
,
expected
:
"https://accounts.google.com/verify"
,
},
{
name
:
"no URL in generic forbidden"
,
body
:
`Access denied`
,
expected
:
""
,
},
{
name
:
"empty body"
,
body
:
""
,
expected
:
""
,
},
{
name
:
"URL present but no validation keywords"
,
body
:
`Error at https://example.com/something`
,
expected
:
""
,
},
{
name
:
"unicode escaped ampersand"
,
body
:
`validation required: https://accounts.google.com/verify?a=1\u0026b=2`
,
expected
:
"https://accounts.google.com/verify?a=1&b=2"
,
},
}
for
_
,
tt
:=
range
tests
{
t
.
Run
(
tt
.
name
,
func
(
t
*
testing
.
T
)
{
got
:=
extractValidationURL
(
tt
.
body
)
require
.
Equal
(
t
,
tt
.
expected
,
got
)
})
}
}
backend/internal/service/error_policy_test.go
View file @
fc00a4e3
...
...
@@ -110,7 +110,9 @@ func TestCheckErrorPolicy(t *testing.T) {
expected
:
ErrorPolicyTempUnscheduled
,
},
{
name
:
"temp_unschedulable_401_second_hit_upgrades_to_none"
,
// Antigravity 401 不走升级逻辑(由 applyErrorPolicy 的 temp_unschedulable_rules 自行控制),
// second hit 仍然返回 TempUnscheduled。
name
:
"temp_unschedulable_401_second_hit_antigravity_stays_temp"
,
account
:
&
Account
{
ID
:
15
,
Type
:
AccountTypeOAuth
,
...
...
@@ -129,7 +131,7 @@ func TestCheckErrorPolicy(t *testing.T) {
},
statusCode
:
401
,
body
:
[]
byte
(
`unauthorized`
),
expected
:
ErrorPolicy
None
,
expected
:
ErrorPolicy
TempUnscheduled
,
},
{
name
:
"temp_unschedulable_body_miss_returns_none"
,
...
...
backend/internal/service/ratelimit_service.go
View file @
fc00a4e3
...
...
@@ -149,8 +149,9 @@ func (s *RateLimitService) HandleUpstreamError(ctx context.Context, account *Acc
}
// 其他 400 错误(如参数问题)不处理,不禁用账号
case
401
:
// 对所有 OAuth 账号在 401 错误时调用缓存失效并强制下次刷新
if
account
.
Type
==
AccountTypeOAuth
{
// OAuth 账号在 401 错误时临时不可调度(给 token 刷新窗口);非 OAuth 账号保持原有 SetError 行为。
// Antigravity 除外:其 401 由 applyErrorPolicy 的 temp_unschedulable_rules 自行控制。
if
account
.
Type
==
AccountTypeOAuth
&&
account
.
Platform
!=
PlatformAntigravity
{
// 1. 失效缓存
if
s
.
tokenCacheInvalidator
!=
nil
{
if
err
:=
s
.
tokenCacheInvalidator
.
InvalidateToken
(
ctx
,
account
);
err
!=
nil
{
...
...
@@ -182,7 +183,7 @@ func (s *RateLimitService) HandleUpstreamError(ctx context.Context, account *Acc
}
shouldDisable
=
true
}
else
{
// 非 OAuth
账号(APIKey)
:保持
原有
SetError 行为
// 非 OAuth
/ Antigravity OAuth
:保持 SetError 行为
msg
:=
"Authentication failed (401): invalid or expired credentials"
if
upstreamMsg
!=
""
{
msg
=
"Authentication failed (401): "
+
upstreamMsg
...
...
@@ -199,11 +200,6 @@ func (s *RateLimitService) HandleUpstreamError(ctx context.Context, account *Acc
s
.
handleAuthError
(
ctx
,
account
,
msg
)
shouldDisable
=
true
case
403
:
// 禁止访问:停止调度,记录错误
msg
:=
"Access forbidden (403): account may be suspended or lack permissions"
if
upstreamMsg
!=
""
{
msg
=
"Access forbidden (403): "
+
upstreamMsg
}
logger
.
LegacyPrintf
(
"service.ratelimit"
,
"[HandleUpstreamErrorRaw] account_id=%d platform=%s type=%s status=403 request_id=%s cf_ray=%s upstream_msg=%s raw_body=%s"
,
...
...
@@ -215,8 +211,7 @@ func (s *RateLimitService) HandleUpstreamError(ctx context.Context, account *Acc
upstreamMsg
,
truncateForLog
(
responseBody
,
1024
),
)
s
.
handleAuthError
(
ctx
,
account
,
msg
)
shouldDisable
=
true
shouldDisable
=
s
.
handle403
(
ctx
,
account
,
upstreamMsg
,
responseBody
)
case
429
:
s
.
handle429
(
ctx
,
account
,
headers
,
responseBody
)
shouldDisable
=
false
...
...
@@ -621,6 +616,62 @@ func (s *RateLimitService) handleAuthError(ctx context.Context, account *Account
slog
.
Warn
(
"account_disabled_auth_error"
,
"account_id"
,
account
.
ID
,
"error"
,
errorMsg
)
}
// handle403 处理 403 Forbidden 错误
// Antigravity 平台区分 validation/violation/generic 三种类型,均 SetError 永久禁用;
// 其他平台保持原有 SetError 行为。
func
(
s
*
RateLimitService
)
handle403
(
ctx
context
.
Context
,
account
*
Account
,
upstreamMsg
string
,
responseBody
[]
byte
)
(
shouldDisable
bool
)
{
if
account
.
Platform
==
PlatformAntigravity
{
return
s
.
handleAntigravity403
(
ctx
,
account
,
upstreamMsg
,
responseBody
)
}
// 非 Antigravity 平台:保持原有行为
msg
:=
"Access forbidden (403): account may be suspended or lack permissions"
if
upstreamMsg
!=
""
{
msg
=
"Access forbidden (403): "
+
upstreamMsg
}
s
.
handleAuthError
(
ctx
,
account
,
msg
)
return
true
}
// handleAntigravity403 处理 Antigravity 平台的 403 错误
// validation(需要验证)→ 永久 SetError(需人工去 Google 验证后恢复)
// violation(违规封号)→ 永久 SetError(需人工处理)
// generic(通用禁止)→ 永久 SetError
func
(
s
*
RateLimitService
)
handleAntigravity403
(
ctx
context
.
Context
,
account
*
Account
,
upstreamMsg
string
,
responseBody
[]
byte
)
(
shouldDisable
bool
)
{
fbType
:=
classifyForbiddenType
(
string
(
responseBody
))
switch
fbType
{
case
forbiddenTypeValidation
:
// VALIDATION_REQUIRED: 永久禁用,需人工去 Google 验证后手动恢复
msg
:=
"Validation required (403): account needs Google verification"
if
upstreamMsg
!=
""
{
msg
=
"Validation required (403): "
+
upstreamMsg
}
if
validationURL
:=
extractValidationURL
(
string
(
responseBody
));
validationURL
!=
""
{
msg
+=
" | validation_url: "
+
validationURL
}
s
.
handleAuthError
(
ctx
,
account
,
msg
)
return
true
case
forbiddenTypeViolation
:
// 违规封号: 永久禁用,需人工处理
msg
:=
"Account violation (403): terms of service violation"
if
upstreamMsg
!=
""
{
msg
=
"Account violation (403): "
+
upstreamMsg
}
s
.
handleAuthError
(
ctx
,
account
,
msg
)
return
true
default
:
// 通用 403: 保持原有行为
msg
:=
"Access forbidden (403): account may be suspended or lack permissions"
if
upstreamMsg
!=
""
{
msg
=
"Access forbidden (403): "
+
upstreamMsg
}
s
.
handleAuthError
(
ctx
,
account
,
msg
)
return
true
}
}
// handleCustomErrorCode 处理自定义错误码,停止账号调度
func
(
s
*
RateLimitService
)
handleCustomErrorCode
(
ctx
context
.
Context
,
account
*
Account
,
statusCode
int
,
errorMsg
string
)
{
msg
:=
"Custom error code "
+
strconv
.
Itoa
(
statusCode
)
+
": "
+
errorMsg
...
...
@@ -1213,7 +1264,8 @@ func (s *RateLimitService) tryTempUnschedulable(ctx context.Context, account *Ac
}
// 401 首次命中可临时不可调度(给 token 刷新窗口);
// 若历史上已因 401 进入过临时不可调度,则本次应升级为 error(返回 false 交由默认错误逻辑处理)。
if
statusCode
==
http
.
StatusUnauthorized
{
// Antigravity 跳过:其 401 由 applyErrorPolicy 的 temp_unschedulable_rules 自行控制,无需升级逻辑。
if
statusCode
==
http
.
StatusUnauthorized
&&
account
.
Platform
!=
PlatformAntigravity
{
reason
:=
account
.
TempUnschedulableReason
// 缓存可能没有 reason,从 DB 回退读取
if
reason
==
""
{
...
...
backend/internal/service/ratelimit_service_401_db_fallback_test.go
View file @
fc00a4e3
...
...
@@ -27,7 +27,40 @@ func (r *dbFallbackRepoStub) GetByID(ctx context.Context, id int64) (*Account, e
func
TestCheckErrorPolicy_401_DBFallback_Escalates
(
t
*
testing
.
T
)
{
// Scenario: cache account has empty TempUnschedulableReason (cache miss),
// but DB account has a previous 401 record → should escalate to ErrorPolicyNone.
// but DB account has a previous 401 record.
// Non-Antigravity: should escalate to ErrorPolicyNone (second 401 = permanent error).
// Antigravity: skips escalation logic (401 handled by applyErrorPolicy rules).
t
.
Run
(
"gemini_escalates"
,
func
(
t
*
testing
.
T
)
{
repo
:=
&
dbFallbackRepoStub
{
dbAccount
:
&
Account
{
ID
:
20
,
TempUnschedulableReason
:
`{"status_code":401,"until_unix":1735689600}`
,
},
}
svc
:=
NewRateLimitService
(
repo
,
nil
,
&
config
.
Config
{},
nil
,
nil
)
account
:=
&
Account
{
ID
:
20
,
Type
:
AccountTypeOAuth
,
Platform
:
PlatformGemini
,
TempUnschedulableReason
:
""
,
Credentials
:
map
[
string
]
any
{
"temp_unschedulable_enabled"
:
true
,
"temp_unschedulable_rules"
:
[]
any
{
map
[
string
]
any
{
"error_code"
:
float64
(
401
),
"keywords"
:
[]
any
{
"unauthorized"
},
"duration_minutes"
:
float64
(
10
),
},
},
},
}
result
:=
svc
.
CheckErrorPolicy
(
context
.
Background
(),
account
,
http
.
StatusUnauthorized
,
[]
byte
(
`unauthorized`
))
require
.
Equal
(
t
,
ErrorPolicyNone
,
result
,
"gemini 401 with DB fallback showing previous 401 should escalate"
)
})
t
.
Run
(
"antigravity_stays_temp"
,
func
(
t
*
testing
.
T
)
{
repo
:=
&
dbFallbackRepoStub
{
dbAccount
:
&
Account
{
ID
:
20
,
...
...
@@ -40,7 +73,7 @@ func TestCheckErrorPolicy_401_DBFallback_Escalates(t *testing.T) {
ID
:
20
,
Type
:
AccountTypeOAuth
,
Platform
:
PlatformAntigravity
,
TempUnschedulableReason
:
""
,
// cache miss — reason is empty
TempUnschedulableReason
:
""
,
Credentials
:
map
[
string
]
any
{
"temp_unschedulable_enabled"
:
true
,
"temp_unschedulable_rules"
:
[]
any
{
...
...
@@ -54,7 +87,8 @@ func TestCheckErrorPolicy_401_DBFallback_Escalates(t *testing.T) {
}
result
:=
svc
.
CheckErrorPolicy
(
context
.
Background
(),
account
,
http
.
StatusUnauthorized
,
[]
byte
(
`unauthorized`
))
require
.
Equal
(
t
,
ErrorPolicyNone
,
result
,
"401 with DB fallback showing previous 401 should escalate to ErrorPolicyNone"
)
require
.
Equal
(
t
,
ErrorPolicyTempUnscheduled
,
result
,
"antigravity 401 skips escalation, stays temp-unscheduled"
)
})
}
func
TestCheckErrorPolicy_401_DBFallback_NoDBRecord_FirstHit
(
t
*
testing
.
T
)
{
...
...
backend/internal/service/ratelimit_service_401_test.go
View file @
fc00a4e3
...
...
@@ -42,23 +42,14 @@ func (r *tokenCacheInvalidatorRecorder) InvalidateToken(ctx context.Context, acc
}
func
TestRateLimitService_HandleUpstreamError_OAuth401SetsTempUnschedulable
(
t
*
testing
.
T
)
{
tests
:=
[]
struct
{
name
string
platform
string
}{
{
name
:
"gemini"
,
platform
:
PlatformGemini
},
{
name
:
"antigravity"
,
platform
:
PlatformAntigravity
},
}
for
_
,
tt
:=
range
tests
{
t
.
Run
(
tt
.
name
,
func
(
t
*
testing
.
T
)
{
t
.
Run
(
"gemini"
,
func
(
t
*
testing
.
T
)
{
repo
:=
&
rateLimitAccountRepoStub
{}
invalidator
:=
&
tokenCacheInvalidatorRecorder
{}
service
:=
NewRateLimitService
(
repo
,
nil
,
&
config
.
Config
{},
nil
,
nil
)
service
.
SetTokenCacheInvalidator
(
invalidator
)
account
:=
&
Account
{
ID
:
100
,
Platform
:
tt
.
p
latform
,
Platform
:
P
latform
Gemini
,
Type
:
AccountTypeOAuth
,
Credentials
:
map
[
string
]
any
{
"temp_unschedulable_enabled"
:
true
,
...
...
@@ -80,7 +71,27 @@ func TestRateLimitService_HandleUpstreamError_OAuth401SetsTempUnschedulable(t *t
require
.
Equal
(
t
,
1
,
repo
.
tempCalls
)
require
.
Len
(
t
,
invalidator
.
accounts
,
1
)
})
t
.
Run
(
"antigravity_401_uses_SetError"
,
func
(
t
*
testing
.
T
)
{
// Antigravity 401 由 applyErrorPolicy 的 temp_unschedulable_rules 控制,
// HandleUpstreamError 中走 SetError 路径。
repo
:=
&
rateLimitAccountRepoStub
{}
invalidator
:=
&
tokenCacheInvalidatorRecorder
{}
service
:=
NewRateLimitService
(
repo
,
nil
,
&
config
.
Config
{},
nil
,
nil
)
service
.
SetTokenCacheInvalidator
(
invalidator
)
account
:=
&
Account
{
ID
:
100
,
Platform
:
PlatformAntigravity
,
Type
:
AccountTypeOAuth
,
}
shouldDisable
:=
service
.
HandleUpstreamError
(
context
.
Background
(),
account
,
401
,
http
.
Header
{},
[]
byte
(
"unauthorized"
))
require
.
True
(
t
,
shouldDisable
)
require
.
Equal
(
t
,
1
,
repo
.
setErrorCalls
)
require
.
Equal
(
t
,
0
,
repo
.
tempCalls
)
require
.
Empty
(
t
,
invalidator
.
accounts
)
})
}
func
TestRateLimitService_HandleUpstreamError_OAuth401InvalidatorError
(
t
*
testing
.
T
)
{
...
...
frontend/src/components/account/AccountUsageCell.vue
View file @
fc00a4e3
...
...
@@ -36,6 +36,10 @@
<!-- Usage data -->
<div
v-else-if=
"usageInfo"
class=
"space-y-1"
>
<!-- API error (degraded response) -->
<div
v-if=
"usageInfo.error"
class=
"text-xs text-amber-600 dark:text-amber-400 truncate max-w-[200px]"
:title=
"usageInfo.error"
>
{{ usageInfo.error }}
</div>
<!-- 5h Window -->
<UsageProgressBar
v-if=
"usageInfo.five_hour"
...
...
@@ -189,8 +193,53 @@
</span>
</div>
<!-- Forbidden state (403) -->
<div
v-if=
"isForbidden"
class=
"space-y-1"
>
<span
:class=
"[
'inline-block rounded px-1.5 py-0.5 text-[10px] font-medium',
forbiddenBadgeClass
]"
>
{{
forbiddenLabel
}}
</span>
<div
v-if=
"validationURL"
class=
"flex items-center gap-1"
>
<a
:href=
"validationURL"
target=
"_blank"
rel=
"noopener noreferrer"
class=
"text-[10px] text-blue-600 hover:text-blue-800 hover:underline dark:text-blue-400 dark:hover:text-blue-300"
:title=
"t('admin.accounts.openVerification')"
>
{{
t
(
'
admin.accounts.openVerification
'
)
}}
</a>
<button
type=
"button"
class=
"text-[10px] text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
:title=
"t('admin.accounts.copyLink')"
@
click=
"copyValidationURL"
>
{{
linkCopied
?
t
(
'
admin.accounts.linkCopied
'
)
:
t
(
'
admin.accounts.copyLink
'
)
}}
</button>
</div>
</div>
<!-- Needs reauth (401) -->
<div
v-else-if=
"needsReauth"
class=
"space-y-1"
>
<span
class=
"inline-block rounded px-1.5 py-0.5 text-[10px] font-medium bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-300"
>
{{
t
(
'
admin.accounts.needsReauth
'
)
}}
</span>
</div>
<!-- Degraded error (non-403, non-401) -->
<div
v-else-if=
"usageInfo?.error"
class=
"space-y-1"
>
<span
class=
"inline-block rounded px-1.5 py-0.5 text-[10px] font-medium bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300"
>
{{
usageErrorLabel
}}
</span>
</div>
<!-- Loading state -->
<div
v-if=
"loading"
class=
"space-y-1.5"
>
<div
v-
else-
if=
"loading"
class=
"space-y-1.5"
>
<div
class=
"flex items-center gap-1"
>
<div
class=
"h-3 w-[32px] animate-pulse rounded bg-gray-200 dark:bg-gray-700"
></div>
<div
class=
"h-1.5 w-8 animate-pulse rounded-full bg-gray-200 dark:bg-gray-700"
></div>
...
...
@@ -816,6 +865,51 @@ const hasIneligibleTiers = computed(() => {
return
Array
.
isArray
(
ineligibleTiers
)
&&
ineligibleTiers
.
length
>
0
})
// Antigravity 403 forbidden 状态
const
isForbidden
=
computed
(()
=>
!!
usageInfo
.
value
?.
is_forbidden
)
const
forbiddenType
=
computed
(()
=>
usageInfo
.
value
?.
forbidden_type
||
'
forbidden
'
)
const
validationURL
=
computed
(()
=>
usageInfo
.
value
?.
validation_url
||
''
)
// 需要重新授权(401)
const
needsReauth
=
computed
(()
=>
!!
usageInfo
.
value
?.
needs_reauth
)
// 降级错误标签(rate_limited / network_error)
const
usageErrorLabel
=
computed
(()
=>
{
const
code
=
usageInfo
.
value
?.
error_code
if
(
code
===
'
rate_limited
'
)
return
t
(
'
admin.accounts.rateLimited
'
)
return
t
(
'
admin.accounts.usageError
'
)
})
const
forbiddenLabel
=
computed
(()
=>
{
switch
(
forbiddenType
.
value
)
{
case
'
validation
'
:
return
t
(
'
admin.accounts.forbiddenValidation
'
)
case
'
violation
'
:
return
t
(
'
admin.accounts.forbiddenViolation
'
)
default
:
return
t
(
'
admin.accounts.forbidden
'
)
}
})
const
forbiddenBadgeClass
=
computed
(()
=>
{
if
(
forbiddenType
.
value
===
'
validation
'
)
{
return
'
bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300
'
}
return
'
bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300
'
})
const
linkCopied
=
ref
(
false
)
const
copyValidationURL
=
async
()
=>
{
if
(
!
validationURL
.
value
)
return
try
{
await
navigator
.
clipboard
.
writeText
(
validationURL
.
value
)
linkCopied
.
value
=
true
setTimeout
(()
=>
{
linkCopied
.
value
=
false
},
2000
)
}
catch
{
// fallback: ignore
}
}
const
loadUsage
=
async
()
=>
{
if
(
!
shouldFetchUsage
.
value
)
return
...
...
frontend/src/components/layout/TablePageLayout.vue
View file @
fc00a4e3
...
...
@@ -84,9 +84,7 @@ onUnmounted(() => {
}
.table-scroll-container
:deep
(
th
)
{
/* 表头高度和文字加粗优化 */
@apply
px-5
py-4
text-left
text-sm
font-bold
text-gray-900
dark
:
text-white
border-b
border-gray-200
dark
:
border-dark-700
;
@apply
uppercase
tracking-wider;
/* 让表头更有设计感 */
@apply
px-5
py-4
text-left
text-sm
font-medium
text-gray-600
dark
:
text-dark-300
border-b
border-gray-200
dark
:
border-dark-700
;
}
.table-scroll-container
:deep
(
td
)
{
...
...
frontend/src/i18n/locales/en.ts
View file @
fc00a4e3
...
...
@@ -2555,7 +2555,16 @@ export default {
unlimited
:
'
Unlimited
'
},
ineligibleWarning
:
'This account is not eligible for Antigravity, but API forwarding still works. Use at your own risk.'
'
This account is not eligible for Antigravity, but API forwarding still works. Use at your own risk.
'
,
forbidden
:
'
Forbidden
'
,
forbiddenValidation
:
'
Verification Required
'
,
forbiddenViolation
:
'
Violation Ban
'
,
openVerification
:
'
Open Verification Link
'
,
copyLink
:
'
Copy Link
'
,
linkCopied
:
'
Link Copied
'
,
needsReauth
:
'
Re-auth Required
'
,
rateLimited
:
'
Rate Limited
'
,
usageError
:
'
Fetch Error
'
},
// Scheduled Tests
...
...
frontend/src/i18n/locales/zh.ts
View file @
fc00a4e3
...
...
@@ -1992,6 +1992,15 @@ export default {
},
ineligibleWarning
:
'
该账号无 Antigravity 使用权限,但仍能进行 API 转发。继续使用请自行承担风险。
'
,
forbidden
:
'
已封禁
'
,
forbiddenValidation
:
'
需要验证
'
,
forbiddenViolation
:
'
违规封禁
'
,
openVerification
:
'
打开验证链接
'
,
copyLink
:
'
复制链接
'
,
linkCopied
:
'
链接已复制
'
,
needsReauth
:
'
需要重新授权
'
,
rateLimited
:
'
限流中
'
,
usageError
:
'
获取失败
'
,
form
:
{
nameLabel
:
'
账号名称
'
,
namePlaceholder
:
'
请输入账号名称
'
,
...
...
frontend/src/types/index.ts
View file @
fc00a4e3
...
...
@@ -769,6 +769,21 @@ export interface AccountUsageInfo {
gemini_pro_minute
?:
UsageProgress
|
null
gemini_flash_minute
?:
UsageProgress
|
null
antigravity_quota
?:
Record
<
string
,
AntigravityModelQuota
>
|
null
// Antigravity 403 forbidden 状态
is_forbidden
?:
boolean
forbidden_reason
?:
string
forbidden_type
?:
string
// "validation" | "violation" | "forbidden"
validation_url
?:
string
// 验证/申诉链接
// 状态标记(后端自动推导)
needs_verify
?:
boolean
// 需要人工验证(forbidden_type=validation)
is_banned
?:
boolean
// 账号被封(forbidden_type=violation)
needs_reauth
?:
boolean
// token 失效需重新授权(401)
// 机器可读错误码:forbidden / unauthenticated / rate_limited / network_error
error_code
?:
string
error
?:
string
// usage 获取失败时的错误信息
}
// OpenAI Codex usage snapshot (from response headers)
...
...
frontend/src/views/admin/AccountsView.vue
View file @
fc00a4e3
...
...
@@ -171,7 +171,15 @@
<
span
v
-
else
class
=
"
text-sm text-gray-400 dark:text-dark-500
"
>-<
/span
>
<
/template
>
<
template
#
cell
-
platform_type
=
"
{ row
}
"
>
<
div
class
=
"
flex flex-wrap items-center gap-1
"
>
<
PlatformTypeBadge
:
platform
=
"
row.platform
"
:
type
=
"
row.type
"
:
plan
-
type
=
"
row.credentials?.plan_type
"
:
privacy
-
mode
=
"
row.extra?.privacy_mode
"
/>
<
span
v
-
if
=
"
getAntigravityTierLabel(row)
"
:
class
=
"
['inline-block rounded px-1.5 py-0.5 text-[10px] font-medium', getAntigravityTierClass(row)]
"
>
{{
getAntigravityTierLabel
(
row
)
}}
<
/span
>
<
/div
>
<
/template
>
<
template
#
cell
-
capacity
=
"
{ row
}
"
>
<
AccountCapacityCell
:
account
=
"
row
"
/>
...
...
@@ -794,6 +802,40 @@ const { pause: pauseAutoRefresh, resume: resumeAutoRefresh } = useIntervalFn(
{
immediate
:
false
}
)
// Antigravity 订阅等级辅助函数
function
getAntigravityTierFromRow
(
row
:
any
):
string
|
null
{
if
(
row
.
platform
!==
'
antigravity
'
)
return
null
const
extra
=
row
.
extra
as
Record
<
string
,
unknown
>
|
undefined
if
(
!
extra
)
return
null
const
lca
=
extra
.
load_code_assist
as
Record
<
string
,
unknown
>
|
undefined
if
(
!
lca
)
return
null
const
paid
=
lca
.
paidTier
as
Record
<
string
,
unknown
>
|
undefined
if
(
paid
&&
typeof
paid
.
id
===
'
string
'
)
return
paid
.
id
const
current
=
lca
.
currentTier
as
Record
<
string
,
unknown
>
|
undefined
if
(
current
&&
typeof
current
.
id
===
'
string
'
)
return
current
.
id
return
null
}
function
getAntigravityTierLabel
(
row
:
any
):
string
|
null
{
const
tier
=
getAntigravityTierFromRow
(
row
)
switch
(
tier
)
{
case
'
free-tier
'
:
return
t
(
'
admin.accounts.tier.free
'
)
case
'
g1-pro-tier
'
:
return
t
(
'
admin.accounts.tier.pro
'
)
case
'
g1-ultra-tier
'
:
return
t
(
'
admin.accounts.tier.ultra
'
)
default
:
return
null
}
}
function
getAntigravityTierClass
(
row
:
any
):
string
{
const
tier
=
getAntigravityTierFromRow
(
row
)
switch
(
tier
)
{
case
'
free-tier
'
:
return
'
bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300
'
case
'
g1-pro-tier
'
:
return
'
bg-blue-100 text-blue-600 dark:bg-blue-900/40 dark:text-blue-300
'
case
'
g1-ultra-tier
'
:
return
'
bg-purple-100 text-purple-600 dark:bg-purple-900/40 dark:text-purple-300
'
default
:
return
''
}
}
// All available columns
const
allColumns
=
computed
(()
=>
{
const
c
=
[
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment