Commit 93d91e20 authored by shaw's avatar shaw
Browse files

fix(vertex): audit fixes for Vertex Service Account feature (#1977)

- Security: force token_uri to Google default, preventing SSRF via crafted service account JSON
- Dedup: extract shared getVertexServiceAccountAccessToken() to eliminate ~35 lines of duplication between ClaudeTokenProvider and GeminiTokenProvider
- Fix: apply model mapping + Vertex model ID normalization in forward_as_responses and forward_as_chat_completions paths
- Fix: exclude service_account from AI Studio endpoint selection (Vertex cannot serve generativelanguage.googleapis.com)
- Feature: add model restriction/mapping UI for service_account in EditAccountModal
- Dedup: extract VERTEX_LOCATION_OPTIONS to shared constants
- i18n: replace all hardcoded Chinese strings in Vertex UI with translation keys
parent 63ef2310
......@@ -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)
}
......@@ -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
......
......@@ -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
......
......@@ -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
}
......
......@@ -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 {
......
......@@ -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{
......
......@@ -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"
>
<p>使用 Google Cloud Service Account JSON 通过 Vertex AI 调用 Anthropic Claude。建议配置模型映射,将客户端 Claude 模型名映射到 Vertex 模型 ID。</p>
<p>{{ t('admin.accounts.vertexAnthropicHint') }}</p>
</div>
</div>
......@@ -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"
>
<p>使用 Google Cloud Service Account JSON 访问 Vertex AI Gemini。建议将 Vertex 账号放入独立分组,避免和 AI Studio/Gemini OAuth 同模型混调。</p>
<p>{{ t('admin.accounts.vertexGeminiHint') }}</p>
</div>
<!-- OAuth Type Selection (only show when oauth-based is selected) -->
......@@ -827,10 +827,10 @@
<div class="min-w-0">
<div class="flex items-center gap-2 text-sm font-medium text-gray-900 dark:text-white">
<Icon name="upload" size="sm" />
<span>{{ vertexClientEmail ? '已读取 Service Account JSON' : '拖入 Service Account JSON' }}</span>
<span>{{ vertexClientEmail ? t('admin.accounts.vertexSaJsonLoaded') : t('admin.accounts.vertexSaJsonDrop') }}</span>
</div>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ vertexClientEmail ? '密钥内容不会在表单中显示。' : '把 .json 文件拖到这里,或点击按钮选择文件。' }}
{{ vertexClientEmail ? t('admin.accounts.vertexSaJsonKeyHidden') : t('admin.accounts.vertexSaJsonDropHint') }}
</p>
</div>
<button
......@@ -839,7 +839,7 @@
@click="vertexServiceAccountFileInput?.click()"
>
<Icon name="upload" size="sm" />
选择 JSON
{{ t('admin.accounts.vertexSaJsonSelectBtn') }}
</button>
</div>
<div
......@@ -850,7 +850,7 @@
<div class="truncate">Client Email: <span class="font-mono">{{ vertexClientEmail }}</span></div>
</div>
</div>
<p class="input-hint">上传或拖入 JSON 后会自动读取 project_id,密钥内容仅用于创建账号提交。</p>
<p class="input-hint">{{ t('admin.accounts.vertexSaJsonUploadHint') }}</p>
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
......@@ -861,7 +861,7 @@
type="text"
class="input font-mono"
readonly
placeholder="从 JSON 自动读取"
:placeholder="t('admin.accounts.vertexProjectIdPlaceholder')"
/>
</div>
<div>
......@@ -872,7 +872,7 @@
class="input font-mono"
>
<optgroup
v-for="group in vertexLocationOptions"
v-for="group in VERTEX_LOCATION_OPTIONS"
:key="group.label"
:label="group.label"
>
......@@ -885,7 +885,7 @@
</option>
</optgroup>
</select>
<p class="input-hint">不同 Vertex 模型可用 location 可能不同,这里选择账号默认 endpoint location。</p>
<p class="input-hint">{{ t('admin.accounts.vertexLocationHint') }}</p>
</div>
</div>
</div>
......@@ -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<TempUnschedRuleForm[]>([])
const getModelMappingKey = createStableObjectKeyResolver<ModelMapping>('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<string, unknown> = {
......
......@@ -577,9 +577,9 @@
type="text"
class="input font-mono"
readonly
placeholder="从 JSON 自动读取"
:placeholder="t('admin.accounts.vertexProjectIdPlaceholder')"
/>
<p class="input-hint">Service Account JSON 不在编辑页显示需要更换 JSON 时请删除账号后重新创建</p>
<p class="input-hint">{{ t('admin.accounts.vertexSaJsonEditHint') }}</p>
</div>
<div>
<label class="input-label">Location</label>
......@@ -589,7 +589,7 @@
class="input font-mono"
>
<optgroup
v-for="group in vertexLocationOptions"
v-for="group in VERTEX_LOCATION_OPTIONS"
:key="group.label"
:label="group.label"
>
......@@ -602,7 +602,182 @@
</option>
</optgroup>
</select>
<p class="input-hint">不同 Vertex 模型可用 location 可能不同这里选择账号默认 endpoint location</p>
<p class="input-hint">{{ t('admin.accounts.vertexLocationHint') }}</p>
</div>
</div>
<!-- Model Restriction Section for Service Account -->
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
<label class="input-label">{{ t('admin.accounts.modelRestriction') }}</label>
<!-- Mode Toggle -->
<div class="mb-4 flex gap-2">
<button
type="button"
@click="modelRestrictionMode = 'whitelist'"
:class="[
'flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-all',
modelRestrictionMode === 'whitelist'
? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
]"
>
<svg
class="mr-1.5 inline h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
{{ t('admin.accounts.modelWhitelist') }}
</button>
<button
type="button"
@click="modelRestrictionMode = 'mapping'"
:class="[
'flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-all',
modelRestrictionMode === 'mapping'
? 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
]"
>
<svg
class="mr-1.5 inline h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4"
/>
</svg>
{{ t('admin.accounts.modelMapping') }}
</button>
</div>
<!-- Whitelist Mode -->
<div v-if="modelRestrictionMode === 'whitelist'">
<ModelWhitelistSelector v-model="allowedModels" :platform="account?.platform || 'anthropic'" />
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.selectedModels', { count: allowedModels.length }) }}
<span v-if="allowedModels.length === 0">{{
t('admin.accounts.supportsAllModels')
}}</span>
</p>
</div>
<!-- Mapping Mode -->
<div v-else>
<div class="mb-3 rounded-lg bg-purple-50 p-3 dark:bg-purple-900/20">
<p class="text-xs text-purple-700 dark:text-purple-400">
<svg
class="mr-1 inline h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
{{ t('admin.accounts.mapRequestModels') }}
</p>
</div>
<!-- Model Mapping List -->
<div v-if="modelMappings.length > 0" class="mb-3 space-y-2">
<div
v-for="(mapping, index) in modelMappings"
:key="getModelMappingKey(mapping)"
class="flex items-center gap-2"
>
<input
v-model="mapping.from"
type="text"
class="input flex-1"
:placeholder="t('admin.accounts.requestModel')"
/>
<svg
class="h-4 w-4 flex-shrink-0 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M14 5l7 7m0 0l-7 7m7-7H3"
/>
</svg>
<input
v-model="mapping.to"
type="text"
class="input flex-1"
:placeholder="t('admin.accounts.actualModel')"
/>
<button
type="button"
@click="removeModelMapping(index)"
class="rounded-lg p-2 text-red-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
</div>
<button
type="button"
@click="addModelMapping"
class="mb-3 w-full rounded-lg border-2 border-dashed border-gray-300 px-4 py-2 text-gray-600 transition-colors hover:border-gray-400 hover:text-gray-700 dark:border-dark-500 dark:text-gray-400 dark:hover:border-dark-400 dark:hover:text-gray-300"
>
<svg
class="mr-1 inline h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
</svg>
{{ t('admin.accounts.addMapping') }}
</button>
<!-- Quick Add Buttons -->
<div class="flex flex-wrap gap-2">
<button
v-for="preset in presetMappings"
:key="preset.label"
type="button"
@click="addPresetMapping(preset.from, preset.to)"
:class="['rounded-lg px-3 py-1 text-xs transition-colors', preset.color]"
>
+ {{ preset.label }}
</button>
</div>
</div>
</div>
</div>
......@@ -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<string, unknown>)?.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<string, string> | 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<string, unknown> = { ...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
......
......@@ -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
......@@ -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)',
......
......@@ -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(长期有效)',
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment