mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 03:52:26 +08:00
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:
@@ -339,6 +339,13 @@ func (a *Account) IsGeminiGoogleOne() bool {
|
||||
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 {
|
||||
return a.Type == AccountTypeOAuth
|
||||
}
|
||||
|
||||
@@ -677,6 +677,72 @@ func TestHandleGeminiUpstreamError_APIKeyFallbackStillPSTMidnight(t *testing.T)
|
||||
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 {
|
||||
mockAccountRepoForGemini
|
||||
setErrorCalls int
|
||||
|
||||
@@ -3159,6 +3159,10 @@ func (s *GeminiMessagesCompatService) handleGeminiUpstreamError(ctx context.Cont
|
||||
if account.IsPoolMode() && !account.IsCustomErrorCodesEnabled() {
|
||||
return
|
||||
}
|
||||
if account.IsGeminiThirdPartyAPIKey() {
|
||||
s.handleGeminiThirdParty429(ctx, account, body)
|
||||
return
|
||||
}
|
||||
|
||||
oauthType := account.GeminiOAuthType()
|
||||
tierID := account.GeminiTierID()
|
||||
@@ -3207,16 +3211,36 @@ func (s *GeminiMessagesCompatService) handleGeminiUpstreamError(ctx context.Cont
|
||||
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 时间戳
|
||||
func ParseGeminiRateLimitResetTime(body []byte) *int64 {
|
||||
// 第一阶段:gjson 结构化提取
|
||||
errMsg := gjson.GetBytes(body, "error.message").String()
|
||||
if looksLikeGeminiDailyQuota(errMsg) {
|
||||
if ts := nextGeminiDailyResetUnix(); ts != nil {
|
||||
return ts
|
||||
}
|
||||
}
|
||||
return parseGeminiRetryDelayResetTime(body)
|
||||
}
|
||||
|
||||
// parseGeminiRetryDelayResetTime 只解析响应里显式给出的重试时长,返回重置时间的 Unix 时间戳。
|
||||
func parseGeminiRetryDelayResetTime(body []byte) *int64 {
|
||||
// 第一阶段:gjson 结构化提取
|
||||
// 遍历 error.details 查找 quotaResetDelay(AI Studio)或 RetryInfo.retryDelay(Vertex)
|
||||
var found *int64
|
||||
gjson.GetBytes(body, "error.details").ForEach(func(_, detail gjson.Result) bool {
|
||||
|
||||
@@ -373,6 +373,10 @@ func geminiQuotaTierKeyForAccount(account *Account) string {
|
||||
if account == nil || account.Platform != PlatformGemini {
|
||||
return ""
|
||||
}
|
||||
// 本地模拟配额只适用于按 Google 账号等级限额的通道;第三方上游与 Vertex 按量付费不套用。
|
||||
if account.IsGeminiThirdPartyAPIKey() || account.IsVertexServiceAccount() {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Note: GeminiOAuthType() already defaults legacy (project_id present) to code_assist.
|
||||
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>
|
||||
<!-- Help icon -->
|
||||
<span
|
||||
v-if="showGeminiQuotaPolicy"
|
||||
class="group relative cursor-help"
|
||||
>
|
||||
<svg
|
||||
@@ -579,7 +580,7 @@
|
||||
</p>
|
||||
</div>
|
||||
<!-- 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') }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -594,7 +595,7 @@
|
||||
<!-- Non-OAuth/Setup-Token accounts -->
|
||||
<div ref="rootRef" v-else>
|
||||
<!-- 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 -->
|
||||
<div v-else class="space-y-1">
|
||||
<OllamaCloudUsageCell
|
||||
@@ -682,6 +683,7 @@ import type { Account, AccountUsageInfo, GeminiCredentials, WindowStats } from '
|
||||
import { buildOpenAIUsageRefreshKey } from '@/utils/accountUsageRefresh'
|
||||
import { enqueueUsageRequest } from '@/utils/usageLoadQueue'
|
||||
import { formatCompactNumber } from '@/utils/format'
|
||||
import { isOfficialGeminiBaseUrl } from '@/utils/geminiExplicitCache'
|
||||
import UsageProgressBar from './UsageProgressBar.vue'
|
||||
import AccountQuotaInfo from './AccountQuotaInfo.vue'
|
||||
import ClaudeResetCreditsCell from './ClaudeResetCreditsCell.vue'
|
||||
@@ -753,10 +755,22 @@ let desktopViewportMediaQuery: MediaQueryList | null = null
|
||||
let desktopViewportListener: ((event: MediaQueryListEvent) => void) | 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
|
||||
const showUsageWindows = computed(() => {
|
||||
// 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 / 订阅)或余额,
|
||||
// 由 CNProviderQuotaCell / CNProviderBalanceCell 自行探测与展示。
|
||||
if (isMultiProtocolApiKeyPlatform(props.account.platform)) {
|
||||
@@ -770,7 +784,7 @@ const shouldFetchUsage = computed(() => {
|
||||
return props.account.type === 'oauth' || props.account.type === 'setup-token'
|
||||
}
|
||||
if (props.account.platform === 'gemini') {
|
||||
return true
|
||||
return !isGeminiThirdPartyApiKey.value && !isGeminiVertex.value
|
||||
}
|
||||
if (props.account.platform === 'antigravity') {
|
||||
return props.account.type === 'oauth'
|
||||
@@ -791,8 +805,9 @@ const cnBalanceCellVisible = computed(() => cnBalanceCellVisibleFn(props.account
|
||||
|
||||
const isBatchManaged = computed(() => typeof props.requestBatchedUsage === 'function')
|
||||
|
||||
// Vertex 没有本地模拟配额,用量窗口展示今日统计
|
||||
const showGeminiTodayStats = computed(() => {
|
||||
return props.account.platform === 'gemini' && props.account.type === 'service_account'
|
||||
return isGeminiVertex.value
|
||||
})
|
||||
|
||||
const geminiUsageAvailable = computed(() => {
|
||||
@@ -956,6 +971,10 @@ const geminiOAuthType = computed(() => {
|
||||
return (creds?.oauth_type || '').trim() || null
|
||||
})
|
||||
|
||||
const showGeminiQuotaPolicy = computed(() => {
|
||||
return !isGeminiVertex.value
|
||||
})
|
||||
|
||||
// Gemini 是否为 Code Assist OAuth
|
||||
const isGeminiCodeAssist = computed(() => {
|
||||
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)
|
||||
})
|
||||
|
||||
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 (isGeminiVertex.value) return 'vertex'
|
||||
// API Key accounts are 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 => {
|
||||
if (props.account.platform !== 'gemini') return null
|
||||
if (isGeminiVertex.value) return null
|
||||
|
||||
const tier = (geminiTier.value || '').toString().trim()
|
||||
const tierLower = tier.toLowerCase()
|
||||
@@ -1036,7 +1057,7 @@ const geminiTierClass = computed(() => {
|
||||
const channel = geminiChannelShort.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'
|
||||
}
|
||||
|
||||
|
||||
@@ -840,7 +840,8 @@
|
||||
</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"
|
||||
>
|
||||
<p>{{ t('admin.accounts.gemini.accountType.apiKeyNote') }}</p>
|
||||
@@ -1068,7 +1069,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
<div class="mt-2">
|
||||
<select
|
||||
@@ -1579,9 +1580,9 @@
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
<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_paid">{{ t('admin.accounts.gemini.tier.aiStudio.paid') }}</option>
|
||||
</select>
|
||||
@@ -6061,7 +6062,7 @@ const handleSubmit = async () => {
|
||||
base_url: apiKeyBaseUrl.value.trim() || defaultBaseUrl,
|
||||
api_key: apiKeyValue.value.trim()
|
||||
}
|
||||
if (form.platform === 'gemini') {
|
||||
if (form.platform === 'gemini' && isOfficialGeminiBaseUrl(apiKeyBaseUrl.value)) {
|
||||
credentials.tier_id = geminiTierAIStudio.value
|
||||
}
|
||||
|
||||
|
||||
@@ -1709,6 +1709,101 @@ describe('AccountUsageCell', () => {
|
||||
expect(wrapper.text()).toContain('0')
|
||||
expect(wrapper.text()).toContain('A $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 () => {
|
||||
|
||||
@@ -931,6 +931,58 @@ describe('CreateAccountModal gemini explicit cache upstream switch', () => {
|
||||
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', () => {
|
||||
|
||||
@@ -1445,8 +1445,8 @@ export default {
|
||||
accountType: {
|
||||
oauthTitle: 'OAuth (Gemini)',
|
||||
oauthDesc: 'Authorize with your Google account and choose an OAuth type.',
|
||||
apiKeyTitle: 'API Key (AI Studio)',
|
||||
apiKeyDesc: 'Fastest setup. Use an AIza API key.',
|
||||
apiKeyTitle: 'API Key',
|
||||
apiKeyDesc: 'An official AI Studio key, or a Gemini-compatible third-party upstream.',
|
||||
apiKeyNote:
|
||||
'Best for light testing. Free tier has strict rate limits and data may be used for training.',
|
||||
apiKeyLink: 'Get API Key',
|
||||
|
||||
@@ -1536,8 +1536,8 @@ export default {
|
||||
accountType: {
|
||||
oauthTitle: 'OAuth 授权(Gemini)',
|
||||
oauthDesc: '使用 Google 账号授权,并选择 OAuth 子类型。',
|
||||
apiKeyTitle: 'API 密钥(AI Studio)',
|
||||
apiKeyDesc: '最快接入方式,使用 AIza API Key。',
|
||||
apiKeyTitle: 'API 密钥',
|
||||
apiKeyDesc: 'AI Studio 官方 Key,或兼容 Gemini 协议的第三方上游。',
|
||||
apiKeyNote: '适合轻量测试。免费层限流严格,数据可能用于训练。',
|
||||
apiKeyLink: '获取 API Key',
|
||||
quotaLink: '配额说明'
|
||||
|
||||
@@ -1010,6 +1010,7 @@ export interface ProxyQualityCheckResult {
|
||||
export interface GeminiCredentials {
|
||||
// API Key authentication
|
||||
api_key?: string
|
||||
base_url?: string
|
||||
|
||||
// OAuth authentication
|
||||
access_token?: string
|
||||
|
||||
Reference in New Issue
Block a user