mirror of
https://gitee.com/wanwujie/sub2api
synced 2026-05-04 21:20:51 +08:00
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
This commit is contained in:
@@ -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(长期有效)',
|
||||
|
||||
Reference in New Issue
Block a user