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

@@ -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',

View File

@@ -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'
}
/**
* 获取已解析内容的徽章
*/