mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-01 17:00:21 +08:00
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:
@@ -56,7 +56,7 @@ export async function getModelCapabilities(modelName: string): Promise<ModelCapa
|
||||
* 获取完整的 API Key(用于查看和复制)
|
||||
*/
|
||||
export interface RevealKeyResult {
|
||||
auth_type: 'api_key' | 'vertex_ai' | 'oauth'
|
||||
auth_type: 'api_key' | 'service_account' | 'oauth'
|
||||
api_key?: string
|
||||
refresh_token?: string
|
||||
auth_config?: string | Record<string, unknown>
|
||||
@@ -119,7 +119,7 @@ export async function addProviderKey(
|
||||
data: {
|
||||
api_formats: string[] // 支持的 API 格式列表(必填)
|
||||
api_key: string
|
||||
auth_type?: 'api_key' | 'vertex_ai' | 'oauth' // 认证类型
|
||||
auth_type?: 'api_key' | 'service_account' | 'oauth' // 认证类型
|
||||
auth_config?: Record<string, unknown> // 认证配置(Vertex AI Service Account JSON)
|
||||
name: string
|
||||
rate_multipliers?: Record<string, number> | null // 按 API 格式的成本倍率
|
||||
@@ -147,7 +147,7 @@ export async function updateProviderKey(
|
||||
data: Partial<{
|
||||
api_formats: string[] // 支持的 API 格式列表
|
||||
api_key: string
|
||||
auth_type: 'api_key' | 'vertex_ai' | 'oauth' // 认证类型
|
||||
auth_type: 'api_key' | 'service_account' | 'oauth' // 认证类型
|
||||
auth_config: Record<string, unknown> // 认证配置(Vertex AI Service Account JSON)
|
||||
name: string
|
||||
rate_multipliers: Record<string, number> | null // 按 API 格式的成本倍率
|
||||
|
||||
@@ -30,7 +30,7 @@ export async function updateProvider(
|
||||
providerId: string,
|
||||
data: Partial<{
|
||||
name: string
|
||||
provider_type: 'custom' | 'claude_code' | 'codex' | 'gemini_cli' | 'antigravity' | 'kiro'
|
||||
provider_type: 'custom' | 'vertex_ai' | 'claude_code' | 'codex' | 'gemini_cli' | 'antigravity' | 'kiro'
|
||||
description: string | null
|
||||
website: string
|
||||
provider_priority: number
|
||||
@@ -62,7 +62,7 @@ export async function updateProvider(
|
||||
export async function createProvider(
|
||||
data: {
|
||||
name: string
|
||||
provider_type?: 'custom' | 'claude_code' | 'codex' | 'gemini_cli' | 'antigravity' | 'kiro'
|
||||
provider_type?: 'custom' | 'vertex_ai' | 'claude_code' | 'codex' | 'gemini_cli' | 'antigravity' | 'kiro'
|
||||
description?: string
|
||||
website?: string
|
||||
billing_type?: 'monthly_quota' | 'pay_as_you_go' | 'free_tier'
|
||||
|
||||
@@ -204,7 +204,7 @@ export interface EndpointAPIKey {
|
||||
api_formats: string[] // 支持的 endpoint signature 列表(如 "openai:chat")
|
||||
api_key_masked: string
|
||||
api_key_plain?: string | null
|
||||
auth_type: 'api_key' | 'vertex_ai' | 'oauth' // 认证类型(必返回)
|
||||
auth_type: 'api_key' | 'service_account' | 'oauth' // 认证类型(必返回)
|
||||
name: string // 密钥名称(必填,用于识别)
|
||||
rate_multipliers?: Record<string, number> | null // 按 endpoint signature 的成本倍率
|
||||
internal_priority: number // Key 内部优先级
|
||||
@@ -347,7 +347,7 @@ export interface EndpointAPIKeyUpdate {
|
||||
api_formats?: string[] // 支持的 API 格式列表
|
||||
name?: string
|
||||
api_key?: string // 仅在需要更新时提供
|
||||
auth_type?: 'api_key' | 'vertex_ai' | 'oauth' // 认证类型
|
||||
auth_type?: 'api_key' | 'service_account' | 'oauth' // 认证类型
|
||||
auth_config?: Record<string, unknown> // 认证配置(Vertex AI Service Account JSON)
|
||||
rate_multipliers?: Record<string, number> | null // 按 API 格式的成本倍率
|
||||
internal_priority?: number
|
||||
@@ -436,7 +436,7 @@ export interface PublicEndpointStatusMonitorResponse {
|
||||
formats: PublicEndpointStatusMonitor[]
|
||||
}
|
||||
|
||||
export type ProviderType = 'custom' | 'claude_code' | 'codex' | 'gemini_cli' | 'antigravity' | 'kiro'
|
||||
export type ProviderType = 'custom' | 'claude_code' | 'codex' | 'gemini_cli' | 'antigravity' | 'kiro' | 'vertex_ai'
|
||||
|
||||
export interface ClaudeCodeAdvancedConfig {
|
||||
// 会话数量控制:null/undefined 表示不限制
|
||||
|
||||
@@ -13,7 +13,7 @@ export interface CandidateRecord {
|
||||
key_id?: string
|
||||
key_name?: string // 密钥名称
|
||||
key_preview?: string // 密钥脱敏预览(如 sk-***abc),OAuth 类型不返回
|
||||
key_auth_type?: string // 密钥认证类型(api_key, oauth, vertex_ai 等)
|
||||
key_auth_type?: string // 密钥认证类型(api_key, service_account, oauth 等)
|
||||
key_oauth_plan_type?: string // OAuth 账号套餐类型(free/plus/team/enterprise)
|
||||
key_capabilities?: Record<string, boolean> | null // Key 支持的能力
|
||||
required_capabilities?: Record<string, boolean> | null // 请求实际需要的能力标签
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -775,14 +775,14 @@ const isCapabilityUsed = (cap: string): boolean => {
|
||||
// 判断是否为 OAuth 类型(provider_type 为具体值时也算 OAuth)
|
||||
const isOAuthType = (authType?: string): boolean => {
|
||||
if (!authType) return false
|
||||
return !['api_key', 'vertex_ai'].includes(authType)
|
||||
return !['api_key', 'service_account'].includes(authType)
|
||||
}
|
||||
|
||||
// 格式化认证类型(合并 plan 信息,避免冗余)
|
||||
const formatAuthTypeWithPlan = (authType: string, planType?: string): string => {
|
||||
const labels: Record<string, string> = {
|
||||
'oauth': 'OAuth',
|
||||
'vertex_ai': 'Vertex AI',
|
||||
'service_account': 'Service Account',
|
||||
'kiro': 'Kiro',
|
||||
'codex': 'Codex',
|
||||
'antigravity': 'Antigravity',
|
||||
|
||||
@@ -131,17 +131,22 @@ export class GeminiParser implements ApiFormatParser {
|
||||
apiFormat: 'gemini',
|
||||
}
|
||||
|
||||
// Gemini 响应格式: { candidates: [{ content: { parts: [...] } }] }
|
||||
const candidates = body.candidates as RawObject[] | undefined
|
||||
const candidate = candidates?.[0] as RawObject | undefined
|
||||
const candidateContent = candidate?.content as RawObject | undefined
|
||||
if (candidateContent?.parts && Array.isArray(candidateContent.parts)) {
|
||||
const contentBlocks = this.parseParts(candidateContent.parts as RawObject[])
|
||||
// Gemini 原生响应格式: { candidates: [{ content: { parts: [...] } }] }
|
||||
const candidateParts = this.getCandidateParts(body)
|
||||
if (candidateParts.length > 0) {
|
||||
const contentBlocks = this.parseParts(candidateParts)
|
||||
if (contentBlocks.length > 0) {
|
||||
result.messages.push(createMessage('assistant', contentBlocks))
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
// 统一响应格式(例如 status/output/output_text)
|
||||
const unifiedMessages = this.parseUnifiedOutputMessages(body)
|
||||
if (unifiedMessages.length > 0) {
|
||||
result.messages.push(...unifiedMessages)
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (e) {
|
||||
return createEmptyConversation('gemini', `解析失败: ${e}`)
|
||||
@@ -363,12 +368,10 @@ export class GeminiParser implements ApiFormatParser {
|
||||
const body = responseBody as RawObject
|
||||
const blocks: RenderBlock[] = []
|
||||
|
||||
// Gemini 响应格式: { candidates: [{ content: { parts: [...] } }] }
|
||||
const candidates = body.candidates as RawObject[] | undefined
|
||||
const candidate = candidates?.[0] as RawObject | undefined
|
||||
const candidateContent = candidate?.content as RawObject | undefined
|
||||
if (candidateContent?.parts && Array.isArray(candidateContent.parts)) {
|
||||
const parts = candidateContent.parts as RawObject[]
|
||||
// Gemini 原生响应格式: { candidates: [{ content: { parts: [...] } }] }
|
||||
const candidateParts = this.getCandidateParts(body)
|
||||
if (candidateParts.length > 0) {
|
||||
const parts = candidateParts
|
||||
const contentBlocks = this.renderParts(parts)
|
||||
if (contentBlocks.length > 0) {
|
||||
const badges = this.getBadgesForParts(parts)
|
||||
@@ -376,9 +379,13 @@ export class GeminiParser implements ApiFormatParser {
|
||||
roleLabel: 'Assistant',
|
||||
badges: badges.length > 0 ? badges : undefined,
|
||||
}))
|
||||
return { blocks, isStream: false }
|
||||
}
|
||||
}
|
||||
|
||||
// 统一响应格式(例如 status/output/output_text)
|
||||
blocks.push(...this.renderUnifiedOutputMessages(body))
|
||||
|
||||
return { blocks, isStream: false }
|
||||
} catch (e) {
|
||||
return createEmptyRenderResult(`渲染失败: ${e}`)
|
||||
@@ -582,6 +589,117 @@ export class GeminiParser implements ApiFormatParser {
|
||||
return badges
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取 Gemini candidates 中的 parts
|
||||
*/
|
||||
private getCandidateParts(body: RawObject): RawObject[] {
|
||||
const candidates = body.candidates as RawObject[] | undefined
|
||||
const candidate = candidates?.[0] as RawObject | undefined
|
||||
const candidateContent = candidate?.content as RawObject | undefined
|
||||
if (Array.isArray(candidateContent?.parts)) {
|
||||
return candidateContent.parts as RawObject[]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析统一响应格式中的 output 消息
|
||||
*/
|
||||
private parseUnifiedOutputMessages(body: RawObject): ParsedMessage[] {
|
||||
const output = body.output
|
||||
if (!Array.isArray(output)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const messages: ParsedMessage[] = []
|
||||
for (const rawItem of output) {
|
||||
const item = rawItem as RawObject
|
||||
if (item?.type !== 'message') {
|
||||
continue
|
||||
}
|
||||
|
||||
const texts = this.extractUnifiedOutputTexts(item.content)
|
||||
if (texts.length === 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
messages.push(createMessage(
|
||||
this.mapUnifiedOutputRole(item.role),
|
||||
texts.map(text => createTextBlock(text))
|
||||
))
|
||||
}
|
||||
|
||||
return messages
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染统一响应格式中的 output 消息
|
||||
*/
|
||||
private renderUnifiedOutputMessages(body: RawObject): RenderBlock[] {
|
||||
const output = body.output
|
||||
if (!Array.isArray(output)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const blocks: RenderBlock[] = []
|
||||
for (const rawItem of output) {
|
||||
const item = rawItem as RawObject
|
||||
if (item?.type !== 'message') {
|
||||
continue
|
||||
}
|
||||
|
||||
const texts = this.extractUnifiedOutputTexts(item.content)
|
||||
if (texts.length === 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
const role = this.mapUnifiedOutputRole(item.role)
|
||||
blocks.push(createMessageBlock(
|
||||
role,
|
||||
texts.map(text => createTextRenderBlock(text)),
|
||||
{ roleLabel: this.getRoleLabel(role) }
|
||||
))
|
||||
}
|
||||
|
||||
return blocks
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取统一响应格式的文本内容
|
||||
*/
|
||||
private extractUnifiedOutputTexts(content: unknown): string[] {
|
||||
if (typeof content === 'string') {
|
||||
return content ? [content] : []
|
||||
}
|
||||
|
||||
if (!Array.isArray(content)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const texts: string[] = []
|
||||
for (const rawPart of content) {
|
||||
const part = rawPart as RawObject
|
||||
if (
|
||||
(part.type === 'output_text' || part.type === 'text' || part.type === 'input_text') &&
|
||||
typeof part.text === 'string'
|
||||
) {
|
||||
texts.push(part.text)
|
||||
}
|
||||
}
|
||||
return texts
|
||||
}
|
||||
|
||||
/**
|
||||
* 映射统一 output 结构的角色
|
||||
*/
|
||||
private mapUnifiedOutputRole(role: unknown): MessageRole {
|
||||
if (role === 'assistant' || role === 'model') return 'assistant'
|
||||
if (role === 'user') return 'user'
|
||||
if (role === 'system') return 'system'
|
||||
if (role === 'tool') return 'tool'
|
||||
return 'assistant'
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取已解析内容的徽章
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user