diff --git a/backend/internal/service/claude_token_provider.go b/backend/internal/service/claude_token_provider.go index 9292979f..d70379c1 100644 --- a/backend/internal/service/claude_token_provider.go +++ b/backend/internal/service/claude_token_provider.go @@ -162,40 +162,5 @@ func (p *ClaudeTokenProvider) GetAccessToken(ctx context.Context, account *Accou } func (p *ClaudeTokenProvider) getServiceAccountAccessToken(ctx context.Context, account *Account) (string, error) { - key, err := parseVertexServiceAccountKey(account) - if err != nil { - return "", err - } - cacheKey := vertexServiceAccountCacheKey(account, key) - - if p.tokenCache != nil { - if token, err := p.tokenCache.GetAccessToken(ctx, cacheKey); err == nil && strings.TrimSpace(token) != "" { - return token, nil - } - } - - locked := false - if p.tokenCache != nil { - var lockErr error - locked, lockErr = p.tokenCache.AcquireRefreshLock(ctx, cacheKey, 30*time.Second) - if lockErr == nil && locked { - defer func() { _ = p.tokenCache.ReleaseRefreshLock(ctx, cacheKey) }() - } else if lockErr != nil { - slog.Warn("vertex_service_account_token_lock_failed", "account_id", account.ID, "error", lockErr) - } else { - time.Sleep(claudeLockWaitTime) - if token, err := p.tokenCache.GetAccessToken(ctx, cacheKey); err == nil && strings.TrimSpace(token) != "" { - return token, nil - } - } - } - - accessToken, ttl, err := exchangeVertexServiceAccountToken(ctx, key) - if err != nil { - return "", err - } - if p.tokenCache != nil { - _ = p.tokenCache.SetAccessToken(ctx, cacheKey, accessToken, ttl) - } - return accessToken, nil + return getVertexServiceAccountAccessToken(ctx, p.tokenCache, account) } diff --git a/backend/internal/service/gateway_forward_as_chat_completions.go b/backend/internal/service/gateway_forward_as_chat_completions.go index c531667e..7ac77f77 100644 --- a/backend/internal/service/gateway_forward_as_chat_completions.go +++ b/backend/internal/service/gateway_forward_as_chat_completions.go @@ -61,10 +61,15 @@ func (s *GatewayService) ForwardAsChatCompletions( // 4. Model mapping mappedModel := originalModel - if account.Type == AccountTypeAPIKey { + if account.Type == AccountTypeAPIKey || account.Type == AccountTypeServiceAccount { mappedModel = account.GetMappedModel(originalModel) } - if mappedModel == originalModel && account.Platform == PlatformAnthropic && account.Type != AccountTypeAPIKey { + if mappedModel == originalModel && account.Platform == PlatformAnthropic && account.Type == AccountTypeServiceAccount { + normalized := normalizeVertexAnthropicModelID(claude.NormalizeModelID(originalModel)) + if normalized != originalModel { + mappedModel = normalized + } + } else if mappedModel == originalModel && account.Platform == PlatformAnthropic && account.Type != AccountTypeAPIKey { normalized := claude.NormalizeModelID(originalModel) if normalized != originalModel { mappedModel = normalized diff --git a/backend/internal/service/gateway_forward_as_responses.go b/backend/internal/service/gateway_forward_as_responses.go index 647193d6..8f8a1e94 100644 --- a/backend/internal/service/gateway_forward_as_responses.go +++ b/backend/internal/service/gateway_forward_as_responses.go @@ -58,10 +58,15 @@ func (s *GatewayService) ForwardAsResponses( // 4. Model mapping mappedModel := originalModel reasoningEffort := ExtractResponsesReasoningEffortFromBody(body) - if account.Type == AccountTypeAPIKey { + if account.Type == AccountTypeAPIKey || account.Type == AccountTypeServiceAccount { mappedModel = account.GetMappedModel(originalModel) } - if mappedModel == originalModel && account.Platform == PlatformAnthropic && account.Type != AccountTypeAPIKey { + if mappedModel == originalModel && account.Platform == PlatformAnthropic && account.Type == AccountTypeServiceAccount { + normalized := normalizeVertexAnthropicModelID(claude.NormalizeModelID(originalModel)) + if normalized != originalModel { + mappedModel = normalized + } + } else if mappedModel == originalModel && account.Platform == PlatformAnthropic && account.Type != AccountTypeAPIKey { normalized := claude.NormalizeModelID(originalModel) if normalized != originalModel { mappedModel = normalized diff --git a/backend/internal/service/gemini_messages_compat_service.go b/backend/internal/service/gemini_messages_compat_service.go index 20293ac8..ea0c0d7d 100644 --- a/backend/internal/service/gemini_messages_compat_service.go +++ b/backend/internal/service/gemini_messages_compat_service.go @@ -515,6 +515,10 @@ func (s *GeminiMessagesCompatService) SelectAccountForAIStudioEndpoints(ctx cont } // Code Assist OAuth tokens often lack AI Studio scopes for models listing. return 3 + case AccountTypeServiceAccount: + // Vertex service accounts use aiplatform.googleapis.com, not the AI Studio + // endpoint (generativelanguage.googleapis.com), so they cannot serve these requests. + return 999 default: return 10 } diff --git a/backend/internal/service/gemini_token_provider.go b/backend/internal/service/gemini_token_provider.go index c22f2131..172b9411 100644 --- a/backend/internal/service/gemini_token_provider.go +++ b/backend/internal/service/gemini_token_provider.go @@ -172,42 +172,7 @@ func (p *GeminiTokenProvider) GetAccessToken(ctx context.Context, account *Accou } func (p *GeminiTokenProvider) getServiceAccountAccessToken(ctx context.Context, account *Account) (string, error) { - key, err := parseVertexServiceAccountKey(account) - if err != nil { - return "", err - } - cacheKey := vertexServiceAccountCacheKey(account, key) - - if p.tokenCache != nil { - if token, err := p.tokenCache.GetAccessToken(ctx, cacheKey); err == nil && strings.TrimSpace(token) != "" { - return token, nil - } - } - - locked := false - if p.tokenCache != nil { - var lockErr error - locked, lockErr = p.tokenCache.AcquireRefreshLock(ctx, cacheKey, 30*time.Second) - if lockErr == nil && locked { - defer func() { _ = p.tokenCache.ReleaseRefreshLock(ctx, cacheKey) }() - } else if lockErr != nil { - slog.Warn("vertex_service_account_token_lock_failed", "account_id", account.ID, "error", lockErr) - } else { - time.Sleep(200 * time.Millisecond) - if token, err := p.tokenCache.GetAccessToken(ctx, cacheKey); err == nil && strings.TrimSpace(token) != "" { - return token, nil - } - } - } - - accessToken, ttl, err := exchangeVertexServiceAccountToken(ctx, key) - if err != nil { - return "", err - } - if p.tokenCache != nil { - _ = p.tokenCache.SetAccessToken(ctx, cacheKey, accessToken, ttl) - } - return accessToken, nil + return getVertexServiceAccountAccessToken(ctx, p.tokenCache, account) } func GeminiTokenCacheKey(account *Account) string { diff --git a/backend/internal/service/vertex_service_account.go b/backend/internal/service/vertex_service_account.go index d4130b93..4430cf81 100644 --- a/backend/internal/service/vertex_service_account.go +++ b/backend/internal/service/vertex_service_account.go @@ -9,6 +9,7 @@ import ( "errors" "fmt" "io" + "log/slog" "net/http" "net/url" "regexp" @@ -23,6 +24,7 @@ const ( vertexDefaultTokenURL = "https://oauth2.googleapis.com/token" vertexCloudPlatformScope = "https://www.googleapis.com/auth/cloud-platform" vertexServiceAccountCacheSkew = 5 * time.Minute + vertexLockWaitTime = 200 * time.Millisecond vertexAnthropicVersion = "vertex-2023-10-16" ) @@ -123,9 +125,8 @@ func parseVertexServiceAccountJSON(raw []byte) (*vertexServiceAccountKey, error) if strings.TrimSpace(key.ProjectID) == "" { return nil, errors.New("service account json missing project_id") } - if strings.TrimSpace(key.TokenURI) == "" { - key.TokenURI = vertexDefaultTokenURL - } + // Always use the well-known Google token endpoint to prevent SSRF via crafted token_uri. + key.TokenURI = vertexDefaultTokenURL return &key, nil } @@ -141,6 +142,47 @@ func vertexServiceAccountCacheKey(account *Account, key *vertexServiceAccountKey return "vertex:service_account:" + fingerprint } +// getVertexServiceAccountAccessToken obtains an access token for a Vertex service account, +// using the shared cache and distributed lock to avoid redundant exchanges. +func getVertexServiceAccountAccessToken(ctx context.Context, cache GeminiTokenCache, account *Account) (string, error) { + key, err := parseVertexServiceAccountKey(account) + if err != nil { + return "", err + } + cacheKey := vertexServiceAccountCacheKey(account, key) + + if cache != nil { + if token, err := cache.GetAccessToken(ctx, cacheKey); err == nil && strings.TrimSpace(token) != "" { + return token, nil + } + } + + locked := false + if cache != nil { + var lockErr error + locked, lockErr = cache.AcquireRefreshLock(ctx, cacheKey, 30*time.Second) + if lockErr == nil && locked { + defer func() { _ = cache.ReleaseRefreshLock(ctx, cacheKey) }() + } else if lockErr != nil { + slog.Warn("vertex_service_account_token_lock_failed", "account_id", account.ID, "error", lockErr) + } else { + time.Sleep(vertexLockWaitTime) + if token, err := cache.GetAccessToken(ctx, cacheKey); err == nil && strings.TrimSpace(token) != "" { + return token, nil + } + } + } + + accessToken, ttl, err := exchangeVertexServiceAccountToken(ctx, key) + if err != nil { + return "", err + } + if cache != nil { + _ = cache.SetAccessToken(ctx, cacheKey, accessToken, ttl) + } + return accessToken, nil +} + func exchangeVertexServiceAccountToken(ctx context.Context, key *vertexServiceAccountKey) (string, time.Duration, error) { now := time.Now() claims := jwt.MapClaims{ diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue index e7a790ec..d38c31c5 100644 --- a/frontend/src/components/account/CreateAccountModal.vue +++ b/frontend/src/components/account/CreateAccountModal.vue @@ -276,7 +276,7 @@ v-if="accountCategory === 'service_account'" class="mt-3 rounded-lg border border-sky-200 bg-sky-50 px-3 py-2 text-xs text-sky-800 dark:border-sky-800/40 dark:bg-sky-900/20 dark:text-sky-200" > -

使用 Google Cloud Service Account JSON 通过 Vertex AI 调用 Anthropic Claude。建议配置模型映射,将客户端 Claude 模型名映射到 Vertex 模型 ID。

+

{{ t('admin.accounts.vertexAnthropicHint') }}

@@ -479,7 +479,7 @@ v-if="accountCategory === 'service_account'" class="mt-3 rounded-lg border border-sky-200 bg-sky-50 px-3 py-2 text-xs text-sky-800 dark:border-sky-800/40 dark:bg-sky-900/20 dark:text-sky-200" > -

使用 Google Cloud Service Account JSON 访问 Vertex AI Gemini。建议将 Vertex 账号放入独立分组,避免和 AI Studio/Gemini OAuth 同模型混调。

+

{{ t('admin.accounts.vertexGeminiHint') }}

@@ -827,10 +827,10 @@
- {{ vertexClientEmail ? '已读取 Service Account JSON' : '拖入 Service Account JSON' }} + {{ vertexClientEmail ? t('admin.accounts.vertexSaJsonLoaded') : t('admin.accounts.vertexSaJsonDrop') }}

- {{ vertexClientEmail ? '密钥内容不会在表单中显示。' : '把 .json 文件拖到这里,或点击按钮选择文件。' }} + {{ vertexClientEmail ? t('admin.accounts.vertexSaJsonKeyHidden') : t('admin.accounts.vertexSaJsonDropHint') }}

Client Email: {{ vertexClientEmail }}
-

上传或拖入 JSON 后会自动读取 project_id,密钥内容仅用于创建账号提交。

+

{{ t('admin.accounts.vertexSaJsonUploadHint') }}

@@ -861,7 +861,7 @@ type="text" class="input font-mono" readonly - placeholder="从 JSON 自动读取" + :placeholder="t('admin.accounts.vertexProjectIdPlaceholder')" />
@@ -872,7 +872,7 @@ class="input font-mono" > @@ -885,7 +885,7 @@ -

不同 Vertex 模型可用 location 可能不同,这里选择账号默认 endpoint location。

+

{{ t('admin.accounts.vertexLocationHint') }}

@@ -3132,6 +3132,7 @@ import QuotaLimitCard from '@/components/account/QuotaLimitCard.vue' import { applyInterceptWarmup } from '@/components/account/credentialsBuilder' import { formatDateTimeLocalInput, parseDateTimeLocalInput } from '@/utils/format' import { createStableObjectKeyResolver } from '@/utils/stableObjectKey' +import { VERTEX_LOCATION_OPTIONS } from '@/constants/account' import { OPENAI_WS_MODE_CTX_POOL, OPENAI_WS_MODE_OFF, @@ -3318,52 +3319,6 @@ const vertexProjectId = ref('') const vertexClientEmail = ref('') const vertexLocation = ref('global') const vertexServiceAccountDragActive = ref(false) -const vertexLocationOptions = [ - { - label: 'Common', - options: [ - { value: 'us-central1', label: 'us-central1 (Iowa)' }, - { value: 'global', label: 'global' }, - { value: 'us', label: 'us' }, - { value: 'eu', label: 'eu' } - ] - }, - { - label: 'United States', - options: [ - { value: 'us-east1', label: 'us-east1 (South Carolina)' }, - { value: 'us-east4', label: 'us-east4 (Northern Virginia)' }, - { value: 'us-east5', label: 'us-east5 (Columbus)' }, - { value: 'us-south1', label: 'us-south1 (Dallas)' }, - { value: 'us-west1', label: 'us-west1 (Oregon)' }, - { value: 'us-west4', label: 'us-west4 (Las Vegas)' } - ] - }, - { - label: 'Europe', - options: [ - { value: 'europe-west1', label: 'europe-west1 (Belgium)' }, - { value: 'europe-west2', label: 'europe-west2 (London)' }, - { value: 'europe-west3', label: 'europe-west3 (Frankfurt)' }, - { value: 'europe-west4', label: 'europe-west4 (Netherlands)' }, - { value: 'europe-west6', label: 'europe-west6 (Zurich)' }, - { value: 'europe-west8', label: 'europe-west8 (Milan)' }, - { value: 'europe-west9', label: 'europe-west9 (Paris)' } - ] - }, - { - label: 'Asia Pacific', - options: [ - { value: 'asia-east1', label: 'asia-east1 (Taiwan)' }, - { value: 'asia-east2', label: 'asia-east2 (Hong Kong)' }, - { value: 'asia-northeast1', label: 'asia-northeast1 (Tokyo)' }, - { value: 'asia-northeast3', label: 'asia-northeast3 (Seoul)' }, - { value: 'asia-south1', label: 'asia-south1 (Mumbai)' }, - { value: 'asia-southeast1', label: 'asia-southeast1 (Singapore)' }, - { value: 'australia-southeast1', label: 'australia-southeast1 (Sydney)' } - ] - } -] as const const tempUnschedEnabled = ref(false) const tempUnschedRules = ref([]) const getModelMappingKey = createStableObjectKeyResolver('create-model-mapping') @@ -4251,7 +4206,7 @@ const applyVertexServiceAccountJson = (value: string) => { const clientEmail = typeof parsed.client_email === 'string' ? parsed.client_email.trim() : '' const privateKey = typeof parsed.private_key === 'string' ? parsed.private_key.trim() : '' if (!projectId || !clientEmail || !privateKey) { - appStore.showError('Service Account JSON 缺少 project_id、client_email 或 private_key') + appStore.showError(t('admin.accounts.vertexSaJsonMissingFields')) return false } vertexProjectId.value = projectId @@ -4259,7 +4214,7 @@ const applyVertexServiceAccountJson = (value: string) => { vertexServiceAccountJson.value = JSON.stringify(parsed) return true } catch { - appStore.showError('Service Account JSON 格式无效') + appStore.showError(t('admin.accounts.vertexSaJsonInvalid')) return false } } @@ -4406,7 +4361,7 @@ const handleSubmit = async () => { return } if (!vertexLocation.value.trim()) { - appStore.showError('请填写 Vertex location') + appStore.showError(t('admin.accounts.vertexLocationRequired')) return } const credentials: Record = { diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue index 69e2186b..56874474 100644 --- a/frontend/src/components/account/EditAccountModal.vue +++ b/frontend/src/components/account/EditAccountModal.vue @@ -577,9 +577,9 @@ type="text" class="input font-mono" readonly - placeholder="从 JSON 自动读取" + :placeholder="t('admin.accounts.vertexProjectIdPlaceholder')" /> -

Service Account JSON 不在编辑页显示;需要更换 JSON 时请删除账号后重新创建。

+

{{ t('admin.accounts.vertexSaJsonEditHint') }}

@@ -589,7 +589,7 @@ class="input font-mono" > @@ -602,7 +602,182 @@ -

不同 Vertex 模型可用 location 可能不同,这里选择账号默认 endpoint location。

+

{{ t('admin.accounts.vertexLocationHint') }}

+
+ + + +
+ + + +
+ + +
+ + +
+ +

+ {{ t('admin.accounts.selectedModels', { count: allowedModels.length }) }} + {{ + t('admin.accounts.supportsAllModels') + }} +

+
+ + +
+
+

+ + + + {{ t('admin.accounts.mapRequestModels') }} +

+
+ + +
+
+ + + + + + +
+
+ + + + +
+ +
@@ -1959,6 +2134,7 @@ import QuotaLimitCard from '@/components/account/QuotaLimitCard.vue' import { applyInterceptWarmup } from '@/components/account/credentialsBuilder' import { formatDateTime, formatDateTimeLocalInput, parseDateTimeLocalInput } from '@/utils/format' import { createStableObjectKeyResolver } from '@/utils/stableObjectKey' +import { VERTEX_LOCATION_OPTIONS } from '@/constants/account' import { OPENAI_WS_MODE_CTX_POOL, OPENAI_WS_MODE_OFF, @@ -2030,52 +2206,6 @@ const editBedrockApiKeyValue = ref('') const editVertexProjectId = ref('') const editVertexClientEmail = ref('') const editVertexLocation = ref('us-central1') -const vertexLocationOptions = [ - { - label: 'Common', - options: [ - { value: 'us-central1', label: 'us-central1 (Iowa)' }, - { value: 'global', label: 'global' }, - { value: 'us', label: 'us' }, - { value: 'eu', label: 'eu' } - ] - }, - { - label: 'United States', - options: [ - { value: 'us-east1', label: 'us-east1 (South Carolina)' }, - { value: 'us-east4', label: 'us-east4 (Northern Virginia)' }, - { value: 'us-east5', label: 'us-east5 (Columbus)' }, - { value: 'us-south1', label: 'us-south1 (Dallas)' }, - { value: 'us-west1', label: 'us-west1 (Oregon)' }, - { value: 'us-west4', label: 'us-west4 (Las Vegas)' } - ] - }, - { - label: 'Europe', - options: [ - { value: 'europe-west1', label: 'europe-west1 (Belgium)' }, - { value: 'europe-west2', label: 'europe-west2 (London)' }, - { value: 'europe-west3', label: 'europe-west3 (Frankfurt)' }, - { value: 'europe-west4', label: 'europe-west4 (Netherlands)' }, - { value: 'europe-west6', label: 'europe-west6 (Zurich)' }, - { value: 'europe-west8', label: 'europe-west8 (Milan)' }, - { value: 'europe-west9', label: 'europe-west9 (Paris)' } - ] - }, - { - label: 'Asia Pacific', - options: [ - { value: 'asia-east1', label: 'asia-east1 (Taiwan)' }, - { value: 'asia-east2', label: 'asia-east2 (Hong Kong)' }, - { value: 'asia-northeast1', label: 'asia-northeast1 (Tokyo)' }, - { value: 'asia-northeast3', label: 'asia-northeast3 (Seoul)' }, - { value: 'asia-south1', label: 'asia-south1 (Mumbai)' }, - { value: 'asia-southeast1', label: 'asia-southeast1 (Singapore)' }, - { value: 'australia-southeast1', label: 'australia-southeast1 (Sydney)' } - ] - } -] as const const isBedrockAPIKeyMode = computed(() => props.account?.type === 'bedrock' && (props.account?.credentials as Record)?.auth_mode === 'apikey' @@ -2564,6 +2694,26 @@ const syncFormFromAccount = (newAccount: Account | null) => { editVertexProjectId.value = (credentials.project_id as string) || '' editVertexClientEmail.value = (credentials.client_email as string) || '' editVertexLocation.value = (credentials.location as string) || (credentials.vertex_location as string) || 'us-central1' + + // Load model mappings for service_account + const existingMappings = credentials.model_mapping as Record | undefined + if (existingMappings && typeof existingMappings === 'object') { + const entries = Object.entries(existingMappings) + const isWhitelistMode = entries.length > 0 && entries.every(([from, to]) => from === to) + if (isWhitelistMode) { + modelRestrictionMode.value = 'whitelist' + allowedModels.value = entries.map(([from]) => from) + modelMappings.value = [] + } else { + modelRestrictionMode.value = 'mapping' + modelMappings.value = entries.map(([from, to]) => ({ from, to })) + allowedModels.value = [] + } + } else { + modelRestrictionMode.value = 'whitelist' + modelMappings.value = [] + allowedModels.value = [] + } } else { const platformDefaultUrl = newAccount.platform === 'openai' @@ -3160,20 +3310,20 @@ const handleSubmit = async () => { const newCredentials: Record = { ...currentCredentials } if (!editVertexProjectId.value.trim()) { - appStore.showError('Service Account JSON 缺少 project_id') + appStore.showError(t('admin.accounts.vertexSaJsonMissingProjectId')) return } if (!editVertexClientEmail.value.trim()) { - appStore.showError('Service Account JSON 缺少 client_email') + appStore.showError(t('admin.accounts.vertexSaJsonMissingClientEmail')) return } if (!editVertexLocation.value.trim()) { - appStore.showError('请填写 Vertex location') + appStore.showError(t('admin.accounts.vertexLocationRequired')) return } if (!currentCredentials.service_account_json && !currentCredentials.service_account) { - appStore.showError('请上传 Service Account JSON') + appStore.showError(t('admin.accounts.vertexSaJsonRequired')) return } newCredentials.project_id = editVertexProjectId.value.trim() @@ -3181,6 +3331,14 @@ const handleSubmit = async () => { newCredentials.location = editVertexLocation.value.trim() newCredentials.tier_id = 'vertex' + // Add model mapping if configured + const modelMapping = buildModelMappingObject(modelRestrictionMode.value, allowedModels.value, modelMappings.value) + if (modelMapping) { + newCredentials.model_mapping = modelMapping + } else { + delete newCredentials.model_mapping + } + applyInterceptWarmup(newCredentials, interceptWarmupRequests.value, 'edit') if (!applyTempUnschedConfig(newCredentials)) { return diff --git a/frontend/src/constants/account.ts b/frontend/src/constants/account.ts index dcfc7fae..776de4fa 100644 --- a/frontend/src/constants/account.ts +++ b/frontend/src/constants/account.ts @@ -13,3 +13,51 @@ export type QuotaThresholdType = typeof QUOTA_THRESHOLD_TYPE_FIXED | typeof QUOT export const QUOTA_RESET_MODE_ROLLING = 'rolling' as const export const QUOTA_RESET_MODE_FIXED = 'fixed' as const export type QuotaResetMode = typeof QUOTA_RESET_MODE_ROLLING | typeof QUOTA_RESET_MODE_FIXED + +/** Vertex AI location options for Service Account accounts */ +export const VERTEX_LOCATION_OPTIONS = [ + { + label: 'Common', + options: [ + { value: 'us-central1', label: 'us-central1 (Iowa)' }, + { value: 'global', label: 'global' }, + { value: 'us', label: 'us' }, + { value: 'eu', label: 'eu' } + ] + }, + { + label: 'United States', + options: [ + { value: 'us-east1', label: 'us-east1 (South Carolina)' }, + { value: 'us-east4', label: 'us-east4 (Northern Virginia)' }, + { value: 'us-east5', label: 'us-east5 (Columbus)' }, + { value: 'us-south1', label: 'us-south1 (Dallas)' }, + { value: 'us-west1', label: 'us-west1 (Oregon)' }, + { value: 'us-west4', label: 'us-west4 (Las Vegas)' } + ] + }, + { + label: 'Europe', + options: [ + { value: 'europe-west1', label: 'europe-west1 (Belgium)' }, + { value: 'europe-west2', label: 'europe-west2 (London)' }, + { value: 'europe-west3', label: 'europe-west3 (Frankfurt)' }, + { value: 'europe-west4', label: 'europe-west4 (Netherlands)' }, + { value: 'europe-west6', label: 'europe-west6 (Zurich)' }, + { value: 'europe-west8', label: 'europe-west8 (Milan)' }, + { value: 'europe-west9', label: 'europe-west9 (Paris)' } + ] + }, + { + label: 'Asia Pacific', + options: [ + { value: 'asia-east1', label: 'asia-east1 (Taiwan)' }, + { value: 'asia-east2', label: 'asia-east2 (Hong Kong)' }, + { value: 'asia-northeast1', label: 'asia-northeast1 (Tokyo)' }, + { value: 'asia-northeast3', label: 'asia-northeast3 (Seoul)' }, + { value: 'asia-south1', label: 'asia-south1 (Mumbai)' }, + { value: 'asia-southeast1', label: 'asia-southeast1 (Singapore)' }, + { value: 'australia-southeast1', label: 'australia-southeast1 (Sydney)' } + ] + } +] as const diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 270cd660..0425955f 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -2815,6 +2815,26 @@ export default { claudeConsole: 'Claude Console', bedrockLabel: 'AWS Bedrock', bedrockDesc: 'SigV4 / API Key', + vertexLabel: 'Vertex', + vertexDesc: 'Service Account', + vertexAnthropicHint: 'Use a Google Cloud Service Account JSON to call Anthropic Claude via Vertex AI. It is recommended to configure model mapping to map client Claude model names to Vertex model IDs.', + vertexGeminiHint: 'Use a Google Cloud Service Account JSON to access Vertex AI Gemini. It is recommended to place Vertex accounts in a separate group to avoid mixing with AI Studio/Gemini OAuth on the same models.', + vertexSaJsonLabel: 'Service Account JSON', + vertexSaJsonLoaded: 'Service Account JSON loaded', + vertexSaJsonDrop: 'Drop Service Account JSON here', + vertexSaJsonKeyHidden: 'Key content is not displayed in the form.', + vertexSaJsonDropHint: 'Drag a .json file here, or click the button to select one.', + vertexSaJsonSelectBtn: 'Select JSON', + vertexSaJsonUploadHint: 'After uploading or dropping a JSON file, the project_id will be auto-extracted. Key content is only used for account creation.', + vertexSaJsonEditHint: 'Service Account JSON is not shown on the edit page; to change the JSON, delete the account and recreate it.', + vertexProjectIdPlaceholder: 'Auto-extracted from JSON', + vertexLocationHint: 'Available locations vary by Vertex model. Select the default endpoint location for this account.', + vertexLocationRequired: 'Please enter a Vertex location', + vertexSaJsonMissingFields: 'Service Account JSON is missing project_id, client_email, or private_key', + vertexSaJsonMissingProjectId: 'Service Account JSON is missing project_id', + vertexSaJsonMissingClientEmail: 'Service Account JSON is missing client_email', + vertexSaJsonInvalid: 'Service Account JSON format is invalid', + vertexSaJsonRequired: 'Please upload a Service Account JSON', oauthSetupToken: 'OAuth / Setup Token', addMethod: 'Add Method', setupTokenLongLived: 'Setup Token (Long-lived)', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index fdfc9e41..a8656a7b 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -2963,6 +2963,26 @@ export default { claudeConsole: 'Claude Console', bedrockLabel: 'AWS Bedrock', bedrockDesc: 'SigV4 / API Key', + vertexLabel: 'Vertex', + vertexDesc: 'Service Account', + vertexAnthropicHint: '使用 Google Cloud Service Account JSON 通过 Vertex AI 调用 Anthropic Claude。建议配置模型映射,将客户端 Claude 模型名映射到 Vertex 模型 ID。', + vertexGeminiHint: '使用 Google Cloud Service Account JSON 访问 Vertex AI Gemini。建议将 Vertex 账号放入独立分组,避免和 AI Studio/Gemini OAuth 同模型混调。', + vertexSaJsonLabel: 'Service Account JSON', + vertexSaJsonLoaded: '已读取 Service Account JSON', + vertexSaJsonDrop: '拖入 Service Account JSON', + vertexSaJsonKeyHidden: '密钥内容不会在表单中显示。', + vertexSaJsonDropHint: '把 .json 文件拖到这里,或点击按钮选择文件。', + vertexSaJsonSelectBtn: '选择 JSON', + vertexSaJsonUploadHint: '上传或拖入 JSON 后会自动读取 project_id,密钥内容仅用于创建账号提交。', + vertexSaJsonEditHint: 'Service Account JSON 不在编辑页显示;需要更换 JSON 时请删除账号后重新创建。', + vertexProjectIdPlaceholder: '从 JSON 自动读取', + vertexLocationHint: '不同 Vertex 模型可用 location 可能不同,这里选择账号默认 endpoint location。', + vertexLocationRequired: '请填写 Vertex location', + vertexSaJsonMissingFields: 'Service Account JSON 缺少 project_id、client_email 或 private_key', + vertexSaJsonMissingProjectId: 'Service Account JSON 缺少 project_id', + vertexSaJsonMissingClientEmail: 'Service Account JSON 缺少 client_email', + vertexSaJsonInvalid: 'Service Account JSON 格式无效', + vertexSaJsonRequired: '请上传 Service Account JSON', oauthSetupToken: 'OAuth / Setup Token', addMethod: '添加方式', setupTokenLongLived: 'Setup Token(长期有效)',