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:
parent
63ef23108c
commit
93d91e20b9
@ -162,40 +162,5 @@ func (p *ClaudeTokenProvider) GetAccessToken(ctx context.Context, account *Accou
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *ClaudeTokenProvider) getServiceAccountAccessToken(ctx context.Context, account *Account) (string, error) {
|
func (p *ClaudeTokenProvider) getServiceAccountAccessToken(ctx context.Context, account *Account) (string, error) {
|
||||||
key, err := parseVertexServiceAccountKey(account)
|
return getVertexServiceAccountAccessToken(ctx, p.tokenCache, 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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -61,10 +61,15 @@ func (s *GatewayService) ForwardAsChatCompletions(
|
|||||||
|
|
||||||
// 4. Model mapping
|
// 4. Model mapping
|
||||||
mappedModel := originalModel
|
mappedModel := originalModel
|
||||||
if account.Type == AccountTypeAPIKey {
|
if account.Type == AccountTypeAPIKey || account.Type == AccountTypeServiceAccount {
|
||||||
mappedModel = account.GetMappedModel(originalModel)
|
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)
|
normalized := claude.NormalizeModelID(originalModel)
|
||||||
if normalized != originalModel {
|
if normalized != originalModel {
|
||||||
mappedModel = normalized
|
mappedModel = normalized
|
||||||
|
|||||||
@ -58,10 +58,15 @@ func (s *GatewayService) ForwardAsResponses(
|
|||||||
// 4. Model mapping
|
// 4. Model mapping
|
||||||
mappedModel := originalModel
|
mappedModel := originalModel
|
||||||
reasoningEffort := ExtractResponsesReasoningEffortFromBody(body)
|
reasoningEffort := ExtractResponsesReasoningEffortFromBody(body)
|
||||||
if account.Type == AccountTypeAPIKey {
|
if account.Type == AccountTypeAPIKey || account.Type == AccountTypeServiceAccount {
|
||||||
mappedModel = account.GetMappedModel(originalModel)
|
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)
|
normalized := claude.NormalizeModelID(originalModel)
|
||||||
if normalized != originalModel {
|
if normalized != originalModel {
|
||||||
mappedModel = normalized
|
mappedModel = normalized
|
||||||
|
|||||||
@ -515,6 +515,10 @@ func (s *GeminiMessagesCompatService) SelectAccountForAIStudioEndpoints(ctx cont
|
|||||||
}
|
}
|
||||||
// Code Assist OAuth tokens often lack AI Studio scopes for models listing.
|
// Code Assist OAuth tokens often lack AI Studio scopes for models listing.
|
||||||
return 3
|
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:
|
default:
|
||||||
return 10
|
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) {
|
func (p *GeminiTokenProvider) getServiceAccountAccessToken(ctx context.Context, account *Account) (string, error) {
|
||||||
key, err := parseVertexServiceAccountKey(account)
|
return getVertexServiceAccountAccessToken(ctx, p.tokenCache, 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func GeminiTokenCacheKey(account *Account) string {
|
func GeminiTokenCacheKey(account *Account) string {
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"regexp"
|
"regexp"
|
||||||
@ -23,6 +24,7 @@ const (
|
|||||||
vertexDefaultTokenURL = "https://oauth2.googleapis.com/token"
|
vertexDefaultTokenURL = "https://oauth2.googleapis.com/token"
|
||||||
vertexCloudPlatformScope = "https://www.googleapis.com/auth/cloud-platform"
|
vertexCloudPlatformScope = "https://www.googleapis.com/auth/cloud-platform"
|
||||||
vertexServiceAccountCacheSkew = 5 * time.Minute
|
vertexServiceAccountCacheSkew = 5 * time.Minute
|
||||||
|
vertexLockWaitTime = 200 * time.Millisecond
|
||||||
vertexAnthropicVersion = "vertex-2023-10-16"
|
vertexAnthropicVersion = "vertex-2023-10-16"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -123,9 +125,8 @@ func parseVertexServiceAccountJSON(raw []byte) (*vertexServiceAccountKey, error)
|
|||||||
if strings.TrimSpace(key.ProjectID) == "" {
|
if strings.TrimSpace(key.ProjectID) == "" {
|
||||||
return nil, errors.New("service account json missing project_id")
|
return nil, errors.New("service account json missing project_id")
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(key.TokenURI) == "" {
|
// Always use the well-known Google token endpoint to prevent SSRF via crafted token_uri.
|
||||||
key.TokenURI = vertexDefaultTokenURL
|
key.TokenURI = vertexDefaultTokenURL
|
||||||
}
|
|
||||||
return &key, nil
|
return &key, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -141,6 +142,47 @@ func vertexServiceAccountCacheKey(account *Account, key *vertexServiceAccountKey
|
|||||||
return "vertex:service_account:" + fingerprint
|
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) {
|
func exchangeVertexServiceAccountToken(ctx context.Context, key *vertexServiceAccountKey) (string, time.Duration, error) {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
claims := jwt.MapClaims{
|
claims := jwt.MapClaims{
|
||||||
|
|||||||
@ -276,7 +276,7 @@
|
|||||||
v-if="accountCategory === 'service_account'"
|
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"
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -479,7 +479,7 @@
|
|||||||
v-if="accountCategory === 'service_account'"
|
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"
|
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>
|
</div>
|
||||||
|
|
||||||
<!-- OAuth Type Selection (only show when oauth-based is selected) -->
|
<!-- OAuth Type Selection (only show when oauth-based is selected) -->
|
||||||
@ -827,10 +827,10 @@
|
|||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<div class="flex items-center gap-2 text-sm font-medium text-gray-900 dark:text-white">
|
<div class="flex items-center gap-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||||
<Icon name="upload" size="sm" />
|
<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>
|
</div>
|
||||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@ -839,7 +839,7 @@
|
|||||||
@click="vertexServiceAccountFileInput?.click()"
|
@click="vertexServiceAccountFileInput?.click()"
|
||||||
>
|
>
|
||||||
<Icon name="upload" size="sm" />
|
<Icon name="upload" size="sm" />
|
||||||
选择 JSON
|
{{ t('admin.accounts.vertexSaJsonSelectBtn') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@ -850,7 +850,7 @@
|
|||||||
<div class="truncate">Client Email: <span class="font-mono">{{ vertexClientEmail }}</span></div>
|
<div class="truncate">Client Email: <span class="font-mono">{{ vertexClientEmail }}</span></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="input-hint">上传或拖入 JSON 后会自动读取 project_id,密钥内容仅用于创建账号提交。</p>
|
<p class="input-hint">{{ t('admin.accounts.vertexSaJsonUploadHint') }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
@ -861,7 +861,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
class="input font-mono"
|
class="input font-mono"
|
||||||
readonly
|
readonly
|
||||||
placeholder="从 JSON 自动读取"
|
:placeholder="t('admin.accounts.vertexProjectIdPlaceholder')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@ -872,7 +872,7 @@
|
|||||||
class="input font-mono"
|
class="input font-mono"
|
||||||
>
|
>
|
||||||
<optgroup
|
<optgroup
|
||||||
v-for="group in vertexLocationOptions"
|
v-for="group in VERTEX_LOCATION_OPTIONS"
|
||||||
:key="group.label"
|
:key="group.label"
|
||||||
:label="group.label"
|
:label="group.label"
|
||||||
>
|
>
|
||||||
@ -885,7 +885,7 @@
|
|||||||
</option>
|
</option>
|
||||||
</optgroup>
|
</optgroup>
|
||||||
</select>
|
</select>
|
||||||
<p class="input-hint">不同 Vertex 模型可用 location 可能不同,这里选择账号默认 endpoint location。</p>
|
<p class="input-hint">{{ t('admin.accounts.vertexLocationHint') }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -3132,6 +3132,7 @@ import QuotaLimitCard from '@/components/account/QuotaLimitCard.vue'
|
|||||||
import { applyInterceptWarmup } from '@/components/account/credentialsBuilder'
|
import { applyInterceptWarmup } from '@/components/account/credentialsBuilder'
|
||||||
import { formatDateTimeLocalInput, parseDateTimeLocalInput } from '@/utils/format'
|
import { formatDateTimeLocalInput, parseDateTimeLocalInput } from '@/utils/format'
|
||||||
import { createStableObjectKeyResolver } from '@/utils/stableObjectKey'
|
import { createStableObjectKeyResolver } from '@/utils/stableObjectKey'
|
||||||
|
import { VERTEX_LOCATION_OPTIONS } from '@/constants/account'
|
||||||
import {
|
import {
|
||||||
OPENAI_WS_MODE_CTX_POOL,
|
OPENAI_WS_MODE_CTX_POOL,
|
||||||
OPENAI_WS_MODE_OFF,
|
OPENAI_WS_MODE_OFF,
|
||||||
@ -3318,52 +3319,6 @@ const vertexProjectId = ref('')
|
|||||||
const vertexClientEmail = ref('')
|
const vertexClientEmail = ref('')
|
||||||
const vertexLocation = ref('global')
|
const vertexLocation = ref('global')
|
||||||
const vertexServiceAccountDragActive = ref(false)
|
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 tempUnschedEnabled = ref(false)
|
||||||
const tempUnschedRules = ref<TempUnschedRuleForm[]>([])
|
const tempUnschedRules = ref<TempUnschedRuleForm[]>([])
|
||||||
const getModelMappingKey = createStableObjectKeyResolver<ModelMapping>('create-model-mapping')
|
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 clientEmail = typeof parsed.client_email === 'string' ? parsed.client_email.trim() : ''
|
||||||
const privateKey = typeof parsed.private_key === 'string' ? parsed.private_key.trim() : ''
|
const privateKey = typeof parsed.private_key === 'string' ? parsed.private_key.trim() : ''
|
||||||
if (!projectId || !clientEmail || !privateKey) {
|
if (!projectId || !clientEmail || !privateKey) {
|
||||||
appStore.showError('Service Account JSON 缺少 project_id、client_email 或 private_key')
|
appStore.showError(t('admin.accounts.vertexSaJsonMissingFields'))
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
vertexProjectId.value = projectId
|
vertexProjectId.value = projectId
|
||||||
@ -4259,7 +4214,7 @@ const applyVertexServiceAccountJson = (value: string) => {
|
|||||||
vertexServiceAccountJson.value = JSON.stringify(parsed)
|
vertexServiceAccountJson.value = JSON.stringify(parsed)
|
||||||
return true
|
return true
|
||||||
} catch {
|
} catch {
|
||||||
appStore.showError('Service Account JSON 格式无效')
|
appStore.showError(t('admin.accounts.vertexSaJsonInvalid'))
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -4406,7 +4361,7 @@ const handleSubmit = async () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!vertexLocation.value.trim()) {
|
if (!vertexLocation.value.trim()) {
|
||||||
appStore.showError('请填写 Vertex location')
|
appStore.showError(t('admin.accounts.vertexLocationRequired'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const credentials: Record<string, unknown> = {
|
const credentials: Record<string, unknown> = {
|
||||||
|
|||||||
@ -577,9 +577,9 @@
|
|||||||
type="text"
|
type="text"
|
||||||
class="input font-mono"
|
class="input font-mono"
|
||||||
readonly
|
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>
|
||||||
<div>
|
<div>
|
||||||
<label class="input-label">Location</label>
|
<label class="input-label">Location</label>
|
||||||
@ -589,7 +589,7 @@
|
|||||||
class="input font-mono"
|
class="input font-mono"
|
||||||
>
|
>
|
||||||
<optgroup
|
<optgroup
|
||||||
v-for="group in vertexLocationOptions"
|
v-for="group in VERTEX_LOCATION_OPTIONS"
|
||||||
:key="group.label"
|
:key="group.label"
|
||||||
:label="group.label"
|
:label="group.label"
|
||||||
>
|
>
|
||||||
@ -602,7 +602,182 @@
|
|||||||
</option>
|
</option>
|
||||||
</optgroup>
|
</optgroup>
|
||||||
</select>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -1959,6 +2134,7 @@ import QuotaLimitCard from '@/components/account/QuotaLimitCard.vue'
|
|||||||
import { applyInterceptWarmup } from '@/components/account/credentialsBuilder'
|
import { applyInterceptWarmup } from '@/components/account/credentialsBuilder'
|
||||||
import { formatDateTime, formatDateTimeLocalInput, parseDateTimeLocalInput } from '@/utils/format'
|
import { formatDateTime, formatDateTimeLocalInput, parseDateTimeLocalInput } from '@/utils/format'
|
||||||
import { createStableObjectKeyResolver } from '@/utils/stableObjectKey'
|
import { createStableObjectKeyResolver } from '@/utils/stableObjectKey'
|
||||||
|
import { VERTEX_LOCATION_OPTIONS } from '@/constants/account'
|
||||||
import {
|
import {
|
||||||
OPENAI_WS_MODE_CTX_POOL,
|
OPENAI_WS_MODE_CTX_POOL,
|
||||||
OPENAI_WS_MODE_OFF,
|
OPENAI_WS_MODE_OFF,
|
||||||
@ -2030,52 +2206,6 @@ const editBedrockApiKeyValue = ref('')
|
|||||||
const editVertexProjectId = ref('')
|
const editVertexProjectId = ref('')
|
||||||
const editVertexClientEmail = ref('')
|
const editVertexClientEmail = ref('')
|
||||||
const editVertexLocation = ref('us-central1')
|
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(() =>
|
const isBedrockAPIKeyMode = computed(() =>
|
||||||
props.account?.type === 'bedrock' &&
|
props.account?.type === 'bedrock' &&
|
||||||
(props.account?.credentials as Record<string, unknown>)?.auth_mode === 'apikey'
|
(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) || ''
|
editVertexProjectId.value = (credentials.project_id as string) || ''
|
||||||
editVertexClientEmail.value = (credentials.client_email as string) || ''
|
editVertexClientEmail.value = (credentials.client_email as string) || ''
|
||||||
editVertexLocation.value = (credentials.location as string) || (credentials.vertex_location as string) || 'us-central1'
|
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 {
|
} else {
|
||||||
const platformDefaultUrl =
|
const platformDefaultUrl =
|
||||||
newAccount.platform === 'openai'
|
newAccount.platform === 'openai'
|
||||||
@ -3160,20 +3310,20 @@ const handleSubmit = async () => {
|
|||||||
const newCredentials: Record<string, unknown> = { ...currentCredentials }
|
const newCredentials: Record<string, unknown> = { ...currentCredentials }
|
||||||
|
|
||||||
if (!editVertexProjectId.value.trim()) {
|
if (!editVertexProjectId.value.trim()) {
|
||||||
appStore.showError('Service Account JSON 缺少 project_id')
|
appStore.showError(t('admin.accounts.vertexSaJsonMissingProjectId'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!editVertexClientEmail.value.trim()) {
|
if (!editVertexClientEmail.value.trim()) {
|
||||||
appStore.showError('Service Account JSON 缺少 client_email')
|
appStore.showError(t('admin.accounts.vertexSaJsonMissingClientEmail'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!editVertexLocation.value.trim()) {
|
if (!editVertexLocation.value.trim()) {
|
||||||
appStore.showError('请填写 Vertex location')
|
appStore.showError(t('admin.accounts.vertexLocationRequired'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!currentCredentials.service_account_json && !currentCredentials.service_account) {
|
if (!currentCredentials.service_account_json && !currentCredentials.service_account) {
|
||||||
appStore.showError('请上传 Service Account JSON')
|
appStore.showError(t('admin.accounts.vertexSaJsonRequired'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
newCredentials.project_id = editVertexProjectId.value.trim()
|
newCredentials.project_id = editVertexProjectId.value.trim()
|
||||||
@ -3181,6 +3331,14 @@ const handleSubmit = async () => {
|
|||||||
newCredentials.location = editVertexLocation.value.trim()
|
newCredentials.location = editVertexLocation.value.trim()
|
||||||
newCredentials.tier_id = 'vertex'
|
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')
|
applyInterceptWarmup(newCredentials, interceptWarmupRequests.value, 'edit')
|
||||||
if (!applyTempUnschedConfig(newCredentials)) {
|
if (!applyTempUnschedConfig(newCredentials)) {
|
||||||
return
|
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_ROLLING = 'rolling' as const
|
||||||
export const QUOTA_RESET_MODE_FIXED = 'fixed' as const
|
export const QUOTA_RESET_MODE_FIXED = 'fixed' as const
|
||||||
export type QuotaResetMode = typeof QUOTA_RESET_MODE_ROLLING | typeof QUOTA_RESET_MODE_FIXED
|
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',
|
claudeConsole: 'Claude Console',
|
||||||
bedrockLabel: 'AWS Bedrock',
|
bedrockLabel: 'AWS Bedrock',
|
||||||
bedrockDesc: 'SigV4 / API Key',
|
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',
|
oauthSetupToken: 'OAuth / Setup Token',
|
||||||
addMethod: 'Add Method',
|
addMethod: 'Add Method',
|
||||||
setupTokenLongLived: 'Setup Token (Long-lived)',
|
setupTokenLongLived: 'Setup Token (Long-lived)',
|
||||||
|
|||||||
@ -2963,6 +2963,26 @@ export default {
|
|||||||
claudeConsole: 'Claude Console',
|
claudeConsole: 'Claude Console',
|
||||||
bedrockLabel: 'AWS Bedrock',
|
bedrockLabel: 'AWS Bedrock',
|
||||||
bedrockDesc: 'SigV4 / API Key',
|
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',
|
oauthSetupToken: 'OAuth / Setup Token',
|
||||||
addMethod: '添加方式',
|
addMethod: '添加方式',
|
||||||
setupTokenLongLived: 'Setup Token(长期有效)',
|
setupTokenLongLived: 'Setup Token(长期有效)',
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user