mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-01 17:00:21 +08:00
Support per-format provider key auth
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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 格式的成本倍率
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'],
|
||||
}])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]'
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -21,7 +21,7 @@ graph LR
|
||||
## 2. 核心原则
|
||||
|
||||
1. **API格式、端点、认证方式说明**
|
||||
提供商支持不同的格式(如 OpenAI Chat, Claude Chat),Aether在接收请求后,将统一管理路由。
|
||||
提供商支持不同的格式(如 OpenAI Chat, Claude Messages),Aether在接收请求后,将统一管理路由。
|
||||
|
||||
2. **统一的入口模型名称**
|
||||
在内部完成多提供商、多模型名称风格的聚合映射管理。客户端只需知道统一的模型名(例如 `claude-3-opus`),Aether将根据映射规则自动寻找真正对应的上游模型名称。
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
|
||||
## 3. 添加端点
|
||||
|
||||
添加端点是添加上游支持的端点, 并非你需要使用的端点. 比如Anyrouter只支持Claude Code接入, 那么就应该仅添加Claude CLI端点。
|
||||
添加端点是添加上游支持的端点, 并非你需要使用的端点. 比如Anyrouter只支持Claude Code接入, 那么就应该添加Claude Messages端点,并在Key中选择对应认证方式。
|
||||
|
||||
1. **选择格式**: 选择上游支持的端点
|
||||
2. **自定义Base URL、Path**: 根据上游提供的信息填写。为空则使用提示的默认值。
|
||||
|
||||
Reference in New Issue
Block a user