feat(vertex-ai): 重构 Vertex AI 为插件化 adapter,支持 service_account 认证与动态路由

将 Vertex AI 从 transport.py 的硬编码逻辑重构为独立的 plugin adapter,
支持 service_account/oauth 认证类型、模型格式自动识别、区域路由和 URL 构建。
前端新增 Key 认证类型选择和 Service Account 配置表单。

Co-authored-by: NyaDoo <65238336+NyaDoo@users.noreply.github.com>
Closes #194
This commit is contained in:
fawney19
2026-03-01 23:32:48 +08:00
parent a137601728
commit 4bf3a453e7
42 changed files with 1855 additions and 546 deletions

View File

@@ -32,7 +32,7 @@
data-1p-ignore="true"
/>
</div>
<div>
<div v-if="providerType === 'vertex_ai'">
<Label :for="authTypeSelectId">认证类型</Label>
<Select
v-model="form.auth_type"
@@ -44,8 +44,8 @@
<SelectItem value="api_key">
API Key
</SelectItem>
<SelectItem value="vertex_ai">
Vertex AI
<SelectItem value="service_account">
Service Account
</SelectItem>
</SelectContent>
</Select>
@@ -55,10 +55,10 @@
<!-- API 密钥 / Service Account JSON -->
<div>
<Label :for="apiKeyInputId">
{{ form.auth_type === 'vertex_ai' ? 'Service Account JSON' : 'API 密钥' }}
{{ form.auth_type === 'service_account' ? 'Service Account JSON' : 'API 密钥' }}
{{ editingKey ? '' : '*' }}
</Label>
<template v-if="form.auth_type === 'vertex_ai'">
<template v-if="form.auth_type === 'service_account'">
<Textarea
:id="apiKeyInputId"
v-model="form.auth_config_text"
@@ -101,11 +101,11 @@
</div>
<!-- API 格式选择 -->
<div v-if="sortedApiFormats.length > 0">
<div v-if="visibleApiFormats.length > 0">
<Label class="mb-1.5 block">支持的 API 格式 *</Label>
<div class="grid grid-cols-2 gap-2">
<div
v-for="format in sortedApiFormats"
v-for="format in visibleApiFormats"
:key="format"
class="flex items-center justify-between rounded-md border px-2 py-1.5 transition-colors cursor-pointer"
:class="form.api_formats.includes(format)
@@ -309,7 +309,7 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ref, computed, onMounted, watch } from 'vue'
import { Dialog, Button, Input, Label, Switch, Select, SelectTrigger, SelectValue, SelectContent, SelectItem, Textarea } from '@/components/ui'
import { Key, SquarePen } from 'lucide-vue-next'
import { useToast } from '@/composables/useToast'
@@ -346,8 +346,26 @@ const emit = defineEmits<{
const { success, error: showError } = useToast()
// 排序后的可用 API 格式列表
const sortedApiFormats = computed(() => sortApiFormats(props.availableApiFormats))
function getVertexAllowedFormatsByAuth(authType: 'api_key' | 'service_account'): Set<string> {
if (authType === 'api_key') {
return new Set(['gemini:chat'])
}
return new Set(['gemini:chat', 'claude:chat'])
}
function normalizeApiFormat(format: string): string {
return String(format || '').trim().toLowerCase()
}
// 按 provider/auth_type 过滤后的可用 API 格式列表
const visibleApiFormats = computed(() => {
const sorted = sortApiFormats(props.availableApiFormats)
if (props.providerType !== 'vertex_ai') {
return sorted
}
const allowed = getVertexAllowedFormatsByAuth(form.value.auth_type)
return sorted.filter(fmt => allowed.has(normalizeApiFormat(fmt)))
})
// 默认认证类型
const defaultAuthType = 'api_key' as const
@@ -370,8 +388,8 @@ const showAutoFetchWarning = computed(() => {
// 检查是否正在切换认证类型
const switchingToVertexAI = computed(() =>
!!props.editingKey &&
props.editingKey.auth_type !== 'vertex_ai' &&
form.value.auth_type === 'vertex_ai'
props.editingKey.auth_type !== 'service_account' &&
form.value.auth_type === 'service_account'
)
const switchingToApiKey = computed(() =>
!!props.editingKey &&
@@ -386,7 +404,7 @@ const canSave = computed(() => {
// 新增模式下根据认证类型判断必填字段
if (!props.editingKey) {
if (form.value.auth_type === 'api_key' && !form.value.api_key.trim()) return false
if (form.value.auth_type === 'vertex_ai' && !form.value.auth_config_text.trim()) return false
if (form.value.auth_type === 'service_account' && !form.value.auth_config_text.trim()) return false
} else {
// 编辑模式下切换认证类型时,必须填写对应字段
if (switchingToApiKey.value && !form.value.api_key.trim()) return false
@@ -409,15 +427,13 @@ const apiKeyFieldName = computed(() => `api-key-field-${formNonce.value}`)
// 可用的能力列表
const availableCapabilities = ref<CapabilityDefinition[]>([])
// 非 custom 提供商默认开启自动获取上游模型
const defaultAutoFetchModels = computed(() =>
!!props.providerType && props.providerType !== 'custom'
)
// 新增密钥时默认不自动开启上游模型获取
const defaultAutoFetchModels = computed(() => false)
const form = ref({
name: '',
api_key: '', // 标准 API Key
auth_type: 'api_key' as 'api_key' | 'vertex_ai', // 认证类型
auth_type: 'api_key' as 'api_key' | 'service_account', // 认证类型
auth_config_text: '', // Service Account JSON 文本(用于表单输入)
api_formats: [] as string[], // 支持的 API 格式列表
rate_multipliers: {} as Record<string, number>, // 按 API 格式的成本倍率
@@ -433,6 +449,21 @@ const form = ref({
model_exclude_patterns_text: '' // 排除规则文本(逗号分隔)
})
watch(
[() => form.value.auth_type, () => props.providerType, () => props.availableApiFormats],
() => {
if (props.providerType !== 'vertex_ai') {
return
}
const allowed = getVertexAllowedFormatsByAuth(form.value.auth_type)
const filtered = form.value.api_formats.filter(fmt => allowed.has(normalizeApiFormat(fmt)))
if (filtered.length !== form.value.api_formats.length) {
form.value.api_formats = [...filtered]
}
},
{ immediate: true }
)
// 加载能力列表
async function loadCapabilities() {
try {
@@ -517,7 +548,7 @@ function loadKeyData() {
form.value = {
name: props.editingKey.name,
api_key: '',
auth_type: props.editingKey.auth_type === 'vertex_ai' ? 'vertex_ai' : 'api_key',
auth_type: props.editingKey.auth_type === 'service_account' ? 'service_account' : 'api_key',
auth_config_text: '', // auth_config 不返回给前端,编辑时需要重新输入
api_formats: props.editingKey.api_formats?.length > 0
? [...props.editingKey.api_formats]
@@ -564,7 +595,7 @@ function parsePatternText(text: string): string[] {
// 解析 Service Account JSON 文本
function parseAuthConfig(): Record<string, unknown> | null {
if (form.value.auth_type !== 'vertex_ai') return null
if (form.value.auth_type !== 'service_account') return null
const text = form.value.auth_config_text.trim()
if (!text) return null
try {
@@ -588,7 +619,7 @@ async function handleSave() {
showError('请输入 API 密钥', '验证失败')
return
}
} else if (form.value.auth_type === 'vertex_ai') {
} else if (form.value.auth_type === 'service_account') {
if (!props.editingKey && !form.value.auth_config_text.trim()) {
showError('请输入 Service Account JSON', '验证失败')
return
@@ -664,7 +695,7 @@ async function handleSave() {
if (form.value.auth_type === 'api_key' && form.value.api_key.trim()) {
updateData.api_key = form.value.api_key
}
if (form.value.auth_type === 'vertex_ai' && authConfig) {
if (form.value.auth_type === 'service_account' && authConfig) {
updateData.auth_config = authConfig
}

View File

@@ -224,7 +224,7 @@
<div class="p-4 border-b border-border/60">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold">
{{ provider.provider_type === 'custom' ? '密钥管理' : '账号管理' }}
{{ isKeyManagedProviderType(provider.provider_type) ? '密钥管理' : '账号管理' }}
</h3>
<div class="flex items-center gap-2">
<Button
@@ -235,7 +235,7 @@
@click="handleAddKeyToFirstEndpoint"
>
<Plus class="w-3.5 h-3.5 mr-1.5" />
{{ provider.provider_type === 'custom' ? '添加密钥' : '添加账号' }}
{{ isKeyManagedProviderType(provider.provider_type) ? '添加密钥' : '添加账号' }}
</Button>
</div>
</div>
@@ -301,7 +301,7 @@
</div>
<div class="flex items-center gap-1">
<span class="text-[11px] font-mono text-muted-foreground">
{{ key.auth_type === 'oauth' ? '[Refresh Token]' : (key.auth_type === 'vertex_ai' ? 'Vertex AI' : key.api_key_masked) }}
{{ key.auth_type === 'oauth' ? '[Refresh Token]' : (key.auth_type === 'service_account' ? '[Service Account]' : key.api_key_masked) }}
</span>
<Button
v-if="key.auth_type === 'oauth'"
@@ -844,7 +844,7 @@
v-if="shouldPaginateKeys"
class="px-4 py-2 flex items-center justify-between text-xs text-muted-foreground mt-auto"
>
<span>共 {{ allKeys.length }} 个{{ provider.provider_type === 'custom' ? '密钥' : '账号' }}</span>
<span>共 {{ allKeys.length }} 个{{ isKeyManagedProviderType(provider.provider_type) ? '密钥' : '账号' }}</span>
<div class="flex items-center gap-1.5">
<Button
variant="ghost"
@@ -876,11 +876,11 @@
>
<Key class="w-12 h-12 mx-auto mb-3 opacity-50" />
<p class="text-sm">
{{ provider.provider_type === 'custom' ? '暂无密钥配置' : '暂无账号配置' }}
{{ isKeyManagedProviderType(provider.provider_type) ? '暂无密钥配置' : '暂无账号配置' }}
</p>
<p class="text-xs mt-1">
{{ endpoints.length > 0
? (provider.provider_type === 'custom' ? '点击上方"添加密钥"按钮创建第一个密钥' : '点击上方"添加账号"按钮添加第一个账号')
? (isKeyManagedProviderType(provider.provider_type) ? '点击上方"添加密钥"按钮创建第一个密钥' : '点击上方"添加账号"按钮添加第一个账号')
: '请先添加端点,然后再添加密钥' }}
</p>
</div>
@@ -1100,6 +1100,7 @@ import {
} from '@/api/endpoints'
import type { UpstreamMetadata, AntigravityModelQuota } from '@/api/endpoints/types'
import { formatApiFormat } from '@/api/endpoints/types/api-format'
import { isOAuthAccountProviderType, isKeyManagedProviderType } from '../utils/providerTypeUtils'
// 扩展端点类型,包含密钥列表
interface ProviderEndpointWithKeys extends ProviderEndpoint {
@@ -1417,11 +1418,11 @@ function handleAddKey(endpoint: ProviderEndpoint) {
function handleAddKeyToFirstEndpoint() {
if (endpoints.value.length === 0) return
// 非自定义提供商:打开 OAuth 账号对话框
if (provider.value?.provider_type !== 'custom') {
// OAuth 账号型提供商:打开 OAuth 账号对话框
if (isOAuthAccountProviderType(provider.value?.provider_type)) {
oauthAccountDialogOpen.value = true
} else {
// 自定义提供商:打开密钥表单对话框
// 密钥型提供商custom/vertex_ai:打开密钥表单对话框
handleAddKey(endpoints.value[0])
}
}
@@ -1455,8 +1456,8 @@ async function copyFullKey(key: EndpointAPIKey) {
const result = await revealEndpointKey(key.id)
let textToCopy: string
if (result.auth_type === 'vertex_ai' && result.auth_config) {
// Vertex AI 类型:复制 auth_config JSON
if (result.auth_type === 'service_account' && result.auth_config) {
// Service Account 类型:复制 auth_config JSON
textToCopy = typeof result.auth_config === 'string'
? result.auth_config
: JSON.stringify(result.auth_config, null, 2)

View File

@@ -42,6 +42,9 @@
<SelectItem value="custom">
自定义
</SelectItem>
<SelectItem value="vertex_ai">
Vertex AI
</SelectItem>
<SelectItem value="claude_code">
ClaudeCode
</SelectItem>
@@ -60,6 +63,9 @@
<SelectItem value="custom">
自定义
</SelectItem>
<SelectItem value="vertex_ai">
Vertex AI
</SelectItem>
<SelectItem value="claude_code">
ClaudeCode
</SelectItem>
@@ -322,7 +328,7 @@ const defaultPriority = computed(() => {
// 表单数据
const form = ref({
name: '',
provider_type: 'custom' as 'custom' | 'claude_code' | 'codex' | 'gemini_cli' | 'antigravity' | 'kiro',
provider_type: 'custom' as 'custom' | 'vertex_ai' | 'claude_code' | 'codex' | 'gemini_cli' | 'antigravity' | 'kiro',
description: '',
website: '',
// 计费配置
@@ -414,10 +420,10 @@ const { isEditMode, handleDialogUpdate, handleCancel } = useFormDialog({
resetForm,
})
// 新建模式下切换 provider_type 时自动设置号池模式:非自定义类型默认开启
watch(() => form.value.provider_type, (newType) => {
// 新建模式下切换 provider_type 时自动开启号池模式
watch(() => form.value.provider_type, () => {
if (!isEditMode.value) {
form.value.pool_mode_enabled = newType !== 'custom'
form.value.pool_mode_enabled = false
}
})

View File

@@ -239,6 +239,7 @@ import Badge from '@/components/ui/badge.vue'
import { type ProviderWithEndpointsSummary, API_FORMAT_SHORT } from '@/api/endpoints'
import { formatBillingType } from '@/utils/format'
import { sortEndpoints, isEndpointAvailable, getEndpointDotColor, getEndpointTooltip } from '@/features/providers/composables/useEndpointStatus'
import { isKeyManagedProviderType } from '../utils/providerTypeUtils'
const props = defineProps<{
provider: ProviderWithEndpointsSummary
@@ -304,7 +305,6 @@ function handleDescriptionKeydown(event: KeyboardEvent) {
}
function getCredentialLabel(provider: ProviderWithEndpointsSummary): '账号' | '密钥' {
const providerType = String(provider.provider_type || '').trim().toLowerCase()
return providerType && providerType !== 'custom' ? '账号' : '密钥'
return isKeyManagedProviderType(provider.provider_type) ? '密钥' : '账号'
}
</script>