fix(gemini): 第三方上游 API Key 与 Vertex 不再套用 AI Studio 等级和模拟配额

- 按 base_url 判定上游:非官方地址的 Gemini API Key 账号视为第三方上游
- 第三方上游与 Vertex 不返回本地模拟配额,隐藏等级与配额说明;
  账号列表中第三方上游按普通 Key 账号展示今日统计与额度进度条,
  Vertex 显示 vertex 徽章与今日统计
- 第三方上游的 429 只认显式重试时长,否则走全局 429 兜底冷却,
  不按每日配额冷却到 PST 午夜
- 创建账号:第三方 Base URL 不显示 AI Studio 等级选项,也不写入 tier_id;
  API 密钥卡片标题与说明不再限定 AI Studio,免费层说明只在官方 Base URL 下显示;
  OAuth 等级回退下拉只在 OAuth 表单显示
This commit is contained in:
feeeei
2026-10-10 23:37:48 +08:00
parent 81e6ec5346
commit 2588675ce7
12 changed files with 368 additions and 17 deletions
+7
View File
@@ -339,6 +339,13 @@ func (a *Account) IsGeminiGoogleOne() bool {
return a.Platform == PlatformGemini && a.Type == AccountTypeOAuth && a.GeminiOAuthType() == "google_one" return a.Platform == PlatformGemini && a.Type == AccountTypeOAuth && a.GeminiOAuthType() == "google_one"
} }
// IsGeminiThirdPartyAPIKey reports whether a Gemini API Key account points at a
// non-Google base URL. Such upstreams carry no AI Studio tier or daily quota.
func (a *Account) IsGeminiThirdPartyAPIKey() bool {
return a != nil && a.Platform == PlatformGemini && a.Type == AccountTypeAPIKey &&
!isOfficialGeminiAPIBaseURL(a.GetCredential("base_url"))
}
func (a *Account) CanGetUsage() bool { func (a *Account) CanGetUsage() bool {
return a.Type == AccountTypeOAuth return a.Type == AccountTypeOAuth
} }
@@ -677,6 +677,72 @@ func TestHandleGeminiUpstreamError_APIKeyFallbackStillPSTMidnight(t *testing.T)
require.WithinDuration(t, expected, repo.lastRateLimitReset, 5*time.Second) require.WithinDuration(t, expected, repo.lastRateLimitReset, 5*time.Second)
} }
// 第三方上游 API Key 的 429 不套用 AI Studio 每日配额语义:只认显式重试时长,
// 否则走全局 429 兜底冷却(未配置时默认 5 秒)。
func TestHandleGeminiUpstreamError_ThirdPartyAPIKey429(t *testing.T) {
const relayBaseURL = "https://relay.example.com"
tests := []struct {
name string
baseURL string
body string
expectWait time.Duration
pstNight bool
}{
{
name: "no_reset_time_uses_global_fallback",
baseURL: relayBaseURL,
body: `{"error":{"code":429,"message":"You have exhausted your capacity on this model. Your quota will reset after 6h53m10s."}}`,
expectWait: defaultRateLimit429CooldownSeconds * time.Second,
},
{
name: "daily_quota_message_not_treated_as_pst_midnight",
baseURL: relayBaseURL,
body: `{"error":{"code":429,"message":"Quota exceeded for metric: generate_content_free_tier_requests, limit: 50 per day"}}`,
expectWait: defaultRateLimit429CooldownSeconds * time.Second,
},
{
name: "explicit_quota_reset_delay_respected",
baseURL: relayBaseURL,
body: `{"error":{"code":429,"message":"rate limit","details":[{"metadata":{"quotaResetDelay":"42s"}}]}}`,
expectWait: 42 * time.Second,
},
{
name: "official_base_url_keeps_pst_midnight",
baseURL: "https://generativelanguage.googleapis.com/",
body: `{"error":{"code":429,"message":"rate limit"}}`,
pstNight: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
repo := &rateLimit429AccountRepoStub{}
svc := &GeminiMessagesCompatService{
accountRepo: repo,
rateLimitService: NewRateLimitService(repo, nil, &config.Config{}, nil, nil),
}
account := &Account{
ID: 700,
Platform: PlatformGemini,
Type: AccountTypeAPIKey,
Credentials: map[string]any{"base_url": tt.baseURL},
}
before := time.Now()
svc.handleGeminiUpstreamError(context.Background(), account, http.StatusTooManyRequests, http.Header{}, []byte(tt.body))
require.Equal(t, 1, repo.rateLimitCalls)
require.Equal(t, int64(700), repo.lastRateLimitID)
if tt.pstNight {
expected := time.Unix(*nextGeminiDailyResetUnix(), 0)
require.WithinDuration(t, expected, repo.lastRateLimitReset, 5*time.Second)
return
}
require.WithinDuration(t, before.Add(tt.expectWait), repo.lastRateLimitReset, 2*time.Second)
})
}
}
type geminiErrorPolicyRepo struct { type geminiErrorPolicyRepo struct {
mockAccountRepoForGemini mockAccountRepoForGemini
setErrorCalls int setErrorCalls int
@@ -3159,6 +3159,10 @@ func (s *GeminiMessagesCompatService) handleGeminiUpstreamError(ctx context.Cont
if account.IsPoolMode() && !account.IsCustomErrorCodesEnabled() { if account.IsPoolMode() && !account.IsCustomErrorCodesEnabled() {
return return
} }
if account.IsGeminiThirdPartyAPIKey() {
s.handleGeminiThirdParty429(ctx, account, body)
return
}
oauthType := account.GeminiOAuthType() oauthType := account.GeminiOAuthType()
tierID := account.GeminiTierID() tierID := account.GeminiTierID()
@@ -3207,16 +3211,36 @@ func (s *GeminiMessagesCompatService) handleGeminiUpstreamError(ctx context.Cont
account.ID, resetTime, oauthType, tierID) account.ID, resetTime, oauthType, tierID)
} }
// handleGeminiThirdParty429 处理第三方上游的 429:不套用 AI Studio 每日配额语义,
// 只认响应里显式给出的重试时间,否则走全局 429 兜底冷却。
func (s *GeminiMessagesCompatService) handleGeminiThirdParty429(ctx context.Context, account *Account, body []byte) {
if resetAt := parseGeminiRetryDelayResetTime(body); resetAt != nil {
resetTime := time.Unix(*resetAt, 0)
_ = s.accountRepo.SetRateLimited(ctx, account.ID, resetTime)
logger.LegacyPrintf("service.gemini_messages_compat", "[Gemini 429] Account %d (third-party API Key) rate limited until %v", account.ID, resetTime)
return
}
if s.rateLimitService != nil {
s.rateLimitService.apply429FallbackRateLimit(ctx, account, "gemini_third_party_no_reset_time")
return
}
_ = s.accountRepo.SetRateLimited(ctx, account.ID, time.Now().Add(defaultRateLimit429CooldownSeconds*time.Second))
}
// ParseGeminiRateLimitResetTime 解析 Gemini 格式的 429 响应,返回重置时间的 Unix 时间戳 // ParseGeminiRateLimitResetTime 解析 Gemini 格式的 429 响应,返回重置时间的 Unix 时间戳
func ParseGeminiRateLimitResetTime(body []byte) *int64 { func ParseGeminiRateLimitResetTime(body []byte) *int64 {
// 第一阶段:gjson 结构化提取
errMsg := gjson.GetBytes(body, "error.message").String() errMsg := gjson.GetBytes(body, "error.message").String()
if looksLikeGeminiDailyQuota(errMsg) { if looksLikeGeminiDailyQuota(errMsg) {
if ts := nextGeminiDailyResetUnix(); ts != nil { if ts := nextGeminiDailyResetUnix(); ts != nil {
return ts return ts
} }
} }
return parseGeminiRetryDelayResetTime(body)
}
// parseGeminiRetryDelayResetTime 只解析响应里显式给出的重试时长,返回重置时间的 Unix 时间戳。
func parseGeminiRetryDelayResetTime(body []byte) *int64 {
// 第一阶段:gjson 结构化提取
// 遍历 error.details 查找 quotaResetDelay(AI Studio)或 RetryInfo.retryDelay(Vertex) // 遍历 error.details 查找 quotaResetDelay(AI Studio)或 RetryInfo.retryDelay(Vertex)
var found *int64 var found *int64
gjson.GetBytes(body, "error.details").ForEach(func(_, detail gjson.Result) bool { gjson.GetBytes(body, "error.details").ForEach(func(_, detail gjson.Result) bool {
+4
View File
@@ -373,6 +373,10 @@ func geminiQuotaTierKeyForAccount(account *Account) string {
if account == nil || account.Platform != PlatformGemini { if account == nil || account.Platform != PlatformGemini {
return "" return ""
} }
// 本地模拟配额只适用于按 Google 账号等级限额的通道;第三方上游与 Vertex 按量付费不套用。
if account.IsGeminiThirdPartyAPIKey() || account.IsVertexServiceAccount() {
return ""
}
// Note: GeminiOAuthType() already defaults legacy (project_id present) to code_assist. // Note: GeminiOAuthType() already defaults legacy (project_id present) to code_assist.
oauthType := strings.ToLower(strings.TrimSpace(account.GeminiOAuthType())) oauthType := strings.ToLower(strings.TrimSpace(account.GeminiOAuthType()))
@@ -0,0 +1,80 @@
//go:build unit
package service
import (
"context"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/stretchr/testify/require"
)
func TestAccountIsGeminiThirdPartyAPIKey(t *testing.T) {
tests := []struct {
name string
account *Account
expected bool
}{
{"nil_account", nil, false},
{"empty_base_url_is_official", geminiQuotaAPIKeyAccount(""), false},
{"official_base_url", geminiQuotaAPIKeyAccount("https://generativelanguage.googleapis.com"), false},
{"official_base_url_trailing_slash", geminiQuotaAPIKeyAccount("https://generativelanguage.googleapis.com/"), false},
{"official_host_over_http", geminiQuotaAPIKeyAccount("http://generativelanguage.googleapis.com"), true},
{"official_host_with_path_prefix", geminiQuotaAPIKeyAccount("https://generativelanguage.googleapis.com/proxy"), true},
{"relay_base_url", geminiQuotaAPIKeyAccount("https://relay.example.com"), true},
{"oauth_account", &Account{Platform: PlatformGemini, Type: AccountTypeOAuth, Credentials: map[string]any{"base_url": "https://relay.example.com"}}, false},
{"vertex_service_account", &Account{Platform: PlatformGemini, Type: AccountTypeServiceAccount}, false},
{"non_gemini_platform", &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{"base_url": "https://relay.example.com"}}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
require.Equal(t, tt.expected, tt.account.IsGeminiThirdPartyAPIKey())
})
}
}
func TestGeminiQuotaService_QuotaForAccountSkipsThirdPartyAndVertex(t *testing.T) {
svc := NewGeminiQuotaService(&config.Config{}, nil)
ctx := context.Background()
quota, ok := svc.QuotaForAccount(ctx, geminiQuotaAPIKeyAccount(""))
require.True(t, ok)
require.Equal(t, int64(50), quota.ProRPD)
paid := geminiQuotaAPIKeyAccount("https://generativelanguage.googleapis.com")
paid.Credentials["tier_id"] = GeminiTierAIStudioPaid
quota, ok = svc.QuotaForAccount(ctx, paid)
require.True(t, ok)
require.Equal(t, int64(-1), quota.ProRPD)
relay := geminiQuotaAPIKeyAccount("https://relay.example.com")
relay.Credentials["tier_id"] = GeminiTierAIStudioFree
_, ok = svc.QuotaForAccount(ctx, relay)
require.False(t, ok, "第三方上游不套用 AI Studio 模拟配额")
vertex := &Account{
Platform: PlatformGemini,
Type: AccountTypeServiceAccount,
Credentials: map[string]any{"project_id": "my-project", "tier_id": "vertex"},
}
_, ok = svc.QuotaForAccount(ctx, vertex)
require.False(t, ok, "Vertex 按量付费不套用模拟配额")
googleOne := &Account{
Platform: PlatformGemini,
Type: AccountTypeOAuth,
Credentials: map[string]any{"oauth_type": "google_one", "tier_id": GeminiTierGoogleOneFree},
}
quota, ok = svc.QuotaForAccount(ctx, googleOne)
require.True(t, ok)
require.Positive(t, quota.SharedRPD)
}
func geminiQuotaAPIKeyAccount(baseURL string) *Account {
credentials := map[string]any{}
if baseURL != "" {
credentials["base_url"] = baseURL
}
return &Account{Platform: PlatformGemini, Type: AccountTypeAPIKey, Credentials: credentials}
}
@@ -489,6 +489,7 @@
</span> </span>
<!-- Help icon --> <!-- Help icon -->
<span <span
v-if="showGeminiQuotaPolicy"
class="group relative cursor-help" class="group relative cursor-help"
> >
<svg <svg
@@ -579,7 +580,7 @@
</p> </p>
</div> </div>
<!-- AI Studio Client OAuth: show unlimited flow (no usage tracking) --> <!-- AI Studio Client OAuth: show unlimited flow (no usage tracking) -->
<div v-else class="text-xs text-gray-400"> <div v-else-if="!showGeminiTodayStats" class="text-xs text-gray-400">
{{ t('admin.accounts.gemini.rateLimit.unlimited') }} {{ t('admin.accounts.gemini.rateLimit.unlimited') }}
</div> </div>
</div> </div>
@@ -594,7 +595,7 @@
<!-- Non-OAuth/Setup-Token accounts --> <!-- Non-OAuth/Setup-Token accounts -->
<div ref="rootRef" v-else> <div ref="rootRef" v-else>
<!-- Gemini API Key accounts: show quota info --> <!-- Gemini API Key accounts: show quota info -->
<AccountQuotaInfo v-if="account.platform === 'gemini'" :account="account" /> <AccountQuotaInfo v-if="account.platform === 'gemini' && !isGeminiThirdPartyApiKey" :account="account" />
<!-- Key/Bedrock accounts: show today stats + optional quota bars --> <!-- Key/Bedrock accounts: show today stats + optional quota bars -->
<div v-else class="space-y-1"> <div v-else class="space-y-1">
<OllamaCloudUsageCell <OllamaCloudUsageCell
@@ -682,6 +683,7 @@ import type { Account, AccountUsageInfo, GeminiCredentials, WindowStats } from '
import { buildOpenAIUsageRefreshKey } from '@/utils/accountUsageRefresh' import { buildOpenAIUsageRefreshKey } from '@/utils/accountUsageRefresh'
import { enqueueUsageRequest } from '@/utils/usageLoadQueue' import { enqueueUsageRequest } from '@/utils/usageLoadQueue'
import { formatCompactNumber } from '@/utils/format' import { formatCompactNumber } from '@/utils/format'
import { isOfficialGeminiBaseUrl } from '@/utils/geminiExplicitCache'
import UsageProgressBar from './UsageProgressBar.vue' import UsageProgressBar from './UsageProgressBar.vue'
import AccountQuotaInfo from './AccountQuotaInfo.vue' import AccountQuotaInfo from './AccountQuotaInfo.vue'
import ClaudeResetCreditsCell from './ClaudeResetCreditsCell.vue' import ClaudeResetCreditsCell from './ClaudeResetCreditsCell.vue'
@@ -753,10 +755,22 @@ let desktopViewportMediaQuery: MediaQueryList | null = null
let desktopViewportListener: ((event: MediaQueryListEvent) => void) | null = null let desktopViewportListener: ((event: MediaQueryListEvent) => void) | null = null
let visibilityObserver: IntersectionObserver | null = null let visibilityObserver: IntersectionObserver | null = null
// Gemini 第三方上游 API Key:没有 AI Studio 等级与本地模拟配额
const isGeminiThirdPartyApiKey = computed(() => {
if (props.account.platform !== 'gemini' || props.account.type !== 'apikey') return false
const creds = props.account.credentials as GeminiCredentials | undefined
return !isOfficialGeminiBaseUrl(creds?.base_url)
})
const isGeminiVertex = computed(() => {
return props.account.platform === 'gemini' && props.account.type === 'service_account'
})
// Show usage windows for OAuth and Setup Token accounts // Show usage windows for OAuth and Setup Token accounts
const showUsageWindows = computed(() => { const showUsageWindows = computed(() => {
// Gemini: we can always compute local usage windows from DB logs (simulated quotas). // Gemini: we can always compute local usage windows from DB logs (simulated quotas).
if (props.account.platform === 'gemini') return true // 第三方上游 API Key 没有模拟配额,按普通 Key 账号展示今日统计与额度进度条。
if (props.account.platform === 'gemini') return !isGeminiThirdPartyApiKey.value
// 多协议 API Key 供应商:apikey 账号也有滚动用量窗口(coding plan / 订阅)或余额, // 多协议 API Key 供应商:apikey 账号也有滚动用量窗口(coding plan / 订阅)或余额,
// 由 CNProviderQuotaCell / CNProviderBalanceCell 自行探测与展示。 // 由 CNProviderQuotaCell / CNProviderBalanceCell 自行探测与展示。
if (isMultiProtocolApiKeyPlatform(props.account.platform)) { if (isMultiProtocolApiKeyPlatform(props.account.platform)) {
@@ -770,7 +784,7 @@ const shouldFetchUsage = computed(() => {
return props.account.type === 'oauth' || props.account.type === 'setup-token' return props.account.type === 'oauth' || props.account.type === 'setup-token'
} }
if (props.account.platform === 'gemini') { if (props.account.platform === 'gemini') {
return true return !isGeminiThirdPartyApiKey.value && !isGeminiVertex.value
} }
if (props.account.platform === 'antigravity') { if (props.account.platform === 'antigravity') {
return props.account.type === 'oauth' return props.account.type === 'oauth'
@@ -791,8 +805,9 @@ const cnBalanceCellVisible = computed(() => cnBalanceCellVisibleFn(props.account
const isBatchManaged = computed(() => typeof props.requestBatchedUsage === 'function') const isBatchManaged = computed(() => typeof props.requestBatchedUsage === 'function')
// Vertex 没有本地模拟配额,用量窗口展示今日统计
const showGeminiTodayStats = computed(() => { const showGeminiTodayStats = computed(() => {
return props.account.platform === 'gemini' && props.account.type === 'service_account' return isGeminiVertex.value
}) })
const geminiUsageAvailable = computed(() => { const geminiUsageAvailable = computed(() => {
@@ -956,6 +971,10 @@ const geminiOAuthType = computed(() => {
return (creds?.oauth_type || '').trim() || null return (creds?.oauth_type || '').trim() || null
}) })
const showGeminiQuotaPolicy = computed(() => {
return !isGeminiVertex.value
})
// Gemini 是否为 Code Assist OAuth // Gemini 是否为 Code Assist OAuth
const isGeminiCodeAssist = computed(() => { const isGeminiCodeAssist = computed(() => {
if (props.account.platform !== 'gemini') return false if (props.account.platform !== 'gemini') return false
@@ -963,9 +982,10 @@ const isGeminiCodeAssist = computed(() => {
return creds?.oauth_type === 'code_assist' || (!creds?.oauth_type && !!creds?.project_id) return creds?.oauth_type === 'code_assist' || (!creds?.oauth_type && !!creds?.project_id)
}) })
const geminiChannelShort = computed((): 'ai studio' | 'gcp' | 'google one' | 'client' | null => { const geminiChannelShort = computed((): 'ai studio' | 'vertex' | 'gcp' | 'google one' | 'client' | null => {
if (props.account.platform !== 'gemini') return null if (props.account.platform !== 'gemini') return null
if (isGeminiVertex.value) return 'vertex'
// API Key accounts are AI Studio. // API Key accounts are AI Studio.
if (props.account.type === 'apikey') return 'ai studio' if (props.account.type === 'apikey') return 'ai studio'
@@ -979,6 +999,7 @@ const geminiChannelShort = computed((): 'ai studio' | 'gcp' | 'google one' | 'cl
const geminiUserLevel = computed((): string | null => { const geminiUserLevel = computed((): string | null => {
if (props.account.platform !== 'gemini') return null if (props.account.platform !== 'gemini') return null
if (isGeminiVertex.value) return null
const tier = (geminiTier.value || '').toString().trim() const tier = (geminiTier.value || '').toString().trim()
const tierLower = tier.toLowerCase() const tierLower = tier.toLowerCase()
@@ -1036,7 +1057,7 @@ const geminiTierClass = computed(() => {
const channel = geminiChannelShort.value const channel = geminiChannelShort.value
const level = geminiUserLevel.value const level = geminiUserLevel.value
if (channel === 'client' || channel === 'ai studio') { if (channel === 'client' || channel === 'ai studio' || channel === 'vertex') {
return 'bg-blue-100 text-blue-600 dark:bg-blue-900/40 dark:text-blue-300' return 'bg-blue-100 text-blue-600 dark:bg-blue-900/40 dark:text-blue-300'
} }
@@ -840,7 +840,8 @@
</div> </div>
<div <div
v-if="accountCategory === 'apikey'" v-if="accountCategory === 'apikey' && isOfficialGeminiBaseUrl(apiKeyBaseUrl)"
data-testid="gemini-apikey-note"
class="mt-3 rounded-lg border border-purple-200 bg-purple-50 px-3 py-2 text-xs text-purple-800 dark:border-purple-800/40 dark:bg-purple-900/20 dark:text-purple-200" class="mt-3 rounded-lg border border-purple-200 bg-purple-50 px-3 py-2 text-xs text-purple-800 dark:border-purple-800/40 dark:bg-purple-900/20 dark:text-purple-200"
> >
<p>{{ t('admin.accounts.gemini.accountType.apiKeyNote') }}</p> <p>{{ t('admin.accounts.gemini.accountType.apiKeyNote') }}</p>
@@ -1068,7 +1069,7 @@
</div> </div>
<!-- Tier selection (used as fallback when auto-detection is unavailable/fails) --> <!-- Tier selection (used as fallback when auto-detection is unavailable/fails) -->
<div v-if="accountCategory !== 'service_account'" class="mt-4"> <div v-if="accountCategory === 'oauth-based'" class="mt-4" data-testid="gemini-oauth-tier">
<label class="input-label">{{ t('admin.accounts.gemini.tier.label') }}</label> <label class="input-label">{{ t('admin.accounts.gemini.tier.label') }}</label>
<div class="mt-2"> <div class="mt-2">
<select <select
@@ -1579,9 +1580,9 @@
</div> </div>
<!-- Gemini API Key tier selection --> <!-- Gemini API Key tier selection -->
<div v-if="form.platform === 'gemini'"> <div v-if="form.platform === 'gemini' && isOfficialGeminiBaseUrl(apiKeyBaseUrl)">
<label class="input-label">{{ t('admin.accounts.gemini.tier.label') }}</label> <label class="input-label">{{ t('admin.accounts.gemini.tier.label') }}</label>
<select v-model="geminiTierAIStudio" class="input"> <select v-model="geminiTierAIStudio" class="input" data-testid="gemini-apikey-tier">
<option value="aistudio_free">{{ t('admin.accounts.gemini.tier.aiStudio.free') }}</option> <option value="aistudio_free">{{ t('admin.accounts.gemini.tier.aiStudio.free') }}</option>
<option value="aistudio_paid">{{ t('admin.accounts.gemini.tier.aiStudio.paid') }}</option> <option value="aistudio_paid">{{ t('admin.accounts.gemini.tier.aiStudio.paid') }}</option>
</select> </select>
@@ -6061,7 +6062,7 @@ const handleSubmit = async () => {
base_url: apiKeyBaseUrl.value.trim() || defaultBaseUrl, base_url: apiKeyBaseUrl.value.trim() || defaultBaseUrl,
api_key: apiKeyValue.value.trim() api_key: apiKeyValue.value.trim()
} }
if (form.platform === 'gemini') { if (form.platform === 'gemini' && isOfficialGeminiBaseUrl(apiKeyBaseUrl.value)) {
credentials.tier_id = geminiTierAIStudio.value credentials.tier_id = geminiTierAIStudio.value
} }
@@ -1709,6 +1709,101 @@ describe('AccountUsageCell', () => {
expect(wrapper.text()).toContain('0') expect(wrapper.text()).toContain('0')
expect(wrapper.text()).toContain('A $0.00') expect(wrapper.text()).toContain('A $0.00')
expect(wrapper.text()).toContain('U $0.00') expect(wrapper.text()).toContain('U $0.00')
expect(wrapper.text()).toContain('vertex')
expect(wrapper.text()).not.toContain('admin.accounts.gemini.quotaPolicy.title')
expect(wrapper.text()).not.toContain('admin.accounts.gemini.rateLimit.unlimited')
expect(getUsage).not.toHaveBeenCalled()
})
it('Gemini 第三方上游 API Key 按普通 Key 账号展示今日统计与额度,不显示 Gemini 徽章', async () => {
const wrapper = mount(AccountUsageCell, {
props: {
account: makeAccount({
id: 4002,
platform: 'gemini',
type: 'apikey',
credentials: {
base_url: 'https://relay.example.com/gemini',
api_key: 'sk-relay',
tier_id: 'aistudio_free'
},
extra: {},
quota_daily_limit: 10,
quota_daily_used: 2.5
}),
todayStats: {
requests: 123,
tokens: 2300000,
cost: 1.79,
standard_cost: 1.79,
user_cost: 0
}
},
global: {
stubs: {
UsageProgressBar: true,
AccountQuotaInfo: true
}
}
})
await flushPromises()
const text = wrapper.text()
expect(text).not.toContain('api key')
expect(text).not.toContain('ai studio')
expect(text).not.toContain('free')
expect(text).not.toContain('relay.example.com')
expect(wrapper.find('account-quota-info-stub').exists()).toBe(false)
expect(text).toContain('123 req')
expect(text).toContain('A $1.79')
expect(text).toContain('U $0.00')
const bars = wrapper.findAll('usage-progress-bar-stub')
expect(bars.map((bar) => bar.attributes('label'))).toEqual(['1d'])
expect(text).not.toContain('admin.accounts.gemini.quotaPolicy.title')
expect(text).not.toContain('admin.accounts.gemini.quotaPolicy.simulatedNote')
expect(text).not.toContain('admin.accounts.gemini.rateLimit.unlimited')
expect(getUsage).not.toHaveBeenCalled()
})
it('Gemini 官方 API Key 保留 AI Studio 等级与模拟配额', async () => {
getUsage.mockResolvedValue({
gemini_pro_daily: {
utilization: 20,
resets_at: '2026-10-11T07:00:00Z',
remaining_seconds: 3600
}
})
const wrapper = mount(AccountUsageCell, {
props: {
account: makeAccount({
id: 4003,
platform: 'gemini',
type: 'apikey',
credentials: {
base_url: 'https://generativelanguage.googleapis.com',
api_key: 'AIza-official',
tier_id: 'aistudio_paid'
},
extra: {}
})
},
global: {
stubs: {
UsageProgressBar: true,
AccountQuotaInfo: true
}
}
})
await flushPromises()
const text = wrapper.text()
expect(text).toContain('ai studio paid')
expect(text).toContain('admin.accounts.gemini.quotaPolicy.title')
expect(text).toContain('admin.accounts.gemini.quotaPolicy.simulatedNote')
expect(getUsage).toHaveBeenCalledWith(4003)
}) })
it('Anthropic OAuth 会渲染 7d F (Fable) 进度条,且 7d S 逻辑保留', async () => { it('Anthropic OAuth 会渲染 7d F (Fable) 进度条,且 7d S 逻辑保留', async () => {
@@ -931,6 +931,58 @@ describe('CreateAccountModal gemini explicit cache upstream switch', () => {
explicit_cache_upstream_max_ttl_seconds: 1800 explicit_cache_upstream_max_ttl_seconds: 1800
}) })
}) })
it('only offers the AI Studio tier for the official base url', async () => {
const wrapper = mountModal()
await selectButtonByText(wrapper, 'Gemini')
await selectButtonByText(wrapper, 'admin.accounts.gemini.accountType.apiKeyTitle')
expect(wrapper.find('[data-testid="gemini-oauth-tier"]').exists()).toBe(false)
expect(wrapper.find('[data-testid="gemini-apikey-note"]').exists()).toBe(true)
await wrapper.get('[data-testid="gemini-apikey-tier"]').setValue('aistudio_paid')
const baseUrlInput = wrapper
.findAll('form#create-account-form input[type="text"]')
.find((input) => (input.element as HTMLInputElement).value === 'https://generativelanguage.googleapis.com')
expect(baseUrlInput).toBeDefined()
await baseUrlInput!.setValue('https://upstream.example.com')
expect(wrapper.find('[data-testid="gemini-apikey-tier"]').exists()).toBe(false)
expect(wrapper.find('[data-testid="gemini-apikey-note"]').exists()).toBe(false)
await wrapper.get('form#create-account-form input[type="text"]').setValue('gemini relay')
await wrapper.get('form#create-account-form input[type="password"]').setValue('sk-upstream')
await wrapper.get('form#create-account-form').trigger('submit.prevent')
await flushPromises()
expect(createAccountMock).toHaveBeenCalledTimes(1)
const credentials = createAccountMock.mock.calls[0]?.[0]?.credentials
expect(credentials).toMatchObject({ base_url: 'https://upstream.example.com' })
expect(credentials).not.toHaveProperty('tier_id')
})
it('shows the OAuth tier fallback only on the OAuth form', async () => {
const wrapper = mountModal()
await selectButtonByText(wrapper, 'Gemini')
await selectButtonByText(wrapper, 'admin.accounts.gemini.accountType.oauthTitle')
expect(wrapper.find('[data-testid="gemini-oauth-tier"]').exists()).toBe(true)
expect(wrapper.find('[data-testid="gemini-apikey-note"]').exists()).toBe(false)
})
it('keeps the selected AI Studio tier for the official base url', async () => {
const wrapper = mountModal()
await selectButtonByText(wrapper, 'Gemini')
await selectButtonByText(wrapper, 'admin.accounts.gemini.accountType.apiKeyTitle')
await wrapper.get('[data-testid="gemini-apikey-tier"]').setValue('aistudio_paid')
await wrapper.get('form#create-account-form input[type="text"]').setValue('gemini official')
await wrapper.get('form#create-account-form input[type="password"]').setValue('AIza-official')
await wrapper.get('form#create-account-form').trigger('submit.prevent')
await flushPromises()
expect(createAccountMock).toHaveBeenCalledTimes(1)
expect(createAccountMock.mock.calls[0]?.[0]?.credentials).toMatchObject({
base_url: 'https://generativelanguage.googleapis.com',
tier_id: 'aistudio_paid'
})
})
}) })
describe('CreateAccountModal Vertex API key auth', () => { describe('CreateAccountModal Vertex API key auth', () => {
@@ -1445,8 +1445,8 @@ export default {
accountType: { accountType: {
oauthTitle: 'OAuth (Gemini)', oauthTitle: 'OAuth (Gemini)',
oauthDesc: 'Authorize with your Google account and choose an OAuth type.', oauthDesc: 'Authorize with your Google account and choose an OAuth type.',
apiKeyTitle: 'API Key (AI Studio)', apiKeyTitle: 'API Key',
apiKeyDesc: 'Fastest setup. Use an AIza API key.', apiKeyDesc: 'An official AI Studio key, or a Gemini-compatible third-party upstream.',
apiKeyNote: apiKeyNote:
'Best for light testing. Free tier has strict rate limits and data may be used for training.', 'Best for light testing. Free tier has strict rate limits and data may be used for training.',
apiKeyLink: 'Get API Key', apiKeyLink: 'Get API Key',
@@ -1536,8 +1536,8 @@ export default {
accountType: { accountType: {
oauthTitle: 'OAuth 授权(Gemini)', oauthTitle: 'OAuth 授权(Gemini)',
oauthDesc: '使用 Google 账号授权,并选择 OAuth 子类型。', oauthDesc: '使用 Google 账号授权,并选择 OAuth 子类型。',
apiKeyTitle: 'API 密钥(AI Studio)', apiKeyTitle: 'API 密钥',
apiKeyDesc: '最快接入方式,使用 AIza API Key。', apiKeyDesc: 'AI Studio 官方 Key,或兼容 Gemini 协议的第三方上游。',
apiKeyNote: '适合轻量测试。免费层限流严格,数据可能用于训练。', apiKeyNote: '适合轻量测试。免费层限流严格,数据可能用于训练。',
apiKeyLink: '获取 API Key', apiKeyLink: '获取 API Key',
quotaLink: '配额说明' quotaLink: '配额说明'
+1
View File
@@ -1010,6 +1010,7 @@ export interface ProxyQualityCheckResult {
export interface GeminiCredentials { export interface GeminiCredentials {
// API Key authentication // API Key authentication
api_key?: string api_key?: string
base_url?: string
// OAuth authentication // OAuth authentication
access_token?: string access_token?: string