feat: 支持固定类型 Provider OAuth 授权

- 新增 provider_type 字段区分自定义/预置 Provider 类型(claude_code/codex/gemini_cli/antigravity)
- 实现完整 OAuth 2.0 授权流程:start(生成授权 URL + PKCE)、complete(换取 token)、refresh
- 前端 KeyFormDialog 添加 OAuth 授权 UI,支持开始授权、粘贴回调 URL、完成授权、强制刷新
- 请求时自动检测 token 过期并刷新(120s 预留窗口 + Redis 分布式锁防并发)
- 固定类型 Provider 自动创建预置端点并锁定 base_url/custom_path
- 数据库迁移:添加 providers.provider_type,扩展 api_key 列为 TEXT
- 可选依赖 tls-client 用于 Claude token 请求的 TLS 指纹伪装
This commit is contained in:
AAEE86
2026-02-04 10:24:25 +08:00
parent f6dac1c38a
commit e4fdc65e52
25 changed files with 1818 additions and 36 deletions

View File

@@ -2,6 +2,7 @@ export * from './types'
export * from './providers'
export * from './endpoints'
export * from './keys'
export * from './provider_oauth'
export * from './health'
export * from './models'
export * from './adaptive'

View File

@@ -56,7 +56,7 @@ export async function getModelCapabilities(modelName: string): Promise<ModelCapa
* 获取完整的 API Key用于查看和复制
*/
export interface RevealKeyResult {
auth_type: 'api_key' | 'vertex_ai'
auth_type: 'api_key' | 'vertex_ai' | 'oauth'
api_key?: string
auth_config?: string | Record<string, any>
}
@@ -94,7 +94,7 @@ export async function addProviderKey(
data: {
api_formats: string[] // 支持的 API 格式列表(必填)
api_key: string
auth_type?: 'api_key' | 'vertex_ai' // 认证类型
auth_type?: 'api_key' | 'vertex_ai' | 'oauth' // 认证类型
auth_config?: Record<string, any> // 认证配置Vertex AI Service Account JSON
name: string
rate_multipliers?: Record<string, number> | null // 按 API 格式的成本倍率
@@ -122,7 +122,7 @@ export async function updateProviderKey(
data: Partial<{
api_formats: string[] // 支持的 API 格式列表
api_key: string
auth_type: 'api_key' | 'vertex_ai' // 认证类型
auth_type: 'api_key' | 'vertex_ai' | 'oauth' // 认证类型
auth_config: Record<string, any> // 认证配置Vertex AI Service Account JSON
name: string
rate_multipliers: Record<string, number> | null // 按 API 格式的成本倍率

View File

@@ -0,0 +1,51 @@
import client from '../client'
export interface ProviderOAuthSupportedType {
provider_type: string
display_name: string
scopes: string[]
redirect_uri: string
authorize_url: string
token_url: string
use_pkce: boolean
}
export interface ProviderOAuthStartResponse {
authorization_url: string
redirect_uri: string
provider_type: string
instructions: string
}
export interface ProviderOAuthCompleteRequest {
callback_url: string
}
export interface ProviderOAuthCompleteResponse {
provider_type: string
expires_at?: number | null
has_refresh_token: boolean
}
export async function getProviderOAuthSupportedTypes(): Promise<ProviderOAuthSupportedType[]> {
const resp = await client.get('/api/admin/provider-oauth/supported-types')
return resp.data
}
export async function startProviderOAuth(keyId: string): Promise<ProviderOAuthStartResponse> {
const resp = await client.post(`/api/admin/provider-oauth/keys/${keyId}/start`)
return resp.data
}
export async function completeProviderOAuth(
keyId: string,
data: ProviderOAuthCompleteRequest
): Promise<ProviderOAuthCompleteResponse> {
const resp = await client.post(`/api/admin/provider-oauth/keys/${keyId}/complete`, data)
return resp.data
}
export async function refreshProviderOAuth(keyId: string): Promise<ProviderOAuthCompleteResponse> {
const resp = await client.post(`/api/admin/provider-oauth/keys/${keyId}/refresh`)
return resp.data
}

View File

@@ -24,6 +24,7 @@ export async function updateProvider(
providerId: string,
data: Partial<{
name: string
provider_type: 'custom' | 'claude_code' | 'codex' | 'gemini_cli' | 'antigravity'
description: string
website: string
provider_priority: number
@@ -38,6 +39,7 @@ export async function updateProvider(
proxy: ProxyConfig | null
cache_ttl_minutes: number // 0表示不支持缓存>0表示支持缓存并设置TTL(分钟)
max_probe_interval_minutes: number
enable_format_conversion: boolean // 是否允许格式转换(提供商级别开关)
is_active: boolean
}>
): Promise<ProviderWithEndpointsSummary> {
@@ -48,7 +50,26 @@ export async function updateProvider(
/**
* 创建 Provider
*/
export async function createProvider(data: any): Promise<any> {
export async function createProvider(
data: {
name: string
provider_type?: 'custom' | 'claude_code' | 'codex' | 'gemini_cli' | 'antigravity'
description?: string
website?: string
billing_type?: 'monthly_quota' | 'pay_as_you_go' | 'free_tier'
monthly_quota_usd?: number
quota_reset_day?: number
quota_last_reset_at?: string
quota_expires_at?: string
provider_priority?: number
keep_priority_on_conversion?: boolean
is_active?: boolean
max_retries?: number
stream_first_byte_timeout?: number | null
request_timeout?: number | null
proxy?: ProxyConfig | null
}
): Promise<{ id: string; name: string; message?: string }> {
const response = await client.post('/api/admin/providers/', data)
return response.data
}

View File

@@ -192,7 +192,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' // 认证类型(必返回)
auth_type: 'api_key' | 'vertex_ai' | 'oauth' // 认证类型(必返回)
name: string // 密钥名称(必填,用于识别)
rate_multipliers?: Record<string, number> | null // 按 endpoint signature 的成本倍率
internal_priority: number // Key 内部优先级
@@ -273,7 +273,7 @@ export interface EndpointAPIKeyUpdate {
api_formats?: string[] // 支持的 API 格式列表
name?: string
api_key?: string // 仅在需要更新时提供
auth_type?: 'api_key' | 'vertex_ai' // 认证类型
auth_type?: 'api_key' | 'vertex_ai' | 'oauth' // 认证类型
auth_config?: Record<string, any> // 认证配置Vertex AI Service Account JSON
rate_multipliers?: Record<string, number> | null // 按 API 格式的成本倍率
internal_priority?: number
@@ -360,9 +360,12 @@ export interface PublicEndpointStatusMonitorResponse {
formats: PublicEndpointStatusMonitor[]
}
export type ProviderType = 'custom' | 'claude_code' | 'codex' | 'gemini_cli' | 'antigravity'
export interface ProviderWithEndpointsSummary {
id: string
name: string
provider_type?: ProviderType
description?: string
website?: string
provider_priority: number

View File

@@ -86,6 +86,7 @@
<Input
:model-value="getEndpointEditState(endpoint.id)?.url ?? endpoint.base_url"
:placeholder="provider?.website || 'https://api.example.com'"
:disabled="isFixedProvider"
@update:model-value="(v) => updateEndpointField(endpoint.id, 'url', v)"
/>
</div>
@@ -94,13 +95,14 @@
<Input
:model-value="getEndpointEditState(endpoint.id)?.path ?? (endpoint.custom_path || '')"
:placeholder="getDefaultPath(endpoint.api_format) || '留空使用默认'"
:disabled="isFixedProvider"
@update:model-value="(v) => updateEndpointField(endpoint.id, 'path', v)"
/>
</div>
</div>
<!-- 保存/撤销按钮URL/路径有修改时显示) -->
<div
v-if="hasUrlChanges(endpoint)"
v-if="!isFixedProvider && hasUrlChanges(endpoint)"
class="flex items-center gap-1 shrink-0"
>
<Button
@@ -371,8 +373,8 @@
<!-- 添加新端点 -->
<div
v-if="availableFormats.length > 0"
class="rounded-lg border border-dashed"
v-if="!isFixedProvider && availableFormats.length > 0"
class="rounded-lg border border-dashed p-3"
>
<!-- 卡片头部API 格式选择 + 添加按钮 -->
<div class="flex items-center justify-between px-4 py-2.5 bg-muted/30 border-b border-dashed">
@@ -437,6 +439,13 @@
>
<p>所有 API 格式都已配置</p>
</div>
<div
v-else-if="isFixedProvider"
class="text-center py-6 text-xs text-muted-foreground"
>
固定类型 Provider 的端点已锁定并由系统自动维护。
</div>
</div>
<template #footer>
@@ -638,6 +647,11 @@ const RESERVED_BODY_FIELDS = new Set([
// 内部状态
const internalOpen = computed(() => props.modelValue)
const isFixedProvider = computed(() => {
const t = props.provider?.provider_type
return !!t && t !== 'custom'
})
// 新端点表单
const newEndpoint = ref({
api_format: '',

View File

@@ -37,6 +37,7 @@
<Select
v-model="form.auth_type"
v-model:open="authTypeSelectOpen"
:disabled="authTypeDisabled"
>
<SelectTrigger :id="authTypeSelectId">
<SelectValue placeholder="选择认证类型" />
@@ -48,6 +49,12 @@
<SelectItem value="vertex_ai">
Vertex AI
</SelectItem>
<SelectItem
v-if="!isCustomProvider"
value="oauth"
>
OAuth
</SelectItem>
</SelectContent>
</Select>
</div>
@@ -56,8 +63,8 @@
<!-- API 密钥 / Service Account JSON -->
<div>
<Label :for="apiKeyInputId">
{{ form.auth_type === 'vertex_ai' ? 'Service Account JSON' : 'API 密钥' }}
{{ editingKey ? '' : '*' }}
{{ form.auth_type === 'vertex_ai' ? 'Service Account JSON' : (form.auth_type === 'oauth' ? 'OAuth Token' : 'API 密钥') }}
{{ editingKey ? '' : (form.auth_type === 'oauth' ? '' : '*') }}
</Label>
<template v-if="form.auth_type === 'vertex_ai'">
<Textarea
@@ -75,6 +82,7 @@
</template>
<template v-else>
<Input
v-if="form.auth_type !== 'oauth'"
:id="apiKeyInputId"
v-model="form.api_key"
:name="apiKeyFieldName"
@@ -82,6 +90,13 @@
:required="!editingKey"
:placeholder="editingKey ? editingKey.api_key_masked : 'sk-...'"
/>
<Input
v-else
:id="apiKeyInputId"
:model-value="editingKey?.api_key_masked || '[OAuth Token]'"
disabled
placeholder="[OAuth Token]"
/>
</template>
<p
v-if="apiKeyError"
@@ -95,6 +110,170 @@
>
留空表示不修改
</p>
<p
v-else-if="form.auth_type === 'oauth'"
class="text-xs text-muted-foreground mt-1"
>
OAuth Token 需在创建后通过开始授权/完成授权写入
</p>
<!-- OAuth 授权流程 -->
<div
v-if="form.auth_type === 'oauth'"
class="space-y-3 py-2 px-3 rounded-md border border-border/60 bg-muted/30 mt-2"
>
<div class="flex items-start justify-between gap-3">
<div class="space-y-0.5">
<Label class="text-sm font-medium">OAuth 授权</Label>
<p class="text-xs text-muted-foreground">
打开授权链接完成授权后将浏览器回调地址栏的完整 URL 粘贴回来
</p>
</div>
<Badge
:variant="oauthStatusVariant"
class="text-xs shrink-0"
>
{{ oauthStatusText }}
</Badge>
</div>
<p
v-if="oauthHelpText"
class="text-xs text-amber-600 dark:text-amber-400"
>
{{ oauthHelpText }}
</p>
<div class="flex flex-wrap gap-2">
<Button
size="sm"
type="button"
:disabled="!canStartOAuth"
@click="handleStartOAuth"
>
{{ oauth.starting ? '开始中...' : '开始授权' }}
</Button>
<Button
size="sm"
variant="outline"
type="button"
:disabled="!canRefreshOAuth"
@click="handleRefreshOAuth"
>
<RefreshCw class="w-3.5 h-3.5 mr-1.5" />
{{ oauth.refreshing ? '刷新中...' : '强制刷新' }}
</Button>
</div>
<div
v-if="oauth.authorization_url"
class="space-y-2 pt-2 border-t border-border/40"
>
<div>
<Label class="text-xs">Authorization URL</Label>
<div class="flex gap-2">
<Input
:model-value="oauth.authorization_url"
disabled
class="h-8 text-xs font-mono"
/>
<Button
size="icon"
variant="outline"
type="button"
class="h-8 w-8"
:disabled="oauthBusy"
title="复制授权链接"
@click="copyToClipboard(oauth.authorization_url)"
>
<Copy class="w-3.5 h-3.5" />
</Button>
<Button
size="icon"
variant="outline"
type="button"
class="h-8 w-8"
:disabled="oauthBusy"
title="打开授权链接"
@click="openAuthorizationUrl"
>
<ExternalLink class="w-3.5 h-3.5" />
</Button>
</div>
</div>
<div>
<Label class="text-xs">Redirect URI</Label>
<div class="flex gap-2">
<Input
:model-value="oauth.redirect_uri"
disabled
class="h-8 text-xs font-mono"
/>
<Button
size="icon"
variant="outline"
type="button"
class="h-8 w-8"
:disabled="oauthBusy || !oauth.redirect_uri"
title="复制 Redirect URI"
@click="copyToClipboard(oauth.redirect_uri)"
>
<Copy class="w-3.5 h-3.5" />
</Button>
</div>
</div>
<div v-if="oauth.instructions">
<Label class="text-xs">说明</Label>
<Textarea
:model-value="oauth.instructions"
disabled
class="min-h-[80px] text-xs whitespace-pre-wrap"
/>
</div>
</div>
<div class="space-y-2 pt-2 border-t border-border/40">
<Label
class="text-xs"
:for="oauthCallbackId"
>回调 URL</Label>
<Textarea
:id="oauthCallbackId"
v-model="oauth.callback_url"
:disabled="!canOAuthOperate"
placeholder="粘贴浏览器地址栏中的完整回调 URL包含 code/state 等参数)"
class="min-h-[80px] text-xs font-mono"
spellcheck="false"
/>
<div class="flex flex-wrap gap-2">
<Button
size="sm"
type="button"
:disabled="!canCompleteOAuth"
@click="handleCompleteOAuth"
>
{{ oauth.completing ? '完成中...' : '完成授权' }}
</Button>
</div>
</div>
<div
v-if="oauth.step === 'completed'"
class="pt-2 border-t border-border/40 text-xs text-muted-foreground space-y-1"
>
<div class="flex items-center justify-between gap-2">
<span>expires_at</span>
<span class="font-mono">{{ formattedExpiresAt }}</span>
</div>
<div class="flex items-center justify-between gap-2">
<span>refresh token</span>
<span>{{ oauth.has_refresh_token ? '有' : '无' }}</span>
</div>
</div>
</div>
</div>
<!-- 备注 -->
@@ -314,11 +493,13 @@
</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 Badge from '@/components/ui/badge.vue'
import { Key, SquarePen, Copy, ExternalLink, RefreshCw } from 'lucide-vue-next'
import { useToast } from '@/composables/useToast'
import { useFormDialog } from '@/composables/useFormDialog'
import { useClipboard } from '@/composables/useClipboard'
import { parseApiError } from '@/utils/errorParser'
import { parseNumberInput, parseNullableNumberInput } from '@/utils/form'
import { log } from '@/utils/logger'
@@ -328,10 +509,14 @@ import {
getAllCapabilities,
API_FORMAT_LABELS,
sortApiFormats,
startProviderOAuth,
completeProviderOAuth,
refreshProviderOAuth,
type EndpointAPIKey,
type EndpointAPIKeyUpdate,
type ProviderEndpoint,
type CapabilityDefinition
type CapabilityDefinition,
type ProviderType
} from '@/api/endpoints'
const props = defineProps<{
@@ -339,19 +524,79 @@ const props = defineProps<{
endpoint: ProviderEndpoint | null
editingKey: EndpointAPIKey | null
providerId: string | null
providerType: ProviderType | null
availableApiFormats: string[] // Provider 支持的所有 API 格式
}>()
const emit = defineEmits<{
close: []
saved: []
editCreatedKey: [key: EndpointAPIKey]
}>()
const { success, error: showError } = useToast()
const { copyToClipboard } = useClipboard()
type OAuthStep = 'idle' | 'started' | 'completed'
interface OAuthState {
step: OAuthStep
authorization_url: string
redirect_uri: string
instructions: string
provider_type: string
callback_url: string
expires_at: number | null
has_refresh_token: boolean | null
starting: boolean
completing: boolean
refreshing: boolean
}
function createInitialOAuthState(): OAuthState {
return {
step: 'idle',
authorization_url: '',
redirect_uri: '',
instructions: '',
provider_type: '',
callback_url: '',
expires_at: null,
has_refresh_token: null,
starting: false,
completing: false,
refreshing: false,
}
}
const oauth = ref<OAuthState>(createInitialOAuthState())
function resetOAuthState() {
oauth.value = createInitialOAuthState()
}
// 排序后的可用 API 格式列表
const sortedApiFormats = computed(() => sortApiFormats(props.availableApiFormats))
// OAuth 专用提供商类型(认证类型固定为 OAuth
const OAUTH_ONLY_PROVIDER_TYPES: ProviderType[] = ['claude_code', 'codex', 'gemini_cli', 'antigravity']
// 是否为 OAuth 专用提供商
const isOAuthOnlyProvider = computed(() =>
props.providerType !== null && OAUTH_ONLY_PROVIDER_TYPES.includes(props.providerType)
)
// 是否为自定义提供商(不支持 OAuth
const isCustomProvider = computed(() => props.providerType === 'custom')
// 认证类型选择是否禁用OAuth 专用提供商不可修改)
const authTypeDisabled = computed(() => isOAuthOnlyProvider.value)
// 根据提供商类型获取默认认证类型
const defaultAuthType = computed<'api_key' | 'vertex_ai' | 'oauth'>(() => {
if (isOAuthOnlyProvider.value) return 'oauth'
return 'api_key'
})
// 显示自动获取模型警告:编辑模式下,原本未启用但现在启用,且已有 allowed_models
const showAutoFetchWarning = computed(() => {
if (!props.editingKey) return false
@@ -387,6 +632,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
// OAuthtoken 由 provider-oauth 授权流程写入,这里不要求填写
} else {
// 编辑模式下切换认证类型时,必须填写对应字段
if (switchingToApiKey.value && !form.value.api_key.trim()) return false
@@ -408,6 +654,87 @@ 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}`)
const oauthCallbackId = computed(() => `oauth-callback-${formNonce.value}`)
const oauthBusy = computed(() =>
saving.value || oauth.value.starting || oauth.value.completing || oauth.value.refreshing
)
const canOAuthOperate = computed(() => {
if (form.value.auth_type !== 'oauth') return false
if (!props.editingKey?.id) return false
if (props.editingKey.auth_type !== 'oauth') return false
return true
})
const oauthHelpText = computed(() => {
if (form.value.auth_type !== 'oauth') return ''
if (!props.editingKey?.id) {
return '请先点击右下角"添加"保存密钥,保存后将自动进入授权流程。'
}
if (props.editingKey.auth_type !== 'oauth') {
return '已切换为 OAuth但尚未保存。请先点击右下角"保存",再开始授权。'
}
return ''
})
type BadgeVariant = 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning' | 'dark'
function normalizeEpochMs(epoch: number): number {
return epoch > 1e12 ? epoch : epoch * 1000
}
function isExpiredEpoch(expiresAt: number | null): boolean {
if (!expiresAt) return false
const ms = normalizeEpochMs(expiresAt)
return Date.now() >= ms
}
const formattedExpiresAt = computed(() => {
if (!oauth.value.expires_at) return '—'
const ms = normalizeEpochMs(oauth.value.expires_at)
return new Date(ms).toLocaleString()
})
const oauthStatusText = computed(() => {
if (form.value.auth_type !== 'oauth') return '—'
if (!props.editingKey?.id) return '未保存'
if (props.editingKey.auth_type !== 'oauth') return '待保存'
if (oauth.value.step === 'started') return '等待回调'
if (oauth.value.step === 'completed') {
if (isExpiredEpoch(oauth.value.expires_at)) return '已过期'
return '已授权'
}
return '未开始'
})
const oauthStatusVariant = computed<BadgeVariant>(() => {
if (form.value.auth_type !== 'oauth') return 'secondary'
if (!props.editingKey?.id) return 'secondary'
if (props.editingKey.auth_type !== 'oauth') return 'warning'
if (oauth.value.step === 'started') return 'warning'
if (oauth.value.step === 'completed') {
if (isExpiredEpoch(oauth.value.expires_at)) return 'destructive'
return 'success'
}
return 'secondary'
})
const canStartOAuth = computed(() => canOAuthOperate.value && !oauthBusy.value)
const canRefreshOAuth = computed(() => {
if (!canOAuthOperate.value) return false
return !oauthBusy.value
})
const canCompleteOAuth = computed(() => {
if (!canOAuthOperate.value) return false
if (!oauth.value.authorization_url) return false
if (!oauth.value.callback_url.trim()) return false
return !oauthBusy.value
})
// 可用的能力列表
const availableCapabilities = ref<CapabilityDefinition[]>([])
@@ -415,7 +742,7 @@ const availableCapabilities = ref<CapabilityDefinition[]>([])
const form = ref({
name: '',
api_key: '', // 标准 API Key
auth_type: 'api_key' as 'api_key' | 'vertex_ai', // 认证类型
auth_type: 'api_key' as 'api_key' | 'vertex_ai' | 'oauth', // 认证类型
auth_config_text: '', // Service Account JSON 文本(用于表单输入)
api_formats: [] as string[], // 支持的 API 格式列表
rate_multipliers: {} as Record<string, number>, // 按 API 格式的成本倍率
@@ -499,11 +826,12 @@ const apiKeyError = computed(() => {
// 重置表单
function resetForm() {
resetOAuthState()
formNonce.value = createFieldNonce()
form.value = {
name: '',
api_key: '',
auth_type: 'api_key',
auth_type: defaultAuthType.value,
auth_config_text: '',
api_formats: [], // 默认不选中任何格式
rate_multipliers: {},
@@ -531,6 +859,7 @@ function clearForNextAdd() {
// 加载密钥数据(编辑模式)
function loadKeyData() {
if (!props.editingKey) return
resetOAuthState()
formNonce.value = createFieldNonce()
form.value = {
name: props.editingKey.name,
@@ -565,6 +894,12 @@ const { isEditMode, handleDialogUpdate, handleCancel } = useFormDialog({
resetForm,
})
watch(() => form.value.auth_type, (newType, oldType) => {
if (oldType === 'oauth' && newType !== 'oauth') {
resetOAuthState()
}
})
function createFieldNonce(): string {
return Math.random().toString(36).slice(2, 10)
}
@@ -592,6 +927,67 @@ function parseAuthConfig(): Record<string, any> | null {
}
}
function openAuthorizationUrl() {
const url = oauth.value.authorization_url
if (!url) return
window.open(url, '_blank', 'noopener,noreferrer')
}
async function handleStartOAuth() {
if (!canStartOAuth.value) return
oauth.value.starting = true
try {
const resp = await startProviderOAuth(props.editingKey!.id)
oauth.value.authorization_url = resp.authorization_url
oauth.value.redirect_uri = resp.redirect_uri
oauth.value.instructions = resp.instructions
oauth.value.provider_type = resp.provider_type
oauth.value.step = 'started'
success('已生成授权链接')
} catch (err: any) {
const errorMessage = parseApiError(err, '开始授权失败')
showError(errorMessage, '错误')
} finally {
oauth.value.starting = false
}
}
async function handleCompleteOAuth() {
if (!canCompleteOAuth.value) return
oauth.value.completing = true
try {
const resp = await completeProviderOAuth(props.editingKey!.id, { callback_url: oauth.value.callback_url.trim() })
oauth.value.expires_at = resp.expires_at ?? null
oauth.value.has_refresh_token = resp.has_refresh_token
oauth.value.step = 'completed'
success('授权完成')
emit('saved')
} catch (err: any) {
const errorMessage = parseApiError(err, '完成授权失败')
showError(errorMessage, '错误')
} finally {
oauth.value.completing = false
}
}
async function handleRefreshOAuth() {
if (!canRefreshOAuth.value) return
oauth.value.refreshing = true
try {
const resp = await refreshProviderOAuth(props.editingKey!.id)
oauth.value.expires_at = resp.expires_at ?? null
oauth.value.has_refresh_token = resp.has_refresh_token
oauth.value.step = 'completed'
success('Token 已刷新')
emit('saved')
} catch (err: any) {
const errorMessage = parseApiError(err, '刷新 Token 失败')
showError(errorMessage, '错误')
} finally {
oauth.value.refreshing = false
}
}
async function handleSave() {
// 必须有 providerId
if (!props.providerId) {
@@ -631,6 +1027,8 @@ async function handleSave() {
return
}
}
} else if (form.value.auth_type === 'oauth') {
// OAuth不在此处输入 token由 provider-oauth 授权流程写入
}
// 验证至少选择一个 API 格式
@@ -697,7 +1095,7 @@ async function handleSave() {
success('密钥已更新', '成功')
} else {
// 新增模式
await addProviderKey(props.providerId, {
const createdKey = await addProviderKey(props.providerId, {
api_formats: form.value.api_formats,
api_key: form.value.auth_type === 'api_key' ? form.value.api_key : '',
auth_type: form.value.auth_type,
@@ -714,6 +1112,15 @@ async function handleSave() {
model_include_patterns: parsePatternText(form.value.model_include_patterns_text),
model_exclude_patterns: parsePatternText(form.value.model_exclude_patterns_text)
})
// OAuth 密钥:自动切换到编辑模式以便立即开始授权
if (form.value.auth_type === 'oauth') {
success('密钥已添加,现在可以开始 OAuth 授权', '成功')
emit('saved')
emit('editCreatedKey', createdKey)
return
}
success('密钥已添加', '成功')
// 添加模式:不关闭对话框,只清除名称和密钥以便继续添加
emit('saved')

View File

@@ -452,9 +452,11 @@
:endpoint="currentEndpoint"
:editing-key="editingKey"
:provider-id="provider ? provider.id : null"
:provider-type="provider?.provider_type || null"
:available-api-formats="provider?.api_formats || []"
@close="keyFormDialogOpen = false"
@saved="handleKeyChanged"
@edit-created-key="handleEditCreatedKey"
/>
<!-- 模型权限对话框 -->
@@ -770,6 +772,10 @@ function handleEditKey(endpoint: ProviderEndpoint | undefined, key: EndpointAPIK
keyFormDialogOpen.value = true
}
function handleEditCreatedKey(key: EndpointAPIKey) {
editingKey.value = key
}
function handleKeyPermissions(key: EndpointAPIKey) {
editingKey.value = key
keyPermissionsDialogOpen.value = true

View File

@@ -27,13 +27,29 @@
/>
</div>
<div class="space-y-1.5">
<Label for="website">主站链接</Label>
<Input
id="website"
v-model="form.website"
placeholder="https://..."
type="url"
/>
<Label>提供商类型</Label>
<Select
v-model="form.provider_type"
v-model:open="providerTypeSelectOpen"
:disabled="isEditMode"
>
<SelectTrigger>
<SelectValue placeholder="请选择" />
</SelectTrigger>
<SelectContent>
<SelectItem value="custom">自定义</SelectItem>
<SelectItem value="claude_code">ClaudeCode</SelectItem>
<SelectItem value="codex">Codex</SelectItem>
<SelectItem value="gemini_cli">GeminiCli</SelectItem>
<SelectItem value="antigravity">Antigravity</SelectItem>
</SelectContent>
</Select>
<p
v-if="!isEditMode && form.provider_type !== 'custom'"
class="text-xs text-muted-foreground"
>
固定类型 Provider 将自动创建并锁定端点base_url/custom_path 不可修改
</p>
</div>
</div>
@@ -45,6 +61,15 @@
placeholder="提供商描述(可选)"
/>
</div>
<div class="space-y-1.5">
<Label for="website">主站链接</Label>
<Input
id="website"
v-model="form.website"
placeholder="https://example.com可选"
/>
</div>
</div>
<!-- 计费与限流 / 请求配置 -->
@@ -297,6 +322,7 @@ const emit = defineEmits<{
const { success, error: showError } = useToast()
const loading = ref(false)
const providerTypeSelectOpen = ref(false)
const billingTypeSelectOpen = ref(false)
// 内部状态
@@ -305,6 +331,7 @@ const internalOpen = computed(() => props.modelValue)
// 表单数据
const form = ref({
name: '',
provider_type: 'custom' as 'custom' | 'claude_code' | 'codex' | 'gemini_cli' | 'antigravity',
description: '',
website: '',
// 计费配置
@@ -335,6 +362,7 @@ const form = ref({
function resetForm() {
form.value = {
name: '',
provider_type: 'custom',
description: '',
website: '',
billing_type: 'pay_as_you_go',
@@ -367,6 +395,7 @@ function loadProviderData() {
const proxy = props.provider.proxy
form.value = {
name: props.provider.name,
provider_type: props.provider.provider_type || 'custom',
description: props.provider.description || '',
website: props.provider.website || '',
billing_type: (props.provider.billing_type as 'monthly_quota' | 'pay_as_you_go' | 'free_tier') || 'pay_as_you_go',
@@ -430,6 +459,7 @@ const handleSubmit = async () => {
const payload = {
name: form.value.name,
provider_type: form.value.provider_type,
description: form.value.description || undefined,
website: form.value.website || undefined,
billing_type: form.value.billing_type,