Add Vertex service account support

This commit is contained in:
Oliver
2026-04-25 20:39:58 -04:00
parent 489a4d934e
commit 6d11f9ed77
17 changed files with 1243 additions and 36 deletions

View File

@@ -153,7 +153,7 @@
<!-- Account Type Selection (Anthropic) -->
<div v-if="form.platform === 'anthropic'">
<label class="input-label">{{ t('admin.accounts.accountType') }}</label>
<div class="mt-2 grid grid-cols-3 gap-3" data-tour="account-form-type">
<div class="mt-2 grid grid-cols-2 gap-3 sm:grid-cols-4" data-tour="account-form-type">
<button
type="button"
@click="accountCategory = 'oauth-based'"
@@ -244,6 +244,39 @@
</div>
</button>
<button
type="button"
@click="accountCategory = 'service_account'"
:class="[
'flex items-center gap-3 rounded-lg border-2 p-3 text-left transition-all',
accountCategory === 'service_account'
? 'border-sky-500 bg-sky-50 dark:bg-sky-900/20'
: 'border-gray-200 hover:border-sky-300 dark:border-dark-600 dark:hover:border-sky-700'
]"
>
<div
:class="[
'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg',
accountCategory === 'service_account'
? 'bg-sky-500 text-white'
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
]"
>
<Icon name="cloud" size="sm" />
</div>
<div>
<span class="block text-sm font-medium text-gray-900 dark:text-white">Vertex</span>
<span class="text-xs text-gray-500 dark:text-gray-400">Service Account</span>
</div>
</button>
</div>
<div
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>
</div>
</div>
@@ -302,6 +335,7 @@
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.types.responsesApi') }}</span>
</div>
</button>
</div>
</div>
@@ -320,7 +354,7 @@
{{ t('admin.accounts.gemini.helpButton') }}
</button>
</div>
<div class="mt-2 grid grid-cols-2 gap-3" data-tour="account-form-type">
<div class="mt-2 grid grid-cols-3 gap-3" data-tour="account-form-type">
<button
type="button"
@click="accountCategory = 'oauth-based'"
@@ -392,6 +426,36 @@
</span>
</div>
</button>
<button
type="button"
@click="accountCategory = 'service_account'"
:class="[
'flex items-center gap-3 rounded-lg border-2 p-3 text-left transition-all',
accountCategory === 'service_account'
? 'border-sky-500 bg-sky-50 dark:bg-sky-900/20'
: 'border-gray-200 hover:border-sky-300 dark:border-dark-600 dark:hover:border-sky-700'
]"
>
<div
:class="[
'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg',
accountCategory === 'service_account'
? 'bg-sky-500 text-white'
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
]"
>
<Icon name="cloud" size="sm" />
</div>
<div>
<span class="block text-sm font-medium text-gray-900 dark:text-white">
Vertex
</span>
<span class="text-xs text-gray-500 dark:text-gray-400">
Service Account
</span>
</div>
</button>
</div>
<div
@@ -411,6 +475,13 @@
</div>
</div>
<div
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>
</div>
<!-- OAuth Type Selection (only show when oauth-based is selected) -->
<div v-if="accountCategory === 'oauth-based'" class="mt-4">
<label class="input-label">{{ t('admin.accounts.oauth.gemini.oauthTypeLabel') }}</label>
@@ -610,7 +681,7 @@
</div>
<!-- Tier selection (used as fallback when auto-detection is unavailable/fails) -->
<div class="mt-4">
<div v-if="accountCategory !== 'service_account'" class="mt-4">
<label class="input-label">{{ t('admin.accounts.gemini.tier.label') }}</label>
<div class="mt-2">
<select
@@ -729,6 +800,96 @@
</div>
</div>
<!-- Vertex Service Account -->
<div v-if="(form.platform === 'gemini' || form.platform === 'anthropic') && accountCategory === 'service_account'" class="space-y-4">
<div>
<label class="input-label">Service Account JSON</label>
<input
ref="vertexServiceAccountFileInput"
type="file"
accept="application/json,.json"
class="hidden"
@change="handleVertexServiceAccountFile"
/>
<div
:class="[
'rounded-lg border-2 border-dashed px-4 py-5 transition-colors',
vertexServiceAccountDragActive
? 'border-sky-500 bg-sky-50 dark:border-sky-500 dark:bg-sky-900/20'
: 'border-gray-300 bg-gray-50 hover:border-sky-400 hover:bg-sky-50/60 dark:border-dark-500 dark:bg-dark-700/40 dark:hover:border-sky-600 dark:hover:bg-sky-900/10'
]"
@dragenter.prevent="vertexServiceAccountDragActive = true"
@dragover.prevent="vertexServiceAccountDragActive = true"
@dragleave.prevent="vertexServiceAccountDragActive = false"
@drop.prevent="handleVertexServiceAccountDrop"
>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<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>
</div>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ vertexClientEmail ? '密钥内容不会在表单中显示。' : '把 .json 文件拖到这里,或点击按钮选择文件。' }}
</p>
</div>
<button
type="button"
class="btn btn-secondary shrink-0"
@click="vertexServiceAccountFileInput?.click()"
>
<Icon name="upload" size="sm" />
选择 JSON
</button>
</div>
<div
v-if="vertexClientEmail"
class="mt-3 rounded-md border border-sky-200 bg-white px-3 py-2 text-xs text-sky-900 dark:border-sky-800/50 dark:bg-dark-800 dark:text-sky-200"
>
<div class="truncate">Project ID: <span class="font-mono">{{ vertexProjectId }}</span></div>
<div class="truncate">Client Email: <span class="font-mono">{{ vertexClientEmail }}</span></div>
</div>
</div>
<p class="input-hint">上传或拖入 JSON 后会自动读取 project_id密钥内容仅用于创建账号提交</p>
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label class="input-label">Project ID</label>
<input
v-model="vertexProjectId"
type="text"
class="input font-mono"
readonly
placeholder="从 JSON 自动读取"
/>
</div>
<div>
<label class="input-label">Location</label>
<select
v-model="vertexLocation"
required
class="input font-mono"
>
<optgroup
v-for="group in vertexLocationOptions"
:key="group.label"
:label="group.label"
>
<option
v-for="option in group.options"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</optgroup>
</select>
<p class="input-hint">不同 Vertex 模型可用 location 可能不同这里选择账号默认 endpoint location</p>
</div>
</div>
</div>
<!-- Antigravity model restriction (applies to OAuth + Upstream) -->
<!-- Antigravity 只支持模型映射模式不支持白名单模式 -->
<div v-if="form.platform === 'antigravity'" class="border-t border-gray-200 pt-4 dark:border-dark-600">
@@ -3085,7 +3246,7 @@ interface TempUnschedRuleForm {
// State
const step = ref(1)
const submitting = ref(false)
const accountCategory = ref<'oauth-based' | 'apikey' | 'bedrock'>('oauth-based') // UI selection for account category
const accountCategory = ref<'oauth-based' | 'apikey' | 'bedrock' | 'service_account'>('oauth-based') // UI selection for account category
const addMethod = ref<AddMethod>('oauth') // For oauth-based: 'oauth' or 'setup-token'
const apiKeyBaseUrl = ref('https://api.anthropic.com')
const apiKeyValue = ref('')
@@ -3151,6 +3312,58 @@ const bedrockSessionToken = ref('')
const bedrockRegion = ref('us-east-1')
const bedrockForceGlobal = ref(false)
const bedrockApiKeyValue = ref('')
const vertexServiceAccountFileInput = ref<HTMLInputElement | null>(null)
const vertexServiceAccountJson = ref('')
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')
@@ -3397,7 +3610,7 @@ watch(
// Sync form.type based on accountCategory, addMethod, and platform-specific type
watch(
[accountCategory, addMethod, antigravityAccountType],
[accountCategory, addMethod, antigravityAccountType, () => form.platform],
([category, method, agType]) => {
// Antigravity upstream 类型(实际创建为 apikey
if (form.platform === 'antigravity' && agType === 'upstream') {
@@ -3409,7 +3622,9 @@ watch(
form.type = 'bedrock' as AccountType
return
}
if (category === 'oauth-based') {
if ((form.platform === 'gemini' || form.platform === 'anthropic') && category === 'service_account') {
form.type = 'service_account' as AccountType
} else if (category === 'oauth-based') {
form.type = method as AccountType // 'oauth' or 'setup-token'
} else {
form.type = 'apikey'
@@ -3447,6 +3662,12 @@ watch(
antigravityModelMappings.value = []
antigravityModelRestrictionMode.value = 'mapping'
}
if (newPlatform !== 'gemini' && newPlatform !== 'anthropic' && accountCategory.value === 'service_account') {
accountCategory.value = 'oauth-based'
}
if (newPlatform !== 'anthropic' && accountCategory.value === 'bedrock') {
accountCategory.value = 'oauth-based'
}
// Reset Bedrock fields when switching platforms
bedrockAccessKeyId.value = ''
bedrockSecretAccessKey.value = ''
@@ -3455,6 +3676,10 @@ watch(
bedrockForceGlobal.value = false
bedrockAuthMode.value = 'sigv4'
bedrockApiKeyValue.value = ''
vertexServiceAccountJson.value = ''
vertexProjectId.value = ''
vertexClientEmail.value = ''
vertexLocation.value = 'global'
// Reset Anthropic/Antigravity-specific settings when switching to other platforms
if (newPlatform !== 'anthropic' && newPlatform !== 'antigravity') {
interceptWarmupRequests.value = false
@@ -3886,6 +4111,10 @@ const resetForm = () => {
antigravityAccountType.value = 'oauth'
upstreamBaseUrl.value = ''
upstreamApiKey.value = ''
vertexServiceAccountJson.value = ''
vertexProjectId.value = ''
vertexClientEmail.value = ''
vertexLocation.value = 'global'
tempUnschedEnabled.value = false
tempUnschedRules.value = []
geminiOAuthType.value = 'code_assist'
@@ -4009,6 +4238,52 @@ const normalizePoolModeRetryCount = (value: number) => {
return normalized
}
const applyVertexServiceAccountJson = (value: string) => {
const raw = value.trim()
if (!raw) {
vertexProjectId.value = ''
vertexClientEmail.value = ''
return false
}
try {
const parsed = JSON.parse(raw) as Record<string, unknown>
const projectId = typeof parsed.project_id === 'string' ? parsed.project_id.trim() : ''
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')
return false
}
vertexProjectId.value = projectId
vertexClientEmail.value = clientEmail
vertexServiceAccountJson.value = JSON.stringify(parsed)
return true
} catch {
appStore.showError('Service Account JSON 格式无效')
return false
}
}
const parseVertexServiceAccountJson = () => applyVertexServiceAccountJson(vertexServiceAccountJson.value)
const handleVertexServiceAccountFile = async (event: Event) => {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
try {
applyVertexServiceAccountJson(await file.text())
} finally {
input.value = ''
}
}
const handleVertexServiceAccountDrop = async (event: DragEvent) => {
vertexServiceAccountDragActive.value = false
const file = event.dataTransfer?.files?.[0]
if (!file) return
applyVertexServiceAccountJson(await file.text())
}
const handleSubmit = async () => {
// For OAuth-based type, handle OAuth flow (goes to step 2)
if (isOAuthFlow.value) {
@@ -4122,6 +4397,29 @@ const handleSubmit = async () => {
return
}
if ((form.platform === 'gemini' || form.platform === 'anthropic') && accountCategory.value === 'service_account') {
if (!form.name.trim()) {
appStore.showError(t('admin.accounts.pleaseEnterAccountName'))
return
}
if (!parseVertexServiceAccountJson()) {
return
}
if (!vertexLocation.value.trim()) {
appStore.showError('请填写 Vertex location')
return
}
const credentials: Record<string, unknown> = {
service_account_json: vertexServiceAccountJson.value.trim(),
project_id: vertexProjectId.value.trim(),
client_email: vertexClientEmail.value.trim(),
location: vertexLocation.value.trim(),
tier_id: 'vertex'
}
await createAccountAndFinish(form.platform, 'service_account' as AccountType, credentials)
return
}
// For apikey type, create directly
if (!apiKeyValue.value.trim()) {
appStore.showError(t('admin.accounts.pleaseEnterApiKey'))

View File

@@ -567,6 +567,46 @@
</div>
</div>
<!-- Vertex Service Account -->
<div v-if="(account.platform === 'gemini' || account.platform === 'anthropic') && account.type === 'service_account'" class="space-y-4">
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label class="input-label">Project ID</label>
<input
v-model="editVertexProjectId"
type="text"
class="input font-mono"
readonly
placeholder="从 JSON 自动读取"
/>
<p class="input-hint">Service Account JSON 不在编辑页显示需要更换 JSON 时请删除账号后重新创建</p>
</div>
<div>
<label class="input-label">Location</label>
<select
v-model="editVertexLocation"
required
class="input font-mono"
>
<optgroup
v-for="group in vertexLocationOptions"
:key="group.label"
:label="group.label"
>
<option
v-for="option in group.options"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</optgroup>
</select>
<p class="input-hint">不同 Vertex 模型可用 location 可能不同这里选择账号默认 endpoint location</p>
</div>
</div>
</div>
<!-- Bedrock fields (for bedrock type, both SigV4 and API Key modes) -->
<div v-if="account.type === 'bedrock'" class="space-y-4">
<!-- SigV4 fields -->
@@ -1987,6 +2027,55 @@ const editBedrockSessionToken = ref('')
const editBedrockRegion = ref('')
const editBedrockForceGlobal = ref(false)
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'
@@ -2246,6 +2335,9 @@ const syncFormFromAccount = (newAccount: Account | null) => {
const credentials = newAccount.credentials as Record<string, unknown> | undefined
interceptWarmupRequests.value = credentials?.intercept_warmup_requests === true
autoPauseOnExpired.value = newAccount.auto_pause_on_expired === true
editVertexProjectId.value = ''
editVertexClientEmail.value = ''
editVertexLocation.value = 'us-central1'
// Load mixed scheduling setting (only for antigravity accounts)
mixedScheduling.value = false
@@ -2467,6 +2559,11 @@ const syncFormFromAccount = (newAccount: Account | null) => {
} else if (newAccount.type === 'upstream' && newAccount.credentials) {
const credentials = newAccount.credentials as Record<string, unknown>
editBaseUrl.value = (credentials.base_url as string) || ''
} else if ((newAccount.platform === 'gemini' || newAccount.platform === 'anthropic') && newAccount.type === 'service_account' && newAccount.credentials) {
const credentials = newAccount.credentials as Record<string, unknown>
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'
} else {
const platformDefaultUrl =
newAccount.platform === 'openai'
@@ -3057,6 +3154,38 @@ const handleSubmit = async () => {
return
}
updatePayload.credentials = newCredentials
} else if ((props.account.platform === 'gemini' || props.account.platform === 'anthropic') && props.account.type === 'service_account') {
const currentCredentials = (props.account.credentials as Record<string, unknown>) || {}
const newCredentials: Record<string, unknown> = { ...currentCredentials }
if (!editVertexProjectId.value.trim()) {
appStore.showError('Service Account JSON 缺少 project_id')
return
}
if (!editVertexClientEmail.value.trim()) {
appStore.showError('Service Account JSON 缺少 client_email')
return
}
if (!editVertexLocation.value.trim()) {
appStore.showError('请填写 Vertex location')
return
}
if (!currentCredentials.service_account_json && !currentCredentials.service_account) {
appStore.showError('请上传 Service Account JSON')
return
}
newCredentials.project_id = editVertexProjectId.value.trim()
newCredentials.client_email = editVertexClientEmail.value.trim()
newCredentials.location = editVertexLocation.value.trim()
newCredentials.tier_id = 'vertex'
applyInterceptWarmup(newCredentials, interceptWarmupRequests.value, 'edit')
if (!applyTempUnschedConfig(newCredentials)) {
return
}
updatePayload.credentials = newCredentials
} else if (props.account.type === 'bedrock') {
const currentCredentials = (props.account.credentials as Record<string, unknown>) || {}

View File

@@ -25,6 +25,7 @@
<!-- Setup Token icon -->
<Icon v-else-if="type === 'setup-token'" name="shield" size="xs" />
<!-- API Key icon -->
<Icon v-else-if="type === 'service_account'" name="cloud" size="xs" />
<Icon v-else name="key" size="xs" />
<span>{{ typeLabel }}</span>
</span>
@@ -88,6 +89,8 @@ const typeLabel = computed(() => {
return 'Key'
case 'bedrock':
return 'AWS'
case 'service_account':
return 'Vertex'
default:
return props.type
}

View File

@@ -641,7 +641,7 @@ export interface UpdateGroupRequest {
// ==================== Account & Proxy Types ====================
export type AccountPlatform = 'anthropic' | 'openai' | 'gemini' | 'antigravity'
export type AccountType = 'oauth' | 'setup-token' | 'apikey' | 'upstream' | 'bedrock'
export type AccountType = 'oauth' | 'setup-token' | 'apikey' | 'upstream' | 'bedrock' | 'service_account'
export type OAuthAddMethod = 'oauth' | 'setup-token'
export type ProxyProtocol = 'http' | 'https' | 'socks5' | 'socks5h'