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"
>
-
不同 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"
>
-
不同 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(长期有效)',