Support per-format provider key auth

This commit is contained in:
fawney19
2026-04-29 15:46:50 +08:00
parent 07a319259b
commit e751289dfb
67 changed files with 1244 additions and 420 deletions

View File

@@ -173,6 +173,7 @@ export interface ProviderKeyExport {
rate_multipliers?: Record<string, number> | null
internal_priority?: number
global_priority_by_format?: Record<string, number> | null
auth_type_by_format?: Record<string, 'api_key' | 'bearer'> | null
rpm_limit?: number | null
allowed_models?: string[] | null
capabilities?: Record<string, boolean>

View File

@@ -138,7 +138,8 @@ export async function addProviderKey(
data: {
api_formats: string[] // 支持的 API 格式列表(必填)
api_key: string
auth_type?: 'api_key' | 'service_account' | 'oauth' // 认证类型
auth_type?: 'api_key' | 'service_account' | 'oauth' | 'bearer' // 认证类型
auth_type_by_format?: Record<string, 'api_key' | 'bearer'> | null
auth_config?: Record<string, unknown> // 认证配置Vertex AI Service Account JSON
name: string
rate_multipliers?: Record<string, number> | null // 按 API 格式的成本倍率
@@ -167,6 +168,7 @@ export async function updateProviderKey(
api_formats: string[] // 支持的 API 格式列表
api_key: string
auth_type: 'api_key' | 'service_account' | 'oauth' | 'bearer' // 认证类型
auth_type_by_format: Record<string, 'api_key' | 'bearer'> | null
auth_config: Record<string, unknown> // 认证配置Vertex AI Service Account JSON
name: string
rate_multipliers: Record<string, number> | null // 按 API 格式的成本倍率

View File

@@ -104,8 +104,9 @@ export interface PoolKeyDetail {
key_name: string
is_active: boolean
auth_type: string
auth_type_by_format?: Record<string, 'api_key' | 'bearer'> | null
credential_kind?: 'raw_secret' | 'oauth_session' | 'service_account' | string | null
runtime_auth_kind?: 'api_key' | 'bearer' | 'service_account' | 'unknown' | string | null
runtime_auth_kind?: 'api_key' | 'bearer' | 'service_account' | 'mixed' | 'unknown' | string | null
oauth_managed?: boolean
can_refresh_oauth?: boolean
can_export_oauth?: boolean
@@ -206,8 +207,9 @@ export interface PoolKeySelectionItem {
key_id: string
key_name: string
auth_type: string
auth_type_by_format?: Record<string, 'api_key' | 'bearer'> | null
credential_kind?: 'raw_secret' | 'oauth_session' | 'service_account' | string | null
runtime_auth_kind?: 'api_key' | 'bearer' | 'service_account' | 'unknown' | string | null
runtime_auth_kind?: 'api_key' | 'bearer' | 'service_account' | 'mixed' | 'unknown' | string | null
oauth_managed?: boolean
can_refresh_oauth?: boolean
can_export_oauth?: boolean

View File

@@ -9,47 +9,51 @@ import {
sortApiFormats,
} from '@/api/endpoints/types'
const openaiAlias = (kind: string) => ['openai', kind].join(':')
const legacyEnumAlias = (kind: string) => ['OPENAI', kind].join('_')
describe('api format display helpers', () => {
it('maps historical OpenAI response aliases to current display names', () => {
expect(normalizeApiFormatAlias(openaiAlias('cli'))).toBe(API_FORMATS.OPENAI_RESPONSES)
expect(formatApiFormat(openaiAlias('cli'))).toBe('OpenAI Responses')
expect(formatApiFormatShort(openaiAlias('cli'))).toBe('OR')
expect(normalizeApiFormatAlias(openaiAlias('compact'))).toBe(API_FORMATS.OPENAI_RESPONSES_COMPACT)
expect(formatApiFormat(openaiAlias('compact'))).toBe('OpenAI Responses Compact')
expect(formatApiFormatShort(openaiAlias('compact'))).toBe('ORC')
it('normalizes current enum-style names to canonical api format ids', () => {
expect(normalizeApiFormatAlias('CLAUDE_MESSAGES')).toBe(API_FORMATS.CLAUDE_MESSAGES)
expect(normalizeApiFormatAlias('OPENAI_RESPONSES')).toBe(API_FORMATS.OPENAI_RESPONSES)
expect(normalizeApiFormatAlias('OPENAI_RESPONSES_COMPACT')).toBe(API_FORMATS.OPENAI_RESPONSES_COMPACT)
expect(normalizeApiFormatAlias('GEMINI_GENERATE_CONTENT')).toBe(API_FORMATS.GEMINI_GENERATE_CONTENT)
})
it('maps historical uppercase enum aliases to current display names', () => {
expect(normalizeApiFormatAlias(legacyEnumAlias('CLI'))).toBe(API_FORMATS.OPENAI_RESPONSES)
expect(formatApiFormat(legacyEnumAlias('CLI'))).toBe('OpenAI Responses')
expect(formatApiFormatShort(legacyEnumAlias('CLI'))).toBe('OR')
it('does not remap retired api format ids', () => {
expect(normalizeApiFormatAlias('openai:cli')).toBe('openai:cli')
expect(formatApiFormat('openai:cli')).toBe('openai:cli')
expect(formatApiFormatShort('openai:cli')).toBe('op')
expect(normalizeApiFormatAlias(legacyEnumAlias('COMPACT'))).toBe(API_FORMATS.OPENAI_RESPONSES_COMPACT)
expect(formatApiFormat(legacyEnumAlias('COMPACT'))).toBe('OpenAI Responses Compact')
expect(formatApiFormatShort(legacyEnumAlias('COMPACT'))).toBe('ORC')
expect(normalizeApiFormatAlias('openai:compact')).toBe('openai:compact')
expect(formatApiFormat('openai:compact')).toBe('openai:compact')
expect(formatApiFormatShort('openai:compact')).toBe('op')
})
it('sorts historical aliases in the same slot as their current formats', () => {
it('does not remap retired enum-style aliases', () => {
expect(normalizeApiFormatAlias('OPENAI_CLI')).toBe('openai_cli')
expect(formatApiFormat('OPENAI_CLI')).toBe('openai_cli')
expect(formatApiFormatShort('OPENAI_CLI')).toBe('op')
expect(normalizeApiFormatAlias('OPENAI_COMPACT')).toBe('openai_compact')
expect(formatApiFormat('OPENAI_COMPACT')).toBe('openai_compact')
expect(formatApiFormatShort('OPENAI_COMPACT')).toBe('op')
})
it('sorts only current canonical formats into known slots', () => {
expect(sortApiFormats([
openaiAlias('compact'),
'openai:compact',
API_FORMATS.OPENAI,
openaiAlias('cli'),
API_FORMATS.OPENAI_RESPONSES,
])).toEqual([
API_FORMATS.OPENAI,
openaiAlias('cli'),
openaiAlias('compact'),
API_FORMATS.OPENAI_RESPONSES,
'openai:compact',
])
})
it('groups uppercase historical aliases under OpenAI', () => {
expect(groupApiFormats([legacyEnumAlias('CLI')])).toEqual([{
family: 'openai',
label: 'OpenAI',
formats: [legacyEnumAlias('CLI')],
it('groups retired enum-style aliases as unknown raw families', () => {
expect(groupApiFormats(['OPENAI_CLI'])).toEqual([{
family: 'openai_cli',
label: 'openai_cli',
formats: ['OPENAI_CLI'],
}])
})
})

View File

@@ -16,7 +16,7 @@ export const API_FORMATS = {
export type APIFormat = typeof API_FORMATS[keyof typeof API_FORMATS]
// API 格式显示名称映射(按品牌分组Chat 在前CLI/Video 在后
// API 格式显示名称映射(按品牌分组)
export const API_FORMAT_LABELS: Record<string, string> = {
[API_FORMATS.CLAUDE_MESSAGES]: 'Claude Messages',
[API_FORMATS.OPENAI]: 'OpenAI Chat',
@@ -87,10 +87,8 @@ export const API_FORMAT_FAMILY_LABELS: Record<string, string> = {
// Kind 显示名称映射
export const API_FORMAT_KIND_LABELS: Record<string, string> = {
chat: 'Chat',
cli: 'CLI',
responses: 'Responses',
'responses:compact': 'Responses Compact',
compact: 'Compact',
messages: 'Messages',
generate_content: 'Generate Content',
image: 'Image',
@@ -110,6 +108,8 @@ export function parseApiFormat(format: string): { family: string; kind: string }
export function normalizeApiFormatAlias(format: string | null | undefined): string {
const raw = format?.trim() ?? ''
// Only normalize current enum-style frontend constants. Retired API format ids
// are migrated in the database and intentionally do not map at runtime.
switch (raw.toUpperCase()) {
case 'CLAUDE':
case 'CLAUDE_MESSAGES':

View File

@@ -230,8 +230,9 @@ export interface EndpointAPIKey {
api_key_masked: string
api_key_plain?: string | null
auth_type: 'api_key' | 'service_account' | 'oauth' | 'bearer' // 认证类型(必返回)
auth_type_by_format?: Record<string, 'api_key' | 'bearer'> | null
credential_kind?: 'raw_secret' | 'oauth_session' | 'service_account' | string | null
runtime_auth_kind?: 'api_key' | 'bearer' | 'service_account' | 'unknown' | string | null
runtime_auth_kind?: 'api_key' | 'bearer' | 'service_account' | 'mixed' | 'unknown' | string | null
oauth_managed?: boolean
can_refresh_oauth?: boolean
can_export_oauth?: boolean
@@ -384,7 +385,8 @@ export interface EndpointAPIKeyUpdate {
api_formats?: string[] // 支持的 API 格式列表
name?: string
api_key?: string // 仅在需要更新时提供
auth_type?: 'api_key' | 'service_account' | 'oauth' // 认证类型
auth_type?: 'api_key' | 'service_account' | 'oauth' | 'bearer' // 认证类型
auth_type_by_format?: Record<string, 'api_key' | 'bearer'> | null
auth_config?: Record<string, unknown> // 认证配置Vertex AI Service Account JSON
rate_multipliers?: Record<string, number> | null // 按 API 格式的成本倍率
internal_priority?: number

View File

@@ -32,39 +32,38 @@
data-1p-ignore="true"
/>
</div>
<div v-if="showAuthTypeSelector">
<Label :for="authTypeSelectId">认证类型</Label>
<Select
v-model="form.auth_type"
>
<SelectTrigger :id="authTypeSelectId">
<SelectValue placeholder="选择认证类型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="api_key">
API Key
</SelectItem>
<SelectItem value="service_account">
Service Account
</SelectItem>
</SelectContent>
</Select>
</div>
<div v-else>
<div>
<Label :for="apiKeyInputId">
{{ form.auth_type === 'service_account' ? 'Service Account JSON' : 'API 密钥' }}
{{ editingKey ? '' : '*' }}
{{ authSecretLabel }}
{{ authSecretRequiredMark }}
</Label>
<Input
:id="apiKeyInputId"
v-model="form.api_key"
:name="apiKeyFieldName"
masked
:required="!editingKey"
:placeholder="editingKey ? editingKey.api_key_masked : 'sk-...'"
/>
<template v-if="form.auth_type === 'service_account'">
<JsonImportInput
v-model="form.auth_config_text"
:disabled="saving"
:reset-key="formNonce"
accept=".json,.txt,application/json,text/plain"
:multiple="false"
drop-title="拖入 Service Account JSON 或点击选择"
drop-hint="支持 .json / .txt单文件导入"
:manual-placeholder="editingKey ? '留空表示不修改或粘贴完整的 Service Account JSON' : '粘贴完整的 Service Account JSON'"
:manual-description="serviceAccountDescription"
textarea-class="min-h-[160px] font-mono text-xs break-all !rounded-xl"
@error="handleServiceAccountImportError"
/>
</template>
<template v-else>
<Input
:id="apiKeyInputId"
v-model="form.api_key"
:name="apiKeyFieldName"
masked
:required="false"
:placeholder="editingKey ? editingKey.api_key_masked : authSecretPlaceholder"
/>
</template>
<p
v-if="editingKey && form.auth_type === 'api_key'"
v-if="editingKey && isRawSecretAuthType(form.auth_type)"
class="text-xs text-muted-foreground mt-1"
>
留空表示不修改
@@ -72,45 +71,6 @@
</div>
</div>
<!-- API 密钥 / Service Account JSON -->
<div v-if="showAuthTypeSelector || form.auth_type === 'service_account'">
<Label :for="apiKeyInputId">
{{ form.auth_type === 'service_account' ? 'Service Account JSON' : 'API 密钥' }}
{{ editingKey ? '' : '*' }}
</Label>
<template v-if="form.auth_type === 'service_account'">
<JsonImportInput
v-model="form.auth_config_text"
:disabled="saving"
:reset-key="formNonce"
accept=".json,.txt,application/json,text/plain"
:multiple="false"
drop-title="拖入 Service Account JSON 或点击选择"
drop-hint="支持 .json / .txt单文件导入"
:manual-placeholder="editingKey ? '留空表示不修改或粘贴完整的 Service Account JSON' : '粘贴完整的 Service Account JSON'"
:manual-description="serviceAccountDescription"
textarea-class="min-h-[160px] font-mono text-xs break-all !rounded-xl"
@error="handleServiceAccountImportError"
/>
</template>
<template v-else>
<Input
:id="apiKeyInputId"
v-model="form.api_key"
:name="apiKeyFieldName"
masked
:required="!editingKey"
:placeholder="editingKey ? editingKey.api_key_masked : 'sk-...'"
/>
</template>
<p
v-if="editingKey && form.auth_type === 'api_key'"
class="text-xs text-muted-foreground mt-1"
>
留空表示不修改
</p>
</div>
<!-- 备注 -->
<div>
<Label for="note">备注</Label>
@@ -121,9 +81,36 @@
/>
</div>
<!-- API 格式选择 -->
<!-- API 格式 & 认证方式 -->
<div v-if="visibleApiFormats.length > 0">
<Label class="mb-1.5 block">支持的 API 格式 *</Label>
<!-- 默认认证方式单个格式可在下方覆盖 -->
<div
v-if="showAuthTypeSelector"
class="flex gap-2 mb-2"
>
<button
v-for="option in authTypeOptions"
:key="option.value"
type="button"
class="flex items-center gap-1.5 px-3 py-1.5 rounded-md border text-sm transition-colors"
:class="form.auth_type === option.value
? 'bg-primary/10 border-primary/50 text-primary'
: 'bg-muted/30 border-border text-muted-foreground hover:border-muted-foreground/40'"
@click="form.auth_type = option.value"
>
<span
class="w-3.5 h-3.5 rounded-full border-2 flex items-center justify-center shrink-0"
:class="form.auth_type === option.value ? 'border-primary' : 'border-muted-foreground/40'"
>
<span
v-if="form.auth_type === option.value"
class="w-1.5 h-1.5 rounded-full bg-primary"
/>
</span>
{{ option.label }}
</button>
</div>
<div class="grid grid-cols-2 gap-2">
<div
v-for="format in visibleApiFormats"
@@ -143,10 +130,29 @@
>
<span v-if="form.api_formats.includes(format)"></span>
</span>
<span
class="text-sm whitespace-nowrap"
:class="form.api_formats.includes(format) ? 'text-primary' : 'text-muted-foreground'"
>{{ formatApiFormat(format) }}</span>
<div class="min-w-0">
<span
class="block text-sm truncate"
:class="form.api_formats.includes(format) ? 'text-primary' : 'text-muted-foreground'"
>{{ formatApiFormat(format) }}</span>
<button
type="button"
class="mt-0.5 inline-flex max-w-full items-center rounded border px-1.5 py-0.5 text-[10px] leading-none transition-colors"
:class="[
form.api_formats.includes(format)
? 'border-primary/20 bg-primary/10 text-primary'
: 'border-border bg-background/60 text-muted-foreground',
canOverrideFormatAuth(format)
? 'cursor-pointer hover:border-primary/40 hover:bg-primary/15'
: 'cursor-default'
]"
:aria-disabled="!canOverrideFormatAuth(format)"
:title="formatAuthMethodTitle(format)"
@click="handleFormatAuthBadgeClick(format, $event)"
>
{{ formatAuthMethodLabel(format) }}
</button>
</div>
</div>
<div
class="flex items-center shrink-0 ml-2 text-xs text-muted-foreground gap-1"
@@ -331,7 +337,7 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { Dialog, Button, Input, Label, Switch, Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui'
import { Dialog, Button, Input, Label, Switch } from '@/components/ui'
import { Key, SquarePen } from 'lucide-vue-next'
import { useToast } from '@/composables/useToast'
import { useFormDialog } from '@/composables/useFormDialog'
@@ -352,6 +358,14 @@ import {
} from '@/api/endpoints'
import { formatApiFormat, normalizeApiFormatAlias } from '@/api/endpoints/types/api-format'
type RawSecretAuthType = 'api_key' | 'bearer'
type ProviderKeyFormAuthType = RawSecretAuthType | 'service_account'
interface AuthTypeOption {
value: ProviderKeyFormAuthType
label: string
}
const props = defineProps<{
open: boolean
endpoint: ProviderEndpoint | null
@@ -368,11 +382,46 @@ const emit = defineEmits<{
const { success, error: showError } = useToast()
function getVertexAllowedFormatsByAuth(authType: 'api_key' | 'service_account'): Set<string> {
function isRawSecretAuthType(authType: string | null | undefined): authType is RawSecretAuthType {
return authType === 'api_key' || authType === 'bearer'
}
function normalizeRawSecretAuthType(authType: string | null | undefined): RawSecretAuthType | null {
const normalized = (authType || '').trim().toLowerCase()
if (normalized === 'api_key' || normalized === 'apikey' || normalized === 'api-key') return 'api_key'
if (normalized === 'bearer' || normalized === 'bearer_token' || normalized === 'bearer-token' || normalized === 'authorization') return 'bearer'
return null
}
function normalizeFormAuthType(authType: string | null | undefined): ProviderKeyFormAuthType {
const normalized = (authType || '').trim().toLowerCase()
if (normalized === 'bearer') return 'bearer'
if (normalized === 'service_account' || normalized === 'vertex_ai') return 'service_account'
return 'api_key'
}
function getAuthTypeOptions(providerType: ProviderType | null): AuthTypeOption[] {
if ((providerType || '').toLowerCase() === 'vertex_ai') {
return [
{ value: 'api_key', label: 'API Key' },
{ value: 'service_account', label: 'Service Account' },
]
}
return [
{ value: 'api_key', label: 'API Key' },
{ value: 'bearer', label: 'Bearer Token' },
]
}
function getVertexAllowedFormatsByAuth(authType: ProviderKeyFormAuthType): Set<string> {
if (authType === 'api_key') {
return new Set(['gemini:generate_content'])
}
return new Set(['gemini:generate_content', 'claude:messages'])
if (authType === 'service_account') {
return new Set(['gemini:generate_content', 'claude:messages'])
}
return new Set()
}
function normalizeApiFormat(format: string): string {
@@ -411,6 +460,27 @@ function sanitizeApiFormats(formats: string[], authType = form.value.auth_type):
return formats.filter(format => selectable.has(normalizeApiFormat(format)))
}
function sanitizeAuthTypeByFormat(
authTypeByFormat: Record<string, string> | null | undefined,
formats = form.value.api_formats,
authType = form.value.auth_type
): Record<string, RawSecretAuthType> {
if (!isRawSecretAuthType(authType) || !authTypeByFormat) {
return {}
}
const selected = new Set(formats.map(normalizeApiFormat))
const sanitized: Record<string, RawSecretAuthType> = {}
for (const [format, rawAuthType] of Object.entries(authTypeByFormat)) {
const normalizedFormat = normalizeApiFormat(format)
if (!selected.has(normalizedFormat)) continue
const normalizedAuthType = normalizeRawSecretAuthType(rawAuthType)
if (!normalizedAuthType || normalizedAuthType === authType) continue
sanitized[normalizedFormat] = normalizedAuthType
}
return sanitized
}
function getDefaultApiFormats(): string[] {
const endpointFormat = props.endpoint?.api_format
if (endpointFormat) {
@@ -427,7 +497,88 @@ function getDefaultApiFormats(): string[] {
// 按 provider/auth_type 过滤后的可用 API 格式列表
const visibleApiFormats = computed(() => getSelectableApiFormats())
const showAuthTypeSelector = computed(() => props.providerType === 'vertex_ai')
const authTypeOptions = computed(() => getAuthTypeOptions(props.providerType))
const showAuthTypeSelector = computed(() => authTypeOptions.value.length > 1)
const authSecretLabel = computed(() => {
if (form.value.auth_type === 'service_account') return 'Service Account JSON'
if (form.value.auth_type === 'bearer') return 'Bearer Token'
return 'API 密钥'
})
const authSecretPlaceholder = computed(() =>
form.value.auth_type === 'bearer' ? 'token-...' : 'sk-...'
)
const authSecretRequiredMark = computed(() => {
if (form.value.auth_type === 'service_account' && (!props.editingKey || switchingToServiceAccount.value)) {
return '*'
}
return ''
})
function formatAuthMethodLabel(format: string): string {
const authType = getFormatAuthType(format)
if (authType === 'service_account') return 'Service Account'
if (authType === 'bearer') return 'Authorization'
const { family } = normalizeApiFormat(format).includes(':')
? { family: normalizeApiFormat(format).split(':', 1)[0] }
: { family: normalizeApiFormat(format) }
if (family === 'claude') return 'x-api-key'
if (family === 'gemini') return 'x-goog-api-key'
if (family === 'openai') return 'Authorization'
return 'API Key'
}
function formatAuthMethodTitle(format: string): string {
const label = formatAuthMethodLabel(format)
if (!canOverrideFormatAuth(format)) {
return label
}
const nextAuthType = getFormatAuthType(format) === 'bearer' ? 'API Key' : 'Bearer Token'
return `${label},点击切换为 ${nextAuthType}`
}
function getFormatAuthType(format: string): ProviderKeyFormAuthType {
if (!isRawSecretAuthType(form.value.auth_type)) {
return form.value.auth_type
}
return form.value.auth_type_by_format[normalizeApiFormat(format)] || form.value.auth_type
}
function canOverrideFormatAuth(format: string): boolean {
return isRawSecretAuthType(form.value.auth_type) && form.value.api_formats.includes(format)
}
function setFormatAuthType(format: string, authType: RawSecretAuthType) {
if (!isRawSecretAuthType(form.value.auth_type)) return
const normalizedFormat = normalizeApiFormat(format)
const next = { ...form.value.auth_type_by_format }
if (authType === form.value.auth_type) {
delete next[normalizedFormat]
} else {
next[normalizedFormat] = authType
}
form.value.auth_type_by_format = sanitizeAuthTypeByFormat(next)
}
function toggleFormatAuthType(format: string) {
if (!canOverrideFormatAuth(format)) return
setFormatAuthType(format, getFormatAuthType(format) === 'bearer' ? 'api_key' : 'bearer')
}
function handleFormatAuthBadgeClick(format: string, event: MouseEvent) {
if (!canOverrideFormatAuth(format)) return
event.stopPropagation()
toggleFormatAuthType(format)
}
function buildAuthTypeByFormatPayload(): Record<string, RawSecretAuthType> | null {
const sanitized = sanitizeAuthTypeByFormat(form.value.auth_type_by_format)
return Object.keys(sanitized).length > 0 ? sanitized : null
}
const serviceAccountDescription = computed(() => (
props.editingKey
@@ -436,7 +587,9 @@ const serviceAccountDescription = computed(() => (
))
// 默认认证类型
const defaultAuthType = 'api_key' as const
function getDefaultAuthType(): ProviderKeyFormAuthType {
return authTypeOptions.value[0]?.value || 'api_key'
}
// 显示自动获取模型警告:编辑模式下,原本未启用但现在启用,且已有 allowed_models
const showAutoFetchWarning = computed(() => {
@@ -463,16 +616,11 @@ const autoFetchWarningMessage = computed(() => {
})
// 检查是否正在切换认证类型
const switchingToVertexAI = computed(() =>
const switchingToServiceAccount = computed(() =>
!!props.editingKey &&
props.editingKey.auth_type !== 'service_account' &&
form.value.auth_type === 'service_account'
)
const switchingToApiKey = computed(() =>
!!props.editingKey &&
props.editingKey.auth_type !== 'api_key' &&
form.value.auth_type === 'api_key'
)
// 表单是否可以保存
const canSave = computed(() => {
@@ -480,12 +628,10 @@ const canSave = computed(() => {
if (!form.value.name.trim()) return false
// 新增模式下根据认证类型判断必填字段
if (!props.editingKey) {
if (form.value.auth_type === 'api_key' && !form.value.api_key.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
if (switchingToVertexAI.value && !form.value.auth_config_text.trim()) return false
if (switchingToServiceAccount.value && !form.value.auth_config_text.trim()) return false
}
// 必须至少选择一个 API 格式
if (form.value.api_formats.length === 0) return false
@@ -497,7 +643,6 @@ const saving = ref(false)
const formNonce = ref(createFieldNonce())
const keyNameInputId = computed(() => `key-name-${formNonce.value}`)
const apiKeyInputId = computed(() => `api-key-${formNonce.value}`)
const authTypeSelectId = computed(() => `auth-type-${formNonce.value}`)
const keyNameFieldName = computed(() => `key-name-field-${formNonce.value}`)
const apiKeyFieldName = computed(() => `api-key-field-${formNonce.value}`)
@@ -510,7 +655,8 @@ const defaultAutoFetchModels = computed(() => false)
const form = ref({
name: '',
api_key: '', // 标准 API Key
auth_type: 'api_key' as 'api_key' | 'service_account', // 认证类型
auth_type: 'api_key' as ProviderKeyFormAuthType, // 认证类型
auth_type_by_format: {} as Record<string, RawSecretAuthType>,
auth_config_text: '', // Service Account JSON 文本(用于表单输入)
api_formats: [] as string[], // 支持的 API 格式列表
rate_multipliers: {} as Record<string, number>, // 按 API 格式的成本倍率
@@ -529,10 +675,17 @@ const form = ref({
watch(
[() => form.value.auth_type, () => props.providerType, () => props.availableApiFormats],
() => {
const allowedAuthTypes = new Set(authTypeOptions.value.map(option => option.value))
if (!allowedAuthTypes.has(form.value.auth_type)) {
form.value.auth_type = getDefaultAuthType()
return
}
const filtered = sanitizeApiFormats(form.value.api_formats)
if (filtered.length !== form.value.api_formats.length) {
form.value.api_formats = [...filtered]
}
form.value.auth_type_by_format = sanitizeAuthTypeByFormat(form.value.auth_type_by_format)
},
{ immediate: true }
)
@@ -547,6 +700,7 @@ watch(
const filtered = sanitizeApiFormats(form.value.api_formats)
if (filtered.length !== form.value.api_formats.length) {
form.value.api_formats = [...filtered]
form.value.auth_type_by_format = sanitizeAuthTypeByFormat(form.value.auth_type_by_format, filtered)
return
}
@@ -612,7 +766,8 @@ function resetForm() {
form.value = {
name: '',
api_key: '',
auth_type: defaultAuthType,
auth_type: getDefaultAuthType(),
auth_type_by_format: {},
auth_config_text: '',
api_formats: getDefaultApiFormats(),
rate_multipliers: {},
@@ -635,6 +790,7 @@ function clearForNextAdd() {
form.value.name = ''
form.value.api_key = ''
form.value.auth_config_text = ''
form.value.auth_type_by_format = sanitizeAuthTypeByFormat(form.value.auth_type_by_format)
}
// 加载密钥数据(编辑模式)
@@ -644,12 +800,17 @@ function loadKeyData() {
form.value = {
name: props.editingKey.name,
api_key: '',
auth_type: props.editingKey.auth_type === 'service_account' ? 'service_account' : 'api_key',
auth_type: normalizeFormAuthType(props.editingKey.auth_type),
auth_type_by_format: sanitizeAuthTypeByFormat(
props.editingKey.auth_type_by_format || {},
props.editingKey.api_formats || [],
normalizeFormAuthType(props.editingKey.auth_type)
),
auth_config_text: '', // auth_config 不返回给前端,编辑时需要重新输入
api_formats: props.editingKey.api_formats?.length > 0
? sanitizeApiFormats(
props.editingKey.api_formats,
props.editingKey.auth_type === 'service_account' ? 'service_account' : 'api_key'
normalizeFormAuthType(props.editingKey.auth_type)
)
: [], // 编辑模式下保持原有选择,不默认全选
rate_multipliers: { ...(props.editingKey.rate_multipliers || {}) },
@@ -716,13 +877,7 @@ async function handleSave() {
}
// 验证认证信息
if (form.value.auth_type === 'api_key') {
// API Key 模式:新增时必填
if (!props.editingKey && !form.value.api_key.trim()) {
showError('请输入 API 密钥', '验证失败')
return
}
} else if (form.value.auth_type === 'service_account') {
if (form.value.auth_type === 'service_account') {
if (!props.editingKey && !form.value.auth_config_text.trim()) {
showError('请输入 Service Account JSON', '验证失败')
return
@@ -774,6 +929,7 @@ async function handleSave() {
// 准备认证相关数据
const authConfig = parseAuthConfig()
const authTypeByFormat = buildAuthTypeByFormatPayload()
if (props.editingKey) {
const shouldClearAllowedModels = !!props.editingKey.auto_fetch_models && !form.value.auto_fetch_models
@@ -784,6 +940,7 @@ async function handleSave() {
api_formats: form.value.api_formats,
name: form.value.name,
auth_type: form.value.auth_type,
auth_type_by_format: authTypeByFormat,
rate_multipliers: rateMultipliersData,
internal_priority: form.value.internal_priority,
rpm_limit: form.value.rpm_limit,
@@ -799,7 +956,7 @@ async function handleSave() {
}
// 根据认证类型设置对应字段
if (form.value.auth_type === 'api_key' && form.value.api_key.trim()) {
if (isRawSecretAuthType(form.value.auth_type) && form.value.api_key.trim()) {
updateData.api_key = form.value.api_key
}
if (form.value.auth_type === 'service_account' && authConfig) {
@@ -814,6 +971,7 @@ async function handleSave() {
api_formats: form.value.api_formats,
api_key: form.value.api_key,
auth_type: form.value.auth_type,
auth_type_by_format: authTypeByFormat,
auth_config: authConfig || undefined,
name: form.value.name,
rate_multipliers: rateMultipliersData,

View File

@@ -38,12 +38,13 @@ export function getProviderCredentialKind(
export function getProviderRuntimeAuthKind(
input: ProviderKeyAuthCarrier,
): 'api_key' | 'bearer' | 'service_account' | 'unknown' {
): 'api_key' | 'bearer' | 'service_account' | 'mixed' | 'unknown' {
const runtimeAuthKind = normalizeText(input.runtime_auth_kind)
if (
runtimeAuthKind === 'api_key'
|| runtimeAuthKind === 'bearer'
|| runtimeAuthKind === 'service_account'
|| runtimeAuthKind === 'mixed'
) {
return runtimeAuthKind
}
@@ -90,11 +91,13 @@ export function canEditOAuthCredential(input: ProviderKeyAuthCarrier): boolean {
export function getProviderAuthLabel(input: ProviderKeyAuthCarrier): string {
if (isOAuthManagedCredential(input)) return 'OAuth'
if (isServiceAccountCredential(input)) return '服务账号'
if (getProviderRuntimeAuthKind(input) === 'mixed') return '混合'
return getProviderRuntimeAuthKind(input) === 'bearer' ? 'Bearer' : 'API Key'
}
export function getProviderMaskedSecretLabel(input: ProviderKeyAuthCarrier): string {
if (isOAuthManagedCredential(input)) return '[OAuth Token]'
if (isServiceAccountCredential(input)) return '[Service Account]'
if (getProviderRuntimeAuthKind(input) === 'mixed') return '[Key]'
return getProviderRuntimeAuthKind(input) === 'bearer' ? '[Bearer Token]' : '[Key]'
}

View File

@@ -1892,6 +1892,7 @@ watch(searchQuery, () => {
function normalizeAuthTypeForEdit(key: PoolKeyDetail): EndpointAPIKey['auth_type'] {
if (isOAuthManagedCredential(key)) return 'oauth'
if (isServiceAccountCredential(key)) return 'service_account'
if ((key.auth_type || '').trim().toLowerCase() === 'bearer') return 'bearer'
return 'api_key'
}
@@ -1903,6 +1904,7 @@ function toEndpointApiKey(key: PoolKeyDetail): EndpointAPIKey {
api_formats: key.api_formats || [],
api_key_masked: getProviderMaskedSecretLabel(key),
auth_type: normalizeAuthTypeForEdit(key),
auth_type_by_format: key.auth_type_by_format ?? null,
credential_kind: key.credential_kind ?? null,
runtime_auth_kind: key.runtime_auth_kind ?? null,
oauth_managed: key.oauth_managed ?? undefined,

View File

@@ -102,7 +102,7 @@ import { BookOpen } from 'lucide-vue-next'
>
<h2>3. 添加端点</h2>
<p class="text-sm text-[#666663] dark:text-[#a3a094] mb-4">
添加端点是添加上游支持的端点并非你需要使用的端点比如 Anyrouter 只支持 Claude Code 接入那么就应该添加 Claude CLI 端点
添加端点是添加上游支持的端点并非你需要使用的端点比如 Anyrouter 只支持 Claude Code 接入那么就应该添加 Claude Messages 端点并在 Key 中选择对应认证方式
</p>
<ul class="list-decimal pl-5 space-y-2 text-[#666663] dark:text-[#a3a094] text-sm">
<li><strong class="text-[#262624] dark:text-[#f1ead8] font-medium">选择格式</strong>选择上游支持的端点格式</li>

View File

@@ -21,7 +21,7 @@ graph LR
## 2. 核心原则
1. **API格式、端点、认证方式说明**
提供商支持不同的格式(如 OpenAI Chat, Claude ChatAether在接收请求后将统一管理路由。
提供商支持不同的格式(如 OpenAI Chat, Claude MessagesAether在接收请求后将统一管理路由。
2. **统一的入口模型名称**
在内部完成多提供商、多模型名称风格的聚合映射管理。客户端只需知道统一的模型名(例如 `claude-3-opus`Aether将根据映射规则自动寻找真正对应的上游模型名称。

View File

@@ -31,7 +31,7 @@
## 3. 添加端点
添加端点是添加上游支持的端点, 并非你需要使用的端点. 比如Anyrouter只支持Claude Code接入, 那么就应该添加Claude CLI端点
添加端点是添加上游支持的端点, 并非你需要使用的端点. 比如Anyrouter只支持Claude Code接入, 那么就应该添加Claude Messages端点并在Key中选择对应认证方式
1. **选择格式**: 选择上游支持的端点
2. **自定义Base URL、Path**: 根据上游提供的信息填写。为空则使用提示的默认值。