Merge pull request #1977 from sholiverlee/vertex
feat: 支持 Vertex Service Account(Anthropic / Gemini)
This commit is contained in:
@@ -332,6 +332,37 @@
|
||||
|
||||
<!-- Usage data or unlimited flow -->
|
||||
<div class="space-y-1">
|
||||
<div
|
||||
v-if="showGeminiTodayStats && todayStats"
|
||||
class="mb-0.5 flex items-center"
|
||||
>
|
||||
<div class="flex items-center gap-1.5 text-[9px] text-gray-500 dark:text-gray-400">
|
||||
<span class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800">
|
||||
{{ formatKeyRequests }} req
|
||||
</span>
|
||||
<span class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800">
|
||||
{{ formatKeyTokens }}
|
||||
</span>
|
||||
<span class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800" :title="t('usage.accountBilled')">
|
||||
A ${{ formatKeyCost }}
|
||||
</span>
|
||||
<span
|
||||
v-if="todayStats.user_cost != null"
|
||||
class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800"
|
||||
:title="t('usage.userBilled')"
|
||||
>
|
||||
U ${{ formatKeyUserCost }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="showGeminiTodayStats && todayStatsLoading"
|
||||
class="mb-0.5 flex items-center gap-1"
|
||||
>
|
||||
<div class="h-3 w-10 animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
|
||||
<div class="h-3 w-8 animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
|
||||
<div class="h-3 w-12 animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
|
||||
</div>
|
||||
<div v-if="loading" class="space-y-1">
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="h-3 w-[32px] animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
|
||||
@@ -512,6 +543,10 @@ const shouldFetchUsage = computed(() => {
|
||||
return false
|
||||
})
|
||||
|
||||
const showGeminiTodayStats = computed(() => {
|
||||
return props.account.platform === 'gemini' && props.account.type === 'service_account'
|
||||
})
|
||||
|
||||
const geminiUsageAvailable = computed(() => {
|
||||
return (
|
||||
!!usageInfo.value?.gemini_shared_daily ||
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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>) || {}
|
||||
|
||||
@@ -57,6 +57,19 @@ function makeAccount(overrides: Partial<Account>): Account {
|
||||
describe('AccountUsageCell', () => {
|
||||
beforeEach(() => {
|
||||
getUsage.mockReset()
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation(() => ({
|
||||
matches: true,
|
||||
media: '(min-width: 768px)',
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
it('Antigravity 图片用量会聚合新旧 image 模型', async () => {
|
||||
@@ -603,4 +616,43 @@ describe('AccountUsageCell', () => {
|
||||
|
||||
expect(wrapper.text().trim()).toBe('-')
|
||||
})
|
||||
|
||||
it('Vertex 账号会在 Gemini 用量窗口里展示 today stats 徽章', async () => {
|
||||
const wrapper = mount(AccountUsageCell, {
|
||||
props: {
|
||||
account: makeAccount({
|
||||
id: 4001,
|
||||
platform: 'gemini',
|
||||
type: 'service_account',
|
||||
credentials: {
|
||||
tier_id: 'vertex',
|
||||
project_id: 'vertex-proj',
|
||||
client_email: 'svc@vertex-proj.iam.gserviceaccount.com',
|
||||
location: 'global'
|
||||
},
|
||||
extra: {}
|
||||
}),
|
||||
todayStats: {
|
||||
requests: 0,
|
||||
tokens: 0,
|
||||
cost: 0,
|
||||
standard_cost: 0,
|
||||
user_cost: 0
|
||||
}
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
UsageProgressBar: true,
|
||||
AccountQuotaInfo: true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('0 req')
|
||||
expect(wrapper.text()).toContain('0')
|
||||
expect(wrapper.text()).toContain('A $0.00')
|
||||
expect(wrapper.text()).toContain('U $0.00')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -643,7 +643,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'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user