mirror of
https://gitee.com/wanwujie/sub2api
synced 2026-04-02 22:42:14 +08:00
feat: add AI Credits balance handling and update model status indicators
This commit is contained in:
@@ -124,10 +124,68 @@ type IneligibleTier struct {
|
||||
type LoadCodeAssistResponse struct {
|
||||
CloudAICompanionProject string `json:"cloudaicompanionProject"`
|
||||
CurrentTier *TierInfo `json:"currentTier,omitempty"`
|
||||
PaidTier *TierInfo `json:"paidTier,omitempty"`
|
||||
PaidTier *PaidTierInfo `json:"paidTier,omitempty"`
|
||||
IneligibleTiers []*IneligibleTier `json:"ineligibleTiers,omitempty"`
|
||||
}
|
||||
|
||||
// PaidTierInfo 付费等级信息,包含 AI Credits 余额。
|
||||
type PaidTierInfo struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
AvailableCredits []AvailableCredit `json:"availableCredits,omitempty"`
|
||||
}
|
||||
|
||||
// UnmarshalJSON 兼容 paidTier 既可能是字符串也可能是对象的情况。
|
||||
func (p *PaidTierInfo) UnmarshalJSON(data []byte) error {
|
||||
data = bytes.TrimSpace(data)
|
||||
if len(data) == 0 || string(data) == "null" {
|
||||
return nil
|
||||
}
|
||||
if data[0] == '"' {
|
||||
var id string
|
||||
if err := json.Unmarshal(data, &id); err != nil {
|
||||
return err
|
||||
}
|
||||
p.ID = id
|
||||
return nil
|
||||
}
|
||||
type alias PaidTierInfo
|
||||
var raw alias
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
*p = PaidTierInfo(raw)
|
||||
return nil
|
||||
}
|
||||
|
||||
// AvailableCredit 表示一条 AI Credits 余额记录。
|
||||
type AvailableCredit struct {
|
||||
CreditType string `json:"creditType,omitempty"`
|
||||
CreditAmount string `json:"creditAmount,omitempty"`
|
||||
MinimumCreditAmountForUsage string `json:"minimumCreditAmountForUsage,omitempty"`
|
||||
}
|
||||
|
||||
// GetAmount 将 creditAmount 解析为浮点数。
|
||||
func (c *AvailableCredit) GetAmount() float64 {
|
||||
if c.CreditAmount == "" {
|
||||
return 0
|
||||
}
|
||||
var value float64
|
||||
fmt.Sscanf(c.CreditAmount, "%f", &value)
|
||||
return value
|
||||
}
|
||||
|
||||
// GetMinimumAmount 将 minimumCreditAmountForUsage 解析为浮点数。
|
||||
func (c *AvailableCredit) GetMinimumAmount() float64 {
|
||||
if c.MinimumCreditAmountForUsage == "" {
|
||||
return 0
|
||||
}
|
||||
var value float64
|
||||
fmt.Sscanf(c.MinimumCreditAmountForUsage, "%f", &value)
|
||||
return value
|
||||
}
|
||||
|
||||
// OnboardUserRequest onboardUser 请求
|
||||
type OnboardUserRequest struct {
|
||||
TierID string `json:"tierId"`
|
||||
@@ -157,6 +215,14 @@ func (r *LoadCodeAssistResponse) GetTier() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetAvailableCredits 返回 paid tier 中的 AI Credits 余额列表。
|
||||
func (r *LoadCodeAssistResponse) GetAvailableCredits() []AvailableCredit {
|
||||
if r.PaidTier == nil {
|
||||
return nil
|
||||
}
|
||||
return r.PaidTier.AvailableCredits
|
||||
}
|
||||
|
||||
// Client Antigravity API 客户端
|
||||
type Client struct {
|
||||
httpClient *http.Client
|
||||
|
||||
@@ -190,7 +190,7 @@ func TestTierInfo_UnmarshalJSON_通过JSON嵌套结构(t *testing.T) {
|
||||
func TestGetTier_PaidTier优先(t *testing.T) {
|
||||
resp := &LoadCodeAssistResponse{
|
||||
CurrentTier: &TierInfo{ID: "free-tier"},
|
||||
PaidTier: &TierInfo{ID: "g1-pro-tier"},
|
||||
PaidTier: &PaidTierInfo{ID: "g1-pro-tier"},
|
||||
}
|
||||
if got := resp.GetTier(); got != "g1-pro-tier" {
|
||||
t.Errorf("应返回 paidTier: got %s", got)
|
||||
@@ -209,7 +209,7 @@ func TestGetTier_回退到CurrentTier(t *testing.T) {
|
||||
func TestGetTier_PaidTier为空ID(t *testing.T) {
|
||||
resp := &LoadCodeAssistResponse{
|
||||
CurrentTier: &TierInfo{ID: "free-tier"},
|
||||
PaidTier: &TierInfo{ID: ""},
|
||||
PaidTier: &PaidTierInfo{ID: ""},
|
||||
}
|
||||
// paidTier.ID 为空时应回退到 currentTier
|
||||
if got := resp.GetTier(); got != "free-tier" {
|
||||
@@ -217,6 +217,32 @@ func TestGetTier_PaidTier为空ID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAvailableCredits(t *testing.T) {
|
||||
resp := &LoadCodeAssistResponse{
|
||||
PaidTier: &PaidTierInfo{
|
||||
ID: "g1-pro-tier",
|
||||
AvailableCredits: []AvailableCredit{
|
||||
{
|
||||
CreditType: "GOOGLE_ONE_AI",
|
||||
CreditAmount: "25",
|
||||
MinimumCreditAmountForUsage: "5",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
credits := resp.GetAvailableCredits()
|
||||
if len(credits) != 1 {
|
||||
t.Fatalf("AI Credits 数量不匹配: got %d", len(credits))
|
||||
}
|
||||
if credits[0].GetAmount() != 25 {
|
||||
t.Errorf("CreditAmount 解析不正确: got %v", credits[0].GetAmount())
|
||||
}
|
||||
if credits[0].GetMinimumAmount() != 5 {
|
||||
t.Errorf("MinimumCreditAmountForUsage 解析不正确: got %v", credits[0].GetMinimumAmount())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetTier_两者都为nil(t *testing.T) {
|
||||
resp := &LoadCodeAssistResponse{}
|
||||
if got := resp.GetTier(); got != "" {
|
||||
|
||||
@@ -166,6 +166,13 @@ type AntigravityModelDetail struct {
|
||||
SupportedMimeTypes map[string]bool `json:"supported_mime_types,omitempty"`
|
||||
}
|
||||
|
||||
// AICredit 表示 Antigravity 账号的 AI Credits 余额信息。
|
||||
type AICredit struct {
|
||||
CreditType string `json:"credit_type,omitempty"`
|
||||
Amount float64 `json:"amount,omitempty"`
|
||||
MinimumBalance float64 `json:"minimum_balance,omitempty"`
|
||||
}
|
||||
|
||||
// UsageInfo 账号使用量信息
|
||||
type UsageInfo struct {
|
||||
UpdatedAt *time.Time `json:"updated_at,omitempty"` // 更新时间
|
||||
@@ -189,6 +196,9 @@ type UsageInfo struct {
|
||||
// Antigravity 模型详细能力信息(与 antigravity_quota 同 key)
|
||||
AntigravityQuotaDetails map[string]*AntigravityModelDetail `json:"antigravity_quota_details,omitempty"`
|
||||
|
||||
// Antigravity AI Credits 余额
|
||||
AICredits []AICredit `json:"ai_credits,omitempty"`
|
||||
|
||||
// Antigravity 废弃模型转发规则 (old_model_id -> new_model_id)
|
||||
ModelForwardingRules map[string]string `json:"model_forwarding_rules,omitempty"`
|
||||
|
||||
|
||||
@@ -78,11 +78,11 @@ func (f *AntigravityQuotaFetcher) FetchQuota(ctx context.Context, account *Accou
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 调用 LoadCodeAssist 获取订阅等级(非关键路径,失败不影响主流程)
|
||||
tierRaw, tierNormalized := f.fetchSubscriptionTier(ctx, client, accessToken)
|
||||
// 调用 LoadCodeAssist 获取订阅等级和 AI Credits 余额(非关键路径,失败不影响主流程)
|
||||
tierRaw, tierNormalized, loadResp := f.fetchSubscriptionTier(ctx, client, accessToken)
|
||||
|
||||
// 转换为 UsageInfo
|
||||
usageInfo := f.buildUsageInfo(modelsResp, tierRaw, tierNormalized)
|
||||
usageInfo := f.buildUsageInfo(modelsResp, tierRaw, tierNormalized, loadResp)
|
||||
|
||||
return &QuotaResult{
|
||||
UsageInfo: usageInfo,
|
||||
@@ -90,20 +90,21 @@ 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) {
|
||||
// fetchSubscriptionTier 获取账号订阅等级,失败返回空字符串。
|
||||
// 同时返回 LoadCodeAssistResponse,以便提取 AI Credits 余额。
|
||||
func (f *AntigravityQuotaFetcher) fetchSubscriptionTier(ctx context.Context, client *antigravity.Client, accessToken string) (raw, normalized string, loadResp *antigravity.LoadCodeAssistResponse) {
|
||||
loadResp, _, err := client.LoadCodeAssist(ctx, accessToken)
|
||||
if err != nil {
|
||||
slog.Warn("failed to fetch subscription tier", "error", err)
|
||||
return "", ""
|
||||
return "", "", nil
|
||||
}
|
||||
if loadResp == nil {
|
||||
return "", ""
|
||||
return "", "", nil
|
||||
}
|
||||
|
||||
raw = loadResp.GetTier() // 已有方法:paidTier > currentTier
|
||||
normalized = normalizeTier(raw)
|
||||
return raw, normalized
|
||||
return raw, normalized, loadResp
|
||||
}
|
||||
|
||||
// normalizeTier 将原始 tier 字符串归一化为 FREE/PRO/ULTRA/UNKNOWN
|
||||
@@ -124,8 +125,8 @@ func normalizeTier(raw string) string {
|
||||
}
|
||||
}
|
||||
|
||||
// buildUsageInfo 将 API 响应转换为 UsageInfo
|
||||
func (f *AntigravityQuotaFetcher) buildUsageInfo(modelsResp *antigravity.FetchAvailableModelsResponse, tierRaw, tierNormalized string) *UsageInfo {
|
||||
// buildUsageInfo 将 API 响应转换为 UsageInfo。
|
||||
func (f *AntigravityQuotaFetcher) buildUsageInfo(modelsResp *antigravity.FetchAvailableModelsResponse, tierRaw, tierNormalized string, loadResp *antigravity.LoadCodeAssistResponse) *UsageInfo {
|
||||
now := time.Now()
|
||||
info := &UsageInfo{
|
||||
UpdatedAt: &now,
|
||||
@@ -190,6 +191,16 @@ func (f *AntigravityQuotaFetcher) buildUsageInfo(modelsResp *antigravity.FetchAv
|
||||
}
|
||||
}
|
||||
|
||||
if loadResp != nil {
|
||||
for _, credit := range loadResp.GetAvailableCredits() {
|
||||
info.AICredits = append(info.AICredits, AICredit{
|
||||
CreditType: credit.CreditType,
|
||||
Amount: credit.GetAmount(),
|
||||
MinimumBalance: credit.GetMinimumAmount(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return info
|
||||
}
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@ func TestBuildUsageInfo_BasicModels(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
info := fetcher.buildUsageInfo(modelsResp, "g1-pro-tier", "PRO")
|
||||
info := fetcher.buildUsageInfo(modelsResp, "g1-pro-tier", "PRO", nil)
|
||||
|
||||
// 基本字段
|
||||
require.NotNil(t, info.UpdatedAt, "UpdatedAt should be set")
|
||||
@@ -141,7 +141,7 @@ func TestBuildUsageInfo_DeprecatedModels(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
info := fetcher.buildUsageInfo(modelsResp, "", "")
|
||||
info := fetcher.buildUsageInfo(modelsResp, "", "", nil)
|
||||
|
||||
require.Len(t, info.ModelForwardingRules, 2)
|
||||
require.Equal(t, "claude-sonnet-4-20250514", info.ModelForwardingRules["claude-3-sonnet-20240229"])
|
||||
@@ -159,7 +159,7 @@ func TestBuildUsageInfo_NoDeprecatedModels(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
info := fetcher.buildUsageInfo(modelsResp, "", "")
|
||||
info := fetcher.buildUsageInfo(modelsResp, "", "", nil)
|
||||
|
||||
require.Nil(t, info.ModelForwardingRules, "ModelForwardingRules should be nil when no deprecated models")
|
||||
}
|
||||
@@ -171,7 +171,7 @@ func TestBuildUsageInfo_EmptyModels(t *testing.T) {
|
||||
Models: map[string]antigravity.ModelInfo{},
|
||||
}
|
||||
|
||||
info := fetcher.buildUsageInfo(modelsResp, "", "")
|
||||
info := fetcher.buildUsageInfo(modelsResp, "", "", nil)
|
||||
|
||||
require.NotNil(t, info)
|
||||
require.NotNil(t, info.AntigravityQuota)
|
||||
@@ -193,7 +193,7 @@ func TestBuildUsageInfo_ModelWithNilQuotaInfo(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
info := fetcher.buildUsageInfo(modelsResp, "", "")
|
||||
info := fetcher.buildUsageInfo(modelsResp, "", "", nil)
|
||||
|
||||
require.NotNil(t, info)
|
||||
require.Empty(t, info.AntigravityQuota, "models with nil QuotaInfo should be skipped")
|
||||
@@ -222,7 +222,7 @@ func TestBuildUsageInfo_FiveHourPriorityOrder(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
info := fetcher.buildUsageInfo(modelsResp, "", "")
|
||||
info := fetcher.buildUsageInfo(modelsResp, "", "", nil)
|
||||
|
||||
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
|
||||
@@ -251,7 +251,7 @@ func TestBuildUsageInfo_FiveHourFallbackToClaude4(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
info := fetcher.buildUsageInfo(modelsResp, "", "")
|
||||
info := fetcher.buildUsageInfo(modelsResp, "", "", nil)
|
||||
|
||||
require.NotNil(t, info.FiveHour)
|
||||
expectedUtilization := (1.0 - 0.60) * 100 // 40
|
||||
@@ -277,7 +277,7 @@ func TestBuildUsageInfo_FiveHourFallbackToGemini(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
info := fetcher.buildUsageInfo(modelsResp, "", "")
|
||||
info := fetcher.buildUsageInfo(modelsResp, "", "", nil)
|
||||
|
||||
require.NotNil(t, info.FiveHour)
|
||||
expectedUtilization := (1.0 - 0.30) * 100 // 70
|
||||
@@ -298,7 +298,7 @@ func TestBuildUsageInfo_FiveHourNoPriorityModel(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
info := fetcher.buildUsageInfo(modelsResp, "", "")
|
||||
info := fetcher.buildUsageInfo(modelsResp, "", "", nil)
|
||||
|
||||
require.Nil(t, info.FiveHour, "FiveHour should be nil when no priority model exists")
|
||||
}
|
||||
@@ -317,7 +317,7 @@ func TestBuildUsageInfo_FiveHourWithEmptyResetTime(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
info := fetcher.buildUsageInfo(modelsResp, "", "")
|
||||
info := fetcher.buildUsageInfo(modelsResp, "", "", nil)
|
||||
|
||||
require.NotNil(t, info.FiveHour)
|
||||
require.Nil(t, info.FiveHour.ResetsAt, "ResetsAt should be nil when ResetTime is empty")
|
||||
@@ -338,7 +338,7 @@ func TestBuildUsageInfo_FullUtilization(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
info := fetcher.buildUsageInfo(modelsResp, "", "")
|
||||
info := fetcher.buildUsageInfo(modelsResp, "", "", nil)
|
||||
|
||||
quota := info.AntigravityQuota["claude-sonnet-4-20250514"]
|
||||
require.NotNil(t, quota)
|
||||
@@ -358,13 +358,38 @@ func TestBuildUsageInfo_ZeroUtilization(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
info := fetcher.buildUsageInfo(modelsResp, "", "")
|
||||
|
||||
info := fetcher.buildUsageInfo(modelsResp, "", "", nil)
|
||||
quota := info.AntigravityQuota["claude-sonnet-4-20250514"]
|
||||
require.NotNil(t, quota)
|
||||
require.Equal(t, 0, quota.Utilization)
|
||||
}
|
||||
|
||||
func TestBuildUsageInfo_AICredits(t *testing.T) {
|
||||
fetcher := &AntigravityQuotaFetcher{}
|
||||
modelsResp := &antigravity.FetchAvailableModelsResponse{
|
||||
Models: map[string]antigravity.ModelInfo{},
|
||||
}
|
||||
loadResp := &antigravity.LoadCodeAssistResponse{
|
||||
PaidTier: &antigravity.PaidTierInfo{
|
||||
ID: "g1-pro-tier",
|
||||
AvailableCredits: []antigravity.AvailableCredit{
|
||||
{
|
||||
CreditType: "GOOGLE_ONE_AI",
|
||||
CreditAmount: "25",
|
||||
MinimumCreditAmountForUsage: "5",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
info := fetcher.buildUsageInfo(modelsResp, "g1-pro-tier", "PRO", loadResp)
|
||||
|
||||
require.Len(t, info.AICredits, 1)
|
||||
require.Equal(t, "GOOGLE_ONE_AI", info.AICredits[0].CreditType)
|
||||
require.Equal(t, 25.0, info.AICredits[0].Amount)
|
||||
require.Equal(t, 5.0, info.AICredits[0].MinimumBalance)
|
||||
}
|
||||
|
||||
func TestFetchQuota_ForbiddenReturnsIsForbidden(t *testing.T) {
|
||||
// 模拟 FetchQuota 遇到 403 时的行为:
|
||||
// FetchAvailableModels 返回 ForbiddenError → FetchQuota 应返回 is_forbidden=true
|
||||
|
||||
@@ -76,19 +76,28 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Model Rate Limit Indicators (Antigravity OAuth Smart Retry) -->
|
||||
<!-- Model Status Indicators (普通限流 / 超量请求中) -->
|
||||
<div
|
||||
v-if="activeModelRateLimits.length > 0"
|
||||
v-if="activeModelStatuses.length > 0"
|
||||
:class="[
|
||||
activeModelRateLimits.length <= 4
|
||||
activeModelStatuses.length <= 4
|
||||
? 'flex flex-col gap-1'
|
||||
: activeModelRateLimits.length <= 8
|
||||
: activeModelStatuses.length <= 8
|
||||
? 'columns-2 gap-x-2'
|
||||
: 'columns-3 gap-x-2'
|
||||
]"
|
||||
>
|
||||
<div v-for="item in activeModelRateLimits" :key="item.model" class="group relative mb-1 break-inside-avoid">
|
||||
<div v-for="item in activeModelStatuses" :key="`${item.kind}-${item.model}`" class="group relative mb-1 break-inside-avoid">
|
||||
<span
|
||||
v-if="item.kind === 'overages'"
|
||||
class="inline-flex items-center gap-1 rounded bg-amber-100 px-1.5 py-0.5 text-xs font-medium text-amber-700 dark:bg-amber-900/30 dark:text-amber-400"
|
||||
>
|
||||
<span>⚡</span>
|
||||
{{ formatScopeName(item.model) }}
|
||||
<span class="text-[10px] opacity-70">{{ formatModelResetTime(item.reset_at) }}</span>
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="inline-flex items-center gap-1 rounded bg-purple-100 px-1.5 py-0.5 text-xs font-medium text-purple-700 dark:bg-purple-900/30 dark:text-purple-400"
|
||||
>
|
||||
<Icon name="exclamationTriangle" size="xs" :stroke-width="2" />
|
||||
@@ -99,7 +108,11 @@
|
||||
<div
|
||||
class="pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 w-56 -translate-x-1/2 whitespace-normal rounded bg-gray-900 px-3 py-2 text-center text-xs leading-relaxed text-white opacity-0 transition-opacity group-hover:opacity-100 dark:bg-gray-700"
|
||||
>
|
||||
{{ t('admin.accounts.status.modelRateLimitedUntil', { model: formatScopeName(item.model), time: formatTime(item.reset_at) }) }}
|
||||
{{
|
||||
item.kind === 'overages'
|
||||
? t('admin.accounts.status.modelCreditOveragesUntil', { model: formatScopeName(item.model), time: formatTime(item.reset_at) })
|
||||
: t('admin.accounts.status.modelRateLimitedUntil', { model: formatScopeName(item.model), time: formatTime(item.reset_at) })
|
||||
}}
|
||||
<div
|
||||
class="absolute left-1/2 top-full -translate-x-1/2 border-4 border-transparent border-t-gray-900 dark:border-t-gray-700"
|
||||
></div>
|
||||
@@ -131,6 +144,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import type { Account } from '@/types'
|
||||
import { formatCountdown, formatDateTime, formatCountdownWithSuffix, formatTime } from '@/utils/format'
|
||||
|
||||
@@ -150,17 +164,37 @@ const isRateLimited = computed(() => {
|
||||
return new Date(props.account.rate_limit_reset_at) > new Date()
|
||||
})
|
||||
|
||||
type AccountModelStatusItem = {
|
||||
kind: 'rate_limit' | 'overages'
|
||||
model: string
|
||||
reset_at: string
|
||||
}
|
||||
|
||||
// Computed: active model rate limits (Antigravity OAuth Smart Retry)
|
||||
const activeModelRateLimits = computed(() => {
|
||||
const modelLimits = (props.account.extra as Record<string, unknown> | undefined)?.model_rate_limits as
|
||||
// Computed: active model statuses (普通模型限流 + 超量请求运行态)
|
||||
const activeModelStatuses = computed<AccountModelStatusItem[]>(() => {
|
||||
const extra = props.account.extra as Record<string, unknown> | undefined
|
||||
const modelLimits = extra?.model_rate_limits as
|
||||
| Record<string, { rate_limited_at: string; rate_limit_reset_at: string }>
|
||||
| undefined
|
||||
if (!modelLimits) return []
|
||||
const now = new Date()
|
||||
return Object.entries(modelLimits)
|
||||
.filter(([, info]) => new Date(info.rate_limit_reset_at) > now)
|
||||
.map(([model, info]) => ({ model, reset_at: info.rate_limit_reset_at }))
|
||||
const items: AccountModelStatusItem[] = []
|
||||
|
||||
if (modelLimits) {
|
||||
items.push(...Object.entries(modelLimits)
|
||||
.filter(([, info]) => new Date(info.rate_limit_reset_at) > now)
|
||||
.map(([model, info]) => ({ kind: 'rate_limit' as const, model, reset_at: info.rate_limit_reset_at })))
|
||||
}
|
||||
|
||||
const overagesStates = extra?.antigravity_credits_overages as
|
||||
| Record<string, { activated_at?: string; active_until: string }>
|
||||
| undefined
|
||||
if (overagesStates) {
|
||||
items.push(...Object.entries(overagesStates)
|
||||
.filter(([, info]) => new Date(info.active_until) > now)
|
||||
.map(([model, info]) => ({ kind: 'overages' as const, model, reset_at: info.active_until })))
|
||||
}
|
||||
|
||||
return items
|
||||
})
|
||||
|
||||
const formatScopeName = (scope: string): string => {
|
||||
|
||||
@@ -289,6 +289,33 @@
|
||||
:resets-at="antigravityClaudeUsageFromAPI.resetTime"
|
||||
color="amber"
|
||||
/>
|
||||
|
||||
<div v-if="antigravityAICreditsDisplay.length > 0" class="mt-1 space-y-0.5 text-[10px] text-gray-500 dark:text-gray-400">
|
||||
<div
|
||||
v-for="credit in antigravityAICreditsDisplay"
|
||||
:key="credit.creditType"
|
||||
>
|
||||
{{ t('admin.accounts.aiCreditsBalance') }}:
|
||||
{{ credit.creditType }}
|
||||
{{ credit.amount }}
|
||||
<span v-if="credit.minimumBalance !== null">
|
||||
(min {{ credit.minimumBalance }})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="antigravityAICreditsDisplay.length > 0" class="space-y-0.5 text-[10px] text-gray-500 dark:text-gray-400">
|
||||
<div
|
||||
v-for="credit in antigravityAICreditsDisplay"
|
||||
:key="credit.creditType"
|
||||
>
|
||||
{{ t('admin.accounts.aiCreditsBalance') }}:
|
||||
{{ credit.creditType }}
|
||||
{{ credit.amount }}
|
||||
<span v-if="credit.minimumBalance !== null">
|
||||
(min {{ credit.minimumBalance }})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-xs text-gray-400">-</div>
|
||||
</template>
|
||||
@@ -581,6 +608,20 @@ const antigravityClaudeUsageFromAPI = computed(() =>
|
||||
])
|
||||
)
|
||||
|
||||
const antigravityAICreditsDisplay = computed(() => {
|
||||
const credits = usageInfo.value?.ai_credits
|
||||
if (!credits || credits.length === 0) return []
|
||||
return credits
|
||||
.filter((credit) => (credit.amount ?? 0) > 0)
|
||||
.map((credit) => ({
|
||||
creditType: credit.credit_type || 'UNKNOWN',
|
||||
amount: Number(credit.amount ?? 0).toFixed(0),
|
||||
minimumBalance: typeof credit.minimum_balance === 'number'
|
||||
? Number(credit.minimum_balance).toFixed(0)
|
||||
: null,
|
||||
}))
|
||||
})
|
||||
|
||||
// Antigravity 账户类型(从 load_code_assist 响应中提取)
|
||||
const antigravityTier = computed(() => {
|
||||
const extra = props.account.extra as Record<string, unknown> | undefined
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import AccountStatusIndicator from '../AccountStatusIndicator.vue'
|
||||
import type { Account } from '@/types'
|
||||
|
||||
vi.mock('vue-i18n', async () => {
|
||||
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
|
||||
return {
|
||||
...actual,
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
function makeAccount(overrides: Partial<Account>): Account {
|
||||
return {
|
||||
id: 1,
|
||||
name: 'account',
|
||||
platform: 'antigravity',
|
||||
type: 'oauth',
|
||||
proxy_id: null,
|
||||
concurrency: 1,
|
||||
priority: 1,
|
||||
status: 'active',
|
||||
error_message: null,
|
||||
last_used_at: null,
|
||||
expires_at: null,
|
||||
auto_pause_on_expired: true,
|
||||
created_at: '2026-03-15T00:00:00Z',
|
||||
updated_at: '2026-03-15T00:00:00Z',
|
||||
schedulable: true,
|
||||
rate_limited_at: null,
|
||||
rate_limit_reset_at: null,
|
||||
overload_until: null,
|
||||
temp_unschedulable_until: null,
|
||||
temp_unschedulable_reason: null,
|
||||
session_window_start: null,
|
||||
session_window_end: null,
|
||||
session_window_status: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('AccountStatusIndicator', () => {
|
||||
it('会将超量请求中的模型显示为独立状态', () => {
|
||||
const wrapper = mount(AccountStatusIndicator, {
|
||||
props: {
|
||||
account: makeAccount({
|
||||
id: 1,
|
||||
name: 'ag-1',
|
||||
extra: {
|
||||
antigravity_credits_overages: {
|
||||
'claude-sonnet-4-5': {
|
||||
activated_at: '2026-03-15T00:00:00Z',
|
||||
active_until: '2099-03-15T00:00:00Z'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
Icon: true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('⚡')
|
||||
expect(wrapper.text()).toContain('CSon45')
|
||||
})
|
||||
|
||||
it('普通模型限流仍显示原有限流状态', () => {
|
||||
const wrapper = mount(AccountStatusIndicator, {
|
||||
props: {
|
||||
account: makeAccount({
|
||||
id: 2,
|
||||
name: 'ag-2',
|
||||
extra: {
|
||||
model_rate_limits: {
|
||||
'claude-sonnet-4-5': {
|
||||
rate_limited_at: '2026-03-15T00:00:00Z',
|
||||
rate_limit_reset_at: '2099-03-15T00:00:00Z'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
Icon: true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('CSon45')
|
||||
expect(wrapper.text()).not.toContain('⚡')
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import AccountUsageCell from '../AccountUsageCell.vue'
|
||||
import type { Account } from '@/types'
|
||||
|
||||
const { getUsage } = vi.hoisted(() => ({
|
||||
getUsage: vi.fn()
|
||||
@@ -24,6 +25,35 @@ vi.mock('vue-i18n', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
function makeAccount(overrides: Partial<Account>): Account {
|
||||
return {
|
||||
id: 1,
|
||||
name: 'account',
|
||||
platform: 'antigravity',
|
||||
type: 'oauth',
|
||||
proxy_id: null,
|
||||
concurrency: 1,
|
||||
priority: 1,
|
||||
status: 'active',
|
||||
error_message: null,
|
||||
last_used_at: null,
|
||||
expires_at: null,
|
||||
auto_pause_on_expired: true,
|
||||
created_at: '2026-03-15T00:00:00Z',
|
||||
updated_at: '2026-03-15T00:00:00Z',
|
||||
schedulable: true,
|
||||
rate_limited_at: null,
|
||||
rate_limit_reset_at: null,
|
||||
overload_until: null,
|
||||
temp_unschedulable_until: null,
|
||||
temp_unschedulable_reason: null,
|
||||
session_window_start: null,
|
||||
session_window_end: null,
|
||||
session_window_status: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('AccountUsageCell', () => {
|
||||
beforeEach(() => {
|
||||
getUsage.mockReset()
|
||||
@@ -49,12 +79,12 @@ describe('AccountUsageCell', () => {
|
||||
|
||||
const wrapper = mount(AccountUsageCell, {
|
||||
props: {
|
||||
account: {
|
||||
account: makeAccount({
|
||||
id: 1001,
|
||||
platform: 'antigravity',
|
||||
type: 'oauth',
|
||||
extra: {}
|
||||
} as any
|
||||
})
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
@@ -72,6 +102,42 @@ describe('AccountUsageCell', () => {
|
||||
expect(wrapper.text()).toContain('admin.accounts.usageWindow.gemini3Image|70|2026-03-01T09:00:00Z')
|
||||
})
|
||||
|
||||
it('Antigravity 会显示 AI Credits 余额信息', async () => {
|
||||
getUsage.mockResolvedValue({
|
||||
ai_credits: [
|
||||
{
|
||||
credit_type: 'GOOGLE_ONE_AI',
|
||||
amount: 25,
|
||||
minimum_balance: 5
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const wrapper = mount(AccountUsageCell, {
|
||||
props: {
|
||||
account: makeAccount({
|
||||
id: 1002,
|
||||
platform: 'antigravity',
|
||||
type: 'oauth',
|
||||
extra: {}
|
||||
})
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
UsageProgressBar: true,
|
||||
AccountQuotaInfo: true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('admin.accounts.aiCreditsBalance')
|
||||
expect(wrapper.text()).toContain('GOOGLE_ONE_AI')
|
||||
expect(wrapper.text()).toContain('25')
|
||||
expect(wrapper.text()).toContain('(min 5)')
|
||||
})
|
||||
|
||||
|
||||
it('OpenAI OAuth 快照已过期时首屏会重新请求 usage', async () => {
|
||||
getUsage.mockResolvedValue({
|
||||
@@ -103,7 +169,7 @@ describe('AccountUsageCell', () => {
|
||||
|
||||
const wrapper = mount(AccountUsageCell, {
|
||||
props: {
|
||||
account: {
|
||||
account: makeAccount({
|
||||
id: 2000,
|
||||
platform: 'openai',
|
||||
type: 'oauth',
|
||||
@@ -114,7 +180,7 @@ describe('AccountUsageCell', () => {
|
||||
codex_7d_used_percent: 34,
|
||||
codex_7d_reset_at: '2026-03-13T12:00:00Z'
|
||||
}
|
||||
} as any
|
||||
})
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
@@ -137,7 +203,7 @@ describe('AccountUsageCell', () => {
|
||||
it('OpenAI OAuth 有现成快照且未限额时不会首屏请求 usage', async () => {
|
||||
const wrapper = mount(AccountUsageCell, {
|
||||
props: {
|
||||
account: {
|
||||
account: makeAccount({
|
||||
id: 2001,
|
||||
platform: 'openai',
|
||||
type: 'oauth',
|
||||
@@ -148,7 +214,7 @@ describe('AccountUsageCell', () => {
|
||||
codex_7d_used_percent: 34,
|
||||
codex_7d_reset_at: '2099-03-13T12:00:00Z'
|
||||
}
|
||||
} as any
|
||||
})
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
@@ -196,15 +262,15 @@ describe('AccountUsageCell', () => {
|
||||
}
|
||||
})
|
||||
|
||||
const wrapper = mount(AccountUsageCell, {
|
||||
props: {
|
||||
account: {
|
||||
id: 2002,
|
||||
platform: 'openai',
|
||||
type: 'oauth',
|
||||
extra: {}
|
||||
} as any
|
||||
},
|
||||
const wrapper = mount(AccountUsageCell, {
|
||||
props: {
|
||||
account: makeAccount({
|
||||
id: 2002,
|
||||
platform: 'openai',
|
||||
type: 'oauth',
|
||||
extra: {}
|
||||
})
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
UsageProgressBar: {
|
||||
@@ -256,16 +322,16 @@ describe('AccountUsageCell', () => {
|
||||
seven_day: null
|
||||
})
|
||||
|
||||
const wrapper = mount(AccountUsageCell, {
|
||||
props: {
|
||||
account: {
|
||||
id: 2003,
|
||||
platform: 'openai',
|
||||
type: 'oauth',
|
||||
updated_at: '2026-03-07T10:00:00Z',
|
||||
extra: {}
|
||||
} as any
|
||||
},
|
||||
const wrapper = mount(AccountUsageCell, {
|
||||
props: {
|
||||
account: makeAccount({
|
||||
id: 2003,
|
||||
platform: 'openai',
|
||||
type: 'oauth',
|
||||
updated_at: '2026-03-07T10:00:00Z',
|
||||
extra: {}
|
||||
})
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
UsageProgressBar: {
|
||||
@@ -324,19 +390,19 @@ describe('AccountUsageCell', () => {
|
||||
}
|
||||
})
|
||||
|
||||
const wrapper = mount(AccountUsageCell, {
|
||||
props: {
|
||||
account: {
|
||||
id: 2004,
|
||||
platform: 'openai',
|
||||
type: 'oauth',
|
||||
rate_limit_reset_at: '2099-03-07T12:00:00Z',
|
||||
extra: {
|
||||
codex_5h_used_percent: 0,
|
||||
codex_7d_used_percent: 0
|
||||
}
|
||||
} as any
|
||||
},
|
||||
const wrapper = mount(AccountUsageCell, {
|
||||
props: {
|
||||
account: makeAccount({
|
||||
id: 2004,
|
||||
platform: 'openai',
|
||||
type: 'oauth',
|
||||
rate_limit_reset_at: '2099-03-07T12:00:00Z',
|
||||
extra: {
|
||||
codex_5h_used_percent: 0,
|
||||
codex_7d_used_percent: 0
|
||||
}
|
||||
})
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
UsageProgressBar: {
|
||||
|
||||
@@ -1867,6 +1867,7 @@ export default {
|
||||
rateLimitedUntil: 'Rate limited and removed from scheduling. Auto resumes at {time}',
|
||||
rateLimitedAutoResume: 'Auto resumes in {time}',
|
||||
modelRateLimitedUntil: '{model} rate limited until {time}',
|
||||
modelCreditOveragesUntil: '{model} using AI Credits until {time}',
|
||||
overloadedUntil: 'Overloaded until {time}',
|
||||
viewTempUnschedDetails: 'View temp unschedulable details'
|
||||
},
|
||||
@@ -2239,6 +2240,7 @@ export default {
|
||||
mixedSchedulingHint: 'Enable to participate in Anthropic/Gemini group scheduling',
|
||||
mixedSchedulingTooltip:
|
||||
'!! WARNING !! Antigravity Claude and Anthropic Claude cannot be used in the same context. If you have both Anthropic and Antigravity accounts, enabling this option will cause frequent 400 errors. When enabled, please use the group feature to isolate Antigravity accounts from Anthropic accounts. Make sure you understand this before enabling!!',
|
||||
aiCreditsBalance: 'AI Credits',
|
||||
allowOverages: 'Allow Overages (AI Credits)',
|
||||
allowOveragesTooltip:
|
||||
'Only use AI Credits after free quota is explicitly exhausted. Ordinary concurrent 429 rate limits will not switch to overages.',
|
||||
|
||||
@@ -2052,6 +2052,7 @@ export default {
|
||||
rateLimitedUntil: '限流中,当前不参与调度,预计 {time} 自动恢复',
|
||||
rateLimitedAutoResume: '{time} 自动恢复',
|
||||
modelRateLimitedUntil: '{model} 限流至 {time}',
|
||||
modelCreditOveragesUntil: '{model} 正在使用 AI Credits,至 {time}',
|
||||
overloadedUntil: '负载过重,重置时间:{time}',
|
||||
viewTempUnschedDetails: '查看临时不可调度详情'
|
||||
},
|
||||
@@ -2389,6 +2390,7 @@ export default {
|
||||
mixedSchedulingHint: '启用后可参与 Anthropic/Gemini 分组的调度',
|
||||
mixedSchedulingTooltip:
|
||||
'!!注意!! Antigravity Claude 和 Anthropic Claude 无法在同个上下文中使用,如果你同时有 Anthropic 账号和 Antigravity 账号,开启此选项会导致经常 400 报错。开启后,请用分组功能做好 Antigravity 账号和 Anthropic 账号的隔离。一定要弄明白再开启!!',
|
||||
aiCreditsBalance: 'AI Credits',
|
||||
allowOverages: '允许超量请求 (AI Credits)',
|
||||
allowOveragesTooltip:
|
||||
'仅在免费配额被明确判定为耗尽后才会使用 AI Credits。普通并发 429 限流不会切换到超量请求。',
|
||||
|
||||
@@ -664,6 +664,7 @@ export interface Account {
|
||||
// Extra fields including Codex usage and model-level rate limits (Antigravity smart retry)
|
||||
extra?: (CodexUsageSnapshot & {
|
||||
model_rate_limits?: Record<string, { rate_limited_at: string; rate_limit_reset_at: string }>
|
||||
antigravity_credits_overages?: Record<string, { activated_at: string; active_until: string }>
|
||||
} & Record<string, unknown>)
|
||||
proxy_id: number | null
|
||||
concurrency: number
|
||||
@@ -780,6 +781,11 @@ export interface AccountUsageInfo {
|
||||
gemini_pro_minute?: UsageProgress | null
|
||||
gemini_flash_minute?: UsageProgress | null
|
||||
antigravity_quota?: Record<string, AntigravityModelQuota> | null
|
||||
ai_credits?: Array<{
|
||||
credit_type?: string
|
||||
amount?: number
|
||||
minimum_balance?: number
|
||||
}> | null
|
||||
// Antigravity 403 forbidden 状态
|
||||
is_forbidden?: boolean
|
||||
forbidden_reason?: string
|
||||
|
||||
Reference in New Issue
Block a user