From 2588675ce7c0fa13189379489cc4edabe48f6fcd Mon Sep 17 00:00:00 2001 From: feeeei Date: Sat, 10 Oct 2026 22:22:29 +0800 Subject: [PATCH] =?UTF-8?q?fix(gemini):=20=E7=AC=AC=E4=B8=89=E6=96=B9?= =?UTF-8?q?=E4=B8=8A=E6=B8=B8=20API=20Key=20=E4=B8=8E=20Vertex=20=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E5=A5=97=E7=94=A8=20AI=20Studio=20=E7=AD=89=E7=BA=A7?= =?UTF-8?q?=E5=92=8C=E6=A8=A1=E6=8B=9F=E9=85=8D=E9=A2=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 按 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 表单显示 --- backend/internal/service/account.go | 7 ++ .../service/gemini_error_policy_test.go | 66 +++++++++++++ .../service/gemini_messages_compat_service.go | 26 ++++- backend/internal/service/gemini_quota.go | 4 + backend/internal/service/gemini_quota_test.go | 80 ++++++++++++++++ .../components/account/AccountUsageCell.vue | 35 +++++-- .../components/account/CreateAccountModal.vue | 11 ++- .../__tests__/AccountUsageCell.spec.ts | 95 +++++++++++++++++++ .../__tests__/CreateAccountModal.spec.ts | 52 ++++++++++ .../src/i18n/locales/en/admin/accounts.ts | 4 +- .../src/i18n/locales/zh/admin/accounts.ts | 4 +- frontend/src/types/index.ts | 1 + 12 files changed, 368 insertions(+), 17 deletions(-) create mode 100644 backend/internal/service/gemini_quota_test.go diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go index f5ce7c8de0..d394e551a2 100644 --- a/backend/internal/service/account.go +++ b/backend/internal/service/account.go @@ -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 } diff --git a/backend/internal/service/gemini_error_policy_test.go b/backend/internal/service/gemini_error_policy_test.go index c868cf6417..2f119ff5e4 100644 --- a/backend/internal/service/gemini_error_policy_test.go +++ b/backend/internal/service/gemini_error_policy_test.go @@ -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 diff --git a/backend/internal/service/gemini_messages_compat_service.go b/backend/internal/service/gemini_messages_compat_service.go index dec439ea8b..a6991ea70c 100644 --- a/backend/internal/service/gemini_messages_compat_service.go +++ b/backend/internal/service/gemini_messages_compat_service.go @@ -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 { diff --git a/backend/internal/service/gemini_quota.go b/backend/internal/service/gemini_quota.go index 3a70232c87..be927754cf 100644 --- a/backend/internal/service/gemini_quota.go +++ b/backend/internal/service/gemini_quota.go @@ -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())) diff --git a/backend/internal/service/gemini_quota_test.go b/backend/internal/service/gemini_quota_test.go new file mode 100644 index 0000000000..36e60f291e --- /dev/null +++ b/backend/internal/service/gemini_quota_test.go @@ -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} +} diff --git a/frontend/src/components/account/AccountUsageCell.vue b/frontend/src/components/account/AccountUsageCell.vue index fd5b9177bc..6bf5f12af5 100644 --- a/frontend/src/components/account/AccountUsageCell.vue +++ b/frontend/src/components/account/AccountUsageCell.vue @@ -489,6 +489,7 @@ -
+
{{ t('admin.accounts.gemini.rateLimit.unlimited') }}
@@ -594,7 +595,7 @@
- +
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' } diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue index 5ea21bbb87..2dccc59363 100644 --- a/frontend/src/components/account/CreateAccountModal.vue +++ b/frontend/src/components/account/CreateAccountModal.vue @@ -840,7 +840,8 @@

{{ t('admin.accounts.gemini.accountType.apiKeyNote') }}

@@ -1068,7 +1069,7 @@
-
+
+ @@ -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 } diff --git a/frontend/src/components/account/__tests__/AccountUsageCell.spec.ts b/frontend/src/components/account/__tests__/AccountUsageCell.spec.ts index 71c5f5d7a5..d595102f66 100644 --- a/frontend/src/components/account/__tests__/AccountUsageCell.spec.ts +++ b/frontend/src/components/account/__tests__/AccountUsageCell.spec.ts @@ -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 () => { diff --git a/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts b/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts index 3d74b506d6..84cfa81123 100644 --- a/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts +++ b/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts @@ -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', () => { diff --git a/frontend/src/i18n/locales/en/admin/accounts.ts b/frontend/src/i18n/locales/en/admin/accounts.ts index 95e8042471..317e8ec933 100644 --- a/frontend/src/i18n/locales/en/admin/accounts.ts +++ b/frontend/src/i18n/locales/en/admin/accounts.ts @@ -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', diff --git a/frontend/src/i18n/locales/zh/admin/accounts.ts b/frontend/src/i18n/locales/zh/admin/accounts.ts index 129225846e..3e9c06fefa 100644 --- a/frontend/src/i18n/locales/zh/admin/accounts.ts +++ b/frontend/src/i18n/locales/zh/admin/accounts.ts @@ -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: '配额说明' diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index cfd3f2f167..3d5dd452f7 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -1010,6 +1010,7 @@ export interface ProxyQualityCheckResult { export interface GeminiCredentials { // API Key authentication api_key?: string + base_url?: string // OAuth authentication access_token?: string